diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6a1906394..1c0491fda4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,8 +8,9 @@ on: # Enrich gradle.properties for CI/CD env: CI_GRADLE_ARG_PROPERTIES: > - -Porg.gradle.jvmargs=-Xmx2g + -Porg.gradle.jvmargs=-Xmx4g -Porg.gradle.parallel=false + --no-daemon jobs: debug: diff --git a/.github/workflows/post-pr.yml b/.github/workflows/post-pr.yml index 6040fd5f78..a7f1d6f204 100644 --- a/.github/workflows/post-pr.yml +++ b/.github/workflows/post-pr.yml @@ -13,6 +13,7 @@ env: CI_GRADLE_ARG_PROPERTIES: > -Porg.gradle.jvmargs=-Xmx4g -Porg.gradle.parallel=false + --no-daemon jobs: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d0797721e6..d7f5ce8b57 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,6 +9,8 @@ on: env: CI_GRADLE_ARG_PROPERTIES: > -Porg.gradle.jvmargs=-Xmx4g + -Porg.gradle.parallel=false + --no-daemon jobs: check: @@ -140,7 +142,7 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - name: Lint analysis - run: ./gradlew clean :vector:lint --stacktrace + run: ./gradlew clean :vector:lint --stacktrace $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() uses: actions/upload-artifact@v3 @@ -173,7 +175,7 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - name: Lint ${{ matrix.target }} release - run: ./gradlew clean lint${{ matrix.target }}Release --stacktrace + run: ./gradlew clean lint${{ matrix.target }}Release --stacktrace $CI_GRADLE_ARG_PROPERTIES - name: Upload ${{ matrix.target }} linting report if: always() uses: actions/upload-artifact@v3 @@ -193,7 +195,7 @@ jobs: - uses: actions/checkout@v3 - name: Run detekt run: | - ./gradlew detekt + ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() uses: actions/upload-artifact@v3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a8a2d91f70..1a9cc5c239 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,8 +8,9 @@ on: # Enrich gradle.properties for CI/CD env: CI_GRADLE_ARG_PROPERTIES: > - -Porg.gradle.jvmargs=-Xmx2g + -Porg.gradle.jvmargs=-Xmx4g -Porg.gradle.parallel=false + --no-daemon jobs: tests: @@ -49,7 +50,10 @@ jobs: emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true emulator-build: 7425822 - script: ./gradlew theCodeCoverageReport -Pandroid.testInstrumentationRunnerArguments.notPackage=im.vector.app.ui --stacktrace $CI_GRADLE_ARG_PROPERTIES + script: | + ./gradlew unitTestsWithCoverage --stacktrace $CI_GRADLE_ARG_PROPERTIES + ./gradlew instrumentationTestsWithCoverage --stacktrace $CI_GRADLE_ARG_PROPERTIES + ./gradlew generateCoverageReport --stacktrace $CI_GRADLE_ARG_PROPERTIES # NB: continue-on-error marks steps.tests.conclusion = 'success' but leaves stes.tests.outcome = 'failure' - name: Run all the codecoverage tests at once (retry if emulator failed) uses: reactivecircus/android-emulator-runner@v2 @@ -62,7 +66,10 @@ jobs: emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true emulator-build: 7425822 - script: ./gradlew theCodeCoverageReport -Pandroid.testInstrumentationRunnerArguments.notPackage=im.vector.app.ui --stacktrace $CI_GRADLE_ARG_PROPERTIES + script: | + ./gradlew unitTestsWithCoverage --stacktrace $CI_GRADLE_ARG_PROPERTIES + ./gradlew instrumentationTestsWithCoverage --stacktrace $CI_GRADLE_ARG_PROPERTIES + ./gradlew generateCoverageReport --stacktrace $CI_GRADLE_ARG_PROPERTIES - run: ./gradlew sonarqube $CI_GRADLE_ARG_PROPERTIES if: always() # we may have failed a previous step and retried, that's OK env: diff --git a/.gitignore b/.gitignore index ff086d7723..8313fb5c63 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ /fastlane/private /fastlane/report.xml -/library/build +/**/build diff --git a/CHANGES.md b/CHANGES.md index e2991a122b..17d3fed2a6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,25 @@ +Changes in Element v1.4.25 (2022-06-27) +======================================= + +Bugfixes 🐛 +---------- +- Second attempt to fix session database migration to version 30. + +Changes in Element v1.4.24 (2022-06-22) +======================================= + +Bugfixes 🐛 +---------- +- First attempt to fix session database migration to version 30. + +Changes in Element v1.4.23 (2022-06-21) +======================================= + +Bugfixes 🐛 +---------- + - Fix loop in timeline and simplify management of chunks and timeline events. ([#6318](https://github.com/vector-im/element-android/issues/6318)) + + Changes in Element v1.4.22 (2022-06-14) ======================================= diff --git a/build.gradle b/build.gradle index 2cb67b7795..0244080ad0 100644 --- a/build.gradle +++ b/build.gradle @@ -28,8 +28,8 @@ buildscript { classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.4.0.2513' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5' classpath "com.likethesalad.android:stem-plugin:2.1.1" - classpath 'org.owasp:dependency-check-gradle:7.1.0.1' - classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.6.21" + classpath 'org.owasp:dependency-check-gradle:7.1.1' + classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.0" classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -43,7 +43,7 @@ plugins { id "io.gitlab.arturbosch.detekt" version "1.20.0" // Dependency Analysis - id 'com.autonomousapps.dependency-analysis' version "1.5.0" + id 'com.autonomousapps.dependency-analysis' version "1.9.0" } // https://github.com/jeremylong/DependencyCheck @@ -168,7 +168,7 @@ def launchTask = getGradle() .toString() .toLowerCase() -if (launchTask.contains("codeCoverageReport".toLowerCase())) { +if (launchTask.contains("coverage".toLowerCase())) { apply from: 'coverage.gradle' } @@ -191,7 +191,7 @@ sonarqube { property "sonar.links.issue", "https://github.com/vector-im/element-android/issues" property "sonar.organization", "new_vector_ltd_organization" property "sonar.java.coveragePlugin", "jacoco" - property "sonar.coverage.jacoco.xmlReportPaths", "${project.buildDir}/reports/jacoco/theCodeCoverageReport/theCodeCoverageReport.xml" + property "sonar.coverage.jacoco.xmlReportPaths", "${project.buildDir}/reports/jacoco/generateCoverageReport/generateCoverageReport.xml" property "sonar.login", project.hasProperty("SONAR_LOGIN") ? SONAR_LOGIN : "invalid" } } @@ -252,11 +252,7 @@ dependencyAnalysis { exclude("org.json:json") // Used in unit tests, overwrites the one bundled into Android } } - project(":library:ui-styles") { - onUnusedDependencies { - exclude("com.github.vector-im:PFLockScreen-Android") // False positive - } - } + project(":library:ui-styles") project(":matrix-sdk-android") { onUnusedDependencies { exclude("io.reactivex.rxjava2:rxkotlin") // Transitively required for mocking realm as monarchy doesn't expose Rx @@ -271,6 +267,8 @@ dependencyAnalysis { onUnusedDependencies { // False positives exclude( + "androidx.fragment:fragment-testing", + "com.facebook.soloader:soloader", "com.vanniktech:emoji-google", "com.vanniktech:emoji-material", "org.maplibre.gl:android-plugin-annotation-v9", diff --git a/changelog.d/5821.bugfix b/changelog.d/5821.bugfix new file mode 100644 index 0000000000..25d8fd2b46 --- /dev/null +++ b/changelog.d/5821.bugfix @@ -0,0 +1 @@ +Fixes concurrent modification crash when signing out or launching the app diff --git a/changelog.d/5864.sdk b/changelog.d/5864.sdk new file mode 100644 index 0000000000..b0a9d1c67d --- /dev/null +++ b/changelog.d/5864.sdk @@ -0,0 +1 @@ +Group all location sharing related API into LocationSharingService diff --git a/changelog.d/6101.bugfix b/changelog.d/6101.bugfix new file mode 100644 index 0000000000..2d8da5327d --- /dev/null +++ b/changelog.d/6101.bugfix @@ -0,0 +1 @@ +Refactor - better naming, return native user id and not sip user id and create a dm with the native user instead of with the sip user. diff --git a/changelog.d/6155.misc b/changelog.d/6155.misc new file mode 100644 index 0000000000..044e21408e --- /dev/null +++ b/changelog.d/6155.misc @@ -0,0 +1 @@ +Add unit tests for LiveLocationAggregationProcessor code diff --git a/changelog.d/6191.sdk b/changelog.d/6191.sdk new file mode 100644 index 0000000000..d7fcf1b40f --- /dev/null +++ b/changelog.d/6191.sdk @@ -0,0 +1 @@ +Add support for MSC2457 - opting in or out of logging out all devices when changing password diff --git a/changelog.d/6217.feature b/changelog.d/6217.feature new file mode 100644 index 0000000000..6a8a31790f --- /dev/null +++ b/changelog.d/6217.feature @@ -0,0 +1 @@ +Improve lock screen implementation. diff --git a/changelog.d/6315.bugfix b/changelog.d/6315.bugfix new file mode 100644 index 0000000000..0b5eb6064d --- /dev/null +++ b/changelog.d/6315.bugfix @@ -0,0 +1 @@ +[Location sharing] Fix crash when starting/stopping a live when offline diff --git a/changelog.d/6318.bugfix b/changelog.d/6318.bugfix new file mode 100644 index 0000000000..9425c9a258 --- /dev/null +++ b/changelog.d/6318.bugfix @@ -0,0 +1 @@ +Fix loop in timeline and simplify management of chunks and timeline events. diff --git a/changelog.d/6326.bugfix b/changelog.d/6326.bugfix new file mode 100644 index 0000000000..c09dd8fec1 --- /dev/null +++ b/changelog.d/6326.bugfix @@ -0,0 +1 @@ +Update design and behaviour on widget permission bottom sheet diff --git a/changelog.d/6328.bugfix b/changelog.d/6328.bugfix new file mode 100644 index 0000000000..7a41996e57 --- /dev/null +++ b/changelog.d/6328.bugfix @@ -0,0 +1 @@ +Fix | Some user verification requests couldn't be accepted/declined diff --git a/changelog.d/6329.misc b/changelog.d/6329.misc new file mode 100644 index 0000000000..dd87c11f6e --- /dev/null +++ b/changelog.d/6329.misc @@ -0,0 +1 @@ +Fix flaky test in voice recording feature. diff --git a/changelog.d/6349.bugfix b/changelog.d/6349.bugfix new file mode 100644 index 0000000000..70718248a7 --- /dev/null +++ b/changelog.d/6349.bugfix @@ -0,0 +1 @@ +[Location sharing] Fix stop of a live not possible from another device diff --git a/changelog.d/6350.feature b/changelog.d/6350.feature new file mode 100644 index 0000000000..e0bc4ac28b --- /dev/null +++ b/changelog.d/6350.feature @@ -0,0 +1 @@ +Promote live location labs flag diff --git a/changelog.d/6357.bugfix b/changelog.d/6357.bugfix new file mode 100644 index 0000000000..231c65030f --- /dev/null +++ b/changelog.d/6357.bugfix @@ -0,0 +1 @@ +Fix backslash escapes in formatted messages diff --git a/changelog.d/6364.feature b/changelog.d/6364.feature new file mode 100644 index 0000000000..207d6d141b --- /dev/null +++ b/changelog.d/6364.feature @@ -0,0 +1 @@ +[Location sharing] - Stop any active live before starting a new one diff --git a/changelog.d/6366.misc b/changelog.d/6366.misc new file mode 100644 index 0000000000..5752b3d700 --- /dev/null +++ b/changelog.d/6366.misc @@ -0,0 +1 @@ +Poll view state unit tests diff --git a/changelog.d/6369.feature b/changelog.d/6369.feature new file mode 100644 index 0000000000..3c3e936dfd --- /dev/null +++ b/changelog.d/6369.feature @@ -0,0 +1,2 @@ + Expose pusher profile tag in advanced settings + \ No newline at end of file diff --git a/changelog.d/6371.bugfix b/changelog.d/6371.bugfix new file mode 100644 index 0000000000..275ec1cd8f --- /dev/null +++ b/changelog.d/6371.bugfix @@ -0,0 +1 @@ +Fixes wrong error message when signing in with wrong credentials diff --git a/changelog.d/6375.bugfix b/changelog.d/6375.bugfix new file mode 100644 index 0000000000..769ed81e69 --- /dev/null +++ b/changelog.d/6375.bugfix @@ -0,0 +1 @@ +[Location Share] - Adding missing prefix "u=" for uncertainty in geo URI diff --git a/changelog.d/6394.misc b/changelog.d/6394.misc new file mode 100644 index 0000000000..16b4fbf616 --- /dev/null +++ b/changelog.d/6394.misc @@ -0,0 +1 @@ +Let LoadRoomMembersTask insert by chunk to release db. diff --git a/changelog.d/6396.doc b/changelog.d/6396.doc new file mode 100644 index 0000000000..9b876d74af --- /dev/null +++ b/changelog.d/6396.doc @@ -0,0 +1 @@ +Update the PR process doc to come back to one reviewer with optional additional reviewers. \ No newline at end of file diff --git a/coverage.gradle b/coverage.gradle index fc69ce7e90..f278a475ef 100644 --- a/coverage.gradle +++ b/coverage.gradle @@ -24,11 +24,13 @@ def excludes = [ def initializeReport(report, projects, classExcludes) { projects.each { project -> project.apply plugin: 'jacoco' } - report.executionData { fileTree(rootProject.rootDir.absolutePath).include( - "**/build/outputs/unit_test_code_coverage/**/*.exec", - "**/build/outputs/code_coverage/**/coverage.ec" - ) } + report.executionData { + fileTree(rootProject.rootDir.absolutePath).include( + "**/build/**/*.exec", + "**/build/outputs/code_coverage/**/coverage.ec", + ) + } report.reports { xml.enabled true html.enabled true @@ -43,13 +45,11 @@ def initializeReport(report, projects, classExcludes) { switch (project) { case { project.plugins.hasPlugin("com.android.application") }: androidClassDirs.add("${project.buildDir}/tmp/kotlin-classes/gplayDebug") - androidSourceDirs.add("${project.buildDir}/generated/source/kapt/gplayDebug") androidSourceDirs.add("${project.projectDir}/src/main/kotlin") androidSourceDirs.add("${project.projectDir}/src/main/java") break case { project.plugins.hasPlugin("com.android.library") }: androidClassDirs.add("${project.buildDir}/tmp/kotlin-classes/debug") - androidSourceDirs.add("${project.buildDir}/generated/source/kapt/debug") androidSourceDirs.add("${project.projectDir}/src/main/kotlin") androidSourceDirs.add("${project.projectDir}/src/main/java") break @@ -70,18 +70,21 @@ def collectProjects(predicate) { return subprojects.findAll { it.buildFile.isFile() && predicate(it) } } -task theCodeCoverageReport(type: JacocoReport) { +task generateCoverageReport(type: JacocoReport) { outputs.upToDateWhen { false } rootProject.apply plugin: 'jacoco' - tasks.withType(Test) { - jacoco.includeNoLocationClasses = true - } - def projects = collectProjects { ['vector','matrix-sdk-android'].contains(it.name) } - dependsOn { - [':vector:testGplayDebugUnitTest'] + - [':vector:connectedGplayDebugAndroidTest'] + - [':matrix-sdk-android:testDebugUnitTest'] + - [':matrix-sdk-android:connectedDebugAndroidTest'] - } + def projects = collectProjects { ['vector', 'matrix-sdk-android'].contains(it.name) } initializeReport(it, projects, excludes) } + +task unitTestsWithCoverage(type: GradleBuild) { + // the 7.1.3 android gradle plugin has a bug where enableTestCoverage generates invalid coverage + startParameter.projectProperties.coverage = [enableTestCoverage: false] + tasks = [':vector:testGplayDebugUnitTest', ':matrix-sdk-android:testDebugUnitTest'] +} + +task instrumentationTestsWithCoverage(type: GradleBuild) { + startParameter.projectProperties.coverage = [enableTestCoverage: true] + startParameter.projectProperties['android.testInstrumentationRunnerArguments.notPackage'] = 'im.vector.app.ui' + tasks = [':vector:connectedGplayDebugAndroidTest', 'matrix-sdk-android:connectedDebugAndroidTest'] +} diff --git a/dependencies.gradle b/dependencies.gradle index 962f07f21f..db9278b975 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -13,7 +13,7 @@ ext.versions = [ def gradle = "7.1.3" // Ref: https://kotlinlang.org/releases.html def kotlin = "1.6.21" -def kotlinCoroutines = "1.6.2" +def kotlinCoroutines = "1.6.3" def dagger = "2.42" def retrofit = "2.9.0" def arrow = "0.8.2" @@ -21,20 +21,21 @@ def markwon = "4.6.2" def moshi = "1.13.0" def lifecycle = "2.4.1" def flowBinding = "1.2.0" +def flipper = "0.151.1" def epoxy = "4.6.2" -def mavericks = "2.6.1" +def mavericks = "2.7.0" def glide = "4.13.2" def bigImageViewer = "1.8.1" def jjwt = "0.11.5" def vanniktechEmoji = "0.15.0" +def fragment = "1.4.1" + // Testing def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819 def espresso = "3.4.0" def androidxTest = "1.4.0" def androidxOrchestrator = "1.4.1" - - ext.libs = [ gradle : [ 'gradlePlugin' : "com.android.tools.build:gradle:$gradle", @@ -48,13 +49,16 @@ ext.libs = [ 'coroutinesTest' : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutines" ], androidx : [ - 'annotation' : "androidx.annotation:annotation:1.3.0", + 'annotation' : "androidx.annotation:annotation:1.4.0", 'activity' : "androidx.activity:activity:1.4.0", + 'annotations' : "androidx.annotation:annotation:1.3.0", 'appCompat' : "androidx.appcompat:appcompat:1.4.2", + 'biometric' : "androidx.biometric:biometric:1.1.0", 'core' : "androidx.core:core-ktx:1.8.0", 'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1", 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3", - 'fragmentKtx' : "androidx.fragment:fragment-ktx:1.4.1", + 'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment", + 'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment", 'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.4", 'work' : "androidx.work:work-runtime-ktx:2.7.1", 'autoFill' : "androidx.autofill:autofill:1.1.0", @@ -85,8 +89,13 @@ ext.libs = [ 'dagger' : "com.google.dagger:dagger:$dagger", 'daggerCompiler' : "com.google.dagger:dagger-compiler:$dagger", 'hilt' : "com.google.dagger:hilt-android:$dagger", + 'hiltAndroidTesting' : "com.google.dagger:hilt-android-testing:$dagger", 'hiltCompiler' : "com.google.dagger:hilt-compiler:$dagger" ], + flipper : [ + 'flipper' : "com.facebook.flipper:flipper:$flipper", + 'flipperNetworkPlugin' : "com.facebook.flipper:flipper-network-plugin:$flipper", + ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshiKt' : "com.squareup.moshi:moshi-kotlin:$moshi", @@ -155,3 +164,5 @@ ext.libs = [ 'junit' : "junit:junit:4.13.2" ] ] + + diff --git a/docs/pull_request.md b/docs/pull_request.md index d2d2bb7a3b..eebf2814a9 100644 --- a/docs/pull_request.md +++ b/docs/pull_request.md @@ -83,15 +83,16 @@ Exceptions can occur: ##### PR Review Assignment -We use automatic assignment for PR reviews. A PR is automatically routed by GitHub to 2 team members using the round robin algorithm. The process is the following: +We use automatic assignment for PR reviews. **A PR is automatically routed by GitHub to one team member** using the round robin algorithm. Additional reviewers can be used for complex changes or when the first reviewer is not confident enough on the changes. +The process is the following: -- The PR creator can assign specific people if they have another Android developer in their team or they think a specific reviewer should take a look at the PR. -- If there are missing reviewers, the PR creator assigns the [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) team as a reviewer. -- GitHub automatically assigns other reviewers. If one of the chosen reviewers is not available (holiday, etc.), remove them and set again the team, GitHub will select another reviewer. +- The PR creator selects the [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) team as a reviewer. +- GitHub automatically assign the reviewer. If the reviewer is not available (holiday, etc.), remove them and set again the team, GitHub will select another reviewer. +- Alternatively, the PR creator can directly assign specific people if they have another Android developer in their team or they think a specific reviewer should take a look at their PR. - Reviewers get a notification to make the review: they review the code following the good practice (see the rest of this document). - After making their own review, if they feel not confident enough, they can ask another person for a full review, or they can tag someone within a PR comment to check specific lines. -For PRs coming from the community, the issue wrangler can assign either the team [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) or any members directly. +For PRs coming from the community, the issue wrangler can assign either the team [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) or any member directly. ##### PR review time @@ -102,6 +103,7 @@ Some tips to achieve it: - Set up your GH notifications correctly - Check your pulls page: [https://github.com/pulls](https://github.com/pulls) - Check your pending assigned PRs before starting or resuming your day to day tasks +- If you are busy with high priority tasks, inform the author. They will find another developer It is hard to define a deadline for a review. It depends on the PR size and the complexity. Let's start with a goal of 24h (working day!) for a PR smaller than 500 lines. If bigger, the submitter and the reviewer should discuss. diff --git a/fastlane/metadata/android/en-US/changelogs/40104230.txt b/fastlane/metadata/android/en-US/changelogs/40104230.txt new file mode 100644 index 0000000000..61db61727a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40104230.txt @@ -0,0 +1,2 @@ +Main changes in this version: Various bug fixes and stability improvements. +Full changelog: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40104240.txt b/fastlane/metadata/android/en-US/changelogs/40104240.txt new file mode 100644 index 0000000000..61db61727a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40104240.txt @@ -0,0 +1,2 @@ +Main changes in this version: Various bug fixes and stability improvements. +Full changelog: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40104250.txt b/fastlane/metadata/android/en-US/changelogs/40104250.txt new file mode 100644 index 0000000000..61db61727a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40104250.txt @@ -0,0 +1,2 @@ +Main changes in this version: Various bug fixes and stability improvements. +Full changelog: https://github.com/vector-im/element-android/releases diff --git a/library/ui-styles/build.gradle b/library/ui-styles/build.gradle index 31cfdd24c7..eabd0f36f6 100644 --- a/library/ui-styles/build.gradle +++ b/library/ui-styles/build.gradle @@ -56,8 +56,6 @@ dependencies { implementation libs.google.material // Pref theme implementation libs.androidx.preferenceKtx - // PFLockScreen attrs - implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' // dialpad dimen implementation 'im.dlg:android-dialer:1.2.5' } diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_background.xml b/library/ui-styles/src/main/res/drawable/lockscreen_background.xml new file mode 100644 index 0000000000..5688c433f7 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_background.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_circle_background.xml b/library/ui-styles/src/main/res/drawable/lockscreen_circle_background.xml new file mode 100644 index 0000000000..87fa99063c --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_circle_background.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_circle_code_empty.xml b/library/ui-styles/src/main/res/drawable/lockscreen_circle_code_empty.xml new file mode 100644 index 0000000000..abde6087e0 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_circle_code_empty.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_circle_code_fill.xml b/library/ui-styles/src/main/res/drawable/lockscreen_circle_code_fill.xml new file mode 100644 index 0000000000..e3f1082324 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_circle_code_fill.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_circle_key_selector.xml b/library/ui-styles/src/main/res/drawable/lockscreen_circle_key_selector.xml new file mode 100644 index 0000000000..3fdebfbbe0 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_circle_key_selector.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_code_selector.xml b/library/ui-styles/src/main/res/drawable/lockscreen_code_selector.xml new file mode 100644 index 0000000000..5de4957a3b --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_code_selector.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_delete.xml b/library/ui-styles/src/main/res/drawable/lockscreen_delete.xml new file mode 100644 index 0000000000..e1d70e8f41 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_delete.xml @@ -0,0 +1,7 @@ + + + + diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_fingerprint.xml b/library/ui-styles/src/main/res/drawable/lockscreen_fingerprint.xml new file mode 100644 index 0000000000..7f0abe850a --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_fingerprint.xml @@ -0,0 +1,5 @@ + + + diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_side_button_background.xml b/library/ui-styles/src/main/res/drawable/lockscreen_side_button_background.xml new file mode 100644 index 0000000000..b205b2d91c --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_side_button_background.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/ui-styles/src/main/res/drawable/lockscreen_touch_selector.xml b/library/ui-styles/src/main/res/drawable/lockscreen_touch_selector.xml new file mode 100644 index 0000000000..141f2ac698 --- /dev/null +++ b/library/ui-styles/src/main/res/drawable/lockscreen_touch_selector.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/library/ui-styles/src/main/res/values-land/lockscreen_default_dimen.xml b/library/ui-styles/src/main/res/values-land/lockscreen_default_dimen.xml new file mode 100644 index 0000000000..2ae3ca0689 --- /dev/null +++ b/library/ui-styles/src/main/res/values-land/lockscreen_default_dimen.xml @@ -0,0 +1,5 @@ + + + 60dp + 15dp + diff --git a/library/ui-styles/src/main/res/values/lockscreen_attr.xml b/library/ui-styles/src/main/res/values/lockscreen_attr.xml new file mode 100644 index 0000000000..64e77d3c4e --- /dev/null +++ b/library/ui-styles/src/main/res/values/lockscreen_attr.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/library/ui-styles/src/main/res/values/lockscreen_default_colors.xml b/library/ui-styles/src/main/res/values/lockscreen_default_colors.xml new file mode 100644 index 0000000000..eb9115d636 --- /dev/null +++ b/library/ui-styles/src/main/res/values/lockscreen_default_colors.xml @@ -0,0 +1,8 @@ + + + #ffffff + #66ffffff + #42000000 + #f4511e + #009688 + diff --git a/library/ui-styles/src/main/res/values/lockscreen_default_dimens.xml b/library/ui-styles/src/main/res/values/lockscreen_default_dimens.xml new file mode 100644 index 0000000000..7d30f179a6 --- /dev/null +++ b/library/ui-styles/src/main/res/values/lockscreen_default_dimens.xml @@ -0,0 +1,7 @@ + + + 70dp + 25dp + 10dp + 5dp + diff --git a/library/ui-styles/src/main/res/values/lockscreen_default_strings.xml b/library/ui-styles/src/main/res/values/lockscreen_default_strings.xml new file mode 100644 index 0000000000..f0d7a75851 --- /dev/null +++ b/library/ui-styles/src/main/res/values/lockscreen_default_strings.xml @@ -0,0 +1,17 @@ + + Cancel + Use pin + Sign in + Next + Forgot? + Input pin code or use biometric authentication + Fingerprint not recognized. Try again + Fingerprint recognized + + Confirm fingerprint to continue + Touch sensor + Fingerprint icon + Confirm PIN + Logo + + diff --git a/library/ui-styles/src/main/res/values/lockscreen_default_styles.xml b/library/ui-styles/src/main/res/values/lockscreen_default_styles.xml new file mode 100644 index 0000000000..dba92df0bb --- /dev/null +++ b/library/ui-styles/src/main/res/values/lockscreen_default_styles.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/library/ui-styles/src/main/res/values/styles_bottom_sheet.xml b/library/ui-styles/src/main/res/values/styles_bottom_sheet.xml index 9f17342ede..f6c30040d9 100644 --- a/library/ui-styles/src/main/res/values/styles_bottom_sheet.xml +++ b/library/ui-styles/src/main/res/values/styles_bottom_sheet.xml @@ -4,10 +4,13 @@ - \ No newline at end of file + diff --git a/library/ui-styles/src/main/res/values/styles_buttons.xml b/library/ui-styles/src/main/res/values/styles_buttons.xml index 004aca5aaa..c8dcacb8ed 100644 --- a/library/ui-styles/src/main/res/values/styles_buttons.xml +++ b/library/ui-styles/src/main/res/values/styles_buttons.xml @@ -65,4 +65,4 @@ ?colorOnPrimary - \ No newline at end of file + diff --git a/library/ui-styles/src/main/res/values/styles_pin_code.xml b/library/ui-styles/src/main/res/values/styles_pin_code.xml index cb22863694..8459778e29 100644 --- a/library/ui-styles/src/main/res/values/styles_pin_code.xml +++ b/library/ui-styles/src/main/res/values/styles_pin_code.xml @@ -22,13 +22,13 @@ diff --git a/library/ui-styles/src/main/res/values/theme_dark.xml b/library/ui-styles/src/main/res/values/theme_dark.xml index 733f7e8eb5..f86a05ed66 100644 --- a/library/ui-styles/src/main/res/values/theme_dark.xml +++ b/library/ui-styles/src/main/res/values/theme_dark.xml @@ -111,14 +111,14 @@ @style/PreferenceThemeOverlay.v14.Material - @style/PinCodeScreenStyle - @style/PinCodeKeyButtonStyle - @style/PinCodeTitleStyle - @style/PinCodeHintStyle - @style/PinCodeDotsViewStyle - @style/PinCodeDeleteButtonStyle - @style/PinCodeFingerprintButtonStyle - @style/PinCodeNextButtonStyle + @style/PinCodeScreenStyle + @style/PinCodeKeyButtonStyle + @style/PinCodeTitleStyle + @style/PinCodeHintStyle + @style/PinCodeDotsViewStyle + @style/PinCodeDeleteButtonStyle + @style/PinCodeFingerprintButtonStyle + @style/PinCodeNextButtonStyle @color/android_status_bar_background_dark @color/android_navigation_bar_background_dark diff --git a/library/ui-styles/src/main/res/values/theme_light.xml b/library/ui-styles/src/main/res/values/theme_light.xml index 77996c8ce5..173b502dcd 100644 --- a/library/ui-styles/src/main/res/values/theme_light.xml +++ b/library/ui-styles/src/main/res/values/theme_light.xml @@ -111,14 +111,14 @@ @style/PreferenceThemeOverlay.v14.Material - @style/PinCodeScreenStyle - @style/PinCodeKeyButtonStyle - @style/PinCodeTitleStyle - @style/PinCodeHintStyle - @style/PinCodeDotsViewStyle - @style/PinCodeDeleteButtonStyle - @style/PinCodeFingerprintButtonStyle - @style/PinCodeNextButtonStyle + @style/PinCodeScreenStyle + @style/PinCodeKeyButtonStyle + @style/PinCodeTitleStyle + @style/PinCodeHintStyle + @style/PinCodeDotsViewStyle + @style/PinCodeDeleteButtonStyle + @style/PinCodeFingerprintButtonStyle + @style/PinCodeNextButtonStyle @color/android_status_bar_background_dark diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 034b549d85..b7c4cacdde 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -5,6 +5,10 @@ apply plugin: 'kotlin-parcelize' apply plugin: 'realm-android' apply plugin: "org.jetbrains.dokka" +if (project.hasProperty("coverage")) { + apply plugin: 'jacoco' +} + buildscript { repositories { // Do not use `mavenCentral()`, it prevents Dependabot from working properly @@ -56,7 +60,7 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.4.24\"" + buildConfigField "String", "SDK_VERSION", "\"1.4.26\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" @@ -74,7 +78,9 @@ android { buildTypes { debug { - testCoverageEnabled true + if (project.hasProperty("coverage")) { + testCoverageEnabled = coverage.enableTestCoverage + } // Set to true to log privacy or sensible data, such as token buildConfigField "boolean", "LOG_PRIVATE_DATA", project.property("vector.debugPrivateData") // Set to BODY instead of NONE to enable logging @@ -193,7 +199,7 @@ dependencies { implementation libs.apache.commonsImaging // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.50' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.51' testImplementation libs.tests.junit // Note: version sticks to 1.9.2 due to https://github.com/mockk/mockk/issues/281 diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/securestorage/TestBuildVersionSdkIntProvider.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/TestBuildVersionSdkIntProvider.kt similarity index 78% rename from matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/securestorage/TestBuildVersionSdkIntProvider.kt rename to matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/TestBuildVersionSdkIntProvider.kt index b08c88fb24..d0d64491ef 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/securestorage/TestBuildVersionSdkIntProvider.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/TestBuildVersionSdkIntProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * Copyright 2022 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.session.securestorage +package org.matrix.android.sdk -import org.matrix.android.sdk.internal.util.system.BuildVersionSdkIntProvider +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider class TestBuildVersionSdkIntProvider : BuildVersionSdkIntProvider { var value: Int = 0 diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/securestorage/SecretStoringUtilsTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtilsTest.kt similarity index 62% rename from matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/securestorage/SecretStoringUtilsTest.kt rename to matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtilsTest.kt index 6bcd12742b..14f985243c 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/session/securestorage/SecretStoringUtilsTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtilsTest.kt @@ -14,40 +14,57 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.session.securestorage +package org.matrix.android.sdk.api.securestorage import android.os.Build +import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.spyk +import org.amshove.kluent.invoking import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldNotThrow +import org.amshove.kluent.shouldThrow +import org.junit.Before import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.MethodSorters -import org.matrix.android.sdk.InstrumentedTest -import org.matrix.android.sdk.api.util.fromBase64 -import org.matrix.android.sdk.api.util.toBase64NoPadding +import org.matrix.android.sdk.TestBuildVersionSdkIntProvider import java.io.ByteArrayOutputStream +import java.security.KeyStore +import java.security.KeyStoreException import java.util.UUID @RunWith(AndroidJUnit4::class) @FixMethodOrder(MethodSorters.JVM) -class SecretStoringUtilsTest : InstrumentedTest { +class SecretStoringUtilsTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider() - private val secretStoringUtils = SecretStoringUtils(context(), buildVersionSdkIntProvider) + private val keyStore = spyk(KeyStore.getInstance("AndroidKeyStore")).also { it.load(null) } + private val secretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider) companion object { const val TEST_STR = "This is something I want to store safely!" } + @Before + fun setup() { + clearAllMocks() + } + @Test fun testStringNominalCaseApi21() { val alias = generateAlias() buildVersionSdkIntProvider.value = Build.VERSION_CODES.LOLLIPOP // Encrypt - val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias) + val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias) // Decrypt - val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias) + val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias)) decrypted shouldBeEqualTo TEST_STR secretStoringUtils.safeDeleteKey(alias) } @@ -57,9 +74,9 @@ class SecretStoringUtilsTest : InstrumentedTest { val alias = generateAlias() buildVersionSdkIntProvider.value = Build.VERSION_CODES.M // Encrypt - val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias) + val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias) // Decrypt - val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias) + val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias)) decrypted shouldBeEqualTo TEST_STR secretStoringUtils.safeDeleteKey(alias) } @@ -69,9 +86,9 @@ class SecretStoringUtilsTest : InstrumentedTest { val alias = generateAlias() buildVersionSdkIntProvider.value = Build.VERSION_CODES.R // Encrypt - val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias) + val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias) // Decrypt - val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias) + val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias)) decrypted shouldBeEqualTo TEST_STR secretStoringUtils.safeDeleteKey(alias) } @@ -81,13 +98,13 @@ class SecretStoringUtilsTest : InstrumentedTest { val alias = generateAlias() buildVersionSdkIntProvider.value = Build.VERSION_CODES.LOLLIPOP // Encrypt - val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias) + val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias) // Simulate a system upgrade buildVersionSdkIntProvider.value = Build.VERSION_CODES.M // Decrypt - val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias) + val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias)) decrypted shouldBeEqualTo TEST_STR secretStoringUtils.safeDeleteKey(alias) } @@ -180,5 +197,56 @@ class SecretStoringUtilsTest : InstrumentedTest { secretStoringUtils.safeDeleteKey(alias) } + @Test + fun testEnsureKeyReturnsSymmetricKeyOnAndroidM() { + buildVersionSdkIntProvider.value = Build.VERSION_CODES.M + val alias = generateAlias() + + val key = secretStoringUtils.ensureKey(alias) + key shouldBeInstanceOf KeyStore.SecretKeyEntry::class + + secretStoringUtils.safeDeleteKey(alias) + } + + @Test + fun testEnsureKeyReturnsPrivateKeyOnAndroidL() { + buildVersionSdkIntProvider.value = Build.VERSION_CODES.LOLLIPOP + val alias = generateAlias() + + val key = secretStoringUtils.ensureKey(alias) + key shouldBeInstanceOf KeyStore.PrivateKeyEntry::class + + secretStoringUtils.safeDeleteKey(alias) + } + + @Test + fun testSafeDeleteCanHandleKeyStoreExceptions() { + every { keyStore.deleteEntry(any()) } throws KeyStoreException() + + invoking { secretStoringUtils.safeDeleteKey(generateAlias()) } shouldNotThrow KeyStoreException::class + } + + @Test + fun testLoadSecureSecretBytesWillThrowOnInvalidStreamFormat() { + invoking { + secretStoringUtils.loadSecureSecretBytes(byteArrayOf(255.toByte()), generateAlias()) + } shouldThrow IllegalArgumentException::class + } + + @Test + fun testLoadSecureSecretWillThrowOnInvalidStreamFormat() { + invoking { + secretStoringUtils.loadSecureSecret(byteArrayOf(255.toByte()).inputStream(), generateAlias()) + } shouldThrow IllegalArgumentException::class + } + private fun generateAlias() = UUID.randomUUID().toString() } + +private fun ByteArray.toBase64NoPadding(): String { + return Base64.encodeToString(this, Base64.NO_PADDING or Base64.NO_WRAP) +} + +private fun String.fromBase64(): ByteArray { + return Base64.decode(this, Base64.DEFAULT) +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestMatrixComponent.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestMatrixComponent.kt index 52beb1b484..6cf01d4ae2 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestMatrixComponent.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestMatrixComponent.kt @@ -20,6 +20,7 @@ import android.content.Context import dagger.BindsInstance import dagger.Component import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.securestorage.SecureStorageModule import org.matrix.android.sdk.internal.auth.AuthModule import org.matrix.android.sdk.internal.debug.DebugModule import org.matrix.android.sdk.internal.di.MatrixComponent @@ -39,7 +40,8 @@ import org.matrix.android.sdk.internal.util.system.SystemModule RawModule::class, DebugModule::class, SettingsModule::class, - SystemModule::class + SystemModule::class, + SecureStorageModule::class, ] ) @MatrixScope @@ -51,7 +53,7 @@ internal interface TestMatrixComponent : MatrixComponent { interface Factory { fun create( @BindsInstance context: Context, - @BindsInstance matrixConfiguration: MatrixConfiguration + @BindsInstance matrixConfiguration: MatrixConfiguration, ): TestMatrixComponent } } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt index 986d58741c..7b0d57abbc 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt @@ -22,7 +22,6 @@ import io.realm.Realm import io.realm.RealmConfiguration import io.realm.kotlin.createObject import org.amshove.kluent.shouldBeEqualTo -import org.amshove.kluent.shouldBeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -30,13 +29,11 @@ import org.matrix.android.sdk.InstrumentedTest import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.database.helper.addTimelineEvent -import org.matrix.android.sdk.internal.database.helper.merge import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.SessionRealmModule import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import org.matrix.android.sdk.internal.util.time.DefaultClock -import org.matrix.android.sdk.session.room.timeline.RoomDataHelper.createFakeListOfEvents import org.matrix.android.sdk.session.room.timeline.RoomDataHelper.createFakeMessageEvent @RunWith(AndroidJUnit4::class) @@ -97,63 +94,6 @@ internal class ChunkEntityTest : InstrumentedTest { } } - @Test - fun merge_shouldAddEvents_whenMergingBackward() { - monarchy.runTransactionSync { realm -> - val chunk1: ChunkEntity = realm.createObject() - val chunk2: ChunkEntity = realm.createObject() - chunk1.addAll(ROOM_ID, createFakeListOfEvents(30), PaginationDirection.BACKWARDS) - chunk2.addAll(ROOM_ID, createFakeListOfEvents(30), PaginationDirection.BACKWARDS) - chunk1.merge(ROOM_ID, chunk2, PaginationDirection.BACKWARDS) - chunk1.timelineEvents.size shouldBeEqualTo 60 - } - } - - @Test - fun merge_shouldAddOnlyDifferentEvents_whenMergingBackward() { - monarchy.runTransactionSync { realm -> - val chunk1: ChunkEntity = realm.createObject() - val chunk2: ChunkEntity = realm.createObject() - val eventsForChunk1 = createFakeListOfEvents(30) - val eventsForChunk2 = eventsForChunk1 + createFakeListOfEvents(10) - chunk1.isLastForward = true - chunk2.isLastForward = false - chunk1.addAll(ROOM_ID, eventsForChunk1, PaginationDirection.FORWARDS) - chunk2.addAll(ROOM_ID, eventsForChunk2, PaginationDirection.BACKWARDS) - chunk1.merge(ROOM_ID, chunk2, PaginationDirection.BACKWARDS) - chunk1.timelineEvents.size shouldBeEqualTo 40 - chunk1.isLastForward.shouldBeTrue() - } - } - - @Test - fun merge_shouldPrevTokenMerged_whenMergingForwards() { - monarchy.runTransactionSync { realm -> - val chunk1: ChunkEntity = realm.createObject() - val chunk2: ChunkEntity = realm.createObject() - val prevToken = "prev_token" - chunk1.prevToken = prevToken - chunk1.addAll(ROOM_ID, createFakeListOfEvents(30), PaginationDirection.BACKWARDS) - chunk2.addAll(ROOM_ID, createFakeListOfEvents(30), PaginationDirection.BACKWARDS) - chunk1.merge(ROOM_ID, chunk2, PaginationDirection.FORWARDS) - chunk1.prevToken shouldBeEqualTo prevToken - } - } - - @Test - fun merge_shouldNextTokenMerged_whenMergingBackwards() { - monarchy.runTransactionSync { realm -> - val chunk1: ChunkEntity = realm.createObject() - val chunk2: ChunkEntity = realm.createObject() - val nextToken = "next_token" - chunk1.nextToken = nextToken - chunk1.addAll(ROOM_ID, createFakeListOfEvents(30), PaginationDirection.BACKWARDS) - chunk2.addAll(ROOM_ID, createFakeListOfEvents(30), PaginationDirection.BACKWARDS) - chunk1.merge(ROOM_ID, chunk2, PaginationDirection.BACKWARDS) - chunk1.nextToken shouldBeEqualTo nextToken - } - } - private fun ChunkEntity.addAll( roomId: String, events: List, diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt index 3dd3f5fa2a..3dbf206e08 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineForwardPaginationTest.kt @@ -163,6 +163,8 @@ class TimelineForwardPaginationTest : InstrumentedTest { // Ask for a forward pagination val snapshot = runBlocking { aliceTimeline.awaitPaginate(Timeline.Direction.FORWARDS, 50) + // We should paginate one more time to check we are at the end now that chunks are not merged. + aliceTimeline.awaitPaginate(Timeline.Direction.FORWARDS, 50) } // 7 for room creation item (backward pagination),and numberOfMessagesToSend (all the message of the room) snapshot.size == 7 + numberOfMessagesToSend && diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt index 03ab6e6767..7c1a097b24 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelinePreviousLastForwardTest.kt @@ -20,6 +20,7 @@ import androidx.test.filters.LargeTest import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue import org.junit.FixMethodOrder +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -39,6 +40,7 @@ import java.util.concurrent.CountDownLatch @RunWith(JUnit4::class) @FixMethodOrder(MethodSorters.JVM) +@Ignore("This test will be ignored until it is fixed") @LargeTest class TimelinePreviousLastForwardTest : InstrumentedTest { @@ -229,6 +231,7 @@ class TimelinePreviousLastForwardTest : InstrumentedTest { bobTimeline.addListener(eventsListener) + bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) bobTimeline.paginate(Timeline.Direction.FORWARDS, 50) commonTestHelper.await(lock) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt index 55569580a4..953ebddcbf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt @@ -17,6 +17,8 @@ package org.matrix.android.sdk.api import android.content.Context +import android.os.Handler +import android.os.Looper import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import androidx.work.WorkManager @@ -30,6 +32,7 @@ import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.api.network.ApiInterceptorListener import org.matrix.android.sdk.api.network.ApiPath import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.securestorage.SecureStorageService import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.di.DaggerMatrixComponent @@ -64,6 +67,9 @@ class Matrix(context: Context, matrixConfiguration: MatrixConfiguration) { @Inject internal lateinit var apiInterceptor: ApiInterceptor @Inject internal lateinit var matrixWorkerFactory: MatrixWorkerFactory @Inject internal lateinit var lightweightSettingsStorage: LightweightSettingsStorage + @Inject internal lateinit var secureStorageService: SecureStorageService + + private val uiHandler = Handler(Looper.getMainLooper()) init { val appContext = context.applicationContext @@ -76,7 +82,9 @@ class Matrix(context: Context, matrixConfiguration: MatrixConfiguration) { .build() WorkManager.initialize(appContext, configuration) } - ProcessLifecycleOwner.get().lifecycle.addObserver(backgroundDetectionObserver) + uiHandler.post { + ProcessLifecycleOwner.get().lifecycle.addObserver(backgroundDetectionObserver) + } } /** @@ -115,6 +123,11 @@ class Matrix(context: Context, matrixConfiguration: MatrixConfiguration) { */ fun legacySessionImporter() = legacySessionImporter + /** + * Returns the SecureStorageService used to encrypt and decrypt sensitive data. + */ + fun secureStorageService(): SecureStorageService = secureStorageService + /** * Get the worker factory. The returned value has to be provided to `WorkConfiguration.Builder()`. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt index 7d1407c0d8..5b6c1897bf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt @@ -21,5 +21,6 @@ data class LoginFlowResult( val ssoIdentityProviders: List?, val isLoginAndRegistrationSupported: Boolean, val homeServerUrl: String, - val isOutdatedHomeserver: Boolean + val isOutdatedHomeserver: Boolean, + val isLogoutDevicesSupported: Boolean ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt index 5b8d2328c7..145cdbdc22 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt @@ -72,7 +72,9 @@ interface LoginWizard { * Confirm the new password, once the user has checked their email * When this method succeed, tha account password will be effectively modified. * - * @param newPassword the desired new password + * @param newPassword the desired new password. + * @param logoutAllDevices defaults to true, all devices will be logged out. False values will only be taken into account + * if [org.matrix.android.sdk.api.auth.data.LoginFlowResult.isLogoutDevicesSupported] is true. */ - suspend fun resetPasswordMailConfirmed(newPassword: String) + suspend fun resetPasswordMailConfirmed(newPassword: String, logoutAllDevices: Boolean = true) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/securestorage/SecretStoringUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt similarity index 82% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/securestorage/SecretStoringUtils.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt index 8b35bd173e..bd2a1078b2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/securestorage/SecretStoringUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt @@ -16,7 +16,7 @@ @file:Suppress("DEPRECATION") -package org.matrix.android.sdk.internal.session.securestorage +package org.matrix.android.sdk.api.securestorage import android.annotation.SuppressLint import android.content.Context @@ -25,7 +25,7 @@ import android.security.KeyPairGeneratorSpec import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import androidx.annotation.RequiresApi -import org.matrix.android.sdk.internal.util.system.BuildVersionSdkIntProvider +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider import timber.log.Timber import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -80,9 +80,11 @@ import javax.security.auth.x500.X500Principal * Important: Keys stored in the keystore can be wiped out (depends of the OS version, like for example if you * add a pin or change the schema); So you might and with a useless pile of bytes. */ -internal class SecretStoringUtils @Inject constructor( +class SecretStoringUtils @Inject constructor( private val context: Context, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider + private val keyStore: KeyStore, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + private val keyNeedsUserAuthentication: Boolean = false, ) { companion object { @@ -94,14 +96,24 @@ internal class SecretStoringUtils @Inject constructor( private const val FORMAT_1: Byte = 1 } - private val keyStore: KeyStore by lazy { - KeyStore.getInstance(ANDROID_KEY_STORE).apply { - load(null) - } - } - private val secureRandom = SecureRandom() + /** + * Allows creation of the crypto keys associated witht he [alias] before encrypting some value with it. + * @return A [KeyStore.Entry] with the keys. + */ + @SuppressLint("NewApi") + fun ensureKey(alias: String): KeyStore.Entry { + when { + buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> getOrGenerateSymmetricKeyForAliasM(alias) + else -> getOrGenerateKeyPairForAlias(alias).privateKey + } + return keyStore.getEntry(alias, null) + } + + /** + * Deletes the key associated with the [keyAlias] and logs any [KeyStoreException] that could happen. + */ fun safeDeleteKey(keyAlias: String) { try { keyStore.deleteEntry(keyAlias) @@ -121,24 +133,24 @@ internal class SecretStoringUtils @Inject constructor( */ @SuppressLint("NewApi") @Throws(Exception::class) - fun securelyStoreString(secret: String, keyAlias: String): ByteArray { + fun securelyStoreBytes(secret: ByteArray, keyAlias: String): ByteArray { return when { - buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> encryptStringM(secret, keyAlias) - else -> encryptString(secret, keyAlias) + buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> encryptBytesM(secret, keyAlias) + else -> encryptBytes(secret, keyAlias) } } /** - * Decrypt a secret that was encrypted by #securelyStoreString(). + * Decrypt a secret that was encrypted by [securelyStoreBytes]. */ @SuppressLint("NewApi") @Throws(Exception::class) - fun loadSecureSecret(encrypted: ByteArray, keyAlias: String): String { + fun loadSecureSecretBytes(encrypted: ByteArray, keyAlias: String): ByteArray { encrypted.inputStream().use { inputStream -> // First get the format return when (val format = inputStream.read().toByte()) { - FORMAT_API_M -> decryptStringM(inputStream, keyAlias) - FORMAT_1 -> decryptString(inputStream, keyAlias) + FORMAT_API_M -> decryptBytesM(inputStream, keyAlias) + FORMAT_1 -> decryptBytes(inputStream, keyAlias) else -> throw IllegalArgumentException("Unknown format $format") } } @@ -162,6 +174,22 @@ internal class SecretStoringUtils @Inject constructor( } } + fun getEncryptCipher(alias: String): Cipher { + val key = when (val keyEntry = ensureKey(alias)) { + is KeyStore.SecretKeyEntry -> keyEntry.secretKey + is KeyStore.PrivateKeyEntry -> keyEntry.certificate.publicKey + else -> throw IllegalStateException("Unknown KeyEntry type.") + } + val cipherMode = when { + buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> AES_MODE + else -> RSA_MODE + } + val cipher = Cipher.getInstance(cipherMode) + cipher.init(Cipher.ENCRYPT_MODE, key) + return cipher + } + + @SuppressLint("NewApi") @RequiresApi(Build.VERSION_CODES.M) private fun getOrGenerateSymmetricKeyForAliasM(alias: String): SecretKey { val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry) @@ -176,6 +204,13 @@ internal class SecretStoringUtils @Inject constructor( .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(128) + .apply { + setUserAuthenticationRequired(keyNeedsUserAuthentication) + if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.N) { + setInvalidatedByBiometricEnrollment(true) + } + } + .setUserAuthenticationRequired(keyNeedsUserAuthentication) .build() generator.init(keyGenSpec) return generator.generateKey() @@ -216,19 +251,16 @@ internal class SecretStoringUtils @Inject constructor( } @RequiresApi(Build.VERSION_CODES.M) - private fun encryptStringM(text: String, keyAlias: String): ByteArray { - val secretKey = getOrGenerateSymmetricKeyForAliasM(keyAlias) - - val cipher = Cipher.getInstance(AES_MODE) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) + private fun encryptBytesM(byteArray: ByteArray, keyAlias: String): ByteArray { + val cipher = getEncryptCipher(keyAlias) val iv = cipher.iv // we happen the iv to the final result - val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8)) + val encryptedBytes: ByteArray = cipher.doFinal(byteArray) return formatMMake(iv, encryptedBytes) } @RequiresApi(Build.VERSION_CODES.M) - private fun decryptStringM(inputStream: InputStream, keyAlias: String): String { + private fun decryptBytesM(inputStream: InputStream, keyAlias: String): ByteArray { val (iv, encryptedText) = formatMExtract(inputStream) val secretKey = getOrGenerateSymmetricKeyForAliasM(keyAlias) @@ -237,10 +269,10 @@ internal class SecretStoringUtils @Inject constructor( val spec = GCMParameterSpec(128, iv) cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - return String(cipher.doFinal(encryptedText), Charsets.UTF_8) + return cipher.doFinal(encryptedText) } - private fun encryptString(text: String, keyAlias: String): ByteArray { + private fun encryptBytes(byteArray: ByteArray, keyAlias: String): ByteArray { // we generate a random symmetric key val key = ByteArray(16) secureRandom.nextBytes(key) @@ -252,12 +284,12 @@ internal class SecretStoringUtils @Inject constructor( val cipher = Cipher.getInstance(AES_MODE) cipher.init(Cipher.ENCRYPT_MODE, sKey) val iv = cipher.iv - val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8)) + val encryptedBytes: ByteArray = cipher.doFinal(byteArray) return format1Make(encryptedKey, iv, encryptedBytes) } - private fun decryptString(inputStream: InputStream, keyAlias: String): String { + private fun decryptBytes(inputStream: InputStream, keyAlias: String): ByteArray { val (encryptedKey, iv, encrypted) = format1Extract(inputStream) // we need to decrypt the key @@ -266,16 +298,13 @@ internal class SecretStoringUtils @Inject constructor( val spec = GCMParameterSpec(128, iv) cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sKeyBytes, "AES"), spec) - return String(cipher.doFinal(encrypted), Charsets.UTF_8) + return cipher.doFinal(encrypted) } @RequiresApi(Build.VERSION_CODES.M) @Throws(IOException::class) private fun saveSecureObjectM(keyAlias: String, output: OutputStream, writeObject: Any) { - val secretKey = getOrGenerateSymmetricKeyForAliasM(keyAlias) - - val cipher = Cipher.getInstance(AES_MODE) - cipher.init(Cipher.ENCRYPT_MODE, secretKey/*, spec*/) + val cipher = getEncryptCipher(keyAlias) val iv = cipher.iv val bos1 = ByteArrayOutputStream() @@ -362,10 +391,8 @@ internal class SecretStoringUtils @Inject constructor( @Throws(Exception::class) private fun rsaEncrypt(alias: String, secret: ByteArray): ByteArray { - val privateKeyEntry = getOrGenerateKeyPairForAlias(alias) // Encrypt the text - val inputCipher = Cipher.getInstance(RSA_MODE) - inputCipher.init(Cipher.ENCRYPT_MODE, privateKeyEntry.certificate.publicKey) + val inputCipher = getEncryptCipher(alias) val outputStream = ByteArrayOutputStream() CipherOutputStream(outputStream, inputCipher).use { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecureStorageModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecureStorageModule.kt new file mode 100644 index 0000000000..37a40fd677 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecureStorageModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.securestorage + +import android.content.Context +import dagger.Binds +import dagger.Module +import dagger.Provides +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider +import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider +import java.security.KeyStore + +@Module +internal abstract class SecureStorageModule { + + @Module + companion object { + @Provides + fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } + + @Provides + fun provideSecretStoringUtils( + context: Context, + keyStore: KeyStore, + buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + ): SecretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider) + } + + @Binds + abstract fun bindBuildVersionSdkIntProvider(provider: DefaultBuildVersionSdkIntProvider): BuildVersionSdkIntProvider +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/securestorage/SecureStorageService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecureStorageService.kt similarity index 93% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/securestorage/SecureStorageService.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecureStorageService.kt index 6b75c94cb2..e217611d96 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/securestorage/SecureStorageService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecureStorageService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.matrix.android.sdk.api.session.securestorage +package org.matrix.android.sdk.api.securestorage import java.io.InputStream import java.io.OutputStream diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt index a0d122635d..1b01239de5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt @@ -47,7 +47,6 @@ import org.matrix.android.sdk.api.session.pushrules.PushRuleService import org.matrix.android.sdk.api.session.room.RoomDirectoryService import org.matrix.android.sdk.api.session.room.RoomService import org.matrix.android.sdk.api.session.search.SearchService -import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.signout.SignOutService import org.matrix.android.sdk.api.session.space.SpaceService @@ -200,11 +199,6 @@ interface Session { */ fun syncService(): SyncService - /** - * Returns the SecureStorageService associated with the session. - */ - fun secureStorageService(): SecureStorageService - /** * Returns the ProfileService associated with the session. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt index e3d52adfc5..094c66f6f7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt @@ -24,13 +24,13 @@ import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor interface AccountService { /** * Ask the homeserver to change the password. + * * @param password Current password. * @param newPassword New password + * @param logoutAllDevices defaults to true, all devices will be logged out. False values will only be taken into account + * if [org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities.canControlLogoutDevices] is true. */ - suspend fun changePassword( - password: String, - newPassword: String - ) + suspend fun changePassword(password: String, newPassword: String, logoutAllDevices: Boolean = true) /** * Deactivate the account. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt index c78fb9cf79..b5d6d891e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt @@ -54,7 +54,12 @@ data class HomeServerCapabilities( /** * True if the home server support threading. */ - val canUseThreading: Boolean = false + val canUseThreading: Boolean = false, + + /** + * True if the home server supports controlling the logout of all devices when changing password. + */ + val canControlLogoutDevices: Boolean = false ) { enum class RoomCapabilitySupport { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt index dd48d51f45..ada3dc85d7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt @@ -16,12 +16,58 @@ package org.matrix.android.sdk.api.session.room.location +import androidx.annotation.MainThread import androidx.lifecycle.LiveData import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.Optional /** * Manage all location sharing related features. */ interface LocationSharingService { + /** + * Send a static location event to the room. + * @param latitude required latitude of the location + * @param longitude required longitude of the location + * @param uncertainty Accuracy of the location in meters + * @param isUserLocation indicates whether the location data corresponds to the user location or not (pinned location) + */ + suspend fun sendStaticLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable + + /** + * Send a live location event to the room. + * To get the beacon info event id, [startLiveLocationShare] must be called before sending live location updates. + * @param beaconInfoEventId event id of the initial beacon info state event + * @param latitude required latitude of the location + * @param longitude required longitude of the location + * @param uncertainty Accuracy of the location in meters + */ + suspend fun sendLiveLocation(beaconInfoEventId: String, latitude: Double, longitude: Double, uncertainty: Double?): Cancelable + + /** + * Starts sharing live location in the room. + * @param timeoutMillis timeout of the live in milliseconds + * @return the result of the update of the live + */ + suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult + + /** + * Stops sharing live location in the room. + * @return the result of the update of the live + */ + suspend fun stopLiveLocationShare(): UpdateLiveLocationShareResult + + /** + * Returns a LiveData on the list of current running live location shares. + */ + @MainThread fun getRunningLiveLocationShareSummaries(): LiveData> + + /** + * Returns a LiveData on the live location share summary with the given eventId. + * @param beaconInfoEventId event id of the initial beacon info state event + */ + @MainThread + fun getLiveLocationShareSummary(beaconInfoEventId: String): LiveData> } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/UpdateLiveLocationShareResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/UpdateLiveLocationShareResult.kt new file mode 100644 index 0000000000..6f8c03be46 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/UpdateLiveLocationShareResult.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.location + +/** + * Represents the result of an update of live location share like a start or a stop. + */ +sealed interface UpdateLiveLocationShareResult { + data class Success(val beaconEventId: String) : UpdateLiveLocationShareResult + data class Failure(val error: Throwable) : UpdateLiveLocationShareResult +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt index a1fd3bd2ec..e0a7846167 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt @@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class LocationInfo( /** - * Required. RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' representing this location. + * Required. RFC5870 formatted geo uri 'geo:latitude,longitude;u=uncertainty' like 'geo:40.05,29.24;u=30' representing this location. */ @Json(name = "uri") val geoUri: String? = null, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt index 0a66a6e400..30420fd3c7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt @@ -35,7 +35,7 @@ data class MessageLocationContent( @Json(name = "body") override val body: String, /** - * Required. RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' representing this location. + * Required. RFC5870 formatted geo uri 'geo:latitude,longitude;u=uncertainty' like 'geo:40.05,29.24;u=30' representing this location. */ @Json(name = "geo_uri") val geoUri: String, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt index 81b034a809..ee31d5959e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollCreationInfo.kt @@ -25,4 +25,7 @@ data class PollCreationInfo( @Json(name = "kind") val kind: PollType? = PollType.DISCLOSED_UNSTABLE, @Json(name = "max_selections") val maxSelections: Int = 1, @Json(name = "answers") val answers: List? = null -) +) { + + fun isUndisclosed() = kind in listOf(PollType.UNDISCLOSED_UNSTABLE, PollType.UNDISCLOSED) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 661c3be5bd..9cf062356f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -142,24 +142,6 @@ interface SendService { */ fun resendMediaMessage(localEcho: TimelineEvent): Cancelable - /** - * Send a location event to the room. - * @param latitude required latitude of the location - * @param longitude required longitude of the location - * @param uncertainty Accuracy of the location in meters - * @param isUserLocation indicates whether the location data corresponds to the user location or not - */ - fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable - - /** - * Send a live location event to the room. beacon_info state event has to be sent before sending live location updates. - * @param beaconInfoEventId event id of the initial beacon info state event - * @param latitude required latitude of the location - * @param longitude required longitude of the location - * @param uncertainty Accuracy of the location in meters - */ - fun sendLiveLocation(beaconInfoEventId: String, latitude: Double, longitude: Double, uncertainty: Double?): Cancelable - /** * Remove this failed message from the timeline. * @param localEcho the unsent local echo diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt index 49c0debe1b..6ca63c2c49 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt @@ -66,19 +66,6 @@ interface StateService { */ suspend fun deleteAvatar() - /** - * Stops sharing live location in the room. - * @param userId user id - */ - suspend fun stopLiveLocation(userId: String) - - /** - * Returns beacon info state event of a user. - * @param userId user id who is sharing location - * @param filterOnlyLive filters only ongoing live location sharing beacons if true else ended event is included - */ - suspend fun getLiveLocationBeaconInfo(userId: String, filterOnlyLive: Boolean): Event? - /** * Send a state event to the room. * @param eventType The type of event to send. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/BuildVersionSdkIntProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt similarity index 87% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/BuildVersionSdkIntProvider.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt index 515656049a..b7ea187ec5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/BuildVersionSdkIntProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.util.system +package org.matrix.android.sdk.api.util -internal interface BuildVersionSdkIntProvider { +interface BuildVersionSdkIntProvider { /** * Return the current version of the Android SDK. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/DefaultBuildVersionSdkIntProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/DefaultBuildVersionSdkIntProvider.kt similarity index 85% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/DefaultBuildVersionSdkIntProvider.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/DefaultBuildVersionSdkIntProvider.kt index 806c6e9735..7f0024cafa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/DefaultBuildVersionSdkIntProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/DefaultBuildVersionSdkIntProvider.kt @@ -14,12 +14,12 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.util.system +package org.matrix.android.sdk.api.util import android.os.Build import javax.inject.Inject -internal class DefaultBuildVersionSdkIntProvider @Inject constructor() : +class DefaultBuildVersionSdkIntProvider @Inject constructor() : BuildVersionSdkIntProvider { override fun get() = Build.VERSION.SDK_INT } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index 92852e4722..9d6b018a67 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -40,6 +40,7 @@ import org.matrix.android.sdk.internal.auth.login.DefaultLoginWizard import org.matrix.android.sdk.internal.auth.login.DirectLoginTask import org.matrix.android.sdk.internal.auth.registration.DefaultRegistrationWizard import org.matrix.android.sdk.internal.auth.version.Versions +import org.matrix.android.sdk.internal.auth.version.doesServerSupportLogoutDevices import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk import org.matrix.android.sdk.internal.auth.version.isSupportedBySdk import org.matrix.android.sdk.internal.di.Unauthenticated @@ -292,7 +293,8 @@ internal class DefaultAuthenticationService @Inject constructor( ssoIdentityProviders = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == LoginFlowTypes.SSO }?.ssoIdentityProvider, isLoginAndRegistrationSupported = versions.isLoginAndRegistrationSupportedBySdk(), homeServerUrl = homeServerUrl, - isOutdatedHomeserver = !versions.isSupportedBySdk() + isOutdatedHomeserver = !versions.isSupportedBySdk(), + isLogoutDevicesSupported = versions.doesServerSupportLogoutDevices() ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt index 20b056f1c7..656a4f671b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt @@ -121,12 +121,13 @@ internal class DefaultLoginWizard( .also { pendingSessionStore.savePendingSessionData(it) } } - override suspend fun resetPasswordMailConfirmed(newPassword: String) { + override suspend fun resetPasswordMailConfirmed(newPassword: String, logoutAllDevices: Boolean) { val resetPasswordData = pendingSessionData.resetPasswordData ?: throw IllegalStateException("Developer error - Must call resetPassword first") val param = ResetPasswordMailConfirmed.create( pendingSessionData.clientSecret, resetPasswordData.addThreePidRegistrationResponse.sid, - newPassword + newPassword, + logoutAllDevices ) executeRequest(null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/ResetPasswordMailConfirmed.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/ResetPasswordMailConfirmed.kt index 4e0c000f87..01481f70dc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/ResetPasswordMailConfirmed.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/ResetPasswordMailConfirmed.kt @@ -30,13 +30,17 @@ internal data class ResetPasswordMailConfirmed( // the new password @Json(name = "new_password") - val newPassword: String? = null + val newPassword: String? = null, + + @Json(name = "logout_devices") + val logoutDevices: Boolean? = null ) { companion object { - fun create(clientSecret: String, sid: String, newPassword: String): ResetPasswordMailConfirmed { + fun create(clientSecret: String, sid: String, newPassword: String, logoutDevices: Boolean?): ResetPasswordMailConfirmed { return ResetPasswordMailConfirmed( auth = AuthParams.createForResetPassword(clientSecret, sid), - newPassword = newPassword + newPassword = newPassword, + logoutDevices = logoutDevices ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt index cd38b68a85..75639c6a21 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt @@ -58,6 +58,7 @@ internal data class HomeServerVersion( val r0_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0) val r0_5_0 = HomeServerVersion(major = 0, minor = 5, patch = 0) val r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0) + val r0_6_1 = HomeServerVersion(major = 0, minor = 6, patch = 1) val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt index cee4b12138..915b25134b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt @@ -111,6 +111,15 @@ private fun Versions.doesServerSeparatesAddAndBind(): Boolean { unstableFeatures?.get(FEATURE_SEPARATE_ADD_AND_BIND) ?: false } +/** + * Indicate if the server supports MSC2457 `logout_devices` parameter when setting a new password. + * + * @return true if logout_devices is supported + */ +internal fun Versions.doesServerSupportLogoutDevices(): Boolean { + return getMaxVersion() >= HomeServerVersion.r0_6_1 +} + private fun Versions.getMaxVersion(): HomeServerVersion { return supportedVersions ?.mapNotNull { HomeServerVersion.parse(it) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationMessageProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationMessageProcessor.kt index 9f123f0c08..821663bcff 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationMessageProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/VerificationMessageProcessor.kt @@ -62,7 +62,7 @@ internal class VerificationMessageProcessor @Inject constructor( // If the request is in the future by more than 5 minutes or more than 10 minutes in the past, // the message should be ignored by the receiver. - if (event.ageLocalTs != null && !VerificationService.isValidRequest(event.ageLocalTs, clock.epochMillis())) return Unit.also { + if (!VerificationService.isValidRequest(event.ageLocalTs, clock.epochMillis())) return Unit.also { Timber.d("## SAS Verification live observer: msgId: ${event.eventId} is outdated age:$event.ageLocalTs ms") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmKeysUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmKeysUtils.kt index b3a039d119..86355ceaa8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmKeysUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmKeysUtils.kt @@ -21,7 +21,7 @@ import androidx.core.content.edit import io.realm.Realm import io.realm.RealmConfiguration import org.matrix.android.sdk.BuildConfig -import org.matrix.android.sdk.internal.session.securestorage.SecretStoringUtils +import org.matrix.android.sdk.api.securestorage.SecretStoringUtils import timber.log.Timber import java.security.SecureRandom import javax.inject.Inject @@ -40,7 +40,7 @@ import javax.inject.Inject */ internal class RealmKeysUtils @Inject constructor( context: Context, - private val secretStoringUtils: SecretStoringUtils + private val secretStoringUtils: SecretStoringUtils, ) { private val rng = SecureRandom() @@ -71,7 +71,7 @@ internal class RealmKeysUtils @Inject constructor( private fun createAndSaveKeyForDatabase(alias: String): ByteArray { val key = generateKeyForRealm() val encodedKey = Base64.encodeToString(key, Base64.NO_PADDING) - val toStore = secretStoringUtils.securelyStoreString(encodedKey, alias) + val toStore = secretStoringUtils.securelyStoreBytes(encodedKey.toByteArray(), alias) sharedPreferences.edit { putString("${ENCRYPTED_KEY_PREFIX}_$alias", Base64.encodeToString(toStore, Base64.NO_PADDING)) } @@ -85,7 +85,7 @@ internal class RealmKeysUtils @Inject constructor( private fun extractKeyForDatabase(alias: String): ByteArray { val encryptedB64 = sharedPreferences.getString("${ENCRYPTED_KEY_PREFIX}_$alias", null) val encryptedKey = Base64.decode(encryptedB64, Base64.NO_PADDING) - val b64 = secretStoringUtils.loadSecureSecret(encryptedKey, alias) + val b64 = secretStoringUtils.loadSecureSecretBytes(encryptedKey, alias) return Base64.decode(b64, Base64.NO_PADDING) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 592461f927..665567bf2a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -47,6 +47,8 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo026 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo027 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo028 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo029 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo030 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo031 import org.matrix.android.sdk.internal.util.Normalizer import timber.log.Timber import javax.inject.Inject @@ -61,7 +63,7 @@ internal class RealmSessionStoreMigration @Inject constructor( override fun equals(other: Any?) = other is RealmSessionStoreMigration override fun hashCode() = 1000 - val schemaVersion = 29L + val schemaVersion = 31L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Realm Session from $oldVersion to $newVersion") @@ -95,5 +97,7 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 27) MigrateSessionTo027(realm).perform() if (oldVersion < 28) MigrateSessionTo028(realm).perform() if (oldVersion < 29) MigrateSessionTo029(realm).perform() + if (oldVersion < 30) MigrateSessionTo030(realm).perform() + if (oldVersion < 31) MigrateSessionTo031(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index ee9e2403d6..234caec970 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -17,7 +17,6 @@ package org.matrix.android.sdk.internal.database.helper import io.realm.Realm -import io.realm.Sort import io.realm.kotlin.createObject import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.internal.database.model.ChunkEntity @@ -34,32 +33,9 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.find import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where -import org.matrix.android.sdk.internal.database.query.whereRoomId -import org.matrix.android.sdk.internal.extensions.assertIsManaged import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import timber.log.Timber -internal fun ChunkEntity.merge(roomId: String, chunkToMerge: ChunkEntity, direction: PaginationDirection) { - assertIsManaged() - val localRealm = this.realm - val eventsToMerge: List - if (direction == PaginationDirection.FORWARDS) { - this.nextToken = chunkToMerge.nextToken - this.isLastForward = chunkToMerge.isLastForward - eventsToMerge = chunkToMerge.timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) - } else { - this.prevToken = chunkToMerge.prevToken - this.isLastBackward = chunkToMerge.isLastBackward - eventsToMerge = chunkToMerge.timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) - } - chunkToMerge.stateEvents.forEach { stateEvent -> - addStateEvent(roomId, stateEvent, direction) - } - eventsToMerge.forEach { - addTimelineEventFromMerge(localRealm, it, direction) - } -} - internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity, direction: PaginationDirection) { if (direction == PaginationDirection.BACKWARDS) { Timber.v("We don't keep chunk state events when paginating backward") @@ -144,40 +120,6 @@ internal fun computeIsUnique( } } -private fun ChunkEntity.addTimelineEventFromMerge(realm: Realm, timelineEventEntity: TimelineEventEntity, direction: PaginationDirection) { - val eventId = timelineEventEntity.eventId - if (timelineEvents.find(eventId) != null) { - return - } - val displayIndex = nextDisplayIndex(direction) - val localId = TimelineEventEntity.nextId(realm) - val copied = realm.createObject().apply { - this.localId = localId - this.root = timelineEventEntity.root - this.eventId = timelineEventEntity.eventId - this.roomId = timelineEventEntity.roomId - this.annotations = timelineEventEntity.annotations - this.readReceipts = timelineEventEntity.readReceipts - this.displayIndex = displayIndex - this.senderAvatar = timelineEventEntity.senderAvatar - this.senderName = timelineEventEntity.senderName - this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName - } - handleThreadSummary(realm, eventId, copied) - timelineEvents.add(copied) -} - -/** - * Upon copy of the timeline events we should update the latestMessage TimelineEventEntity with the new one. - */ -private fun handleThreadSummary(realm: Realm, oldEventId: String, newTimelineEventEntity: TimelineEventEntity) { - EventEntity - .whereRoomId(realm, newTimelineEventEntity.roomId) - .equalTo(EventEntityFields.IS_ROOT_THREAD, true) - .equalTo(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.EVENT_ID, oldEventId) - .findFirst()?.threadSummaryLatestMessage = newTimelineEventEntity -} - private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventEntity, senderId: String): ReadReceiptsSummaryEntity { val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst() ?: realm.createObject(eventEntity.eventId).apply { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt index 79a99cdfac..0a6d4bf833 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt @@ -271,7 +271,7 @@ private fun HashMap.addSenderState(realm: Realm, roo * Create an EventEntity for the root thread event or get an existing one. */ private fun createEventEntity(realm: Realm, roomId: String, event: Event, currentTimeMillis: Long): EventEntity { - val ageLocalTs = event.unsignedData?.age?.let { currentTimeMillis - it } + val ageLocalTs = currentTimeMillis - (event.unsignedData?.age ?: 0) return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt index 5b60c53642..0f0a847c78 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt @@ -130,7 +130,7 @@ internal fun EventEntity.asDomain(castJsonNumbers: Boolean = false): Event { internal fun Event.toEntity( roomId: String, sendState: SendState, - ageLocalTs: Long?, + ageLocalTs: Long, contentToInject: String? = null ): EventEntity { return EventMapper.map(this, roomId).apply { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt index 20af43530c..184a0108b9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt @@ -42,7 +42,8 @@ internal object HomeServerCapabilitiesMapper { lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported, defaultIdentityServerUrl = entity.defaultIdentityServerUrl, roomVersions = mapRoomVersion(entity.roomVersionsJson), - canUseThreading = entity.canUseThreading + canUseThreading = entity.canUseThreading, + canControlLogoutDevices = entity.canControlLogoutDevices ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt index 9460e4c6ba..4a4c730a0b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/LiveLocationShareAggregatedSummaryMapper.kt @@ -16,15 +16,17 @@ package org.matrix.android.sdk.internal.database.mapper +import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity import javax.inject.Inject -internal class LiveLocationShareAggregatedSummaryMapper @Inject constructor() { +internal class LiveLocationShareAggregatedSummaryMapper @Inject constructor() : + Monarchy.Mapper { - fun map(entity: LiveLocationShareAggregatedSummaryEntity): LiveLocationShareAggregatedSummary { + override fun map(entity: LiveLocationShareAggregatedSummaryEntity): LiveLocationShareAggregatedSummary { return LiveLocationShareAggregatedSummary( userId = entity.userId, isActive = entity.isActive, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt index aebca11c2b..17dc0f7c82 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo029.kt @@ -25,7 +25,7 @@ import org.matrix.android.sdk.internal.util.database.RealmMigrator * Migrating to: * Live location sharing aggregated summary: adding new field userId. */ -internal class MigrateSessionTo029(realm: DynamicRealm) : RealmMigrator(realm, 28) { +internal class MigrateSessionTo029(realm: DynamicRealm) : RealmMigrator(realm, 29) { override fun doMigrate(realm: DynamicRealm) { realm.schema.get("LiveLocationShareAggregatedSummaryEntity") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo030.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo030.kt new file mode 100644 index 0000000000..5d24b1433c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo030.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +/** + * Migrating to: + * Cleaning old chunks which may have broken links. + */ +internal class MigrateSessionTo030(realm: DynamicRealm) : RealmMigrator(realm, 30) { + + override fun doMigrate(realm: DynamicRealm) { + // Delete all previous chunks + val chunks = realm.where("ChunkEntity") + .equalTo(ChunkEntityFields.IS_LAST_FORWARD, false) + .findAll() + + val nbOfDeletedChunks = chunks.size + var nbOfDeletedTimelineEvents = 0 + var nbOfDeletedEvents = 0 + chunks.forEach { chunk -> + val timelineEvents = chunk.getList(ChunkEntityFields.TIMELINE_EVENTS.`$`) + timelineEvents.forEach { timelineEvent -> + // Don't delete state events + val event = timelineEvent.getObject(TimelineEventEntityFields.ROOT.`$`) + if (event?.isNull(EventEntityFields.STATE_KEY) == true) { + nbOfDeletedEvents++ + event.deleteFromRealm() + } + } + nbOfDeletedTimelineEvents += timelineEvents.size + timelineEvents.deleteAllFromRealm() + } + chunks.deleteAllFromRealm() + Timber.d( + "MigrateSessionTo030: $nbOfDeletedChunks deleted chunk(s)," + + " $nbOfDeletedTimelineEvents deleted TimelineEvent(s)" + + " and $nbOfDeletedEvents deleted Event(s)." + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo031.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo031.kt new file mode 100644 index 0000000000..e278b74756 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo031.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields +import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +internal class MigrateSessionTo031(realm: DynamicRealm) : RealmMigrator(realm, 31) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField(HomeServerCapabilitiesEntityFields.CAN_CONTROL_LOGOUT_DEVICES, Boolean::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt index 47a83f0ed9..9d90973f8a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt @@ -29,7 +29,8 @@ internal open class HomeServerCapabilitiesEntity( var lastVersionIdentityServerSupported: Boolean = false, var defaultIdentityServerUrl: String? = null, var lastUpdatedTimestamp: Long = 0L, - var canUseThreading: Boolean = false + var canUseThreading: Boolean = false, + var canControlLogoutDevices: Boolean = false ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt index 9350102137..1e5d96b496 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt @@ -31,6 +31,7 @@ internal fun ChunkEntity.Companion.where(realm: Realm, roomId: String): RealmQue internal fun ChunkEntity.Companion.find(realm: Realm, roomId: String, prevToken: String? = null, nextToken: String? = null): ChunkEntity? { val query = where(realm, roomId) + if (prevToken == null && nextToken == null) return null if (prevToken != null) { query.equalTo(ChunkEntityFields.PREV_TOKEN, prevToken) } @@ -40,7 +41,7 @@ internal fun ChunkEntity.Companion.find(realm: Realm, roomId: String, prevToken: return query.findFirst() } -internal fun ChunkEntity.Companion.findAll(realm: Realm, roomId: String, prevToken: String? = null, nextToken: String? = null): RealmResults? { +internal fun ChunkEntity.Companion.findAll(realm: Realm, roomId: String, prevToken: String? = null, nextToken: String? = null): RealmResults { val query = where(realm, roomId) if (prevToken != null) { query.equalTo(ChunkEntityFields.PREV_TOKEN, prevToken) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt index 7dfeb6884a..d69f251f6f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LiveLocationShareAggregatedSummaryEntityQuery.kt @@ -76,7 +76,7 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveIn realm: Realm, roomId: String, userId: String, - ignoredEventId: String + ignoredEventId: String, ): List { return LiveLocationShareAggregatedSummaryEntity .whereRoomId(realm, roomId = roomId) @@ -84,6 +84,7 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveIn .equalTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true) .notEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, ignoredEventId) .findAll() + .toList() } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt index d668c0498f..44ec90ed40 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt @@ -28,6 +28,8 @@ import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.securestorage.SecureStorageModule +import org.matrix.android.sdk.api.securestorage.SecureStorageService import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.auth.AuthModule @@ -53,7 +55,8 @@ import java.io.File DebugModule::class, SettingsModule::class, SystemModule::class, - NoOpTestModule::class + NoOpTestModule::class, + SecureStorageModule::class, ] ) @MatrixScope @@ -96,6 +99,8 @@ internal interface MatrixComponent { fun sessionManager(): SessionManager + fun secureStorageService(): SecureStorageService + fun matrixWorkerFactory(): MatrixWorkerFactory fun inject(matrix: Matrix) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/securestorage/DefaultSecureStorageService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/securestorage/DefaultSecureStorageService.kt similarity index 86% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/securestorage/DefaultSecureStorageService.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/securestorage/DefaultSecureStorageService.kt index ef8133dd15..8f6605d657 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/securestorage/DefaultSecureStorageService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/securestorage/DefaultSecureStorageService.kt @@ -14,9 +14,10 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.session.securestorage +package org.matrix.android.sdk.internal.securestorage -import org.matrix.android.sdk.api.session.securestorage.SecureStorageService +import org.matrix.android.sdk.api.securestorage.SecretStoringUtils +import org.matrix.android.sdk.api.securestorage.SecureStorageService import java.io.InputStream import java.io.OutputStream import javax.inject.Inject diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt index 36d3f0606b..7c50a0ff84 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt @@ -55,7 +55,6 @@ import org.matrix.android.sdk.api.session.pushrules.PushRuleService import org.matrix.android.sdk.api.session.room.RoomDirectoryService import org.matrix.android.sdk.api.session.room.RoomService import org.matrix.android.sdk.api.session.search.SearchService -import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.signout.SignOutService import org.matrix.android.sdk.api.session.space.SpaceService @@ -111,7 +110,6 @@ internal class DefaultSession @Inject constructor( private val cryptoService: Lazy, private val defaultFileService: Lazy, private val permalinkService: Lazy, - private val secureStorageService: Lazy, private val profileService: Lazy, private val syncService: Lazy, private val mediaService: Lazy, @@ -220,7 +218,6 @@ internal class DefaultSession @Inject constructor( override fun eventService(): EventService = eventService.get() override fun termsService(): TermsService = termsService.get() override fun syncService(): SyncService = syncService.get() - override fun secureStorageService(): SecureStorageService = secureStorageService.get() override fun profileService(): ProfileService = profileService.get() override fun presenceService(): PresenceService = presenceService.get() override fun accountService(): AccountService = accountService.get() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt index f01451b688..d3cae3ac2d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt @@ -20,6 +20,7 @@ import dagger.BindsInstance import dagger.Component import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.api.securestorage.SecureStorageModule import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.internal.crypto.CryptoModule import org.matrix.android.sdk.internal.crypto.crosssigning.UpdateTrustWorker @@ -98,7 +99,8 @@ import org.matrix.android.sdk.internal.util.system.SystemModule ThirdPartyModule::class, SpaceModule::class, PresenceModule::class, - RequestModule::class + RequestModule::class, + SecureStorageModule::class, ] ) @SessionScope diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt index 756b9cef83..0dae24e64b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt @@ -19,12 +19,13 @@ package org.matrix.android.sdk.internal.session import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session import timber.log.Timber +import java.util.concurrent.CopyOnWriteArraySet import javax.inject.Inject @SessionScope internal class SessionListeners @Inject constructor() { - private val listeners = mutableSetOf() + private val listeners = CopyOnWriteArraySet() fun addListener(listener: Session.Listener) { synchronized(listeners) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt index 950cb899f8..f8a52f0b7e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt @@ -41,7 +41,6 @@ import org.matrix.android.sdk.api.session.events.EventService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.openid.OpenIdService import org.matrix.android.sdk.api.session.permalinks.PermalinkService -import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.typing.TypingUsersTracker import org.matrix.android.sdk.api.util.md5 @@ -93,7 +92,6 @@ import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcesso import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessorCoroutine import org.matrix.android.sdk.internal.session.room.tombstone.RoomTombstoneEventProcessor -import org.matrix.android.sdk.internal.session.securestorage.DefaultSecureStorageService import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker import org.matrix.android.sdk.internal.session.user.accountdata.DefaultSessionAccountDataService import org.matrix.android.sdk.internal.session.widgets.DefaultWidgetURLFormatter @@ -367,9 +365,6 @@ internal abstract class SessionModule { @IntoSet abstract fun bindEventSenderProcessorAsSessionLifecycleObserver(processor: EventSenderProcessorCoroutine): SessionLifecycleObserver - @Binds - abstract fun bindSecureStorageService(service: DefaultSecureStorageService): SecureStorageService - @Binds abstract fun bindHomeServerCapabilitiesService(service: DefaultHomeServerCapabilitiesService): HomeServerCapabilitiesService diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordParams.kt index 1b95820918..f6778327d9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordParams.kt @@ -29,13 +29,17 @@ internal data class ChangePasswordParams( val auth: UserPasswordAuth? = null, @Json(name = "new_password") - val newPassword: String? = null + val newPassword: String? = null, + + @Json(name = "logout_devices") + val logoutDevices: Boolean = true ) { companion object { - fun create(userId: String, oldPassword: String, newPassword: String): ChangePasswordParams { + fun create(userId: String, oldPassword: String, newPassword: String, logoutAllDevices: Boolean): ChangePasswordParams { return ChangePasswordParams( auth = UserPasswordAuth(user = userId, password = oldPassword), - newPassword = newPassword + newPassword = newPassword, + logoutDevices = logoutAllDevices ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordTask.kt index 7b21ba2e63..e767950ff7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/ChangePasswordTask.kt @@ -26,7 +26,8 @@ import javax.inject.Inject internal interface ChangePasswordTask : Task { data class Params( val password: String, - val newPassword: String + val newPassword: String, + val logoutAllDevices: Boolean ) } @@ -37,7 +38,7 @@ internal class DefaultChangePasswordTask @Inject constructor( ) : ChangePasswordTask { override suspend fun execute(params: ChangePasswordTask.Params) { - val changePasswordParams = ChangePasswordParams.create(userId, params.password, params.newPassword) + val changePasswordParams = ChangePasswordParams.create(userId, params.password, params.newPassword, params.logoutAllDevices) try { executeRequest(globalErrorReceiver) { accountAPI.changePassword(changePasswordParams) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt index bb830a5e41..9d03ec479b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt @@ -25,8 +25,8 @@ internal class DefaultAccountService @Inject constructor( private val deactivateAccountTask: DeactivateAccountTask ) : AccountService { - override suspend fun changePassword(password: String, newPassword: String) { - changePasswordTask.execute(ChangePasswordTask.Params(password, newPassword)) + override suspend fun changePassword(password: String, newPassword: String, logoutAllDevices: Boolean) { + changePasswordTask.execute(ChangePasswordTask.Params(password, newPassword, logoutAllDevices)) } override suspend fun deactivateAccount(eraseAllData: Boolean, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt index d22da8f6f2..add69dd8c7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt @@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.internal.auth.version.Versions +import org.matrix.android.sdk.internal.auth.version.doesServerSupportLogoutDevices import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity @@ -142,6 +143,7 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor( if (getVersionResult != null) { homeServerCapabilitiesEntity.lastVersionIdentityServerSupported = getVersionResult.isLoginAndRegistrationSupportedBySdk() + homeServerCapabilitiesEntity.canControlLogoutDevices = getVersionResult.doesServerSupportLogoutDevices() } if (getWellknownResult != null && getWellknownResult is WellknownResult.Prompt) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index 556e31356e..e1dd22a211 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -55,6 +55,18 @@ import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDire import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask +import org.matrix.android.sdk.internal.session.room.location.CheckIfExistingActiveLiveTask +import org.matrix.android.sdk.internal.session.room.location.DefaultCheckIfExistingActiveLiveTask +import org.matrix.android.sdk.internal.session.room.location.DefaultGetActiveBeaconInfoForUserTask +import org.matrix.android.sdk.internal.session.room.location.DefaultSendLiveLocationTask +import org.matrix.android.sdk.internal.session.room.location.DefaultSendStaticLocationTask +import org.matrix.android.sdk.internal.session.room.location.DefaultStartLiveLocationShareTask +import org.matrix.android.sdk.internal.session.room.location.DefaultStopLiveLocationShareTask +import org.matrix.android.sdk.internal.session.room.location.GetActiveBeaconInfoForUserTask +import org.matrix.android.sdk.internal.session.room.location.SendLiveLocationTask +import org.matrix.android.sdk.internal.session.room.location.SendStaticLocationTask +import org.matrix.android.sdk.internal.session.room.location.StartLiveLocationShareTask +import org.matrix.android.sdk.internal.session.room.location.StopLiveLocationShareTask import org.matrix.android.sdk.internal.session.room.membership.DefaultLoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.admin.DefaultMembershipAdminTask @@ -309,4 +321,22 @@ internal abstract class RoomModule { @Binds abstract fun bindFetchThreadSummariesTask(task: DefaultFetchThreadSummariesTask): FetchThreadSummariesTask + + @Binds + abstract fun bindStartLiveLocationShareTask(task: DefaultStartLiveLocationShareTask): StartLiveLocationShareTask + + @Binds + abstract fun bindStopLiveLocationShareTask(task: DefaultStopLiveLocationShareTask): StopLiveLocationShareTask + + @Binds + abstract fun bindSendStaticLocationTask(task: DefaultSendStaticLocationTask): SendStaticLocationTask + + @Binds + abstract fun bindSendLiveLocationTask(task: DefaultSendLiveLocationTask): SendLiveLocationTask + + @Binds + abstract fun bindGetActiveBeaconInfoForUserTask(task: DefaultGetActiveBeaconInfoForUserTask): GetActiveBeaconInfoForUserTask + + @Binds + abstract fun bindCheckIfExistingActiveLiveTask(task: DefaultCheckIfExistingActiveLiveTask): CheckIfExistingActiveLiveTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt index 05bde8f83f..921749122b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessor.kt @@ -36,16 +36,22 @@ import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject -// TODO add unit tests +/** + * Aggregates all live location sharing related events in local database. + */ internal class LiveLocationAggregationProcessor @Inject constructor( @SessionId private val sessionId: String, private val workManagerProvider: WorkManagerProvider, private val clock: Clock, ) { - fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean) { + /** + * Handle the content of a beacon info. + * @return true if it has been processed, false if ignored. + */ + fun handleBeaconInfo(realm: Realm, event: Event, content: MessageBeaconInfoContent, roomId: String, isLocalEcho: Boolean): Boolean { if (event.senderId.isNullOrEmpty() || isLocalEcho) { - return + return false } val isLive = content.isLive.orTrue() @@ -58,7 +64,7 @@ internal class LiveLocationAggregationProcessor @Inject constructor( if (targetEventId.isNullOrEmpty()) { Timber.w("no target event id found for the beacon content") - return + return false } val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate( @@ -83,6 +89,8 @@ internal class LiveLocationAggregationProcessor @Inject constructor( } else { cancelDeactivationAfterTimeout(targetEventId, roomId) } + + return true } private fun scheduleDeactivationAfterTimeout(eventId: String, roomId: String, endOfLiveTimestampMillis: Long?) { @@ -110,6 +118,10 @@ internal class LiveLocationAggregationProcessor @Inject constructor( workManagerProvider.workManager.cancelUniqueWork(workName) } + /** + * Handle the content of a beacon location data. + * @return true if it has been processed, false if ignored. + */ fun handleBeaconLocationData( realm: Realm, event: Event, @@ -117,14 +129,14 @@ internal class LiveLocationAggregationProcessor @Inject constructor( roomId: String, relatedEventId: String?, isLocalEcho: Boolean - ) { + ): Boolean { if (event.senderId.isNullOrEmpty() || isLocalEcho) { - return + return false } if (relatedEventId.isNullOrEmpty()) { Timber.w("no related event id found for the live location content") - return + return false } val aggregatedSummary = LiveLocationShareAggregatedSummaryEntity.getOrCreate( @@ -139,9 +151,12 @@ internal class LiveLocationAggregationProcessor @Inject constructor( ?.getBestTimestampMillis() ?: 0 - if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) { + return if (updatedLocationTimestamp.isMoreRecentThan(currentLocationTimestamp)) { Timber.d("updating last location of the summary of id=$relatedEventId") aggregatedSummary.lastLocationContent = ContentMapper.map(content.toContent()) + true + } else { + false } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/CheckIfExistingActiveLiveTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/CheckIfExistingActiveLiveTask.kt new file mode 100644 index 0000000000..228a046f53 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/CheckIfExistingActiveLiveTask.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface CheckIfExistingActiveLiveTask : Task { + data class Params( + val roomId: String, + ) +} + +internal class DefaultCheckIfExistingActiveLiveTask @Inject constructor( + private val getActiveBeaconInfoForUserTask: GetActiveBeaconInfoForUserTask, +) : CheckIfExistingActiveLiveTask { + + override suspend fun execute(params: CheckIfExistingActiveLiveTask.Params): Boolean { + val getActiveBeaconTaskParams = GetActiveBeaconInfoForUserTask.Params( + roomId = params.roomId + ) + return getActiveBeaconInfoForUserTask.execute(getActiveBeaconTaskParams) + ?.getClearContent() + ?.toModel() + ?.isLive + .orFalse() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt index 8cf6fcdfbf..20320cad23 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt @@ -17,21 +17,31 @@ package org.matrix.android.sdk.internal.session.room.location import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import com.zhuinden.monarchy.Monarchy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import org.matrix.android.sdk.api.session.room.location.LocationSharingService +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.mapper.LiveLocationShareAggregatedSummaryMapper import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.query.findRunningLiveInRoom +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase -// TODO add unit tests internal class DefaultLocationSharingService @AssistedInject constructor( @Assisted private val roomId: String, @SessionDatabase private val monarchy: Monarchy, + private val sendStaticLocationTask: SendStaticLocationTask, + private val sendLiveLocationTask: SendLiveLocationTask, + private val startLiveLocationShareTask: StartLiveLocationShareTask, + private val stopLiveLocationShareTask: StopLiveLocationShareTask, + private val checkIfExistingActiveLiveTask: CheckIfExistingActiveLiveTask, private val liveLocationShareAggregatedSummaryMapper: LiveLocationShareAggregatedSummaryMapper, ) : LocationSharingService { @@ -40,10 +50,72 @@ internal class DefaultLocationSharingService @AssistedInject constructor( fun create(roomId: String): DefaultLocationSharingService } + override suspend fun sendStaticLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable { + val params = SendStaticLocationTask.Params( + roomId = roomId, + latitude = latitude, + longitude = longitude, + uncertainty = uncertainty, + isUserLocation = isUserLocation, + ) + return sendStaticLocationTask.execute(params) + } + + override suspend fun sendLiveLocation(beaconInfoEventId: String, latitude: Double, longitude: Double, uncertainty: Double?): Cancelable { + val params = SendLiveLocationTask.Params( + beaconInfoEventId = beaconInfoEventId, + roomId = roomId, + latitude = latitude, + longitude = longitude, + uncertainty = uncertainty, + ) + return sendLiveLocationTask.execute(params) + } + + override suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult { + // Ensure to stop any active live before starting a new one + if (checkIfExistingActiveLive()) { + val result = stopLiveLocationShare() + if (result is UpdateLiveLocationShareResult.Failure) { + return result + } + } + val params = StartLiveLocationShareTask.Params( + roomId = roomId, + timeoutMillis = timeoutMillis + ) + return startLiveLocationShareTask.execute(params) + } + + private suspend fun checkIfExistingActiveLive(): Boolean { + val params = CheckIfExistingActiveLiveTask.Params( + roomId = roomId + ) + return checkIfExistingActiveLiveTask.execute(params) + } + + override suspend fun stopLiveLocationShare(): UpdateLiveLocationShareResult { + val params = StopLiveLocationShareTask.Params( + roomId = roomId, + ) + return stopLiveLocationShareTask.execute(params) + } + override fun getRunningLiveLocationShareSummaries(): LiveData> { return monarchy.findAllMappedWithChanges( { LiveLocationShareAggregatedSummaryEntity.findRunningLiveInRoom(it, roomId = roomId) }, - { liveLocationShareAggregatedSummaryMapper.map(it) } + liveLocationShareAggregatedSummaryMapper ) } + + override fun getLiveLocationShareSummary(beaconInfoEventId: String): LiveData> { + return Transformations.map( + monarchy.findAllMappedWithChanges( + { LiveLocationShareAggregatedSummaryEntity.where(it, roomId = roomId, eventId = beaconInfoEventId) }, + liveLocationShareAggregatedSummaryMapper + ) + ) { + it.firstOrNull().toOptional() + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt new file mode 100644 index 0000000000..a8d955af1d --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.query.QueryStringValue +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.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface GetActiveBeaconInfoForUserTask : Task { + data class Params( + val roomId: String, + ) +} + +internal class DefaultGetActiveBeaconInfoForUserTask @Inject constructor( + @UserId private val userId: String, + private val stateEventDataSource: StateEventDataSource, +) : GetActiveBeaconInfoForUserTask { + + override suspend fun execute(params: GetActiveBeaconInfoForUserTask.Params): Event? { + return EventType.STATE_ROOM_BEACON_INFO + .mapNotNull { + stateEventDataSource.getStateEvent( + roomId = params.roomId, + eventType = it, + stateKey = QueryStringValue.Equals(userId) + ) + } + .firstOrNull { beaconInfoEvent -> + beaconInfoEvent.getClearContent()?.toModel()?.isLive.orFalse() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/SendLiveLocationTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/SendLiveLocationTask.kt new file mode 100644 index 0000000000..bebd9c774a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/SendLiveLocationTask.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory +import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface SendLiveLocationTask : Task { + data class Params( + val roomId: String, + val beaconInfoEventId: String, + val latitude: Double, + val longitude: Double, + val uncertainty: Double?, + ) +} + +internal class DefaultSendLiveLocationTask @Inject constructor( + private val localEchoEventFactory: LocalEchoEventFactory, + private val eventSenderProcessor: EventSenderProcessor, +) : SendLiveLocationTask { + + override suspend fun execute(params: SendLiveLocationTask.Params): Cancelable { + val event = localEchoEventFactory.createLiveLocationEvent( + beaconInfoEventId = params.beaconInfoEventId, + roomId = params.roomId, + latitude = params.latitude, + longitude = params.longitude, + uncertainty = params.uncertainty, + ) + localEchoEventFactory.createLocalEcho(event) + return eventSenderProcessor.postEvent(event) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/SendStaticLocationTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/SendStaticLocationTask.kt new file mode 100644 index 0000000000..e08b82f3d4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/SendStaticLocationTask.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory +import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface SendStaticLocationTask : Task { + data class Params( + val roomId: String, + val latitude: Double, + val longitude: Double, + val uncertainty: Double?, + val isUserLocation: Boolean + ) +} + +internal class DefaultSendStaticLocationTask @Inject constructor( + private val localEchoEventFactory: LocalEchoEventFactory, + private val eventSenderProcessor: EventSenderProcessor, +) : SendStaticLocationTask { + + override suspend fun execute(params: SendStaticLocationTask.Params): Cancelable { + val event = localEchoEventFactory.createStaticLocationEvent( + roomId = params.roomId, + latitude = params.latitude, + longitude = params.longitude, + uncertainty = params.uncertainty, + isUserLocation = params.isUserLocation + ) + localEchoEventFactory.createLocalEcho(event) + return eventSenderProcessor.postEvent(event) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt new file mode 100644 index 0000000000..b943c27977 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.state.SendStateTask +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.time.Clock +import javax.inject.Inject + +internal interface StartLiveLocationShareTask : Task { + data class Params( + val roomId: String, + val timeoutMillis: Long, + ) +} + +internal class DefaultStartLiveLocationShareTask @Inject constructor( + @UserId private val userId: String, + private val clock: Clock, + private val sendStateTask: SendStateTask, +) : StartLiveLocationShareTask { + + override suspend fun execute(params: StartLiveLocationShareTask.Params): UpdateLiveLocationShareResult { + val beaconContent = MessageBeaconInfoContent( + timeout = params.timeoutMillis, + isLive = true, + unstableTimestampMillis = clock.epochMillis() + ).toContent() + val eventType = EventType.STATE_ROOM_BEACON_INFO.first() + val sendStateTaskParams = SendStateTask.Params( + roomId = params.roomId, + stateKey = userId, + eventType = eventType, + body = beaconContent + ) + return try { + val eventId = sendStateTask.executeRetry(sendStateTaskParams, 3) + if (eventId.isNotEmpty()) { + UpdateLiveLocationShareResult.Success(eventId) + } else { + UpdateLiveLocationShareResult.Failure(Exception("empty event id for new state event")) + } + } catch (error: Throwable) { + UpdateLiveLocationShareResult.Failure(error) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt new file mode 100644 index 0000000000..da5fd76940 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +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.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.internal.session.room.state.SendStateTask +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface StopLiveLocationShareTask : Task { + data class Params( + val roomId: String, + ) +} + +internal class DefaultStopLiveLocationShareTask @Inject constructor( + private val sendStateTask: SendStateTask, + private val getActiveBeaconInfoForUserTask: GetActiveBeaconInfoForUserTask, +) : StopLiveLocationShareTask { + + override suspend fun execute(params: StopLiveLocationShareTask.Params): UpdateLiveLocationShareResult { + val beaconInfoStateEvent = getActiveLiveLocationBeaconInfoForUser(params.roomId) ?: return getResultForIncorrectBeaconInfoEvent() + val stateKey = beaconInfoStateEvent.stateKey ?: return getResultForIncorrectBeaconInfoEvent() + val content = beaconInfoStateEvent.getClearContent()?.toModel() ?: return getResultForIncorrectBeaconInfoEvent() + val updatedContent = content.copy(isLive = false).toContent() + val sendStateTaskParams = SendStateTask.Params( + roomId = params.roomId, + stateKey = stateKey, + eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + body = updatedContent + ) + return try { + val eventId = sendStateTask.executeRetry(sendStateTaskParams, 3) + if (eventId.isNotEmpty()) { + UpdateLiveLocationShareResult.Success(eventId) + } else { + UpdateLiveLocationShareResult.Failure(Exception("empty event id for new state event")) + } + } catch (error: Throwable) { + UpdateLiveLocationShareResult.Failure(error) + } + } + + private fun getResultForIncorrectBeaconInfoEvent() = + UpdateLiveLocationShareResult.Failure(Exception("incorrect last beacon info event")) + + private suspend fun getActiveLiveLocationBeaconInfoForUser(roomId: String): Event? { + val params = GetActiveBeaconInfoForUserTask.Params( + roomId = roomId + ) + return getActiveBeaconInfoForUserTask.execute(params) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt index 005d7f26db..ef89ca33a7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/DefaultMembershipService.kt @@ -58,7 +58,7 @@ internal class DefaultMembershipService @AssistedInject constructor( } override suspend fun loadRoomMembersIfNeeded() { - val params = LoadRoomMembersTask.Params(roomId, Membership.LEAVE) + val params = LoadRoomMembersTask.Params(roomId, excludeMembership = Membership.LEAVE) loadRoomMembersTask.execute(params) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/LoadRoomMembersTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/LoadRoomMembersTask.kt index 15d0889255..7052eb23e2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/LoadRoomMembersTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/LoadRoomMembersTask.kt @@ -43,6 +43,7 @@ import org.matrix.android.sdk.internal.session.sync.SyncTokenStore import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.util.awaitTransaction import org.matrix.android.sdk.internal.util.time.Clock +import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -105,32 +106,37 @@ internal class DefaultLoadRoomMembersTask @Inject constructor( } private suspend fun insertInDb(response: RoomMembersResponse, roomId: String) { + val chunks = response.roomMemberEvents.chunked(500) + chunks.forEach { roomMemberEvents -> + monarchy.awaitTransaction { realm -> + Timber.v("Insert ${roomMemberEvents.size} member events in room $roomId") + // We ignore all the already known members + val now = clock.epochMillis() + for (roomMemberEvent in roomMemberEvents) { + if (roomMemberEvent.eventId == null || roomMemberEvent.stateKey == null || roomMemberEvent.type == null) { + continue + } + val ageLocalTs = now - (roomMemberEvent.unsignedData?.age ?: 0) + val eventEntity = roomMemberEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + CurrentStateEventEntity.getOrCreate( + realm, + roomId, + roomMemberEvent.stateKey, + roomMemberEvent.type + ).apply { + eventId = roomMemberEvent.eventId + root = eventEntity + } + roomMemberEventHandler.handle(realm, roomId, roomMemberEvent, false) + } + } + } monarchy.awaitTransaction { realm -> - // We ignore all the already known members val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: realm.createObject(roomId) - val now = clock.epochMillis() - for (roomMemberEvent in response.roomMemberEvents) { - if (roomMemberEvent.eventId == null || roomMemberEvent.stateKey == null || roomMemberEvent.type == null) { - continue - } - val ageLocalTs = roomMemberEvent.unsignedData?.age?.let { now - it } - val eventEntity = roomMemberEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) - CurrentStateEventEntity.getOrCreate( - realm, - roomId, - roomMemberEvent.stateKey, - roomMemberEvent.type - ).apply { - eventId = roomMemberEvent.eventId - root = eventEntity - } - roomMemberEventHandler.handle(realm, roomId, roomMemberEvent, false) - } roomEntity.membersLoadStatus = RoomMembersLoadStatusType.LOADED roomSummaryUpdater.update(realm, roomId, updateMembers = true) } - if (cryptoSessionInfoProvider.isRoomEncrypted(roomId)) { deviceListManager.onRoomMembersLoadedFor(roomId) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/RoomMemberEventHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/RoomMemberEventHandler.kt index 1e36e9c6da..fd6552525e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/RoomMemberEventHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/RoomMemberEventHandler.kt @@ -83,23 +83,21 @@ internal class RoomMemberEventHandler @Inject constructor( userId: String, roomMember: RoomMemberContent ) { - val roomMemberEntity = RoomMemberEntityFactory.create( - roomId, - userId, - roomMember, - // When an update is happening, insertOrUpdate replace existing values with null if they are not provided, - // but we want to preserve presence record value and not replace it with null - getExistingPresenceState(realm, roomId, userId) - ) - realm.insertOrUpdate(roomMemberEntity) - } - - /** - * Get the already existing presence state for a specific user & room in order NOT to be replaced in RoomMemberSummaryEntity - * by NULL value. - */ - private fun getExistingPresenceState(realm: Realm, roomId: String, userId: String): UserPresenceEntity? { - return RoomMemberSummaryEntity.where(realm, roomId, userId).findFirst()?.userPresenceEntity + val existingRoomMemberSummary = RoomMemberSummaryEntity.where(realm, roomId, userId).findFirst() + if (existingRoomMemberSummary != null) { + existingRoomMemberSummary.displayName = roomMember.displayName + existingRoomMemberSummary.avatarUrl = roomMember.avatarUrl + existingRoomMemberSummary.membership = roomMember.membership + } else { + val presenceEntity = UserPresenceEntity.where(realm, userId).findFirst() + val roomMemberEntity = RoomMemberEntityFactory.create( + roomId, + userId, + roomMember, + presenceEntity + ) + realm.insert(roomMemberEntity) + } } private fun saveUserEntityLocallyIfNecessary( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt index bad734173e..bac810f424 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt @@ -209,7 +209,8 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor( * Create an EventEntity to be added in the TimelineEventEntity. */ private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity { - val ageLocalTs = event.unsignedData?.age?.let { clock.epochMillis() - it } + val now = clock.epochMillis() + val ageLocalTs = now - (event.unsignedData?.age ?: 0) return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index fc78abcfd9..418000abed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -129,18 +129,6 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable { - return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty, isUserLocation) - .also { createLocalEcho(it) } - .let { sendEvent(it) } - } - - override fun sendLiveLocation(beaconInfoEventId: String, latitude: Double, longitude: Double, uncertainty: Double?): Cancelable { - return localEchoEventFactory.createLiveLocationEvent(beaconInfoEventId, roomId, latitude, longitude, uncertainty) - .also { createLocalEcho(it) } - .let { sendEvent(it) } - } - override fun redactEvent(event: Event, reason: String?): Cancelable { // TODO manage media/attachements? val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 3b9ca44d18..f52500de1b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -244,7 +244,7 @@ internal class LocalEchoEventFactory @Inject constructor( ) } - fun createLocationEvent( + fun createStaticLocationEvent( roomId: String, latitude: Double, longitude: Double, @@ -708,7 +708,7 @@ internal class LocalEchoEventFactory @Inject constructor( } /** - * Returns RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' + * Returns RFC5870 formatted geo uri 'geo:latitude,longitude;u=uncertainty' like 'geo:40.05,29.24;u=30' * Uncertainty of the location is in meters and not required. */ private fun buildGeoUri(latitude: Double, longitude: Double, uncertainty: Double?): String { @@ -718,7 +718,7 @@ internal class LocalEchoEventFactory @Inject constructor( append(",") append(longitude) uncertainty?.let { - append(";") + append(";u=") append(it) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt index c15bcb1c1a..ad47b82428 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt @@ -21,33 +21,27 @@ import androidx.lifecycle.LiveData import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStateEventValue -import org.matrix.android.sdk.api.query.QueryStringValue 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.toContent -import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomJoinRules import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesAllowEntry import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.session.content.FileUploader -import org.matrix.android.sdk.internal.session.permalinks.ViaParameterFinder internal class DefaultStateService @AssistedInject constructor( @Assisted private val roomId: String, private val stateEventDataSource: StateEventDataSource, private val sendStateTask: SendStateTask, private val fileUploader: FileUploader, - private val viaParameterFinder: ViaParameterFinder ) : StateService { @AssistedFactory @@ -191,35 +185,4 @@ internal class DefaultStateService @AssistedInject constructor( } updateJoinRule(RoomJoinRules.RESTRICTED, null, allowEntries) } - - override suspend fun stopLiveLocation(userId: String) { - getLiveLocationBeaconInfo(userId, true)?.let { beaconInfoStateEvent -> - beaconInfoStateEvent.getClearContent()?.toModel()?.let { content -> - val updatedContent = content.copy(isLive = false).toContent() - - beaconInfoStateEvent.stateKey?.let { - sendStateEvent( - eventType = EventType.STATE_ROOM_BEACON_INFO.first(), - body = updatedContent, - stateKey = it - ) - } - } - } - } - - override suspend fun getLiveLocationBeaconInfo(userId: String, filterOnlyLive: Boolean): Event? { - return EventType.STATE_ROOM_BEACON_INFO - .mapNotNull { - stateEventDataSource.getStateEvent( - roomId = roomId, - eventType = it, - stateKey = QueryStringValue.Equals(userId) - ) - } - .firstOrNull { beaconInfoEvent -> - !filterOnlyLive || - beaconInfoEvent.getClearContent()?.toModel()?.isLive.orFalse() - } - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index 05379a1a7b..6e72cbdb8f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.timeline import io.realm.Realm import io.realm.RealmConfiguration +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher @@ -32,6 +33,7 @@ import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent @@ -236,11 +238,15 @@ internal class DefaultTimeline( val loadMoreResult = try { strategy.loadMore(count, direction, fetchOnServerIfNeeded) } catch (throwable: Throwable) { - // Timeline could not be loaded with a (likely) permanent issue, such as the - // server now knowing the initialEventId, so we want to show an error message - // and possibly restart without initialEventId. - onTimelineFailure(throwable) - return false + if (throwable is CancellationException) { + LoadMoreResult.FAILURE + } else { + // Timeline could not be loaded with a (likely) permanent issue, such as the + // server now knowing the initialEventId, so we want to show an error message + // and possibly restart without initialEventId. + onTimelineFailure(throwable) + return false + } } Timber.v("$baseLogMessage: result $loadMoreResult") val hasMoreToLoad = loadMoreResult != LoadMoreResult.REACHED_END @@ -385,7 +391,7 @@ internal class DefaultTimeline( private suspend fun loadRoomMembersIfNeeded() { if (RoomLocalEcho.isLocalEchoId(roomId)) return - val loadRoomMembersParam = LoadRoomMembersTask.Params(roomId) + val loadRoomMembersParam = LoadRoomMembersTask.Params(roomId, excludeMembership = Membership.LEAVE) try { loadRoomMembersTask.execute(loadRoomMembersParam) } catch (failure: Throwable) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt index aef9e24c8b..7c662444e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt @@ -61,7 +61,7 @@ internal class DefaultGetEventTask @Inject constructor( } } - event.ageLocalTs = event.unsignedData?.age?.let { clock.epochMillis() - it } + event.ageLocalTs = clock.epochMillis() - (event.unsignedData?.age ?: 0) return event } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt index fd76d5ae28..e13f3f454f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -70,6 +70,7 @@ internal class TimelineChunk( private val isLastForward = AtomicBoolean(chunkEntity.isLastForward) private val isLastBackward = AtomicBoolean(chunkEntity.isLastBackward) + private val nextToken = chunkEntity.nextToken private var prevChunkLatch: CompletableDeferred? = null private var nextChunkLatch: CompletableDeferred? = null @@ -136,8 +137,10 @@ internal class TimelineChunk( val prevEvents = prevChunk?.builtItems(includesNext = false, includesPrev = true).orEmpty() deepBuiltItems.addAll(prevEvents) } - - return deepBuiltItems + // In some scenario (permalink) we might end up with duplicate timeline events, so we want to be sure we only expose one. + return deepBuiltItems.distinctBy { + it.eventId + } } /** @@ -154,10 +157,6 @@ internal class TimelineChunk( val loadFromStorage = loadFromStorage(count, direction).also { logLoadedFromStorage(it, direction) } - if (loadFromStorage.numberOfEvents == 6) { - Timber.i("here") - } - val offsetCount = count - loadFromStorage.numberOfEvents return if (offsetCount == 0) { @@ -251,10 +250,6 @@ internal class TimelineChunk( } fun getBuiltEventIndex(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): Int? { - val builtEventIndex = builtEventsIndexes[eventId] - if (builtEventIndex != null) { - return getOffsetIndex() + builtEventIndex - } if (searchInNext) { val nextBuiltEventIndex = nextChunk?.getBuiltEventIndex(eventId, searchInNext = true, searchInPrev = false) if (nextBuiltEventIndex != null) { @@ -267,7 +262,12 @@ internal class TimelineChunk( return prevBuiltEventIndex } } - return null + val builtEventIndex = builtEventsIndexes[eventId] + return if (builtEventIndex != null) { + getOffsetIndex() + builtEventIndex + } else { + null + } } fun getBuiltEvent(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): TimelineEvent? { @@ -445,7 +445,7 @@ internal class TimelineChunk( Timber.e(failure, "Failed to fetch from server") LoadMoreResult.FAILURE } - return if (loadMoreResult == LoadMoreResult.SUCCESS) { + return if (loadMoreResult != LoadMoreResult.FAILURE) { latch?.await() loadMore(count, direction, fetchOnServerIfNeeded = false) } else { @@ -470,11 +470,15 @@ internal class TimelineChunk( } private fun getOffsetIndex(): Int { + if (nextToken == null) return 0 var offset = 0 var currentNextChunk = nextChunk while (currentNextChunk != null) { offset += currentNextChunk.builtEvents.size - currentNextChunk = currentNextChunk.nextChunk + currentNextChunk = currentNextChunk.nextChunk?.takeIf { + // In case of permalink we can end up with a linked nextChunk (which is the lastForward Chunk) but no nextToken + it.nextToken != null + } } return offset } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt index b9aca7d37b..b1b9e4bb22 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDataSource.kt @@ -56,7 +56,8 @@ internal class TimelineEventDataSource @Inject constructor( // TODO pretty bad query.. maybe we should denormalize clear type in base? return realmSessionProvider.withRealm { realm -> TimelineEventEntity.whereRoomId(realm, roomId) - .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + .sort(TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS, Sort.ASCENDING) + .distinct(TimelineEventEntityFields.EVENT_ID) .findAll() ?.mapNotNull { timelineEventMapper.map(it).takeIf { it.root.isImageMessage() || it.root.isVideoMessage() } } .orEmpty() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEvent.kt index 465b0faac8..e9626a2173 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEvent.kt @@ -24,5 +24,5 @@ internal interface TokenChunkEvent { val events: List val stateEvents: List? - fun hasMore() = start != end + fun hasMore() = end != null && start != end } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index da73727065..ea22f8cd78 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -33,12 +33,10 @@ import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.find -import org.matrix.android.sdk.internal.database.query.findAll +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.UserId @@ -83,27 +81,22 @@ internal class TokenChunkEventPersistor @Inject constructor( nextToken = receivedChunk.start prevToken = receivedChunk.end } - val existingChunk = ChunkEntity.find(realm, roomId, prevToken = prevToken, nextToken = nextToken) if (existingChunk != null) { - Timber.v("This chunk is already in the db, checking if this might be caused by broken links") - existingChunk.fixChunkLinks(realm, roomId, direction, prevToken, nextToken) + Timber.v("This chunk is already in the db, return.") return@awaitTransaction } + + // Creates links in both directions val prevChunk = ChunkEntity.find(realm, roomId, nextToken = prevToken) val nextChunk = ChunkEntity.find(realm, roomId, prevToken = nextToken) val currentChunk = ChunkEntity.create(realm, prevToken = prevToken, nextToken = nextToken).apply { this.nextChunk = nextChunk this.prevChunk = prevChunk } - val allNextChunks = ChunkEntity.findAll(realm, roomId, prevToken = nextToken) - val allPrevChunks = ChunkEntity.findAll(realm, roomId, nextToken = prevToken) - allNextChunks?.forEach { - it.prevChunk = currentChunk - } - allPrevChunks?.forEach { - it.nextChunk = currentChunk - } + nextChunk?.prevChunk = currentChunk + prevChunk?.nextChunk = currentChunk + if (receivedChunk.events.isEmpty() && !receivedChunk.hasMore()) { handleReachEnd(roomId, direction, currentChunk) } else { @@ -122,38 +115,13 @@ internal class TokenChunkEventPersistor @Inject constructor( } } - private fun ChunkEntity.fixChunkLinks( - realm: Realm, - roomId: String, - direction: PaginationDirection, - prevToken: String?, - nextToken: String?, - ) { - if (direction == PaginationDirection.FORWARDS) { - val prevChunks = ChunkEntity.findAll(realm, roomId, nextToken = prevToken) - Timber.v("Found ${prevChunks?.size} prevChunks") - prevChunks?.forEach { - if (it.nextChunk != this) { - Timber.i("Set nextChunk for ${it.identifier()} from ${it.nextChunk?.identifier()} to ${identifier()}") - it.nextChunk = this - } - } - } else { - val nextChunks = ChunkEntity.findAll(realm, roomId, prevToken = nextToken) - Timber.v("Found ${nextChunks?.size} nextChunks") - nextChunks?.forEach { - if (it.prevChunk != this) { - Timber.i("Set prevChunk for ${it.identifier()} from ${it.prevChunk?.identifier()} to ${identifier()}") - it.prevChunk = this - } - } - } - } - private fun handleReachEnd(roomId: String, direction: PaginationDirection, currentChunk: ChunkEntity) { - Timber.v("Reach end of $roomId") + Timber.v("Reach end of $roomId in $direction") if (direction == PaginationDirection.FORWARDS) { - Timber.v("We should keep the lastForward chunk unique, the one from sync") + // We should keep the lastForward chunk unique, the one from sync, so make an unidirectional link. + // This will allow us to get live events from sync even from a permalink but won't make the link in the opposite. + val realm = currentChunk.realm + currentChunk.nextChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) } else { currentChunk.isLastBackward = true } @@ -174,7 +142,7 @@ internal class TokenChunkEventPersistor @Inject constructor( val now = clock.epochMillis() stateEvents?.forEach { stateEvent -> - val ageLocalTs = stateEvent.unsignedData?.age?.let { now - it } + val ageLocalTs = now - (stateEvent.unsignedData?.age ?: 0) val stateEventEntity = stateEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) currentChunk.addStateEvent(roomId, stateEventEntity, direction) if (stateEvent.type == EventType.STATE_ROOM_MEMBER && stateEvent.stateKey != null) { @@ -187,38 +155,8 @@ internal class TokenChunkEventPersistor @Inject constructor( if (event.eventId == null || event.senderId == null) { return@forEach } - // We check for the timeline event with this id, but not in the thread chunk - val eventId = event.eventId - val existingTimelineEvent = TimelineEventEntity - .where(realm, roomId, eventId) - .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) - .findFirst() - // If it exists, we want to stop here, just link the prevChunk - val existingChunk = existingTimelineEvent?.chunk?.firstOrNull() - if (existingChunk != null) { - when (direction) { - PaginationDirection.BACKWARDS -> { - if (currentChunk.nextChunk == existingChunk) { - Timber.w("Avoid double link, shouldn't happen in an ideal world") - } else { - currentChunk.prevChunk = existingChunk - existingChunk.nextChunk = currentChunk - } - } - PaginationDirection.FORWARDS -> { - if (currentChunk.prevChunk == existingChunk) { - Timber.w("Avoid double link, shouldn't happen in an ideal world") - } else { - currentChunk.nextChunk = existingChunk - existingChunk.prevChunk = currentChunk - } - } - } - // Stop processing here - return@processTimelineEvents - } - val ageLocalTs = event.unsignedData?.age?.let { now - it } - var eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + val ageLocalTs = now - (event.unsignedData?.age ?: 0) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) { val contentToUse = if (direction == PaginationDirection.BACKWARDS) { event.prevContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index c854587853..30e1ec6679 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -57,6 +57,7 @@ import org.matrix.android.sdk.internal.database.model.deleteOnCascade import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.find +import org.matrix.android.sdk.internal.database.query.findAll import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.getOrCreate @@ -243,7 +244,7 @@ internal class RoomSyncHandler @Inject constructor( if (event.eventId == null || event.stateKey == null || event.type == null) { continue } - val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } + val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0) val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) Timber.v("## received state event ${event.type} and key ${event.stateKey}") CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { @@ -305,7 +306,7 @@ internal class RoomSyncHandler @Inject constructor( if (event.stateKey == null || event.type == null) { return@forEach } - val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } + val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0) val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = eventEntity.eventId @@ -335,7 +336,7 @@ internal class RoomSyncHandler @Inject constructor( if (event.eventId == null || event.stateKey == null || event.type == null) { continue } - val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } + val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0) val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId @@ -347,7 +348,7 @@ internal class RoomSyncHandler @Inject constructor( if (event.eventId == null || event.senderId == null || event.type == null) { continue } - val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } + val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0) val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { @@ -381,12 +382,13 @@ internal class RoomSyncHandler @Inject constructor( aggregator: SyncResponsePostTreatmentAggregator ): ChunkEntity { val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) - if (isLimited && lastChunk != null) { - lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true) - } val chunkEntity = if (!isLimited && lastChunk != null) { lastChunk } else { + // Delete all chunks of the room in case of gap. + ChunkEntity.findAll(realm, roomId).forEach { + it.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true) + } realm.createObject().apply { this.prevToken = prevToken this.isLastForward = true @@ -399,7 +401,10 @@ internal class RoomSyncHandler @Inject constructor( for (rawEvent in eventList) { // It's annoying roomId is not there, but lot of code rely on it. // And had to do it now as copy would delete all decryption results.. - val event = rawEvent.copy(roomId = roomId) + val ageLocalTs = syncLocalTimestampMillis - (rawEvent.unsignedData?.age ?: 0) + val event = rawEvent.copy(roomId = roomId).also { + it.ageLocalTs = ageLocalTs + } if (event.eventId == null || event.senderId == null || event.type == null) { continue } @@ -421,7 +426,6 @@ internal class RoomSyncHandler @Inject constructor( contentToInject = threadsAwarenessHandler.makeEventThreadAware(realm, roomId, event) } - val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs, contentToInject).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt index 8c7557a5b8..70553359ff 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt @@ -53,6 +53,7 @@ import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.timeline.GetEventTask import org.matrix.android.sdk.internal.util.awaitTransaction +import org.matrix.android.sdk.internal.util.time.Clock import javax.inject.Inject /** @@ -64,7 +65,8 @@ internal class ThreadsAwarenessHandler @Inject constructor( private val permalinkFactory: PermalinkFactory, @SessionDatabase private val monarchy: Monarchy, private val lightweightSettingsStorage: LightweightSettingsStorage, - private val getEventTask: GetEventTask + private val getEventTask: GetEventTask, + private val clock: Clock, ) { // This caching is responsible to improve the performance when we receive a root event @@ -120,7 +122,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( private suspend fun fetchThreadsEvents(threadsToFetch: Map) { val eventEntityList = threadsToFetch.mapNotNull { (eventId, roomId) -> fetchEvent(eventId, roomId)?.let { - it.toEntity(roomId, SendState.SYNCED, it.ageLocalTs) + it.toEntity(roomId, SendState.SYNCED, it.ageLocalTs ?: clock.epochMillis()) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/SystemModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/SystemModule.kt index 396d12f369..8c7d7704ed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/SystemModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/system/SystemModule.kt @@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.util.system import dagger.Binds import dagger.Module +import org.matrix.android.sdk.api.securestorage.SecureStorageService +import org.matrix.android.sdk.internal.securestorage.DefaultSecureStorageService import org.matrix.android.sdk.internal.util.time.Clock import org.matrix.android.sdk.internal.util.time.DefaultClock @@ -25,7 +27,7 @@ import org.matrix.android.sdk.internal.util.time.DefaultClock internal abstract class SystemModule { @Binds - abstract fun bindBuildVersionSdkIntProvider(provider: DefaultBuildVersionSdkIntProvider): BuildVersionSdkIntProvider + abstract fun bindSecureStorageService(service: DefaultSecureStorageService): SecureStorageService @Binds abstract fun bindClock(clock: DefaultClock): Clock diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt index 32b1d44fb9..dac33069f3 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/pushers/DefaultAddPusherTaskTest.kt @@ -23,10 +23,12 @@ import org.amshove.kluent.shouldBeEqualTo import org.junit.Test import org.matrix.android.sdk.api.session.pushers.PusherState import org.matrix.android.sdk.internal.database.model.PusherEntity +import org.matrix.android.sdk.internal.database.model.PusherEntityFields import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver import org.matrix.android.sdk.test.fakes.FakeMonarchy import org.matrix.android.sdk.test.fakes.FakePushersAPI import org.matrix.android.sdk.test.fakes.FakeRequestExecutor +import org.matrix.android.sdk.test.fakes.givenEqualTo import java.net.SocketException private val A_JSON_PUSHER = JsonPusher( @@ -56,6 +58,7 @@ class DefaultAddPusherTaskTest { @Test fun `given no persisted pusher when adding Pusher then updates api and inserts result with Registered state`() { monarchy.givenWhereReturns(result = null) + .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) runTest { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } @@ -71,6 +74,7 @@ class DefaultAddPusherTaskTest { fun `given a persisted pusher when adding Pusher then updates api and mutates persisted result with Registered state`() { val realmResult = PusherEntity(appDisplayName = null) monarchy.givenWhereReturns(result = realmResult) + .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) runTest { addPusherTask.execute(AddPusherTask.Params(A_JSON_PUSHER)) } @@ -84,6 +88,7 @@ class DefaultAddPusherTaskTest { fun `given a persisted push entity and SetPush API fails when adding Pusher then mutates persisted result with Failed registration state and rethrows`() { val realmResult = PusherEntity() monarchy.givenWhereReturns(result = realmResult) + .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) pushersAPI.givenSetPusherErrors(SocketException()) assertFailsWith { @@ -96,6 +101,7 @@ class DefaultAddPusherTaskTest { @Test fun `given no persisted push entity and SetPush API fails when adding Pusher then rethrows error`() { monarchy.givenWhereReturns(result = null) + .givenEqualTo(PusherEntityFields.PUSH_KEY, A_JSON_PUSHER.pushKey) pushersAPI.givenSetPusherErrors(SocketException()) assertFailsWith { diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessorTest.kt new file mode 100644 index 0000000000..933087af2b --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/livelocation/LiveLocationAggregationProcessorTest.kt @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.aggregation.livelocation + +import androidx.work.ExistingWorkPolicy +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.UnsignedData +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.LocationInfo +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent +import org.matrix.android.sdk.internal.database.mapper.ContentMapper +import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields +import org.matrix.android.sdk.test.fakes.FakeClock +import org.matrix.android.sdk.test.fakes.FakeRealm +import org.matrix.android.sdk.test.fakes.FakeWorkManagerProvider +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenFindAll +import org.matrix.android.sdk.test.fakes.givenFindFirst +import org.matrix.android.sdk.test.fakes.givenNotEqualTo + +private const val A_SESSION_ID = "session_id" +private const val A_SENDER_ID = "sender_id" +private const val AN_EVENT_ID = "event_id" +private const val A_ROOM_ID = "room_id" +private const val A_TIMESTAMP = 1654689143L +private const val A_TIMEOUT_MILLIS = 15 * 60 * 1000L +private const val A_LATITUDE = 40.05 +private const val A_LONGITUDE = 29.24 +private const val A_UNCERTAINTY = 30.0 +private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;u=$A_UNCERTAINTY" + +internal class LiveLocationAggregationProcessorTest { + + private val fakeWorkManagerProvider = FakeWorkManagerProvider() + private val fakeClock = FakeClock() + private val fakeRealm = FakeRealm() + private val fakeQuery = fakeRealm.givenWhere() + + private val liveLocationAggregationProcessor = LiveLocationAggregationProcessor( + sessionId = A_SESSION_ID, + workManagerProvider = fakeWorkManagerProvider.instance, + clock = fakeClock + ) + + @Test + fun `given beacon info when it is local echo then it is ignored`() { + val event = Event(senderId = A_SENDER_ID) + val beaconInfo = MessageBeaconInfoContent() + + val result = liveLocationAggregationProcessor.handleBeaconInfo( + realm = fakeRealm.instance, + event = event, + content = beaconInfo, + roomId = A_ROOM_ID, + isLocalEcho = true + ) + + result shouldBeEqualTo false + } + + private data class IgnoredBeaconInfoEvent( + val event: Event, + val beaconInfo: MessageBeaconInfoContent + ) + + @Test + fun `given beacon info and event when some values are missing then it is ignored`() { + val ignoredInfoEvents = listOf( + // missing senderId + IgnoredBeaconInfoEvent( + event = Event(eventId = AN_EVENT_ID, senderId = null), + beaconInfo = MessageBeaconInfoContent() + ), + // empty senderId + IgnoredBeaconInfoEvent( + event = Event(eventId = AN_EVENT_ID, senderId = ""), + beaconInfo = MessageBeaconInfoContent() + ), + // beacon is live and no eventId + IgnoredBeaconInfoEvent( + event = Event(eventId = null, senderId = A_SENDER_ID), + beaconInfo = MessageBeaconInfoContent(isLive = true) + ), + // beacon is live and eventId is empty + IgnoredBeaconInfoEvent( + event = Event(eventId = "", senderId = A_SENDER_ID), + beaconInfo = MessageBeaconInfoContent(isLive = true) + ), + // beacon is not live and replaced event id is null + IgnoredBeaconInfoEvent( + event = Event( + eventId = AN_EVENT_ID, + senderId = A_SENDER_ID, + unsignedData = UnsignedData( + age = 123, + replacesState = null + ) + ), + beaconInfo = MessageBeaconInfoContent(isLive = false) + ), + // beacon is not live and replaced event id is empty + IgnoredBeaconInfoEvent( + event = Event( + eventId = AN_EVENT_ID, + senderId = A_SENDER_ID, + unsignedData = UnsignedData( + age = 123, + replacesState = "" + ) + ), + beaconInfo = MessageBeaconInfoContent(isLive = false) + ), + ) + + ignoredInfoEvents.forEach { + val result = liveLocationAggregationProcessor.handleBeaconInfo( + realm = fakeRealm.instance, + event = it.event, + content = it.beaconInfo, + roomId = A_ROOM_ID, + isLocalEcho = false + ) + + result shouldBeEqualTo false + } + } + + @Test + fun `given beacon info and existing entity when beacon content is correct and active then it is aggregated`() { + val event = Event( + senderId = A_SENDER_ID, + eventId = AN_EVENT_ID + ) + val beaconInfo = MessageBeaconInfoContent( + isLive = true, + unstableTimestampMillis = A_TIMESTAMP, + timeout = A_TIMEOUT_MILLIS + ) + fakeClock.givenEpoch(A_TIMESTAMP + 5000) + fakeWorkManagerProvider.fakeWorkManager.expectEnqueueUniqueWork() + val aggregatedEntity = givenLastSummaryQueryReturns(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + val previousEntities = givenActiveSummaryListQueryReturns( + listOf( + LiveLocationShareAggregatedSummaryEntity( + eventId = "${AN_EVENT_ID}1", + roomId = A_ROOM_ID, + userId = A_SENDER_ID, + isActive = true + ) + ) + ) + + val result = liveLocationAggregationProcessor.handleBeaconInfo( + realm = fakeRealm.instance, + event = event, + content = beaconInfo, + roomId = A_ROOM_ID, + isLocalEcho = false + ) + + result shouldBeEqualTo true + aggregatedEntity.eventId shouldBeEqualTo AN_EVENT_ID + aggregatedEntity.roomId shouldBeEqualTo A_ROOM_ID + aggregatedEntity.userId shouldBeEqualTo A_SENDER_ID + aggregatedEntity.isActive shouldBeEqualTo true + aggregatedEntity.endOfLiveTimestampMillis shouldBeEqualTo A_TIMESTAMP + A_TIMEOUT_MILLIS + aggregatedEntity.lastLocationContent shouldBeEqualTo null + previousEntities.forEach { entity -> + entity.isActive shouldBeEqualTo false + } + fakeWorkManagerProvider.fakeWorkManager.verifyEnqueueUniqueWork( + workName = DeactivateLiveLocationShareWorker.getWorkName(eventId = AN_EVENT_ID, roomId = A_ROOM_ID), + policy = ExistingWorkPolicy.REPLACE + ) + } + + @Test + fun `given beacon info and existing entity when beacon content is correct and inactive then it is aggregated`() { + val unsignedData = UnsignedData( + age = 123, + replacesState = AN_EVENT_ID + ) + val event = Event( + senderId = A_SENDER_ID, + eventId = "", + unsignedData = unsignedData + ) + val beaconInfo = MessageBeaconInfoContent( + isLive = false, + unstableTimestampMillis = A_TIMESTAMP, + timeout = A_TIMEOUT_MILLIS + ) + fakeClock.givenEpoch(A_TIMESTAMP + 5000) + fakeWorkManagerProvider.fakeWorkManager.expectCancelUniqueWork() + val aggregatedEntity = givenLastSummaryQueryReturns(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + val previousEntities = givenActiveSummaryListQueryReturns( + listOf( + LiveLocationShareAggregatedSummaryEntity( + eventId = "${AN_EVENT_ID}1", + roomId = A_ROOM_ID, + userId = A_SENDER_ID, + isActive = true + ) + ) + + ) + + val result = liveLocationAggregationProcessor.handleBeaconInfo( + realm = fakeRealm.instance, + event = event, + content = beaconInfo, + roomId = A_ROOM_ID, + isLocalEcho = false + ) + + result shouldBeEqualTo true + aggregatedEntity.eventId shouldBeEqualTo AN_EVENT_ID + aggregatedEntity.roomId shouldBeEqualTo A_ROOM_ID + aggregatedEntity.userId shouldBeEqualTo A_SENDER_ID + aggregatedEntity.isActive shouldBeEqualTo false + aggregatedEntity.endOfLiveTimestampMillis shouldBeEqualTo A_TIMESTAMP + A_TIMEOUT_MILLIS + aggregatedEntity.lastLocationContent shouldBeEqualTo null + previousEntities.forEach { entity -> + entity.isActive shouldBeEqualTo false + } + fakeWorkManagerProvider.fakeWorkManager.verifyCancelUniqueWork( + workName = DeactivateLiveLocationShareWorker.getWorkName(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + ) + } + + @Test + fun `given beacon location data when it is local echo then it is ignored`() { + val event = Event(senderId = A_SENDER_ID) + val beaconLocationData = MessageBeaconLocationDataContent() + + val result = liveLocationAggregationProcessor.handleBeaconLocationData( + realm = fakeRealm.instance, + event = event, + content = beaconLocationData, + roomId = A_ROOM_ID, + relatedEventId = AN_EVENT_ID, + isLocalEcho = true + ) + + result shouldBeEqualTo false + } + + private data class IgnoredBeaconLocationDataEvent( + val event: Event, + val beaconLocationData: MessageBeaconLocationDataContent + ) + + @Test + fun `given event and beacon location data when some values are missing then it is ignored`() { + val ignoredLocationDataEvents = listOf( + // missing sender id + IgnoredBeaconLocationDataEvent( + event = Event(eventId = AN_EVENT_ID), + beaconLocationData = MessageBeaconLocationDataContent() + ), + // empty sender id + IgnoredBeaconLocationDataEvent( + event = Event(eventId = AN_EVENT_ID, senderId = ""), + beaconLocationData = MessageBeaconLocationDataContent() + ), + ) + + ignoredLocationDataEvents.forEach { + val result = liveLocationAggregationProcessor.handleBeaconLocationData( + realm = fakeRealm.instance, + event = it.event, + content = it.beaconLocationData, + roomId = A_ROOM_ID, + relatedEventId = "", + isLocalEcho = false + ) + result shouldBeEqualTo false + } + } + + @Test + fun `given beacon location data when relatedEventId is null or empty then it is ignored`() { + val event = Event(senderId = A_SENDER_ID) + val beaconLocationData = MessageBeaconLocationDataContent() + + listOf(null, "").forEach { + val result = liveLocationAggregationProcessor.handleBeaconLocationData( + realm = fakeRealm.instance, + event = event, + content = beaconLocationData, + roomId = A_ROOM_ID, + relatedEventId = it, + isLocalEcho = false + ) + result shouldBeEqualTo false + } + } + + @Test + fun `given beacon location data when location is less recent than the saved one then it is ignored`() { + val event = Event(eventId = AN_EVENT_ID, senderId = A_SENDER_ID) + val beaconLocationData = MessageBeaconLocationDataContent( + unstableTimestampMillis = A_TIMESTAMP - 60_000 + ) + val lastBeaconLocationContent = MessageBeaconLocationDataContent( + unstableTimestampMillis = A_TIMESTAMP + ) + givenLastSummaryQueryReturns( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + beaconLocationContent = lastBeaconLocationContent + ) + + val result = liveLocationAggregationProcessor.handleBeaconLocationData( + realm = fakeRealm.instance, + event = event, + content = beaconLocationData, + roomId = A_ROOM_ID, + relatedEventId = AN_EVENT_ID, + isLocalEcho = false + ) + + result shouldBeEqualTo false + } + + @Test + fun `given beacon location data when location is more recent than the saved one then it is aggregated`() { + val event = Event(eventId = AN_EVENT_ID, senderId = A_SENDER_ID) + val locationInfo = LocationInfo(geoUri = A_GEO_URI) + val beaconLocationData = MessageBeaconLocationDataContent( + unstableTimestampMillis = A_TIMESTAMP, + unstableLocationInfo = locationInfo + ) + val lastBeaconLocationContent = MessageBeaconLocationDataContent( + unstableTimestampMillis = A_TIMESTAMP - 60_000 + ) + val entity = givenLastSummaryQueryReturns( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + beaconLocationContent = lastBeaconLocationContent + ) + + val result = liveLocationAggregationProcessor.handleBeaconLocationData( + realm = fakeRealm.instance, + event = event, + content = beaconLocationData, + roomId = A_ROOM_ID, + relatedEventId = AN_EVENT_ID, + isLocalEcho = false + ) + + result shouldBeEqualTo true + val savedLocationData = ContentMapper.map(entity.lastLocationContent).toModel() + savedLocationData?.getBestTimestampMillis() shouldBeEqualTo A_TIMESTAMP + savedLocationData?.getBestLocationInfo()?.geoUri shouldBeEqualTo A_GEO_URI + } + + private fun givenLastSummaryQueryReturns( + eventId: String, + roomId: String, + beaconLocationContent: MessageBeaconLocationDataContent? = null + ): LiveLocationShareAggregatedSummaryEntity { + val result = LiveLocationShareAggregatedSummaryEntity( + eventId = eventId, + roomId = roomId, + lastLocationContent = ContentMapper.map(beaconLocationContent?.toContent()) + ) + fakeQuery + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, eventId) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, roomId) + .givenFindFirst(result) + return result + } + + private fun givenActiveSummaryListQueryReturns( + summaryList: List + ): List { + fakeQuery + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID) + .givenNotEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, AN_EVENT_ID) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.USER_ID, A_SENDER_ID) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true) + .givenFindAll(summaryList) + return summaryList + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt index 837bbeea26..3044ca5d43 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt @@ -19,8 +19,6 @@ package org.matrix.android.sdk.internal.session.room.aggregation.poll import io.mockk.every import io.mockk.mockk import io.realm.RealmList -import io.realm.RealmModel -import io.realm.RealmQuery import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue import org.junit.Before @@ -46,6 +44,8 @@ import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_TIMELINE_EVENT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_USER_ID_1 import org.matrix.android.sdk.test.fakes.FakeRealm +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenFindFirst class PollAggregationProcessorTest { @@ -135,14 +135,11 @@ class PollAggregationProcessorTest { pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, event).shouldBeFalse() } - private inline fun RealmQuery.givenEqualTo(fieldName: String, value: String, result: RealmQuery) { - every { equalTo(fieldName, value) } returns result - } - private fun mockEventAnnotationsSummaryEntity() { - val queryResult = realm.givenWhereReturns(result = EventAnnotationsSummaryEntity()) - queryResult.givenEqualTo(EventAnnotationsSummaryEntityFields.ROOM_ID, A_POLL_REPLACE_EVENT.roomId!!, queryResult) - queryResult.givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, A_POLL_REPLACE_EVENT.eventId!!, queryResult) + realm.givenWhere() + .givenFindFirst(EventAnnotationsSummaryEntity()) + .givenEqualTo(EventAnnotationsSummaryEntityFields.ROOM_ID, A_POLL_REPLACE_EVENT.roomId!!) + .givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, A_POLL_REPLACE_EVENT.eventId!!) } private fun mockRoom( diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultCheckIfExistingActiveLiveTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultCheckIfExistingActiveLiveTaskTest.kt new file mode 100644 index 0000000000..3198392eab --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultCheckIfExistingActiveLiveTaskTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.test.fakes.FakeGetActiveBeaconInfoForUserTask + +private const val A_USER_ID = "user-id" +private const val A_ROOM_ID = "room-id" +private const val A_TIMEOUT = 15_000L +private const val AN_EPOCH = 1655210176L + +@ExperimentalCoroutinesApi +class DefaultCheckIfExistingActiveLiveTaskTest { + + private val fakeGetActiveBeaconInfoForUserTask = FakeGetActiveBeaconInfoForUserTask() + + private val defaultCheckIfExistingActiveLiveTask = DefaultCheckIfExistingActiveLiveTask( + getActiveBeaconInfoForUserTask = fakeGetActiveBeaconInfoForUserTask + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given parameters and existing active live event when calling the task then result is true`() = runTest { + val params = CheckIfExistingActiveLiveTask.Params( + roomId = A_ROOM_ID + ) + val currentStateEvent = Event( + stateKey = A_USER_ID, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ) + fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent) + + val result = defaultCheckIfExistingActiveLiveTask.execute(params) + + result shouldBeEqualTo true + val expectedGetActiveBeaconParams = GetActiveBeaconInfoForUserTask.Params( + roomId = params.roomId + ) + fakeGetActiveBeaconInfoForUserTask.verifyExecute(expectedGetActiveBeaconParams) + } + + @Test + fun `given parameters and no existing active live event when calling the task then result is false`() = runTest { + val params = CheckIfExistingActiveLiveTask.Params( + roomId = A_ROOM_ID + ) + val inactiveEvents = listOf( + // no event + null, + // null content + Event( + stateKey = A_USER_ID, + content = null + ), + // inactive live + Event( + stateKey = A_USER_ID, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = false, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ) + ) + + inactiveEvents.forEach { currentStateEvent -> + fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent) + + val result = defaultCheckIfExistingActiveLiveTask.execute(params) + + result shouldBeEqualTo false + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt new file mode 100644 index 0000000000..588bfaa979 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +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.toContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.test.fakes.FakeStateEventDataSource + +private const val A_USER_ID = "user-id" +private const val A_ROOM_ID = "room-id" +private const val A_TIMEOUT = 15_000L +private const val AN_EPOCH = 1655210176L + +@ExperimentalCoroutinesApi +class DefaultGetActiveBeaconInfoForUserTaskTest { + + private val fakeStateEventDataSource = FakeStateEventDataSource() + + private val defaultGetActiveBeaconInfoForUserTask = DefaultGetActiveBeaconInfoForUserTask( + userId = A_USER_ID, + stateEventDataSource = fakeStateEventDataSource.instance + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given parameters and no error when calling the task then result is computed`() = runTest { + val currentStateEvent = Event( + stateKey = A_USER_ID, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ) + fakeStateEventDataSource.givenGetStateEventReturns(currentStateEvent) + val params = GetActiveBeaconInfoForUserTask.Params( + roomId = A_ROOM_ID + ) + + val result = defaultGetActiveBeaconInfoForUserTask.execute(params) + + result shouldBeEqualTo currentStateEvent + fakeStateEventDataSource.verifyGetStateEvent( + roomId = params.roomId, + eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + stateKey = A_USER_ID + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt new file mode 100644 index 0000000000..de91206531 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import androidx.arch.core.util.Function +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult +import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional +import org.matrix.android.sdk.internal.database.mapper.LiveLocationShareAggregatedSummaryMapper +import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenIsNotEmpty +import org.matrix.android.sdk.test.fakes.givenIsNotNull + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" +private const val A_LATITUDE = 1.4 +private const val A_LONGITUDE = 40.0 +private const val AN_UNCERTAINTY = 5.0 +private const val A_TIMEOUT = 15_000L + +@ExperimentalCoroutinesApi +internal class DefaultLocationSharingServiceTest { + + private val fakeMonarchy = FakeMonarchy() + private val sendStaticLocationTask = mockk() + private val sendLiveLocationTask = mockk() + private val startLiveLocationShareTask = mockk() + private val stopLiveLocationShareTask = mockk() + private val checkIfExistingActiveLiveTask = mockk() + private val fakeLiveLocationShareAggregatedSummaryMapper = mockk() + + private val defaultLocationSharingService = DefaultLocationSharingService( + roomId = A_ROOM_ID, + monarchy = fakeMonarchy.instance, + sendStaticLocationTask = sendStaticLocationTask, + sendLiveLocationTask = sendLiveLocationTask, + startLiveLocationShareTask = startLiveLocationShareTask, + stopLiveLocationShareTask = stopLiveLocationShareTask, + checkIfExistingActiveLiveTask = checkIfExistingActiveLiveTask, + liveLocationShareAggregatedSummaryMapper = fakeLiveLocationShareAggregatedSummaryMapper + ) + + @Before + fun setUp() { + mockkStatic("androidx.lifecycle.Transformations") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `static location can be sent`() = runTest { + val isUserLocation = true + val cancelable = mockk() + coEvery { sendStaticLocationTask.execute(any()) } returns cancelable + + val result = defaultLocationSharingService.sendStaticLocation( + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_UNCERTAINTY, + isUserLocation = isUserLocation + ) + + result shouldBeEqualTo cancelable + val expectedParams = SendStaticLocationTask.Params( + roomId = A_ROOM_ID, + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_UNCERTAINTY, + isUserLocation = isUserLocation, + ) + coVerify { sendStaticLocationTask.execute(expectedParams) } + } + + @Test + fun `live location can be sent`() = runTest { + val cancelable = mockk() + coEvery { sendLiveLocationTask.execute(any()) } returns cancelable + + val result = defaultLocationSharingService.sendLiveLocation( + beaconInfoEventId = AN_EVENT_ID, + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_UNCERTAINTY + ) + + result shouldBeEqualTo cancelable + val expectedParams = SendLiveLocationTask.Params( + roomId = A_ROOM_ID, + beaconInfoEventId = AN_EVENT_ID, + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_UNCERTAINTY + ) + coVerify { sendLiveLocationTask.execute(expectedParams) } + } + + @Test + fun `given existing active live can be stopped when starting a live then the current live is stopped and the new live is started`() = runTest { + coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns true + coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success("stopped-event-id") + coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( + roomId = A_ROOM_ID + ) + coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) } + val expectedStopParams = StopLiveLocationShareTask.Params( + roomId = A_ROOM_ID + ) + coVerify { stopLiveLocationShareTask.execute(expectedStopParams) } + val expectedStartParams = StartLiveLocationShareTask.Params( + roomId = A_ROOM_ID, + timeoutMillis = A_TIMEOUT + ) + coVerify { startLiveLocationShareTask.execute(expectedStartParams) } + } + + @Test + fun `given existing active live cannot be stopped when starting a live then the result is failure`() = runTest { + coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns true + val error = Throwable() + coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Failure(error) + + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Failure(error) + val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( + roomId = A_ROOM_ID + ) + coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) } + val expectedStopParams = StopLiveLocationShareTask.Params( + roomId = A_ROOM_ID + ) + coVerify { stopLiveLocationShareTask.execute(expectedStopParams) } + } + + @Test + fun `given no existing active live when starting a live then the new live is started`() = runTest { + coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns false + coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( + roomId = A_ROOM_ID + ) + coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) } + val expectedStartParams = StartLiveLocationShareTask.Params( + roomId = A_ROOM_ID, + timeoutMillis = A_TIMEOUT + ) + coVerify { startLiveLocationShareTask.execute(expectedStartParams) } + } + + @Test + fun `live location share can be stopped`() = runTest { + coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + + val result = defaultLocationSharingService.stopLiveLocationShare() + + result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + val expectedParams = StopLiveLocationShareTask.Params( + roomId = A_ROOM_ID + ) + coVerify { stopLiveLocationShareTask.execute(expectedParams) } + } + + @Test + fun `livedata of live summaries is correctly computed`() { + val entity = LiveLocationShareAggregatedSummaryEntity() + val summary = LiveLocationShareAggregatedSummary( + userId = "", + isActive = true, + endOfLiveTimestampMillis = 123, + lastLocationDataContent = null + ) + + fakeMonarchy.givenWhere() + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true) + .givenIsNotEmpty(LiveLocationShareAggregatedSummaryEntityFields.USER_ID) + .givenIsNotNull(LiveLocationShareAggregatedSummaryEntityFields.LAST_LOCATION_CONTENT) + fakeMonarchy.givenFindAllMappedWithChangesReturns( + realmEntities = listOf(entity), + mappedResult = listOf(summary), + fakeLiveLocationShareAggregatedSummaryMapper + ) + + val result = defaultLocationSharingService.getRunningLiveLocationShareSummaries().value + + result shouldBeEqualTo listOf(summary) + } + + @Test + fun `given an event id when getting livedata on corresponding live summary then it is correctly computed`() { + val entity = LiveLocationShareAggregatedSummaryEntity() + val summary = LiveLocationShareAggregatedSummary( + userId = "", + isActive = true, + endOfLiveTimestampMillis = 123, + lastLocationDataContent = null + ) + + fakeMonarchy.givenWhere() + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID) + .givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, AN_EVENT_ID) + val liveData = fakeMonarchy.givenFindAllMappedWithChangesReturns( + realmEntities = listOf(entity), + mappedResult = listOf(summary), + fakeLiveLocationShareAggregatedSummaryMapper + ) + val mapper = slot, Optional>>() + every { + Transformations.map( + liveData, + capture(mapper) + ) + } answers { + val value = secondArg, Optional>>().apply(listOf(summary)) + MutableLiveData(value) + } + + val result = defaultLocationSharingService.getLiveLocationShareSummary(AN_EVENT_ID).value + + result shouldBeEqualTo summary.toOptional() + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultSendLiveLocationTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultSendLiveLocationTaskTest.kt new file mode 100644 index 0000000000..423c680054 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultSendLiveLocationTaskTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.test.fakes.FakeEventSenderProcessor +import org.matrix.android.sdk.test.fakes.FakeLocalEchoEventFactory + +private const val A_ROOM_ID = "room_id" +private const val AN_EVENT_ID = "event_id" +private const val A_LATITUDE = 1.4 +private const val A_LONGITUDE = 44.0 +private const val AN_UNCERTAINTY = 5.0 + +@ExperimentalCoroutinesApi +internal class DefaultSendLiveLocationTaskTest { + + private val fakeLocalEchoEventFactory = FakeLocalEchoEventFactory() + private val fakeEventSenderProcessor = FakeEventSenderProcessor() + + private val defaultSendLiveLocationTask = DefaultSendLiveLocationTask( + localEchoEventFactory = fakeLocalEchoEventFactory.instance, + eventSenderProcessor = fakeEventSenderProcessor + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given parameters when calling the task then it is correctly executed`() = runTest { + val params = SendLiveLocationTask.Params( + roomId = A_ROOM_ID, + beaconInfoEventId = AN_EVENT_ID, + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_UNCERTAINTY + ) + val event = fakeLocalEchoEventFactory.givenCreateLiveLocationEvent( + withLocalEcho = true + ) + val cancelable = mockk() + fakeEventSenderProcessor.givenPostEventReturns(event, cancelable) + + val result = defaultSendLiveLocationTask.execute(params) + + result shouldBeEqualTo cancelable + fakeLocalEchoEventFactory.verifyCreateLiveLocationEvent( + roomId = params.roomId, + beaconInfoEventId = params.beaconInfoEventId, + latitude = params.latitude, + longitude = params.longitude, + uncertainty = params.uncertainty + ) + fakeLocalEchoEventFactory.verifyCreateLocalEcho(event) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultSendStaticLocationTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultSendStaticLocationTaskTest.kt new file mode 100644 index 0000000000..cfde568b71 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultSendStaticLocationTaskTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.test.fakes.FakeEventSenderProcessor +import org.matrix.android.sdk.test.fakes.FakeLocalEchoEventFactory + +private const val A_ROOM_ID = "room_id" +private const val A_LATITUDE = 1.4 +private const val A_LONGITUDE = 44.0 +private const val AN_UNCERTAINTY = 5.0 + +@ExperimentalCoroutinesApi +internal class DefaultSendStaticLocationTaskTest { + + private val fakeLocalEchoEventFactory = FakeLocalEchoEventFactory() + private val fakeEventSenderProcessor = FakeEventSenderProcessor() + + private val defaultSendStaticLocationTask = DefaultSendStaticLocationTask( + localEchoEventFactory = fakeLocalEchoEventFactory.instance, + eventSenderProcessor = fakeEventSenderProcessor + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given parameters when calling the task then it is correctly executed`() = runTest { + val params = SendStaticLocationTask.Params( + roomId = A_ROOM_ID, + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_UNCERTAINTY, + isUserLocation = true + ) + val event = fakeLocalEchoEventFactory.givenCreateStaticLocationEvent( + withLocalEcho = true + ) + val cancelable = mockk() + fakeEventSenderProcessor.givenPostEventReturns(event, cancelable) + + val result = defaultSendStaticLocationTask.execute(params) + + result shouldBeEqualTo cancelable + fakeLocalEchoEventFactory.verifyCreateStaticLocationEvent( + roomId = params.roomId, + latitude = params.latitude, + longitude = params.longitude, + uncertainty = params.uncertainty, + isUserLocation = params.isUserLocation + ) + fakeLocalEchoEventFactory.verifyCreateLocalEcho(event) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt new file mode 100644 index 0000000000..909ba5d048 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.internal.session.room.state.SendStateTask +import org.matrix.android.sdk.test.fakes.FakeClock +import org.matrix.android.sdk.test.fakes.FakeSendStateTask + +private const val A_USER_ID = "user-id" +private const val A_ROOM_ID = "room-id" +private const val AN_EVENT_ID = "event-id" +private const val A_TIMEOUT = 15_000L +private const val AN_EPOCH = 1655210176L + +@ExperimentalCoroutinesApi +internal class DefaultStartLiveLocationShareTaskTest { + + private val fakeClock = FakeClock() + private val fakeSendStateTask = FakeSendStateTask() + + private val defaultStartLiveLocationShareTask = DefaultStartLiveLocationShareTask( + userId = A_USER_ID, + clock = fakeClock, + sendStateTask = fakeSendStateTask + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given parameters and no error when calling the task then result is success`() = runTest { + val params = StartLiveLocationShareTask.Params( + roomId = A_ROOM_ID, + timeoutMillis = A_TIMEOUT + ) + fakeClock.givenEpoch(AN_EPOCH) + fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID) + + val result = defaultStartLiveLocationShareTask.execute(params) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + val expectedBeaconContent = MessageBeaconInfoContent( + timeout = params.timeoutMillis, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + val expectedParams = SendStateTask.Params( + roomId = params.roomId, + stateKey = A_USER_ID, + eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + body = expectedBeaconContent + ) + fakeSendStateTask.verifyExecuteRetry( + params = expectedParams, + remainingRetry = 3 + ) + } + + @Test + fun `given parameters and an empty returned event id when calling the task then result is failure`() = runTest { + val params = StartLiveLocationShareTask.Params( + roomId = A_ROOM_ID, + timeoutMillis = A_TIMEOUT + ) + fakeClock.givenEpoch(AN_EPOCH) + fakeSendStateTask.givenExecuteRetryReturns("") + + val result = defaultStartLiveLocationShareTask.execute(params) + + result shouldBeInstanceOf UpdateLiveLocationShareResult.Failure::class + } + + @Test + fun `given parameters and error during event sending when calling the task then result is failure`() = runTest { + val params = StartLiveLocationShareTask.Params( + roomId = A_ROOM_ID, + timeoutMillis = A_TIMEOUT + ) + fakeClock.givenEpoch(AN_EPOCH) + val error = Throwable() + fakeSendStateTask.givenExecuteRetryThrows(error) + + val result = defaultStartLiveLocationShareTask.execute(params) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Failure(error) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt new file mode 100644 index 0000000000..1abf179ccf --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.location + +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.After +import org.junit.Test +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.toContent +import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.internal.session.room.state.SendStateTask +import org.matrix.android.sdk.test.fakes.FakeGetActiveBeaconInfoForUserTask +import org.matrix.android.sdk.test.fakes.FakeSendStateTask + +private const val A_USER_ID = "user-id" +private const val A_ROOM_ID = "room-id" +private const val AN_EVENT_ID = "event-id" +private const val A_TIMEOUT = 15_000L +private const val AN_EPOCH = 1655210176L + +@ExperimentalCoroutinesApi +class DefaultStopLiveLocationShareTaskTest { + + private val fakeSendStateTask = FakeSendStateTask() + private val fakeGetActiveBeaconInfoForUserTask = FakeGetActiveBeaconInfoForUserTask() + + private val defaultStopLiveLocationShareTask = DefaultStopLiveLocationShareTask( + sendStateTask = fakeSendStateTask, + getActiveBeaconInfoForUserTask = fakeGetActiveBeaconInfoForUserTask + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given parameters and no error when calling the task then result is success`() = runTest { + val params = StopLiveLocationShareTask.Params(roomId = A_ROOM_ID) + val currentStateEvent = Event( + stateKey = A_USER_ID, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ) + fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent) + fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID) + + val result = defaultStopLiveLocationShareTask.execute(params) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) + val expectedBeaconContent = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = false, + unstableTimestampMillis = AN_EPOCH + ).toContent() + val expectedSendParams = SendStateTask.Params( + roomId = params.roomId, + stateKey = A_USER_ID, + eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + body = expectedBeaconContent + ) + fakeSendStateTask.verifyExecuteRetry( + params = expectedSendParams, + remainingRetry = 3 + ) + val expectedGetBeaconParams = GetActiveBeaconInfoForUserTask.Params( + roomId = params.roomId + ) + fakeGetActiveBeaconInfoForUserTask.verifyExecute( + expectedGetBeaconParams + ) + } + + @Test + fun `given parameters and an incorrect current state event when calling the task then result is failure`() = runTest { + val incorrectCurrentStateEvents = listOf( + // no event + null, + // no stateKey + Event( + stateKey = null, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ), + // null content + Event( + stateKey = A_USER_ID, + content = null + ) + ) + + incorrectCurrentStateEvents.forEach { currentStateEvent -> + fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent) + fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID) + val params = StopLiveLocationShareTask.Params(roomId = A_ROOM_ID) + + val result = defaultStopLiveLocationShareTask.execute(params) + + result shouldBeInstanceOf UpdateLiveLocationShareResult.Failure::class + } + } + + @Test + fun `given parameters and an empty returned event id when calling the task then result is failure`() = runTest { + val params = StopLiveLocationShareTask.Params(roomId = A_ROOM_ID) + val currentStateEvent = Event( + stateKey = A_USER_ID, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ) + fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent) + fakeSendStateTask.givenExecuteRetryReturns("") + + val result = defaultStopLiveLocationShareTask.execute(params) + + result shouldBeInstanceOf UpdateLiveLocationShareResult.Failure::class + } + + @Test + fun `given parameters and error during event sending when calling the task then result is failure`() = runTest { + val params = StopLiveLocationShareTask.Params(roomId = A_ROOM_ID) + val currentStateEvent = Event( + stateKey = A_USER_ID, + content = MessageBeaconInfoContent( + timeout = A_TIMEOUT, + isLive = true, + unstableTimestampMillis = AN_EPOCH + ).toContent() + ) + fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent) + val error = Throwable() + fakeSendStateTask.givenExecuteRetryThrows(error) + + val result = defaultStopLiveLocationShareTask.execute(params) + + result shouldBeEqualTo UpdateLiveLocationShareResult.Failure(error) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClock.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClock.kt new file mode 100644 index 0000000000..febf94f4cf --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClock.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.internal.util.time.Clock + +internal class FakeClock : Clock by mockk() { + fun givenEpoch(epoch: Long) { + every { epochMillis() } returns epoch + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeEventSenderProcessor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeEventSenderProcessor.kt new file mode 100644 index 0000000000..fbdcf5bfd7 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeEventSenderProcessor.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor + +internal class FakeEventSenderProcessor : EventSenderProcessor by mockk() { + + fun givenPostEventReturns(event: Event, cancelable: Cancelable) { + every { postEvent(event) } returns cancelable + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetActiveBeaconInfoForUserTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetActiveBeaconInfoForUserTask.kt new file mode 100644 index 0000000000..dc4a48908a --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeGetActiveBeaconInfoForUserTask.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.internal.session.room.location.GetActiveBeaconInfoForUserTask + +internal class FakeGetActiveBeaconInfoForUserTask : GetActiveBeaconInfoForUserTask by mockk() { + + fun givenExecuteReturns(event: Event?) { + coEvery { execute(any()) } returns event + } + + fun verifyExecute(params: GetActiveBeaconInfoForUserTask.Params) { + coVerify { execute(params) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeLocalEchoEventFactory.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeLocalEchoEventFactory.kt new file mode 100644 index 0000000000..50ec85f14a --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeLocalEchoEventFactory.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory + +internal class FakeLocalEchoEventFactory { + + val instance = mockk() + + fun givenCreateStaticLocationEvent(withLocalEcho: Boolean): Event { + val event = Event() + every { + instance.createStaticLocationEvent( + roomId = any(), + latitude = any(), + longitude = any(), + uncertainty = any(), + isUserLocation = any() + ) + } returns event + + if (withLocalEcho) { + every { instance.createLocalEcho(event) } just runs + } + return event + } + + fun givenCreateLiveLocationEvent(withLocalEcho: Boolean): Event { + val event = Event() + every { + instance.createLiveLocationEvent( + beaconInfoEventId = any(), + roomId = any(), + latitude = any(), + longitude = any(), + uncertainty = any() + ) + } returns event + + if (withLocalEcho) { + every { instance.createLocalEcho(event) } just runs + } + return event + } + + fun verifyCreateStaticLocationEvent( + roomId: String, + latitude: Double, + longitude: Double, + uncertainty: Double?, + isUserLocation: Boolean + ) { + verify { + instance.createStaticLocationEvent( + roomId = roomId, + latitude = latitude, + longitude = longitude, + uncertainty = uncertainty, + isUserLocation = isUserLocation + ) + } + } + + fun verifyCreateLiveLocationEvent( + roomId: String, + beaconInfoEventId: String, + latitude: Double, + longitude: Double, + uncertainty: Double? + ) { + verify { + instance.createLiveLocationEvent( + roomId = roomId, + beaconInfoEventId = beaconInfoEventId, + latitude = latitude, + longitude = longitude, + uncertainty = uncertainty + ) + } + } + + fun verifyCreateLocalEcho(event: Event) { + verify { instance.createLocalEcho(event) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt index 0a22ef8996..d77084fe3b 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt @@ -16,40 +16,65 @@ package org.matrix.android.sdk.test.fakes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import com.zhuinden.monarchy.Monarchy import io.mockk.MockKVerificationScope import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.verify +import io.mockk.slot import io.realm.Realm import io.realm.RealmModel import io.realm.RealmQuery -import io.realm.kotlin.where import org.matrix.android.sdk.internal.util.awaitTransaction internal class FakeMonarchy { val instance = mockk() - private val realm = mockk(relaxed = true) + private val fakeRealm = FakeRealm() init { mockkStatic("org.matrix.android.sdk.internal.util.MonarchyKt") coEvery { instance.awaitTransaction(any Any>()) } coAnswers { - secondArg Any>().invoke(realm) + secondArg Any>().invoke(fakeRealm.instance) } } - inline fun givenWhereReturns(result: T?) { - val queryResult = mockk>(relaxed = true) - every { queryResult.findFirst() } returns result - every { realm.where() } returns queryResult + inline fun givenWhere(): RealmQuery { + return fakeRealm.givenWhere() + } + + inline fun givenWhereReturns(result: T?): RealmQuery { + return fakeRealm.givenWhere() + .givenFindFirst(result) } inline fun verifyInsertOrUpdate(crossinline verification: MockKVerificationScope.() -> T) { - verify { realm.insertOrUpdate(verification()) } + fakeRealm.verifyInsertOrUpdate(verification) + } + + inline fun givenFindAllMappedWithChangesReturns( + realmEntities: List, + mappedResult: List, + mapper: Monarchy.Mapper + ): LiveData> { + every { mapper.map(any()) } returns mockk() + val monarchyQuery = slot>() + val monarchyMapper = slot>() + val result = MutableLiveData(mappedResult) + every { + instance.findAllMappedWithChanges(capture(monarchyQuery), capture(monarchyMapper)) + } answers { + monarchyQuery.captured.createQuery(fakeRealm.instance) + realmEntities.forEach { + monarchyMapper.captured.map(it) + } + result + } + return result } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt index c07f8e1873..0ebff87278 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt @@ -16,21 +16,84 @@ package org.matrix.android.sdk.test.fakes +import io.mockk.MockKVerificationScope import io.mockk.every import io.mockk.mockk +import io.mockk.verify import io.realm.Realm import io.realm.RealmModel import io.realm.RealmQuery +import io.realm.RealmResults import io.realm.kotlin.where internal class FakeRealm { val instance = mockk(relaxed = true) - inline fun givenWhereReturns(result: T?): RealmQuery { - val queryResult = mockk>() - every { queryResult.findFirst() } returns result - every { instance.where() } returns queryResult - return queryResult + inline fun givenWhere(): RealmQuery { + val query = mockk>() + every { instance.where() } returns query + return query + } + + inline fun verifyInsertOrUpdate(crossinline verification: MockKVerificationScope.() -> T) { + verify { instance.insertOrUpdate(verification()) } } } + +inline fun RealmQuery.givenFindFirst( + result: T? +): RealmQuery { + every { findFirst() } returns result + return this +} + +inline fun RealmQuery.givenFindAll( + result: List +): RealmQuery { + val realmResults = mockk>() + result.forEachIndexed { index, t -> + every { realmResults[index] } returns t + } + every { realmResults.size } returns result.size + every { findAll() } returns realmResults + return this +} + +inline fun RealmQuery.givenEqualTo( + fieldName: String, + value: String +): RealmQuery { + every { equalTo(fieldName, value) } returns this + return this +} + +inline fun RealmQuery.givenEqualTo( + fieldName: String, + value: Boolean +): RealmQuery { + every { equalTo(fieldName, value) } returns this + return this +} + +inline fun RealmQuery.givenNotEqualTo( + fieldName: String, + value: String +): RealmQuery { + every { notEqualTo(fieldName, value) } returns this + return this +} + +inline fun RealmQuery.givenIsNotEmpty( + fieldName: String +): RealmQuery { + every { isNotEmpty(fieldName) } returns this + return this +} + +inline fun RealmQuery.givenIsNotNull( + fieldName: String +): RealmQuery { + every { isNotNull(fieldName) } returns this + return this +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSendStateTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSendStateTask.kt new file mode 100644 index 0000000000..08a25be93e --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSendStateTask.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.state.SendStateTask + +internal class FakeSendStateTask : SendStateTask by mockk() { + + fun givenExecuteRetryReturns(eventId: String) { + coEvery { executeRetry(any(), any()) } returns eventId + } + + fun givenExecuteRetryThrows(error: Throwable) { + coEvery { executeRetry(any(), any()) } throws error + } + + fun verifyExecuteRetry(params: SendStateTask.Params, remainingRetry: Int) { + coVerify { executeRetry(params, remainingRetry) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt new file mode 100644 index 0000000000..ca03316fa7 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource + +internal class FakeStateEventDataSource { + + val instance: StateEventDataSource = mockk() + + fun givenGetStateEventReturns(event: Event?) { + every { + instance.getStateEvent( + roomId = any(), + eventType = any(), + stateKey = any() + ) + } returns event + } + + fun verifyGetStateEvent(roomId: String, eventType: String, stateKey: String) { + verify { + instance.getStateEvent( + roomId = roomId, + eventType = eventType, + stateKey = QueryStringValue.Equals(stateKey) + ) + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeWorkManager.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeWorkManager.kt new file mode 100644 index 0000000000..b29d015a43 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeWorkManager.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class FakeWorkManager { + + val instance = mockk() + + fun expectEnqueueUniqueWork() { + every { instance.enqueueUniqueWork(any(), any(), any()) } returns mockk() + } + + fun verifyEnqueueUniqueWork(workName: String, policy: ExistingWorkPolicy) { + verify { instance.enqueueUniqueWork(workName, policy, any()) } + } + + fun expectCancelUniqueWork() { + every { instance.cancelUniqueWork(any()) } returns mockk() + } + + fun verifyCancelUniqueWork(workName: String) { + verify { instance.cancelUniqueWork(workName) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeWorkManagerProvider.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeWorkManagerProvider.kt new file mode 100644 index 0000000000..51ff24c01d --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeWorkManagerProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.internal.di.WorkManagerProvider + +internal class FakeWorkManagerProvider( + val fakeWorkManager: FakeWorkManager = FakeWorkManager(), +) { + + val instance = mockk().also { + every { it.workManager } returns fakeWorkManager.instance + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/util/DefaultBuildVersionSdkIntProviderTests.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/util/DefaultBuildVersionSdkIntProviderTests.kt new file mode 100644 index 0000000000..c118cf07a1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/util/DefaultBuildVersionSdkIntProviderTests.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.util + +import android.os.Build +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider + +class DefaultBuildVersionSdkIntProviderTests { + + @Test + fun getReturnsCurrentVersionFromBuild_Version_SDK_INT() { + val provider = DefaultBuildVersionSdkIntProvider() + provider.get() shouldBeEqualTo Build.VERSION.SDK_INT + } +} diff --git a/vector/build.gradle b/vector/build.gradle index 8d704141e5..15b54a71d7 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -9,6 +9,10 @@ apply plugin: 'com.likethesalad.stem' apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'kotlinx-knit' +if (project.hasProperty("coverage")) { + apply plugin: 'jacoco' +} + kapt { correctErrorTypes = true } @@ -31,7 +35,7 @@ ext.versionMinor = 4 // Note: even values are reserved for regular release, odd values for hotfix release. // When creating a hotfix, you should decrease the value, since the current value // is the value for the next regular release. -ext.versionPatch = 24 +ext.versionPatch = 26 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -119,8 +123,6 @@ ext.abiVersionCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86": 3, "x86_64": 4]. def buildNumber = System.env.BUILDKITE_BUILD_NUMBER as Integer ?: 0 android { - - // Due to a bug introduced in Android gradle plugin 3.6.0, we have to specify the ndk version to use // Ref: https://issuetracker.google.com/issues/144111441 ndkVersion "21.3.6528147" @@ -145,7 +147,7 @@ android { versionName "${versionMajor}.${versionMinor}.${versionPatch}-sonar" // Generate a random app task affinity - manifestPlaceholders = [appTaskAffinitySuffix:"H_${gitRevision()}"] + manifestPlaceholders = [appTaskAffinitySuffix: "H_${gitRevision()}"] buildConfigField "String", "GIT_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_REVISION_DATE", "\"${gitRevisionDate()}\"" @@ -244,7 +246,10 @@ android { buildConfigField "boolean", "ENABLE_STRICT_MODE_LOGS", "false" signingConfig signingConfigs.debug - testCoverageEnabled true + + if (project.hasProperty("coverage")) { + testCoverageEnabled = coverage.enableTestCoverage + } } release { @@ -361,12 +366,12 @@ dependencies { implementation libs.androidx.core implementation "androidx.media:media:1.6.0" implementation "androidx.transition:transition:1.4.1" + implementation libs.androidx.biometric implementation "org.threeten:threetenbp:1.4.0:no-tzdb" implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.10.0" implementation libs.squareup.moshi - implementation libs.squareup.moshiKt kapt libs.squareup.moshiKotlin // Lifecycle @@ -384,7 +389,7 @@ dependencies { implementation 'com.facebook.stetho:stetho:1.6.0' // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.50' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.51' // FlowBinding implementation libs.github.flowBinding @@ -421,7 +426,6 @@ dependencies { implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation libs.androidx.autoFill implementation 'jp.wasabeef:glide-transformations:4.3.0' - implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' implementation 'com.github.hyuwah:DraggableView:1.0.0' // Custom Tab @@ -465,7 +469,7 @@ dependencies { // UnifiedPush implementation 'com.github.UnifiedPush:android-connector:2.0.0' // UnifiedPush gplay flavor only - gplayImplementation('com.github.UnifiedPush:android-embedded_fcm_distributor:2.0.0') { + gplayImplementation('com.github.UnifiedPush:android-embedded_fcm_distributor:2.1.1') { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' @@ -529,14 +533,14 @@ dependencies { } // Flipper, debug builds only - debugImplementation('com.facebook.flipper:flipper:0.149.0') { + debugImplementation(libs.flipper.flipper) { exclude group: 'com.facebook.fbjni', module: 'fbjni' } - debugImplementation('com.facebook.flipper:flipper-network-plugin:0.149.0') { + debugImplementation(libs.flipper.flipperNetworkPlugin) { exclude group: 'com.facebook.fbjni', module: 'fbjni' } - debugImplementation 'com.facebook.soloader:soloader:0.10.3' - debugImplementation "com.kgurgul.flipper:flipper-realm-android:2.1.0" + debugImplementation 'com.facebook.soloader:soloader:0.10.4' + debugImplementation "com.kgurgul.flipper:flipper-realm-android:2.2.0" // Activate when you want to check for leaks, from time to time. //debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3' @@ -561,4 +565,5 @@ dependencies { } androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator + debugImplementation libs.androidx.fragmentTesting } diff --git a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt index 1399d1d6a9..7920e8e0d8 100644 --- a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt +++ b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt @@ -29,6 +29,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import im.vector.app.features.MainActivity +import im.vector.app.features.analytics.ui.consent.AnalyticsOptInActivity import im.vector.app.features.home.HomeActivity import org.hamcrest.CoreMatchers.not import org.junit.Ignore @@ -106,6 +107,12 @@ class RegistrationTest { .check(matches(isEnabled())) .perform(closeSoftKeyboard(), click()) + withIdlingResource(activityIdlingResource(AnalyticsOptInActivity::class.java)) { + onView(withId(R.id.later)) + .check(matches(isDisplayed())) + .perform(click()) + } + withIdlingResource(activityIdlingResource(HomeActivity::class.java)) { onView(withId(R.id.roomListContainer)) .check(matches(isDisplayed())) diff --git a/vector/src/androidTest/java/im/vector/app/TestBuildVersionSdkIntProvider.kt b/vector/src/androidTest/java/im/vector/app/TestBuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..ddf89b5e46 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/TestBuildVersionSdkIntProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 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 + +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider + +class TestBuildVersionSdkIntProvider : BuildVersionSdkIntProvider { + var value: Int = 0 + + override fun get() = value +} diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/WaitUntil.kt b/vector/src/androidTest/java/im/vector/app/core/utils/WaitUntil.kt new file mode 100644 index 0000000000..16abada04c --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/core/utils/WaitUntil.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 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.core.utils + +import kotlinx.coroutines.delay +import org.amshove.kluent.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Tries a [condition] several times until it returns true or a [timeout] is reached waiting for some [retryDelay] time between retries. + * On timeout it fails with an [errorMessage]. + */ +suspend fun waitUntilCondition( + errorMessage: String, + timeout: Duration = 1.seconds, + retryDelay: Duration = 50.milliseconds, + condition: () -> Boolean, +) { + val start = System.currentTimeMillis() + do { + if (condition()) return + delay(retryDelay.inWholeMilliseconds) + } while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) + fail(errorMessage) +} + +/** + * Tries a [block] several times until it runs with no errors or a [timeout] is reached waiting for some [retryDelay] time between retries. + * On timeout it fails with a custom [errorMessage] or a caught [AssertionError]. + */ +suspend fun waitUntil( + errorMessage: String? = null, + timeout: Duration = 1.seconds, + retryDelay: Duration = 50.milliseconds, + block: () -> Unit, +) { + var error: AssertionError? + val start = System.currentTimeMillis() + do { + try { + block() + return + } catch (e: AssertionError) { + error = e + } + delay(retryDelay.inWholeMilliseconds) + } while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) + if (errorMessage != null) { + fail(errorMessage) + } else { + throw error!! + } +} diff --git a/vector/src/androidTest/java/im/vector/app/features/html/SpanUtilsTest.kt b/vector/src/androidTest/java/im/vector/app/features/html/SpanUtilsTest.kt index 428efdea86..0c8aa95ee4 100644 --- a/vector/src/androidTest/java/im/vector/app/features/html/SpanUtilsTest.kt +++ b/vector/src/androidTest/java/im/vector/app/features/html/SpanUtilsTest.kt @@ -25,9 +25,11 @@ import android.text.style.ForegroundColorSpan import android.text.style.StrikethroughSpan import android.text.style.UnderlineSpan import androidx.emoji2.text.EmojiCompat +import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.InstrumentedTest import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeTrue +import org.junit.BeforeClass import org.junit.FixMethodOrder import org.junit.Ignore import org.junit.Test @@ -42,6 +44,14 @@ import java.util.concurrent.TimeUnit @Ignore class SpanUtilsTest : InstrumentedTest { + companion object { + @BeforeClass + @JvmStatic + fun setupClass() { + EmojiCompat.init(InstrumentationRegistry.getInstrumentation().targetContext) + } + } + private val spanUtils = SpanUtils { val emojiCompat = EmojiCompat.get() emojiCompat.waitForInit() diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/LockScreenTestConstants.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/LockScreenTestConstants.kt new file mode 100644 index 0000000000..21e15e1585 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/LockScreenTestConstants.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 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.pin.lockscreen + +object LockScreenTestConstants { + const val ALIAS = "some_alias" +} diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt new file mode 100644 index 0000000000..b519d2f623 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2022 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.pin.lockscreen.biometrics + +import android.content.Intent +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.lifecycle.lifecycleScope +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.TestBuildVersionSdkIntProvider +import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration +import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider +import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode +import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository +import im.vector.app.features.pin.lockscreen.tests.LockScreenTestActivity +import im.vector.app.features.pin.lockscreen.ui.fallbackprompt.FallbackBiometricDialogFragment +import im.vector.app.features.pin.lockscreen.utils.DevicePromptCheck +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class BiometricHelperTests { + + private val biometricManager = mockk(relaxed = true) + private val lockScreenKeyRepository = mockk(relaxed = true) + private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider() + + @Before + fun setup() { + clearAllMocks() + } + + @Test + fun canUseWeakBiometricAuthReturnsTrueIfIsFaceUnlockEnabledAndCanAuthenticate() { + every { biometricManager.canAuthenticate(BIOMETRIC_WEAK) } returns BIOMETRIC_SUCCESS + val configuration = createDefaultConfiguration(isFaceUnlockEnabled = true) + val biometricUtils = createBiometricHelper(configuration) + + biometricUtils.canUseWeakBiometricAuth.shouldBeTrue() + + val biometricUtilsWithDisabledAuth = createBiometricHelper(createDefaultConfiguration(isFaceUnlockEnabled = false)) + biometricUtilsWithDisabledAuth.canUseWeakBiometricAuth.shouldBeFalse() + + every { biometricManager.canAuthenticate(BIOMETRIC_WEAK) } returns BIOMETRIC_ERROR_NONE_ENROLLED + biometricUtils.canUseWeakBiometricAuth.shouldBeFalse() + } + + @Test + fun canUseStrongBiometricAuthReturnsTrueIfIsBiometricsEnabledAndCanAuthenticate() { + every { biometricManager.canAuthenticate(BIOMETRIC_STRONG) } returns BIOMETRIC_SUCCESS + val configuration = createDefaultConfiguration(isBiometricsEnabled = true) + val biometricUtils = createBiometricHelper(configuration) + + biometricUtils.canUseStrongBiometricAuth.shouldBeTrue() + + val biometricUtilsWithDisabledAuth = createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = false)) + biometricUtilsWithDisabledAuth.canUseStrongBiometricAuth.shouldBeFalse() + + every { biometricManager.canAuthenticate(BIOMETRIC_STRONG) } returns BIOMETRIC_ERROR_NONE_ENROLLED + biometricUtils.canUseStrongBiometricAuth.shouldBeFalse() + } + + @Test + fun canUseDeviceCredentialAuthReturnsTrueIfIsDeviceCredentialsUnlockEnabledAndCanAuthenticate() { + every { biometricManager.canAuthenticate(DEVICE_CREDENTIAL) } returns BIOMETRIC_SUCCESS + val configuration = createDefaultConfiguration(isDeviceCredentialUnlockEnabled = true) + val biometricUtils = createBiometricHelper(configuration) + + biometricUtils.canUseDeviceCredentialsAuth.shouldBeTrue() + + val biometricUtilsWithDisabledAuth = createBiometricHelper(createDefaultConfiguration(isDeviceCredentialUnlockEnabled = false)) + biometricUtilsWithDisabledAuth.canUseDeviceCredentialsAuth.shouldBeFalse() + + every { biometricManager.canAuthenticate(DEVICE_CREDENTIAL) } returns BIOMETRIC_ERROR_NONE_ENROLLED + biometricUtils.canUseDeviceCredentialsAuth.shouldBeFalse() + } + + @Test + fun isSystemAuthEnabledReturnsTrueIfAnyAuthenticationMethodIsAvailableAndEnabledAndSystemKeyExists() { + val biometricHelper = mockk(relaxed = true) { + every { hasSystemKey } returns true + every { isSystemKeyValid } returns true + every { canUseAnySystemAuth } answers { callOriginal() } + every { isSystemAuthEnabledAndValid } answers { callOriginal() } + } + biometricHelper.isSystemAuthEnabledAndValid.shouldBeFalse() + + every { biometricHelper.canUseWeakBiometricAuth } returns true + biometricHelper.isSystemAuthEnabledAndValid.shouldBeTrue() + + every { biometricHelper.canUseWeakBiometricAuth } returns false + every { biometricHelper.canUseStrongBiometricAuth } returns true + biometricHelper.isSystemAuthEnabledAndValid.shouldBeTrue() + + every { biometricHelper.canUseStrongBiometricAuth } returns false + every { biometricHelper.canUseDeviceCredentialsAuth } returns true + biometricHelper.isSystemAuthEnabledAndValid.shouldBeTrue() + + every { biometricHelper.isSystemKeyValid } returns false + biometricHelper.isSystemAuthEnabledAndValid.shouldBeFalse() + } + + @Test + fun hasSystemKeyReturnsKeyHelperHasSystemKey() { + val biometricUtils = createBiometricHelper(createDefaultConfiguration()) + every { lockScreenKeyRepository.hasSystemKey() } returns true + biometricUtils.hasSystemKey.shouldBeTrue() + + every { lockScreenKeyRepository.hasSystemKey() } returns false + biometricUtils.hasSystemKey.shouldBeFalse() + } + + @Test + fun isSystemKeyValidReturnsKeyHelperIsSystemKeyValid() { + val biometricUtils = createBiometricHelper(createDefaultConfiguration()) + every { lockScreenKeyRepository.isSystemKeyValid() } returns true + biometricUtils.isSystemKeyValid.shouldBeTrue() + + every { lockScreenKeyRepository.isSystemKeyValid() } returns false + biometricUtils.isSystemKeyValid.shouldBeFalse() + } + + @Test + fun disableAuthenticationDeletesSystemKeyAndCancelsPrompt() { + val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration())) + biometricUtils.disableAuthentication() + + verify { lockScreenKeyRepository.deleteSystemKey() } + verify { biometricUtils.cancelPrompt() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Ignore("This won't work in CI as the emulator won't have biometric auth enabled.") + @Test + fun authenticateShowsPrompt() = runTest { + val biometricUtils = createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true)) + every { lockScreenKeyRepository.isSystemKeyValid() } returns true + val latch = CountDownLatch(1) + with(ActivityScenario.launch(LockScreenTestActivity::class.java)) { + onActivity { activity -> + biometricUtils.authenticate(activity) + activity.supportFragmentManager.fragments.isNotEmpty().shouldBeTrue() + close() + latch.countDown() + } + } + latch.await(1, TimeUnit.SECONDS) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun authenticateInDeviceWithIssuesShowsFallbackPromptDialog() = runTest { + mockkStatic("kotlinx.coroutines.flow.FlowKt") + val mockAuthChannel: Channel = mockk(relaxed = true) { + // Empty flow to keep the dialog open + every { receiveAsFlow() } returns flowOf() + } + val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true))) { + every { createAuthChannel() } returns mockAuthChannel + } + mockkObject(DevicePromptCheck) + every { DevicePromptCheck.isDeviceWithNoBiometricUI } returns true + every { lockScreenKeyRepository.isSystemKeyValid() } returns true + val latch = CountDownLatch(1) + val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, LockScreenTestActivity::class.java) + with(ActivityScenario.launch(intent)) { + onActivity { activity -> + biometricUtils.authenticate(activity) + launch { + activity.supportFragmentManager.fragments.any { it is FallbackBiometricDialogFragment }.shouldBeTrue() + close() + latch.countDown() + } + } + } + latch.await(1, TimeUnit.SECONDS) + unmockkObject(DevicePromptCheck) + unmockkStatic("kotlinx.coroutines.flow.FlowKt") + } + + @Test + fun authenticateCreatesSystemKeyIfNeededOnSuccessOnAndroidM() = runTest { + buildVersionSdkIntProvider.value = Build.VERSION_CODES.M + every { lockScreenKeyRepository.isSystemKeyValid() } returns true + val mockAuthChannel = Channel(capacity = 1) + val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true))) { + every { createAuthChannel() } returns mockAuthChannel + every { authenticateWithPromptInternal(any(), any(), any()) } returns mockk() + } + + val latch = CountDownLatch(1) + val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, LockScreenTestActivity::class.java) + ActivityScenario.launch(intent).onActivity { activity -> + activity.lifecycleScope.launch { + launch { + mockAuthChannel.send(true) + mockAuthChannel.close() + } + biometricUtils.authenticate(activity).collect() + latch.countDown() + } + } + + latch.await(1, TimeUnit.SECONDS) + verify { lockScreenKeyRepository.ensureSystemKey() } + } + + private fun createBiometricHelper(configuration: LockScreenConfiguration): BiometricHelper { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val configProvider = LockScreenConfiguratorProvider(configuration) + return BiometricHelper(context, lockScreenKeyRepository, configProvider, biometricManager, buildVersionSdkIntProvider) + } + + private fun createDefaultConfiguration( + mode: LockScreenMode = LockScreenMode.VERIFY, + pinCodeLength: Int = 4, + isBiometricsEnabled: Boolean = false, + isFaceUnlockEnabled: Boolean = false, + isDeviceCredentialUnlockEnabled: Boolean = false, + needsNewCodeValidation: Boolean = false, + otherChanges: LockScreenConfiguration.() -> LockScreenConfiguration = { this }, + ): LockScreenConfiguration = LockScreenConfiguration( + mode, + pinCodeLength, + isBiometricsEnabled, + isFaceUnlockEnabled, + isDeviceCredentialUnlockEnabled, + needsNewCodeValidation + ).let(otherChanges) +} diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt new file mode 100644 index 0000000000..68e1244791 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 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.pin.lockscreen.crypto + +import android.os.Build +import android.security.keystore.KeyPermanentlyInvalidatedException +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.TestBuildVersionSdkIntProvider +import io.mockk.every +import io.mockk.spyk +import io.mockk.verify +import org.amshove.kluent.invoking +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldThrow +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.securestorage.SecretStoringUtils +import java.security.KeyStore + +class KeyStoreCryptoTests { + + private val alias = "some_alias" + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } + private val versionProvider = TestBuildVersionSdkIntProvider().also { it.value = Build.VERSION_CODES.M } + private val secretStoringUtils = spyk(SecretStoringUtils(context, keyStore, versionProvider)) + private val keyStoreCrypto = spyk( + KeyStoreCrypto(alias, false, context, versionProvider, keyStore, secretStoringUtils) + ) + + @After + fun setup() { + keyStore.deleteEntry(alias) + } + + @Test + fun ensureKeyChecksValidityOfKeyAndThrows() { + keyStore.containsAlias(alias) shouldBe false + + val exception = KeyPermanentlyInvalidatedException() + every { secretStoringUtils.getEncryptCipher(any()) } throws exception + + invoking { keyStoreCrypto.ensureKey() } shouldThrow exception + keyStoreCrypto.hasValidKey() shouldBe false + } + + @Test + fun hasValidKeyChecksValidityOfKey() { + runCatching { keyStoreCrypto.ensureKey() } + keyStoreCrypto.hasValidKey() shouldBe true + + val exception = KeyPermanentlyInvalidatedException() + every { secretStoringUtils.getEncryptCipher(any()) } throws exception + + runCatching { keyStoreCrypto.ensureKey() } + keyStoreCrypto.hasValidKey() shouldBe false + } + + @Test + fun hasKeyChecksIfKeyExists() { + keyStoreCrypto.hasKey() shouldBe false + + keyStoreCrypto.ensureKey() + keyStoreCrypto.hasKey() shouldBe true + keyStore.containsAlias(keyStoreCrypto.alias) + + keyStoreCrypto.deleteKey() + keyStoreCrypto.hasKey() shouldBe false + } + + @Test + fun deleteKeyRemovesTheKey() { + keyStore.containsAlias(alias) shouldBe false + + keyStoreCrypto.ensureKey() + keyStore.containsAlias(alias) shouldBe true + + keyStoreCrypto.deleteKey() + keyStore.containsAlias(alias) shouldBe false + } + + @Test + fun checkEncryptionAndDecryptionOfStringsWorkAsExpected() { + val original = "some plain text" + val encryptedString = keyStoreCrypto.encryptToString(original) + val encryptedBytes = keyStoreCrypto.encrypt(original) + val result = keyStoreCrypto.decryptToString(encryptedString) + val resultFromBytes = keyStoreCrypto.decryptToString(encryptedBytes) + result shouldBeEqualTo original + resultFromBytes shouldBeEqualTo original + } + + @Test + fun checkEncryptionAndDecryptionWorkAsExpected() { + val original = "some plain text".toByteArray() + val encryptedBytes = keyStoreCrypto.encrypt(original) + val encryptedString = keyStoreCrypto.encryptToString(original) + val result = keyStoreCrypto.decrypt(encryptedBytes) + val resultFromString = keyStoreCrypto.decrypt(encryptedString) + result shouldBeEqualTo original + resultFromString shouldBeEqualTo original + } + + @Test + fun hasValidKeyReturnsFalseWhenKeyPermanentlyInvalidatedExceptionIsThrown() { + every { keyStoreCrypto.hasKey() } returns true + every { secretStoringUtils.getEncryptCipher(any()) } throws KeyPermanentlyInvalidatedException() + + keyStoreCrypto.hasValidKey().shouldBeFalse() + } + + @Test + fun hasValidKeyReturnsFalseWhenKeyDoesNotExist() { + every { keyStoreCrypto.hasKey() } returns false + keyStoreCrypto.hasValidKey().shouldBeFalse() + } + + @Test + fun hasValidKeyReturnsIfKeyExistsOnAndroidL() { + versionProvider.value = Build.VERSION_CODES.LOLLIPOP + + every { keyStoreCrypto.hasKey() } returns true + keyStoreCrypto.hasValidKey().shouldBeTrue() + + every { keyStoreCrypto.hasKey() } returns false + keyStoreCrypto.hasValidKey().shouldBeFalse() + } + + @Test + fun getCryptoObjectUsesCipherFromSecretStoringUtils() { + keyStoreCrypto.getCryptoObject() + verify { secretStoringUtils.getEncryptCipher(any()) } + + every { secretStoringUtils.getEncryptCipher(any()) } throws KeyPermanentlyInvalidatedException() + invoking { keyStoreCrypto.getCryptoObject() } shouldThrow KeyPermanentlyInvalidatedException::class + } +} diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt new file mode 100644 index 0000000000..23eefe6577 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2022 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.pin.lockscreen.crypto + +import android.security.keystore.KeyPermanentlyInvalidatedException +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.features.settings.VectorPreferences +import io.mockk.clearAllMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.coInvoking +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldNotThrow +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider +import java.security.KeyStore + +class LockScreenKeyRepositoryTests { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val buildVersionSdkIntProvider = DefaultBuildVersionSdkIntProvider() + + private val keyStoreCryptoFactory: KeyStoreCrypto.Factory = mockk { + every { provide(any(), any()) } answers { + KeyStoreCrypto(arg(0), false, context, buildVersionSdkIntProvider, keyStore) + } + } + + private lateinit var lockScreenKeyRepository: LockScreenKeyRepository + private val pinCodeMigrator: PinCodeMigrator = mockk(relaxed = true) + private val vectorPreferences: VectorPreferences = mockk(relaxed = true) + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(LockScreenCryptoConstants.ANDROID_KEY_STORE).also { it.load(null) } + } + + @Before + fun setup() { + lockScreenKeyRepository = spyk(LockScreenKeyRepository("base", pinCodeMigrator, vectorPreferences, keyStoreCryptoFactory)) + } + + @After + fun tearDown() { + clearAllMocks() + keyStore.deleteEntry("base.pin_code") + keyStore.deleteEntry("base.system") + } + + @Test + fun ensureSystemKeyCreatesSystemKeyIfNeeded() { + lockScreenKeyRepository.ensureSystemKey() + lockScreenKeyRepository.hasSystemKey().shouldBeTrue() + } + + @Test + fun encryptPinCodeCreatesPinCodeKey() { + lockScreenKeyRepository.encryptPinCode("1234") + lockScreenKeyRepository.hasPinCodeKey().shouldBeTrue() + } + + @Test + fun decryptPinCodeDecryptsEncodedPinCode() { + val decodedPinCode = "1234" + val pinCodeKeyCryptoMock = mockk(relaxed = true) { + every { decryptToString(any()) } returns decodedPinCode + } + every { keyStoreCryptoFactory.provide(any(), any()) } returns pinCodeKeyCryptoMock + lockScreenKeyRepository.decryptPinCode("SOME_VALUE") shouldBeEqualTo decodedPinCode + } + + @Test + fun isSystemKeyValidReturnsWhatKeyStoreCryptoHasValidKeyReplies() { + val systemKeyCryptoMock = mockk(relaxed = true) { + every { hasKey() } returns true + } + every { keyStoreCryptoFactory.provide(any(), any()) } returns systemKeyCryptoMock + + every { systemKeyCryptoMock.hasValidKey() } returns false + lockScreenKeyRepository.isSystemKeyValid().shouldBeFalse() + + every { systemKeyCryptoMock.hasValidKey() } returns true + lockScreenKeyRepository.isSystemKeyValid().shouldBeTrue() + } + + @Test + fun hasSystemKeyReturnsTrueAfterSystemKeyIsCreated() { + lockScreenKeyRepository.hasSystemKey().shouldBeFalse() + + lockScreenKeyRepository.ensureSystemKey() + + lockScreenKeyRepository.hasSystemKey().shouldBeTrue() + } + + @Test + fun hasPinCodeKeyReturnsTrueAfterPinCodeKeyIsCreated() { + lockScreenKeyRepository.hasPinCodeKey().shouldBeFalse() + + lockScreenKeyRepository.encryptPinCode("1234") + + lockScreenKeyRepository.hasPinCodeKey().shouldBeTrue() + } + + @Test + fun deleteSystemKeyRemovesTheKeyFromKeyStore() { + lockScreenKeyRepository.ensureSystemKey() + lockScreenKeyRepository.hasSystemKey().shouldBeTrue() + + lockScreenKeyRepository.deleteSystemKey() + + lockScreenKeyRepository.hasSystemKey().shouldBeFalse() + } + + @Test + fun deletePinCodeKeyRemovesTheKeyFromKeyStore() { + lockScreenKeyRepository.encryptPinCode("1234") + lockScreenKeyRepository.hasPinCodeKey().shouldBeTrue() + + lockScreenKeyRepository.deletePinCodeKey() + + lockScreenKeyRepository.hasPinCodeKey().shouldBeFalse() + } + + @Test + fun migrateKeysIfNeededReturnsEarlyIfNotNeeded() = runTest { + every { pinCodeMigrator.isMigrationNeeded() } returns false + + lockScreenKeyRepository.migrateKeysIfNeeded() + + coVerify(exactly = 0) { pinCodeMigrator.migrate(any()) } + } + + @Test + fun migrateKeysIfNeededWillMigratePinCodeAndKeys() = runTest { + every { pinCodeMigrator.isMigrationNeeded() } returns true + + lockScreenKeyRepository.migrateKeysIfNeeded() + + coVerify { pinCodeMigrator.migrate(any()) } + } + + @Test + fun migrateKeysIfNeededWillCreateSystemKeyIfNeeded() = runTest { + every { pinCodeMigrator.isMigrationNeeded() } returns true + every { vectorPreferences.useBiometricsToUnlock() } returns true + every { lockScreenKeyRepository.ensureSystemKey() } returns mockk() + + lockScreenKeyRepository.migrateKeysIfNeeded() + + verify { lockScreenKeyRepository.ensureSystemKey() } + } + + @Test + fun migrateKeysIfNeededWillHandleKeyPermanentlyInvalidatedException() = runTest { + every { pinCodeMigrator.isMigrationNeeded() } returns true + every { vectorPreferences.useBiometricsToUnlock() } returns true + every { lockScreenKeyRepository.ensureSystemKey() } throws KeyPermanentlyInvalidatedException() + + coInvoking { lockScreenKeyRepository.migrateKeysIfNeeded() } shouldNotThrow KeyPermanentlyInvalidatedException::class + + verify { lockScreenKeyRepository.ensureSystemKey() } + } +} diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigratorTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigratorTests.kt new file mode 100644 index 0000000000..297793c7a4 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigratorTests.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2022 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. + */ + +@file:Suppress("DEPRECATION") + +package im.vector.app.features.pin.lockscreen.crypto + +import android.os.Build +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.preference.PreferenceManager +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.features.pin.PinCodeStore +import im.vector.app.features.pin.SharedPrefPinCodeStore +import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants.ANDROID_KEY_STORE +import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants.LEGACY_PIN_CODE_KEY_ALIAS +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.securestorage.SecretStoringUtils +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.spec.MGF1ParameterSpec +import java.security.spec.X509EncodedKeySpec +import java.util.Calendar +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.security.auth.x500.X500Principal +import kotlin.math.abs + +class PinCodeMigratorTests { + + private val alias = UUID.randomUUID().toString() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val pinCodeStore: PinCodeStore = spyk( + SharedPrefPinCodeStore(PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().context)) + ) + private val keyStore: KeyStore = spyk(KeyStore.getInstance(ANDROID_KEY_STORE)).also { it.load(null) } + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider = mockk { + every { get() } returns Build.VERSION_CODES.M + } + private val secretStoringUtils: SecretStoringUtils = spyk( + SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider) + ) + private val pinCodeMigrator = spyk(PinCodeMigrator(pinCodeStore, keyStore, secretStoringUtils, buildVersionSdkIntProvider)) + + @After + fun tearDown() { + if (keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)) { + keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) + } + if (keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias) + } + runBlocking { pinCodeStore.deletePinCode() } + } + + @Test + fun isMigrationNeededReturnsTrueIfLegacyKeyExists() { + pinCodeMigrator.isMigrationNeeded() shouldBe false + + generateLegacyKey() + + pinCodeMigrator.isMigrationNeeded() shouldBe true + } + + @Test + fun migrateWillReturnEarlyIfPinCodeDoesNotExist() = runTest { + every { pinCodeMigrator.isMigrationNeeded() } returns false + coEvery { pinCodeStore.getPinCode() } returns null + + pinCodeMigrator.migrate(alias) + + coVerify(exactly = 0) { pinCodeMigrator.getDecryptedPinCode() } + verify(exactly = 0) { secretStoringUtils.securelyStoreBytes(any(), any()) } + coVerify(exactly = 0) { pinCodeStore.savePinCode(any()) } + verify(exactly = 0) { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } + } + + @Test + fun migrateWillReturnEarlyIfIsNotNeeded() = runTest { + every { pinCodeMigrator.isMigrationNeeded() } returns false + coEvery { pinCodeMigrator.getDecryptedPinCode() } returns "1234" + every { secretStoringUtils.securelyStoreBytes(any(), any()) } returns ByteArray(0) + + pinCodeMigrator.migrate(alias) + + coVerify(exactly = 0) { pinCodeMigrator.getDecryptedPinCode() } + verify(exactly = 0) { secretStoringUtils.securelyStoreBytes(any(), any()) } + coVerify(exactly = 0) { pinCodeStore.savePinCode(any()) } + verify(exactly = 0) { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } + } + + @Test + fun migratePinCodeM() = runTest { + val pinCode = "1234" + saveLegacyPinCode(pinCode) + + pinCodeMigrator.migrate(alias) + + coVerify { pinCodeMigrator.getDecryptedPinCode() } + verify { secretStoringUtils.securelyStoreBytes(any(), any()) } + coVerify { pinCodeStore.savePinCode(any()) } + verify { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } + + val decodedPinCode = String(secretStoringUtils.loadSecureSecretBytes(Base64.decode(pinCodeStore.getPinCode().orEmpty(), Base64.NO_WRAP), alias)) + decodedPinCode shouldBeEqualTo pinCode + keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS) shouldBe false + keyStore.containsAlias(alias) shouldBe true + } + + @Test + fun migratePinCodeL() = runTest { + val pinCode = "1234" + every { buildVersionSdkIntProvider.get() } returns Build.VERSION_CODES.LOLLIPOP + saveLegacyPinCode(pinCode) + + pinCodeMigrator.migrate(alias) + + coVerify { pinCodeMigrator.getDecryptedPinCode() } + verify { secretStoringUtils.securelyStoreBytes(any(), any()) } + coVerify { pinCodeStore.savePinCode(any()) } + verify { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } + + val decodedPinCode = String(secretStoringUtils.loadSecureSecretBytes(Base64.decode(pinCodeStore.getPinCode().orEmpty(), Base64.NO_WRAP), alias)) + decodedPinCode shouldBeEqualTo pinCode + keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS) shouldBe false + keyStore.containsAlias(alias) shouldBe true + } + + private fun generateLegacyKey() { + if (keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)) return + + if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M) { + generateLegacyKeyM() + } else { + generateLegacyKeyL() + } + } + + private fun generateLegacyKeyL() { + val start = Calendar.getInstance() + val end = Calendar.getInstance().also { it.add(Calendar.YEAR, 25) } + + val keyGen = KeyPairGenerator + .getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEY_STORE) + + val spec = KeyPairGeneratorSpec.Builder(context) + .setAlias(LEGACY_PIN_CODE_KEY_ALIAS) + .setSubject(X500Principal("CN=$LEGACY_PIN_CODE_KEY_ALIAS")) + .setSerialNumber(BigInteger.valueOf(abs(LEGACY_PIN_CODE_KEY_ALIAS.hashCode()).toLong())) + .setEndDate(end.time) + .setStartDate(start.time) + .setSerialNumber(BigInteger.ONE) + .setSubject(X500Principal("CN = Secured Preference Store, O = Devliving Online")) + .build() + + keyGen.initialize(spec) + keyGen.generateKeyPair() + } + + private fun generateLegacyKeyM() { + val keyGenerator: KeyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEY_STORE) + keyGenerator.initialize( + KeyGenParameterSpec.Builder(LEGACY_PIN_CODE_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + ) + keyGenerator.generateKeyPair() + } + + private suspend fun saveLegacyPinCode(value: String) { + generateLegacyKey() + val publicKey = keyStore.getCertificate(LEGACY_PIN_CODE_KEY_ALIAS).publicKey + val cipher = getLegacyCipher() + if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M) { + val unrestrictedKey = KeyFactory.getInstance(publicKey.algorithm).generatePublic(X509EncodedKeySpec(publicKey.encoded)) + val spec = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT) + cipher.init(Cipher.ENCRYPT_MODE, unrestrictedKey, spec) + } else { + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + } + val bytes = cipher.doFinal(value.toByteArray()) + val encryptedPinCode = Base64.encodeToString(bytes, Base64.NO_WRAP) + pinCodeStore.savePinCode(encryptedPinCode) + } + + private fun getLegacyCipher(): Cipher { + return when (buildVersionSdkIntProvider.get()) { + Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1 -> getCipherL() + else -> getCipherM() + } + } + + private fun getCipherL(): Cipher { + val provider = if (buildVersionSdkIntProvider.get() < Build.VERSION_CODES.M) "AndroidOpenSSL" else "AndroidKeyStoreBCWorkaround" + val transformation = "RSA/ECB/PKCS1Padding" + return Cipher.getInstance(transformation, provider) + } + + private fun getCipherM(): Cipher { + val transformation = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + return Cipher.getInstance(transformation) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/tests/LockScreenTestActivity.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/tests/LockScreenTestActivity.kt new file mode 100644 index 0000000000..1545e140a0 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/tests/LockScreenTestActivity.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 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.pin.lockscreen.tests + +import androidx.fragment.app.FragmentActivity + +class LockScreenTestActivity : FragmentActivity() diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/ui/fallbackprompt/FallbackBiometricDialogFragmentTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/ui/fallbackprompt/FallbackBiometricDialogFragmentTests.kt new file mode 100644 index 0000000000..3781535f72 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/ui/fallbackprompt/FallbackBiometricDialogFragmentTests.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2022 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.pin.lockscreen.ui.fallbackprompt + +import android.view.View +import android.widget.Button +import android.widget.TextView +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.testing.launchFragment +import androidx.lifecycle.Lifecycle +import androidx.test.platform.app.InstrumentationRegistry +import com.airbnb.mvrx.Mavericks +import im.vector.app.R +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import java.util.concurrent.CountDownLatch + +class FallbackBiometricDialogFragmentTests { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun dismissTriggersOnDismissCallback() { + val latch = CountDownLatch(1) + val fragmentScenario = launchFragment(noArgsBundle()) + fragmentScenario.onFragment { fragment -> + fragment.onDismiss = { latch.countDown() } + fragment.dismiss() + } + latch.await() + } + + @Test + fun argsModifyUI() { + val latch = CountDownLatch(1) + val args = FallbackBiometricDialogFragment.Args( + title = "Title", + description = "Description", + cancelActionText = "Cancel text", + ) + val fragmentScenario = launchFragment(bundleOf(Mavericks.KEY_ARG to args)) + fragmentScenario.onFragment { fragment -> + val view = fragment.requireView() + view.findViewById