Merge branch 'release/0.4.15' into main
This commit is contained in:
commit
d574584a4e
1502 changed files with 10092 additions and 3996 deletions
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -9,8 +9,8 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC -Dkotlin.daemon.jvm.options="-Xmx3g"
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
debug:
|
debug:
|
||||||
|
|
|
||||||
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
||||||
- run: |
|
- run: |
|
||||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||||
- name: Danger
|
- name: Danger
|
||||||
uses: danger/danger-js@12.3.1
|
uses: danger/danger-js@12.3.2
|
||||||
with:
|
with:
|
||||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
4
.github/workflows/maestro.yml
vendored
4
.github/workflows/maestro.yml
vendored
|
|
@ -8,8 +8,8 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC -Dkotlin.daemon.jvm.options="-Xmx3g"
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-apk:
|
build-apk:
|
||||||
|
|
|
||||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
|
|
@ -7,8 +7,8 @@ on:
|
||||||
- cron: "0 4 * * *"
|
- cron: "0 4 * * *"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nightly:
|
nightly:
|
||||||
|
|
|
||||||
4
.github/workflows/nightlyReports.yml
vendored
4
.github/workflows/nightlyReports.yml
vendored
|
|
@ -8,8 +8,8 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx3g" -Dkotlin.incremental=false -XX:+UseG1GC
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nightlyReports:
|
nightlyReports:
|
||||||
|
|
|
||||||
42
.github/workflows/quality.yml
vendored
42
.github/workflows/quality.yml
vendored
|
|
@ -10,7 +10,7 @@ on:
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checkScript:
|
checkScript:
|
||||||
|
|
@ -33,6 +33,27 @@ jobs:
|
||||||
- name: Search for invalid screenshot files
|
- name: Search for invalid screenshot files
|
||||||
run: ./tools/test/checkInvalidScreenshots.py
|
run: ./tools/test/checkInvalidScreenshots.py
|
||||||
|
|
||||||
|
checkDependencies:
|
||||||
|
name: Search for invalid dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
java-version: '17'
|
||||||
|
- name: Configure gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v3
|
||||||
|
with:
|
||||||
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
- name: Search for invalid dependencies
|
||||||
|
run: ./tools/dependencies/checkDependencies.py
|
||||||
|
|
||||||
# Code checks
|
# Code checks
|
||||||
konsist:
|
konsist:
|
||||||
name: Konsist tests
|
name: Konsist tests
|
||||||
|
|
@ -88,6 +109,10 @@ jobs:
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/actions/setup-gradle@v3
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
|
- name: Build Gplay Debug
|
||||||
|
run: ./gradlew :app:compileGplayDebugKotlin $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
- name: Build Fdroid Debug
|
||||||
|
run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES
|
||||||
- name: Upload reports
|
- name: Upload reports
|
||||||
|
|
@ -187,6 +212,19 @@ jobs:
|
||||||
- name: Run Knit
|
- name: Run Knit
|
||||||
run: ./gradlew knitCheck $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew knitCheck $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
|
||||||
|
# Note: to auto fix issues you can use the following command:
|
||||||
|
# shellcheck -f diff <files> | git apply
|
||||||
|
shellcheck:
|
||||||
|
name: Check shell scripts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run shellcheck
|
||||||
|
uses: ludeeus/action-shellcheck@2.0.0
|
||||||
|
with:
|
||||||
|
scandir: ./tools
|
||||||
|
severity: warning
|
||||||
|
|
||||||
upload_reports:
|
upload_reports:
|
||||||
name: Project Check Suite
|
name: Project Check Suite
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -207,7 +245,7 @@ jobs:
|
||||||
yarn add danger-plugin-lint-report --dev
|
yarn add danger-plugin-lint-report --dev
|
||||||
- name: Danger lint
|
- name: Danger lint
|
||||||
if: always()
|
if: always()
|
||||||
uses: danger/danger-js@12.3.1
|
uses: danger/danger-js@12.3.2
|
||||||
with:
|
with:
|
||||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
2
.github/workflows/recordScreenshots.yml
vendored
2
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx5g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC -Dsonar.gradle.skipCompile=true
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx5g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
record:
|
record:
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
gplay:
|
gplay:
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,10 @@ if [[ -z ${REPO} ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Deleting previous screenshots"
|
echo "Deleting previous screenshots"
|
||||||
./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
./gradlew removeOldSnapshots --stacktrace --warn
|
||||||
|
|
||||||
echo "Record screenshots"
|
echo "Record screenshots"
|
||||||
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
./gradlew recordPaparazziDebug --stacktrace --warn
|
||||||
|
|
||||||
echo "Committing changes"
|
echo "Committing changes"
|
||||||
git config http.sslVerify false
|
git config http.sslVerify false
|
||||||
|
|
|
||||||
21
.github/workflows/sonar.yml
vendored
21
.github/workflows/sonar.yml
vendored
|
|
@ -9,8 +9,9 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.incremental=false -XX:+UseG1GC
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --warn -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true
|
||||||
|
GROUP: ${{ format('sonar-{0}', github.ref) }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sonar:
|
sonar:
|
||||||
|
|
@ -18,8 +19,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Allow all jobs on main and develop. Just one per PR.
|
# Allow all jobs on main and develop. Just one per PR.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.ref == 'refs/heads/main' && format('sonar-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('sonar-develop-{0}', github.sha) || format('sonar-{0}', github.ref) }}
|
group: ${{ format('sonar-{0}', github.ref) }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -35,8 +36,16 @@ jobs:
|
||||||
uses: gradle/actions/setup-gradle@v3
|
uses: gradle/actions/setup-gradle@v3
|
||||||
with:
|
with:
|
||||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||||
- name: Build projects
|
- name: Build Gplay Debug
|
||||||
run: ./gradlew assembleDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
- name: Build Fdroid Debug
|
||||||
|
run: ./gradlew :app:assembleFdroidDebug $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
- name: Build Sample
|
||||||
|
run: ./gradlew :samples:minimal:assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
- name: Build library fixtures
|
||||||
|
run: ./gradlew assembleDebug createFullJarDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
- name: Build app fixtures
|
||||||
|
run: ./gradlew :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
|
||||||
- name: 🔊 Publish results to Sonar
|
- name: 🔊 Publish results to Sonar
|
||||||
env:
|
env:
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
|
|
||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -10,7 +10,7 @@ on:
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --warn -Dsonar.gradle.skipCompile=true
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
|
|
|
||||||
1
.idea/dictionaries/shared.xml
generated
1
.idea/dictionaries/shared.xml
generated
|
|
@ -13,6 +13,7 @@
|
||||||
<w>onboarding</w>
|
<w>onboarding</w>
|
||||||
<w>placeables</w>
|
<w>placeables</w>
|
||||||
<w>posthog</w>
|
<w>posthog</w>
|
||||||
|
<w>rageshake</w>
|
||||||
<w>securebackup</w>
|
<w>securebackup</w>
|
||||||
<w>showkase</w>
|
<w>showkase</w>
|
||||||
<w>snackbar</w>
|
<w>snackbar</w>
|
||||||
|
|
|
||||||
23
CHANGES.md
23
CHANGES.md
|
|
@ -1,3 +1,26 @@
|
||||||
|
Changes in Element X v0.4.15 (2024-06-19)
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
Features ✨
|
||||||
|
----------
|
||||||
|
- Ringing call notifications and full screen ringing screen for DMs when the device is locked. ([#2894](https://github.com/element-hq/element-x-android/issues/2894))
|
||||||
|
|
||||||
|
Bugfixes 🐛
|
||||||
|
----------
|
||||||
|
- Improve UX on notification setting changes. ([#1647](https://github.com/element-hq/element-x-android/issues/1647))
|
||||||
|
- Fix tracing configuration in debug and nightlies:
|
||||||
|
- Debug will now write the logs to disk too.
|
||||||
|
- Nightly will be able to customise tracing filters.
|
||||||
|
- Improved the configure tracing and bug report screens. ([#3016](https://github.com/element-hq/element-x-android/issues/3016))
|
||||||
|
|
||||||
|
Other changes
|
||||||
|
-------------
|
||||||
|
- Allow cancelling jump to event in timeline. ([#2876](https://github.com/element-hq/element-x-android/issues/2876))
|
||||||
|
- Make Element Call widget URL configurable ([#3009](https://github.com/element-hq/element-x-android/issues/3009))
|
||||||
|
- Enable hidden access to developer options in release mode apps. ([#3020](https://github.com/element-hq/element-x-android/issues/3020))
|
||||||
|
- Improve how active calls work by also taking into account external url calls and waiting for the sync process to start before sending the `m.call.notify` event. ([#3029](https://github.com/element-hq/element-x-android/issues/3029))
|
||||||
|
|
||||||
|
|
||||||
Changes in Element X v0.4.14 (2024-06-07)
|
Changes in Element X v0.4.14 (2024-06-07)
|
||||||
=========================================
|
=========================================
|
||||||
|
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -14,6 +14,8 @@ The application is a total rewrite of [Element-Android](https://github.com/eleme
|
||||||
|
|
||||||
Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/).
|
Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/).
|
||||||
|
|
||||||
|
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=io.element.android.x)[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/io.element.android.x)
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
||||||
<!--- TOC -->
|
<!--- TOC -->
|
||||||
|
|
@ -25,13 +27,13 @@ Learn more about why we are building Element X in our blog post: [https://elemen
|
||||||
* [Contributing](#contributing)
|
* [Contributing](#contributing)
|
||||||
* [Build instructions](#build-instructions)
|
* [Build instructions](#build-instructions)
|
||||||
* [Support](#support)
|
* [Support](#support)
|
||||||
* [Copyright & License](#copyright-&-license)
|
* [Copyright and License](#copyright-and-license)
|
||||||
|
|
||||||
<!--- END -->
|
<!--- END -->
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Here are some early screenshots of the application:
|
Here are some screenshots of the application:
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Commands run before taking the screenshots:
|
Commands run before taking the screenshots:
|
||||||
|
|
@ -47,9 +49,9 @@ And to exit demo mode:
|
||||||
adb shell am broadcast -a com.android.systemui.demo -e command exit
|
adb shell am broadcast -a com.android.systemui.demo -e command exit
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|<img src=./docs/images-lfs/screen_1_light.png width=280 />|<img src=./docs/images-lfs/screen_2_light.png width=280 />|<img src=./docs/images-lfs/screen_3_light.png width=280 />|<img src=./docs/images-lfs/screen_4_light.png width=280 />|
|
|<img src="./docs/images-lfs/screen_1_light.png" width="280" />|<img src="./docs/images-lfs/screen_2_light.png" width="280" />|<img src="./docs/images-lfs/screen_3_light.png" width="280" />|<img src="./docs/images-lfs/screen_4_light.png" width="280" />|
|
||||||
|-|-|-|-|
|
|-|-|-|-|
|
||||||
|<img src=./docs/images-lfs/screen_1_dark.png width=280 />|<img src=./docs/images-lfs/screen_2_dark.png width=280 />|<img src=./docs/images-lfs/screen_3_dark.png width=280 />|<img src=./docs/images-lfs/screen_4_dark.png width=280 />|
|
|<img src="./docs/images-lfs/screen_1_dark.png" width="280" />|<img src="./docs/images-lfs/screen_2_dark.png" width="280" />|<img src="./docs/images-lfs/screen_3_dark.png" width="280" />|<img src="./docs/images-lfs/screen_4_dark.png" width="280" />|
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
|
|
@ -90,7 +92,7 @@ When you are experiencing an issue on Element X Android, please first search in
|
||||||
and then in [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org).
|
and then in [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org).
|
||||||
If after your research you still have a question, ask at [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting from the application settings. This is especially recommended when you encounter a crash.
|
If after your research you still have a question, ask at [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting from the application settings. This is especially recommended when you encounter a crash.
|
||||||
|
|
||||||
## Copyright & License
|
## Copyright and License
|
||||||
|
|
||||||
Copyright © New Vector Ltd
|
Copyright © New Vector Ltd
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,6 @@ dependencies {
|
||||||
allLibrariesImpl()
|
allLibrariesImpl()
|
||||||
allServicesImpl()
|
allServicesImpl()
|
||||||
allFeaturesImpl(rootDir, logger)
|
allFeaturesImpl(rootDir, logger)
|
||||||
implementation(projects.features.call)
|
|
||||||
implementation(projects.features.migration.api)
|
implementation(projects.features.migration.api)
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
implementation(projects.appnav)
|
implementation(projects.appnav)
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import com.squareup.anvil.annotations.ContributesTo
|
||||||
import io.element.android.features.api.MigrationEntryPoint
|
import io.element.android.features.api.MigrationEntryPoint
|
||||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||||
import io.element.android.features.lockscreen.api.LockScreenService
|
import io.element.android.features.lockscreen.api.LockScreenService
|
||||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.tracing.TracingService
|
import io.element.android.libraries.matrix.api.tracing.TracingService
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
interface AppBindings {
|
interface AppBindings {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ import androidx.preference.PreferenceManager
|
||||||
import androidx.startup.Initializer
|
import androidx.startup.Initializer
|
||||||
import io.element.android.features.preferences.impl.developer.tracing.SharedPreferencesTracingConfigurationStore
|
import io.element.android.features.preferences.impl.developer.tracing.SharedPreferencesTracingConfigurationStore
|
||||||
import io.element.android.features.preferences.impl.developer.tracing.TargetLogLevelMapBuilder
|
import io.element.android.features.preferences.impl.developer.tracing.TargetLogLevelMapBuilder
|
||||||
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
|
||||||
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
|
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
|
||||||
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
|
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
|
||||||
|
|
@ -36,31 +38,27 @@ class TracingInitializer : Initializer<Unit> {
|
||||||
val tracingService = appBindings.tracingService()
|
val tracingService = appBindings.tracingService()
|
||||||
val bugReporter = appBindings.bugReporter()
|
val bugReporter = appBindings.bugReporter()
|
||||||
Timber.plant(tracingService.createTimberTree())
|
Timber.plant(tracingService.createTimberTree())
|
||||||
val tracingConfiguration = if (BuildConfig.DEBUG) {
|
val tracingConfiguration = if (BuildConfig.BUILD_TYPE == BuildType.RELEASE.name) {
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
val store = SharedPreferencesTracingConfigurationStore(prefs)
|
|
||||||
val builder = TargetLogLevelMapBuilder(store)
|
|
||||||
TracingConfiguration(
|
TracingConfiguration(
|
||||||
filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()),
|
filterConfiguration = TracingFilterConfigurations.release,
|
||||||
writesToLogcat = true,
|
writesToLogcat = false,
|
||||||
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
|
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val config = if (BuildConfig.BUILD_TYPE == "nightly") {
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
TracingFilterConfigurations.nightly
|
val store = SharedPreferencesTracingConfigurationStore(prefs)
|
||||||
} else {
|
val builder = TargetLogLevelMapBuilder(
|
||||||
TracingFilterConfigurations.release
|
tracingConfigurationStore = store,
|
||||||
}
|
defaultConfig = if (BuildConfig.BUILD_TYPE == BuildType.NIGHTLY.name) {
|
||||||
|
TracingFilterConfigurations.nightly
|
||||||
|
} else {
|
||||||
|
TracingFilterConfigurations.debug
|
||||||
|
}
|
||||||
|
)
|
||||||
TracingConfiguration(
|
TracingConfiguration(
|
||||||
filterConfiguration = config,
|
filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()),
|
||||||
writesToLogcat = false,
|
writesToLogcat = BuildConfig.DEBUG,
|
||||||
writesToFilesConfiguration = WriteToFilesConfiguration.Enabled(
|
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
|
||||||
directory = bugReporter.logDirectory().absolutePath,
|
|
||||||
filenamePrefix = "logs",
|
|
||||||
filenameSuffix = null,
|
|
||||||
// Keep a minimum of 1 week of log files.
|
|
||||||
numberOfFiles = 7 * 24,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
bugReporter.setCurrentTracingFilter(tracingConfiguration.filterConfiguration.filter)
|
bugReporter.setCurrentTracingFilter(tracingConfiguration.filterConfiguration.filter)
|
||||||
|
|
@ -69,5 +67,15 @@ class TracingInitializer : Initializer<Unit> {
|
||||||
Os.setenv("RUST_BACKTRACE", "1", true)
|
Os.setenv("RUST_BACKTRACE", "1", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun defaultWriteToDiskConfiguration(bugReporter: BugReporter): WriteToFilesConfiguration.Enabled {
|
||||||
|
return WriteToFilesConfiguration.Enabled(
|
||||||
|
directory = bugReporter.logDirectory().absolutePath,
|
||||||
|
filenamePrefix = "logs",
|
||||||
|
filenameSuffix = null,
|
||||||
|
// Keep a minimum of 1 week of log files.
|
||||||
|
numberOfFiles = 7 * 24,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
|
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
~ Copyright (c) 2023 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.
|
|
||||||
-->
|
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<!-- The https://github.com/LikeTheSalad/android-stem requires a non empty strings.xml -->
|
|
||||||
<string name="ignored_placeholder" translatable="false" tools:ignore="UnusedResources">ignored</string>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<locale android:name="de"/>
|
<locale android:name="de"/>
|
||||||
<locale android:name="en"/>
|
<locale android:name="en"/>
|
||||||
<locale android:name="es"/>
|
<locale android:name="es"/>
|
||||||
|
<locale android:name="et"/>
|
||||||
<locale android:name="fr"/>
|
<locale android:name="fr"/>
|
||||||
<locale android:name="hu"/>
|
<locale android:name="hu"/>
|
||||||
<locale android:name="in"/>
|
<locale android:name="in"/>
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,4 @@ object ApplicationConfig {
|
||||||
* For Element, the value is "Element". We use the same name for desktop and mobile for now.
|
* For Element, the value is "Element". We use the same name for desktop and mobile for now.
|
||||||
*/
|
*/
|
||||||
const val DESKTOP_APPLICATION_NAME: String = "Element"
|
const val DESKTOP_APPLICATION_NAME: String = "Element"
|
||||||
|
|
||||||
/**
|
|
||||||
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
|
|
||||||
*/
|
|
||||||
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,13 @@
|
||||||
package io.element.android.appconfig
|
package io.element.android.appconfig
|
||||||
|
|
||||||
object ElementCallConfig {
|
object ElementCallConfig {
|
||||||
|
/**
|
||||||
|
* The default base URL for the Element Call service.
|
||||||
|
*/
|
||||||
const val DEFAULT_BASE_URL = "https://call.element.io"
|
const val DEFAULT_BASE_URL = "https://call.element.io"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default duration of a ringing call in seconds before it's automatically dismissed.
|
||||||
|
*/
|
||||||
|
const val RINGING_CALL_DURATION_SECONDS = 15
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.appconfig
|
||||||
|
|
||||||
|
object RageshakeConfig {
|
||||||
|
/**
|
||||||
|
* The URL to submit bug reports to.
|
||||||
|
*/
|
||||||
|
const val BUG_REPORT_URL = "https://riot.im/bugreports/submit"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As per https://github.com/matrix-org/rageshake:
|
||||||
|
* Identifier for the application (eg 'riot-web').
|
||||||
|
* Should correspond to a mapping configured in the configuration file for github issue reporting to work.
|
||||||
|
*/
|
||||||
|
const val BUG_REPORT_APP_NAME = "element-x-android"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
|
||||||
|
*/
|
||||||
|
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ dependencies {
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
|
testImplementation(projects.libraries.pushproviders.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.features.login.impl)
|
testImplementation(projects.features.login.impl)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.loggedin.LoggedInNode
|
import io.element.android.appnav.loggedin.LoggedInNode
|
||||||
|
import io.element.android.appnav.loggedin.SendQueues
|
||||||
import io.element.android.appnav.room.RoomFlowNode
|
import io.element.android.appnav.room.RoomFlowNode
|
||||||
import io.element.android.appnav.room.RoomNavigationTarget
|
import io.element.android.appnav.room.RoomNavigationTarget
|
||||||
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||||
|
|
@ -102,6 +103,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||||
private val shareEntryPoint: ShareEntryPoint,
|
private val shareEntryPoint: ShareEntryPoint,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
|
private val sendingQueue: SendQueues,
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -157,6 +159,11 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
observeSyncStateAndNetworkStatus()
|
observeSyncStateAndNetworkStatus()
|
||||||
|
setupSendingQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSendingQueue() {
|
||||||
|
sendingQueue.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
|
|
@ -231,7 +238,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
|
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
|
||||||
NavTarget.LoggedInPermanent -> {
|
NavTarget.LoggedInPermanent -> {
|
||||||
createNode<LoggedInNode>(buildContext)
|
val callback = object : LoggedInNode.Callback {
|
||||||
|
override fun navigateToNotificationTroubleshoot() {
|
||||||
|
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<LoggedInNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.RoomList -> {
|
NavTarget.RoomList -> {
|
||||||
val callback = object : RoomListEntryPoint.Callback {
|
val callback = object : RoomListEntryPoint.Callback {
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
|
||||||
// Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
|
// Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
|
||||||
runBlocking {
|
runBlocking {
|
||||||
sessionIds.forEach { sessionId ->
|
sessionIds.forEach { sessionId ->
|
||||||
restore(sessionId)
|
getOrRestore(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.appnav.loggedin
|
||||||
|
|
||||||
|
sealed interface LoggedInEvents {
|
||||||
|
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
|
@ -35,11 +36,22 @@ class LoggedInNode @AssistedInject constructor(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins
|
plugins = plugins
|
||||||
) {
|
) {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun navigateToNotificationTroubleshoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToNotificationTroubleshoot() {
|
||||||
|
plugins<Callback>().forEach {
|
||||||
|
it.navigateToNotificationTroubleshoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val loggedInState = loggedInPresenter.present()
|
val loggedInState = loggedInPresenter.present()
|
||||||
LoggedInView(
|
LoggedInView(
|
||||||
state = loggedInState,
|
state = loggedInState,
|
||||||
|
navigateToNotificationTroubleshoot = ::navigateToNotificationTroubleshoot,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,20 @@ package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
import im.vector.app.features.analytics.plan.UserProperties
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
|
|
@ -34,11 +39,17 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
|
import io.element.android.libraries.pushproviders.api.RegistrationFailure
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private val pusherTag = LoggerTag("Pusher", LoggerTag.PushLoggerTag)
|
||||||
|
|
||||||
class LoggedInPresenter @Inject constructor(
|
class LoggedInPresenter @Inject constructor(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
|
@ -49,36 +60,26 @@ class LoggedInPresenter @Inject constructor(
|
||||||
) : Presenter<LoggedInState> {
|
) : Presenter<LoggedInState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoggedInState {
|
override fun present(): LoggedInState {
|
||||||
val isVerified by remember {
|
val coroutineScope = rememberCoroutineScope()
|
||||||
sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
|
val ignoreRegistrationError by remember {
|
||||||
|
pushService.ignoreRegistrationError(matrixClient.sessionId)
|
||||||
}.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
|
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
|
||||||
LaunchedEffect(isVerified) {
|
LaunchedEffect(Unit) {
|
||||||
if (isVerified) {
|
sessionVerificationService.sessionVerifiedStatus
|
||||||
// Ensure pusher is registered
|
.onEach { sessionVerifiedStatus ->
|
||||||
val currentPushProvider = pushService.getCurrentPushProvider()
|
when (sessionVerifiedStatus) {
|
||||||
val result = if (currentPushProvider == null) {
|
SessionVerifiedStatus.Unknown -> Unit
|
||||||
// Register with the first available push provider
|
SessionVerifiedStatus.Verified -> {
|
||||||
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
|
ensurePusherIsRegistered(pusherRegistrationState)
|
||||||
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
|
}
|
||||||
pushService.registerWith(matrixClient, pushProvider, distributor)
|
SessionVerifiedStatus.NotVerified -> {
|
||||||
} else {
|
pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified())
|
||||||
val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient)
|
}
|
||||||
if (currentPushDistributor == null) {
|
|
||||||
// Register with the first available distributor
|
|
||||||
val distributor = currentPushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
|
|
||||||
pushService.registerWith(matrixClient, currentPushProvider, distributor)
|
|
||||||
} else {
|
|
||||||
// Re-register with the current distributor
|
|
||||||
pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.onFailure {
|
.launchIn(this)
|
||||||
Timber.e(it, "Failed to register pusher")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
||||||
val networkStatus by networkMonitor.connectivity.collectAsState()
|
val networkStatus by networkMonitor.connectivity.collectAsState()
|
||||||
val showSyncSpinner by remember {
|
val showSyncSpinner by remember {
|
||||||
|
|
@ -86,14 +87,86 @@ class LoggedInPresenter @Inject constructor(
|
||||||
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
|
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val verificationState by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
LaunchedEffect(Unit) {
|
||||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
combine(
|
||||||
LaunchedEffect(verificationState, recoveryState) {
|
sessionVerificationService.sessionVerifiedStatus,
|
||||||
reportCryptoStatusToAnalytics(verificationState, recoveryState)
|
encryptionService.recoveryStateStateFlow
|
||||||
|
) { verificationState, recoveryState ->
|
||||||
|
reportCryptoStatusToAnalytics(verificationState, recoveryState)
|
||||||
|
}.launchIn(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: LoggedInEvents) {
|
||||||
|
when (event) {
|
||||||
|
is LoggedInEvents.CloseErrorDialog -> {
|
||||||
|
pusherRegistrationState.value = AsyncData.Uninitialized
|
||||||
|
if (event.doNotShowAgain) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
pushService.setIgnoreRegistrationError(matrixClient.sessionId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return LoggedInState(
|
return LoggedInState(
|
||||||
showSyncSpinner = showSyncSpinner,
|
showSyncSpinner = showSyncSpinner,
|
||||||
|
pusherRegistrationState = pusherRegistrationState.value,
|
||||||
|
ignoreRegistrationError = ignoreRegistrationError,
|
||||||
|
eventSink = ::handleEvent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState<AsyncData<Unit>>) {
|
||||||
|
Timber.tag(pusherTag.value).d("Ensure pusher is registered")
|
||||||
|
val currentPushProvider = pushService.getCurrentPushProvider()
|
||||||
|
val result = if (currentPushProvider == null) {
|
||||||
|
Timber.tag(pusherTag.value).d("Register with the first available push provider with at least one distributor")
|
||||||
|
val pushProvider = pushService.getAvailablePushProviders()
|
||||||
|
.firstOrNull { it.getDistributors().isNotEmpty() }
|
||||||
|
// Else fallback to the first available push provider (the list should never be empty)
|
||||||
|
?: pushService.getAvailablePushProviders().firstOrNull()
|
||||||
|
?: return Unit
|
||||||
|
.also { Timber.tag(pusherTag.value).w("No push providers available") }
|
||||||
|
.also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoProvidersAvailable()) }
|
||||||
|
val distributor = pushProvider.getDistributors().firstOrNull()
|
||||||
|
?: return Unit
|
||||||
|
.also { Timber.tag(pusherTag.value).w("No distributors available") }
|
||||||
|
.also {
|
||||||
|
// In this case, consider the push provider is chosen.
|
||||||
|
pushService.selectPushProvider(matrixClient, pushProvider)
|
||||||
|
}
|
||||||
|
.also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable()) }
|
||||||
|
pushService.registerWith(matrixClient, pushProvider, distributor)
|
||||||
|
} else {
|
||||||
|
val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient)
|
||||||
|
if (currentPushDistributor == null) {
|
||||||
|
Timber.tag(pusherTag.value).d("Register with the first available distributor")
|
||||||
|
val distributor = currentPushProvider.getDistributors().firstOrNull()
|
||||||
|
?: return Unit
|
||||||
|
.also { Timber.tag(pusherTag.value).w("No distributors available") }
|
||||||
|
.also { pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable()) }
|
||||||
|
pushService.registerWith(matrixClient, currentPushProvider, distributor)
|
||||||
|
} else {
|
||||||
|
Timber.tag(pusherTag.value).d("Re-register with the current distributor")
|
||||||
|
pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.fold(
|
||||||
|
onSuccess = {
|
||||||
|
Timber.tag(pusherTag.value).d("Pusher registered")
|
||||||
|
pusherRegistrationState.value = AsyncData.Success(Unit)
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
Timber.tag(pusherTag.value).e(it, "Failed to register pusher")
|
||||||
|
if (it is RegistrationFailure) {
|
||||||
|
pusherRegistrationState.value = AsyncData.Failure(
|
||||||
|
PusherRegistrationFailure.RegistrationFailure(it.clientException, it.isRegisteringAgain)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pusherRegistrationState.value = AsyncData.Failure(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
|
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
data class LoggedInState(
|
data class LoggedInState(
|
||||||
val showSyncSpinner: Boolean,
|
val showSyncSpinner: Boolean,
|
||||||
|
val pusherRegistrationState: AsyncData<Unit>,
|
||||||
|
val ignoreRegistrationError: Boolean,
|
||||||
|
val eventSink: (LoggedInEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,23 @@
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
|
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
|
||||||
override val values: Sequence<LoggedInState>
|
override val values: Sequence<LoggedInState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aLoggedInState(false),
|
aLoggedInState(),
|
||||||
aLoggedInState(true),
|
aLoggedInState(showSyncSpinner = true),
|
||||||
// Add other state here
|
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aLoggedInState(
|
fun aLoggedInState(
|
||||||
showSyncSpinner: Boolean = true,
|
showSyncSpinner: Boolean = false,
|
||||||
|
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||||
) = LoggedInState(
|
) = LoggedInState(
|
||||||
showSyncSpinner = showSyncSpinner,
|
showSyncSpinner = showSyncSpinner,
|
||||||
|
pusherRegistrationState = pusherRegistrationState,
|
||||||
|
ignoreRegistrationError = false,
|
||||||
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,19 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogWithDoNotShowAgain
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.matrix.api.exception.isNetworkError
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoggedInView(
|
fun LoggedInView(
|
||||||
state: LoggedInState,
|
state: LoggedInState,
|
||||||
|
navigateToNotificationTroubleshoot: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -41,12 +47,53 @@ fun LoggedInView(
|
||||||
isVisible = state.showSyncSpinner,
|
isVisible = state.showSyncSpinner,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
when (state.pusherRegistrationState) {
|
||||||
|
is AsyncData.Uninitialized,
|
||||||
|
is AsyncData.Loading,
|
||||||
|
is AsyncData.Success -> Unit
|
||||||
|
is AsyncData.Failure -> {
|
||||||
|
state.pusherRegistrationState.errorOrNull()
|
||||||
|
?.takeIf { !state.ignoreRegistrationError }
|
||||||
|
?.getReason()
|
||||||
|
?.let { reason ->
|
||||||
|
ErrorDialogWithDoNotShowAgain(
|
||||||
|
content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason),
|
||||||
|
cancelText = stringResource(id = CommonStrings.common_settings),
|
||||||
|
onDismiss = {
|
||||||
|
state.eventSink(LoggedInEvents.CloseErrorDialog(it))
|
||||||
|
},
|
||||||
|
onCancel = {
|
||||||
|
state.eventSink(LoggedInEvents.CloseErrorDialog(false))
|
||||||
|
navigateToNotificationTroubleshoot()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.getReason(): String? {
|
||||||
|
return when (this) {
|
||||||
|
is PusherRegistrationFailure.RegistrationFailure -> {
|
||||||
|
if (isRegisteringAgain && clientException.isNetworkError()) {
|
||||||
|
// When registering again, ignore network error
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
clientException.message ?: "Unknown error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PusherRegistrationFailure.AccountNotVerified -> null
|
||||||
|
is PusherRegistrationFailure.NoDistributorsAvailable -> "No distributors available"
|
||||||
|
is PusherRegistrationFailure.NoProvidersAvailable -> "No providers available"
|
||||||
|
else -> "Other error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
|
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
|
||||||
LoggedInView(
|
LoggedInView(
|
||||||
state = state
|
state = state,
|
||||||
|
navigateToNotificationTroubleshoot = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.appnav.loggedin
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||||
|
|
||||||
|
sealed class PusherRegistrationFailure : Exception() {
|
||||||
|
class AccountNotVerified : PusherRegistrationFailure()
|
||||||
|
class NoProvidersAvailable : PusherRegistrationFailure()
|
||||||
|
class NoDistributorsAvailable : PusherRegistrationFailure()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param clientException the failure that occurred.
|
||||||
|
* @param isRegisteringAgain true if the server should already have a the same pusher registered.
|
||||||
|
*/
|
||||||
|
class RegistrationFailure(
|
||||||
|
val clientException: ClientException,
|
||||||
|
val isRegisteringAgain: Boolean,
|
||||||
|
) : PusherRegistrationFailure()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.appnav.loggedin
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
const val SEND_QUEUES_RETRY_DELAY_MILLIS = 1500L
|
||||||
|
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
class SendQueues @Inject constructor(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
) {
|
||||||
|
fun launchIn(coroutineScope: CoroutineScope) {
|
||||||
|
networkMonitor.connectivity
|
||||||
|
.onEach { networkStatus ->
|
||||||
|
matrixClient.setAllSendQueuesEnabled(enabled = networkStatus == NetworkStatus.Online)
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
matrixClient.sendQueueDisabledFlow()
|
||||||
|
.onEach { roomId: RoomId ->
|
||||||
|
Timber.d("Send queue disabled for room $roomId")
|
||||||
|
if (networkMonitor.connectivity.value == NetworkStatus.Online) {
|
||||||
|
delay(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||||
|
matrixClient.getRoom(roomId)?.use { room ->
|
||||||
|
room.setSendQueueEnabled(enabled = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ class MatrixClientsHolderTest {
|
||||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||||
// Do it again to it the cache
|
// Do it again to hit the cache
|
||||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,39 @@ package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||||
import im.vector.app.features.analytics.plan.UserProperties
|
import im.vector.app.features.analytics.plan.UserProperties
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
|
import io.element.android.libraries.push.api.PushService
|
||||||
import io.element.android.libraries.push.test.FakePushService
|
import io.element.android.libraries.push.test.FakePushService
|
||||||
|
import io.element.android.libraries.pushproviders.api.Distributor
|
||||||
|
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||||
|
import io.element.android.libraries.pushproviders.test.FakePushProvider
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
|
import io.element.android.tests.testutils.lambda.any
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -51,6 +67,8 @@ class LoggedInPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.showSyncSpinner).isFalse()
|
assertThat(initialState.showSyncSpinner).isFalse()
|
||||||
|
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
|
||||||
|
assertThat(initialState.ignoreRegistrationError).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,16 +108,12 @@ class LoggedInPresenterTest {
|
||||||
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
||||||
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
|
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||||
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||||
|
skipItems(2)
|
||||||
skipItems(4)
|
|
||||||
|
|
||||||
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
|
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
|
||||||
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
|
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
|
||||||
|
|
||||||
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
|
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
|
||||||
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
|
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
|
||||||
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
|
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
|
||||||
|
|
||||||
// ensure a sync status change does not trigger a new capture
|
// ensure a sync status change does not trigger a new capture
|
||||||
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
|
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
@ -107,17 +121,399 @@ class LoggedInPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensure default pusher is not registered if session is not verified`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val pushService = createFakePushService(registerWithLambda = lambda)
|
||||||
|
val verificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = verificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||||
|
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
|
||||||
|
lambda.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensure default pusher is registered with default provider`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||||
|
lambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// MatrixClient
|
||||||
|
any(),
|
||||||
|
// PushProvider with highest priority (lower index)
|
||||||
|
value(pushService.getAvailablePushProviders()[0]),
|
||||||
|
// First distributor
|
||||||
|
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.failure(AN_EXCEPTION)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
|
||||||
|
lambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// MatrixClient
|
||||||
|
any(),
|
||||||
|
// PushProvider with highest priority (lower index)
|
||||||
|
value(pushService.getAvailablePushProviders()[0]),
|
||||||
|
// First distributor
|
||||||
|
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - ensure current provider is registered with current distributor`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val distributor = Distributor("aDistributorValue1", "aDistributorName1")
|
||||||
|
val pushProvider = FakePushProvider(
|
||||||
|
index = 0,
|
||||||
|
name = "aFakePushProvider0",
|
||||||
|
distributors = listOf(
|
||||||
|
Distributor("aDistributorValue0", "aDistributorName0"),
|
||||||
|
distributor,
|
||||||
|
),
|
||||||
|
currentDistributor = { distributor },
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
pushProvider1 = pushProvider,
|
||||||
|
currentPushProvider = { pushProvider },
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||||
|
lambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// MatrixClient
|
||||||
|
any(),
|
||||||
|
// Current push provider
|
||||||
|
value(pushProvider),
|
||||||
|
// Current distributor
|
||||||
|
value(distributor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - if current push provider does not have current distributor, the first one is used`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val pushProvider = FakePushProvider(
|
||||||
|
index = 0,
|
||||||
|
name = "aFakePushProvider0",
|
||||||
|
distributors = listOf(
|
||||||
|
Distributor("aDistributorValue0", "aDistributorName0"),
|
||||||
|
Distributor("aDistributorValue1", "aDistributorName1"),
|
||||||
|
),
|
||||||
|
currentDistributor = { null },
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
pushProvider0 = pushProvider,
|
||||||
|
currentPushProvider = { pushProvider },
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||||
|
lambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// MatrixClient
|
||||||
|
any(),
|
||||||
|
// PushProvider with highest priority (lower index)
|
||||||
|
value(pushService.getAvailablePushProviders()[0]),
|
||||||
|
// First distributor
|
||||||
|
value(pushService.getAvailablePushProviders()[0].getDistributors()[0]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - if current push provider does not have distributors, nothing happen`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val pushProvider = FakePushProvider(
|
||||||
|
index = 0,
|
||||||
|
name = "aFakePushProvider0",
|
||||||
|
distributors = emptyList(),
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
pushProvider0 = pushProvider,
|
||||||
|
currentPushProvider = { pushProvider },
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||||
|
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
|
||||||
|
lambda.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - case no push provider available provider`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.Verified)
|
||||||
|
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
pushProvider0 = null,
|
||||||
|
pushProvider1 = null,
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||||
|
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
|
||||||
|
lambda.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
|
// Reset the error and do not show again
|
||||||
|
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true))
|
||||||
|
skipItems(1)
|
||||||
|
setIgnoreRegistrationErrorLambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// SessionId
|
||||||
|
value(A_SESSION_ID),
|
||||||
|
// Ignore
|
||||||
|
value(true),
|
||||||
|
)
|
||||||
|
val lastState = awaitItem()
|
||||||
|
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
|
||||||
|
assertThat(lastState.ignoreRegistrationError).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - case one push provider but no distributor available`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val selectPushProviderLambda = lambdaRecorder<MatrixClient, PushProvider, Unit> { _, _ -> }
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val pushProvider = FakePushProvider(
|
||||||
|
index = 0,
|
||||||
|
name = "aFakePushProvider",
|
||||||
|
distributors = emptyList(),
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
pushProvider0 = pushProvider,
|
||||||
|
pushProvider1 = null,
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
selectPushProviderLambda = selectPushProviderLambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||||
|
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
|
||||||
|
lambda.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
|
selectPushProviderLambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// MatrixClient
|
||||||
|
any(),
|
||||||
|
// PushProvider
|
||||||
|
value(pushProvider),
|
||||||
|
)
|
||||||
|
// Reset the error
|
||||||
|
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false))
|
||||||
|
val lastState = awaitItem()
|
||||||
|
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - case two push providers but first one does not have distributor - second one will be used`() = runTest {
|
||||||
|
val lambda = lambdaRecorder<MatrixClient, PushProvider, Distributor, Result<Unit>> { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(
|
||||||
|
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
|
||||||
|
)
|
||||||
|
val pushProvider0 = FakePushProvider(
|
||||||
|
index = 0,
|
||||||
|
name = "aFakePushProvider0",
|
||||||
|
distributors = emptyList(),
|
||||||
|
)
|
||||||
|
val distributor = Distributor("aDistributorValue1", "aDistributorName1")
|
||||||
|
val pushProvider1 = FakePushProvider(
|
||||||
|
index = 1,
|
||||||
|
name = "aFakePushProvider1",
|
||||||
|
distributors = listOf(distributor),
|
||||||
|
)
|
||||||
|
val pushService = createFakePushService(
|
||||||
|
pushProvider0 = pushProvider0,
|
||||||
|
pushProvider1 = pushProvider1,
|
||||||
|
registerWithLambda = lambda,
|
||||||
|
)
|
||||||
|
val presenter = createLoggedInPresenter(
|
||||||
|
pushService = pushService,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val finalState = awaitFirstItem()
|
||||||
|
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||||
|
lambda.assertions().isCalledOnce()
|
||||||
|
.with(
|
||||||
|
// MatrixClient
|
||||||
|
any(),
|
||||||
|
// PushProvider with the distributor
|
||||||
|
value(pushProvider1),
|
||||||
|
// First distributor of second push provider
|
||||||
|
value(distributor),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFakePushService(
|
||||||
|
pushProvider0: PushProvider? = FakePushProvider(
|
||||||
|
index = 0,
|
||||||
|
name = "aFakePushProvider0",
|
||||||
|
distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")),
|
||||||
|
currentDistributor = { null },
|
||||||
|
),
|
||||||
|
pushProvider1: PushProvider? = FakePushProvider(
|
||||||
|
index = 1,
|
||||||
|
name = "aFakePushProvider1",
|
||||||
|
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
|
||||||
|
currentDistributor = { null },
|
||||||
|
),
|
||||||
|
registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
selectPushProviderLambda: (MatrixClient, PushProvider) -> Unit = { _, _ -> lambdaError() },
|
||||||
|
currentPushProvider: () -> PushProvider? = { null },
|
||||||
|
setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
|
||||||
|
): PushService {
|
||||||
|
return FakePushService(
|
||||||
|
availablePushProviders = listOfNotNull(pushProvider0, pushProvider1),
|
||||||
|
registerWithLambda = registerWithLambda,
|
||||||
|
currentPushProvider = currentPushProvider,
|
||||||
|
selectPushProviderLambda = selectPushProviderLambda,
|
||||||
|
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
|
skipItems(1)
|
||||||
|
return awaitItem()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createLoggedInPresenter(
|
private fun createLoggedInPresenter(
|
||||||
roomListService: RoomListService = FakeRoomListService(),
|
roomListService: RoomListService = FakeRoomListService(),
|
||||||
networkStatus: NetworkStatus = NetworkStatus.Offline,
|
networkStatus: NetworkStatus = NetworkStatus.Offline,
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
|
||||||
|
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||||
|
pushService: PushService = FakePushService(),
|
||||||
): LoggedInPresenter {
|
): LoggedInPresenter {
|
||||||
return LoggedInPresenter(
|
return LoggedInPresenter(
|
||||||
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||||
networkMonitor = FakeNetworkMonitor(networkStatus),
|
networkMonitor = FakeNetworkMonitor(networkStatus),
|
||||||
pushService = FakePushService(),
|
pushService = pushService,
|
||||||
sessionVerificationService = FakeSessionVerificationService(),
|
sessionVerificationService = sessionVerificationService,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
encryptionService = encryptionService
|
encryptionService = encryptionService
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.appnav.loggedin
|
||||||
|
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class) class SendQueuesTest {
|
||||||
|
private val matrixClient = FakeMatrixClient()
|
||||||
|
private val room = FakeMatrixRoom()
|
||||||
|
private val networkMonitor = FakeNetworkMonitor()
|
||||||
|
private val sut = SendQueues(matrixClient, networkMonitor)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test network status online and sending queue failed`() = runTest {
|
||||||
|
val sendQueueDisabledFlow = MutableSharedFlow<RoomId>(replay = 1)
|
||||||
|
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
|
||||||
|
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
|
||||||
|
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
|
||||||
|
matrixClient.givenGetRoomResult(room.roomId, room)
|
||||||
|
|
||||||
|
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
|
||||||
|
room.setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
|
||||||
|
|
||||||
|
sut.launchIn(backgroundScope)
|
||||||
|
|
||||||
|
sendQueueDisabledFlow.emit(room.roomId)
|
||||||
|
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assert(setAllSendQueuesEnabledLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(true))
|
||||||
|
|
||||||
|
assert(setRoomSendQueueEnabledLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test network status offline and sending queue failed`() = runTest {
|
||||||
|
val sendQueueDisabledFlow = MutableSharedFlow<RoomId>(replay = 1)
|
||||||
|
|
||||||
|
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
|
||||||
|
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
|
||||||
|
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
|
||||||
|
networkMonitor.connectivity.value = NetworkStatus.Offline
|
||||||
|
matrixClient.givenGetRoomResult(room.roomId, room)
|
||||||
|
|
||||||
|
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
|
||||||
|
room.setSendQueueEnabledLambda = setRoomSendQueueEnabledLambda
|
||||||
|
|
||||||
|
sut.launchIn(backgroundScope)
|
||||||
|
|
||||||
|
sendQueueDisabledFlow.emit(room.roomId)
|
||||||
|
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assert(setAllSendQueuesEnabledLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(false))
|
||||||
|
|
||||||
|
assert(setRoomSendQueueEnabledLambda)
|
||||||
|
.isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test network status getting offline and online`() = runTest {
|
||||||
|
val setEnableSendingQueueLambda = lambdaRecorder { _: Boolean -> }
|
||||||
|
matrixClient.setAllSendQueuesEnabledLambda = setEnableSendingQueueLambda
|
||||||
|
|
||||||
|
sut.launchIn(backgroundScope)
|
||||||
|
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||||
|
networkMonitor.connectivity.value = NetworkStatus.Offline
|
||||||
|
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||||
|
networkMonitor.connectivity.value = NetworkStatus.Online
|
||||||
|
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||||
|
|
||||||
|
assert(setEnableSendingQueueLambda)
|
||||||
|
.isCalledExactly(3)
|
||||||
|
.withSequence(
|
||||||
|
listOf(value(true)),
|
||||||
|
listOf(value(false)),
|
||||||
|
listOf(value(true)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
fastlane/metadata/android/en-US/changelogs/40004150.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40004150.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Main changes in this version: Ringing call notifications.
|
||||||
|
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_analytics_settings_help_us_improve">"Võimalike rakenduse vigade leidmiseks palun jaga anonüümset kasutusteavet."</string>
|
||||||
|
<string name="screen_analytics_settings_read_terms">"Sa võid lugeda meie kasutustingimusi %1$s"</string>
|
||||||
|
<string name="screen_analytics_settings_read_terms_content_link">"siin"</string>
|
||||||
|
<string name="screen_analytics_settings_share_data">"Jaga andmeid rakenduse kasutuse kohta"</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_analytics_prompt_data_usage">"Me ei salvesta ega profileeri sinu isiklikke andmeid"</string>
|
||||||
|
<string name="screen_analytics_prompt_help_us_improve">"Võimalike rakenduse vigade leidmiseks palun jaga anonüümset kasutusteavet."</string>
|
||||||
|
<string name="screen_analytics_prompt_read_terms">"Sa võid lugeda meie kasutustingimusi %1$s"</string>
|
||||||
|
<string name="screen_analytics_prompt_read_terms_content_link">"siin"</string>
|
||||||
|
<string name="screen_analytics_prompt_settings">"Selle valiku saad igal ajal välja lülitada"</string>
|
||||||
|
<string name="screen_analytics_prompt_third_party_sharing">"Me ei jaga andmeid kolmandate osapooltega"</string>
|
||||||
|
<string name="screen_analytics_prompt_title">"Aita parandada %1$s rakendust"</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,13 +14,18 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
id("kotlin-parcelize")
|
||||||
|
}
|
||||||
sealed interface RetrySendMenuEvents {
|
|
||||||
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
|
android {
|
||||||
data object Retry : RetrySendMenuEvents
|
namespace = "io.element.android.features.call.api"
|
||||||
data object Remove : RetrySendMenuEvents
|
}
|
||||||
data object Dismiss : RetrySendMenuEvents
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.api
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for the call feature.
|
||||||
|
*/
|
||||||
|
interface ElementCallEntryPoint {
|
||||||
|
/**
|
||||||
|
* Start a call of the given type.
|
||||||
|
* @param callType The type of call to start.
|
||||||
|
*/
|
||||||
|
fun startCall(callType: CallType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming call.
|
||||||
|
* @param callType The type of call.
|
||||||
|
* @param eventId The event id of the event that started the call.
|
||||||
|
* @param senderId The user id of the sender of the event that started the call.
|
||||||
|
* @param roomName The name of the room the call is in.
|
||||||
|
* @param senderName The name of the sender of the event that started the call.
|
||||||
|
* @param avatarUrl The avatar url of the room or DM.
|
||||||
|
* @param timestamp The timestamp of the event that started the call.
|
||||||
|
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
||||||
|
*/
|
||||||
|
fun handleIncomingCall(
|
||||||
|
callType: CallType.RoomCall,
|
||||||
|
eventId: EventId,
|
||||||
|
senderId: UserId,
|
||||||
|
roomName: String?,
|
||||||
|
senderName: String?,
|
||||||
|
avatarUrl: String?,
|
||||||
|
timestamp: Long,
|
||||||
|
notificationChannelId: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -23,11 +23,15 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.call"
|
namespace = "io.element.android.features.call.impl"
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
anvil {
|
anvil {
|
||||||
|
|
@ -41,12 +45,18 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrix.impl)
|
implementation(projects.libraries.matrix.impl)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.push.api)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.network.retrofit)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
|
api(projects.features.call.api)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
@ -54,9 +64,12 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(libs.test.mockk)
|
||||||
|
testImplementation(projects.features.call.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
}
|
}
|
||||||
|
|
@ -23,11 +23,17 @@
|
||||||
android:name="android.hardware.microphone"
|
android:name="android.hardware.microphone"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- Permissions for call foreground services -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
|
|
@ -70,10 +76,24 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name=".ui.IncomingCallActivity"
|
||||||
|
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:taskAffinity="io.element.android.features.call" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".CallForegroundService"
|
android:name=".services.CallForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:foregroundServiceType="mediaPlayback" />
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="phoneCall" />
|
||||||
|
|
||||||
|
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
|
||||||
|
android:exported="false"
|
||||||
|
android:enabled="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
|
import io.element.android.features.call.impl.utils.IntentProvider
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultElementCallEntryPoint @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val activeCallManager: ActiveCallManager,
|
||||||
|
) : ElementCallEntryPoint {
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
|
||||||
|
const val REQUEST_CODE = 2255
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startCall(callType: CallType) {
|
||||||
|
context.startActivity(IntentProvider.createIntent(context, callType))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleIncomingCall(
|
||||||
|
callType: CallType.RoomCall,
|
||||||
|
eventId: EventId,
|
||||||
|
senderId: UserId,
|
||||||
|
roomName: String?,
|
||||||
|
senderName: String?,
|
||||||
|
avatarUrl: String?,
|
||||||
|
timestamp: Long,
|
||||||
|
notificationChannelId: String,
|
||||||
|
) {
|
||||||
|
val incomingCallNotificationData = CallNotificationData(
|
||||||
|
sessionId = callType.sessionId,
|
||||||
|
roomId = callType.roomId,
|
||||||
|
eventId = eventId,
|
||||||
|
senderId = senderId,
|
||||||
|
roomName = roomName,
|
||||||
|
senderName = senderName,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
timestamp = timestamp,
|
||||||
|
notificationChannelId = notificationChannelId,
|
||||||
|
)
|
||||||
|
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.data
|
package io.element.android.features.call.impl.data
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -14,13 +14,17 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.di
|
package io.element.android.features.call.impl.di
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import io.element.android.features.call.ui.ElementCallActivity
|
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
|
||||||
|
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||||
|
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
interface CallBindings {
|
interface CallBindings {
|
||||||
fun inject(callActivity: ElementCallActivity)
|
fun inject(callActivity: ElementCallActivity)
|
||||||
|
fun inject(callActivity: IncomingCallActivity)
|
||||||
|
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.notifications
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class CallNotificationData(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val roomId: RoomId,
|
||||||
|
val eventId: EventId,
|
||||||
|
val senderId: UserId,
|
||||||
|
val roomName: String?,
|
||||||
|
val senderName: String?,
|
||||||
|
val avatarUrl: String?,
|
||||||
|
val notificationChannelId: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
) : Parcelable
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.notifications
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.Person
|
||||||
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
|
||||||
|
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
||||||
|
import io.element.android.features.call.impl.utils.IntentProvider
|
||||||
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a notification for a ringing call.
|
||||||
|
*/
|
||||||
|
class RingingCallNotificationCreator @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
|
private val imageLoaderHolder: ImageLoaderHolder,
|
||||||
|
private val notificationBitmapLoader: NotificationBitmapLoader,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Request code for the decline action.
|
||||||
|
*/
|
||||||
|
const val DECLINE_REQUEST_CODE = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request code for the full screen intent.
|
||||||
|
*/
|
||||||
|
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createNotification(
|
||||||
|
sessionId: SessionId,
|
||||||
|
roomId: RoomId,
|
||||||
|
eventId: EventId,
|
||||||
|
senderId: UserId,
|
||||||
|
roomName: String?,
|
||||||
|
senderDisplayName: String,
|
||||||
|
roomAvatarUrl: String?,
|
||||||
|
notificationChannelId: String,
|
||||||
|
timestamp: Long,
|
||||||
|
): Notification? {
|
||||||
|
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
|
||||||
|
val imageLoader = imageLoaderHolder.get(matrixClient)
|
||||||
|
val largeIcon = notificationBitmapLoader.getUserIcon(roomAvatarUrl, imageLoader)
|
||||||
|
|
||||||
|
val caller = Person.Builder()
|
||||||
|
.setName(senderDisplayName)
|
||||||
|
.setIcon(largeIcon)
|
||||||
|
.setImportant(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
|
||||||
|
val notificationData = CallNotificationData(
|
||||||
|
sessionId = sessionId,
|
||||||
|
roomId = roomId,
|
||||||
|
eventId = eventId,
|
||||||
|
senderId = senderId,
|
||||||
|
roomName = roomName,
|
||||||
|
senderName = senderDisplayName,
|
||||||
|
avatarUrl = roomAvatarUrl,
|
||||||
|
notificationChannelId = notificationChannelId,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||||
|
context,
|
||||||
|
DECLINE_REQUEST_CODE,
|
||||||
|
Intent(context, DeclineCallBroadcastReceiver::class.java).apply {
|
||||||
|
putExtra(DeclineCallBroadcastReceiver.EXTRA_NOTIFICATION_DATA, notificationData)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||||
|
false,
|
||||||
|
)!!
|
||||||
|
|
||||||
|
val fullScreenIntent = PendingIntentCompat.getActivity(
|
||||||
|
context,
|
||||||
|
FULL_SCREEN_INTENT_REQUEST_CODE,
|
||||||
|
Intent(context, IncomingCallActivity::class.java).apply {
|
||||||
|
putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
val ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
|
||||||
|
return NotificationCompat.Builder(context, notificationChannelId)
|
||||||
|
.setSmallIcon(CommonDrawables.ic_notification_small)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true))
|
||||||
|
.addPerson(caller)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setWhen(timestamp)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setSound(ringtoneUri, AudioManager.STREAM_RING)
|
||||||
|
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
|
||||||
|
.setContentIntent(answerIntent)
|
||||||
|
.setDeleteIntent(declineIntent)
|
||||||
|
.setFullScreenIntent(fullScreenIntent, true)
|
||||||
|
.build()
|
||||||
|
.apply {
|
||||||
|
flags = flags.or(Notification.FLAG_INSISTENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.di.CallBindings
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast receiver to decline the incoming call.
|
||||||
|
*/
|
||||||
|
class DeclineCallBroadcastReceiver : BroadcastReceiver() {
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
|
||||||
|
}
|
||||||
|
@Inject
|
||||||
|
lateinit var activeCallManager: ActiveCallManager
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||||
|
?: return
|
||||||
|
context.bindings<CallBindings>().inject(this)
|
||||||
|
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 New Vector Ltd
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,30 +14,36 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.impl.services
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import io.element.android.features.call.ui.ElementCallActivity
|
import io.element.android.features.call.impl.R
|
||||||
|
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A foreground service that shows a notification for an ongoing call while the UI is in background.
|
||||||
|
*/
|
||||||
class CallForegroundService : Service() {
|
class CallForegroundService : Service() {
|
||||||
companion object {
|
companion object {
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, CallForegroundService::class.java)
|
val intent = Intent(context, CallForegroundService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
ContextCompat.startForegroundService(context, intent)
|
||||||
context.startForegroundService(intent)
|
|
||||||
} else {
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
|
|
@ -69,7 +75,17 @@ class CallForegroundService : Service() {
|
||||||
.setContentText(getString(R.string.call_foreground_service_message_android))
|
.setContentText(getString(R.string.call_foreground_service_message_android))
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.build()
|
||||||
startForeground(1, notification)
|
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
|
||||||
|
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to start ongoing call foreground service")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
|
@ -14,11 +14,12 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
|
||||||
|
|
||||||
sealed interface CallScreenEvents {
|
sealed interface CallScreenEvents {
|
||||||
data object Hangup : CallScreenEvents
|
data object Hangup : CallScreenEvents
|
||||||
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
|
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) :
|
||||||
|
CallScreenEvents
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
@ -30,23 +30,25 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.features.call.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.data.WidgetMessage
|
import io.element.android.features.call.impl.data.WidgetMessage
|
||||||
import io.element.android.features.call.utils.CallWidgetProvider
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.CallWidgetProvider
|
||||||
import io.element.android.features.call.utils.WidgetMessageSerializer
|
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
|
||||||
|
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||||
import io.element.android.services.analytics.api.ScreenTracker
|
import io.element.android.services.analytics.api.ScreenTracker
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -65,6 +67,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
private val matrixClientsProvider: MatrixClientProvider,
|
private val matrixClientsProvider: MatrixClientProvider,
|
||||||
private val screenTracker: ScreenTracker,
|
private val screenTracker: ScreenTracker,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val activeCallManager: ActiveCallManager,
|
||||||
) : Presenter<CallScreenState> {
|
) : Presenter<CallScreenState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -73,6 +76,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
private val isInWidgetMode = callType is CallType.RoomCall
|
private val isInWidgetMode = callType is CallType.RoomCall
|
||||||
private val userAgent = userAgentProvider.provide()
|
private val userAgent = userAgentProvider.provide()
|
||||||
|
private var notifiedCallStart = false
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): CallScreenState {
|
override fun present(): CallScreenState {
|
||||||
|
|
@ -82,8 +86,15 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
|
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
|
||||||
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
|
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
loadUrl(callType, urlState, callWidgetDriver)
|
coroutineScope.launch {
|
||||||
|
// Sets the call as joined
|
||||||
|
activeCallManager.joinedCall(callType)
|
||||||
|
loadUrl(callType, urlState, callWidgetDriver)
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
activeCallManager.hungUpCall(callType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (callType) {
|
when (callType) {
|
||||||
|
|
@ -159,28 +170,28 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
urlState = urlState.value,
|
urlState = urlState.value,
|
||||||
userAgent = userAgent,
|
userAgent = userAgent,
|
||||||
isInWidgetMode = isInWidgetMode,
|
isInWidgetMode = isInWidgetMode,
|
||||||
eventSink = ::handleEvents,
|
eventSink = { handleEvents(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.loadUrl(
|
private suspend fun loadUrl(
|
||||||
inputs: CallType,
|
inputs: CallType,
|
||||||
urlState: MutableState<AsyncData<String>>,
|
urlState: MutableState<AsyncData<String>>,
|
||||||
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
|
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
|
||||||
) = launch {
|
) {
|
||||||
urlState.runCatchingUpdatingState {
|
urlState.runCatchingUpdatingState {
|
||||||
when (inputs) {
|
when (inputs) {
|
||||||
is CallType.ExternalUrl -> {
|
is CallType.ExternalUrl -> {
|
||||||
inputs.url
|
inputs.url
|
||||||
}
|
}
|
||||||
is CallType.RoomCall -> {
|
is CallType.RoomCall -> {
|
||||||
val (driver, url) = callWidgetProvider.getWidget(
|
val result = callWidgetProvider.getWidget(
|
||||||
sessionId = inputs.sessionId,
|
sessionId = inputs.sessionId,
|
||||||
roomId = inputs.roomId,
|
roomId = inputs.roomId,
|
||||||
clientId = UUID.randomUUID().toString(),
|
clientId = UUID.randomUUID().toString(),
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
callWidgetDriver.value = driver
|
callWidgetDriver.value = result.driver
|
||||||
url
|
result.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,15 +204,15 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
|
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
|
||||||
matrixClientsProvider.getOrNull(it)
|
matrixClientsProvider.getOrNull(it)
|
||||||
} ?: return@DisposableEffect onDispose { }
|
} ?: return@DisposableEffect onDispose { }
|
||||||
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
client.syncService().syncState
|
client.syncService().syncState
|
||||||
.onEach { state ->
|
.collect { state ->
|
||||||
if (state != SyncState.Running) {
|
if (state == SyncState.Running) {
|
||||||
|
client.notifyCallStartIfNeeded(callType.roomId)
|
||||||
|
} else {
|
||||||
client.syncService().startSync()
|
client.syncService().startSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
// We can't use the local coroutine scope here because it will be disposed before this effect
|
// We can't use the local coroutine scope here because it will be disposed before this effect
|
||||||
|
|
@ -216,6 +227,13 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
|
||||||
|
if (!notifiedCallStart) {
|
||||||
|
getRoom(roomId)?.sendCallNotificationIfNeeded()
|
||||||
|
?.onSuccess { notifiedCallStart = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseMessage(message: String): WidgetMessage? {
|
private fun parseMessage(message: String): WidgetMessage? {
|
||||||
return WidgetMessageSerializer.deserialize(message).getOrNull()
|
return WidgetMessageSerializer.deserialize(message).getOrNull()
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
|
open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
|
||||||
|
override val values: Sequence<CallScreenState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aCallScreenState(),
|
||||||
|
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aCallScreenState(
|
||||||
|
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
|
||||||
|
userAgent: String = "",
|
||||||
|
isInWidgetMode: Boolean = false,
|
||||||
|
eventSink: (CallScreenEvents) -> Unit = {},
|
||||||
|
): CallScreenState {
|
||||||
|
return CallScreenState(
|
||||||
|
urlState = urlState,
|
||||||
|
userAgent = userAgent,
|
||||||
|
isInWidgetMode = isInWidgetMode,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -32,17 +32,21 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.call.R
|
import io.element.android.features.call.impl.R
|
||||||
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
typealias RequestPermissionCallback = (Array<String>) -> Unit
|
typealias RequestPermissionCallback = (Array<String>) -> Unit
|
||||||
|
|
||||||
|
|
@ -91,6 +95,17 @@ internal fun CallScreenView(
|
||||||
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
when (state.urlState) {
|
||||||
|
AsyncData.Uninitialized,
|
||||||
|
is AsyncData.Loading ->
|
||||||
|
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
|
||||||
|
is AsyncData.Failure ->
|
||||||
|
ErrorDialog(
|
||||||
|
content = state.urlState.error.message.orEmpty(),
|
||||||
|
onDismiss = { state.eventSink(CallScreenEvents.Hangup) },
|
||||||
|
)
|
||||||
|
is AsyncData.Success -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,16 +172,11 @@ private fun WebView.setup(
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallScreenViewPreview() {
|
internal fun CallScreenViewPreview(
|
||||||
ElementPreview {
|
@PreviewParameter(CallScreenStateProvider::class) state: CallScreenState,
|
||||||
CallScreenView(
|
) = ElementPreview {
|
||||||
state = CallScreenState(
|
CallScreenView(
|
||||||
urlState = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
|
state = state,
|
||||||
isInWidgetMode = false,
|
requestPermissions = { _, _ -> },
|
||||||
userAgent = "",
|
)
|
||||||
eventSink = {},
|
|
||||||
),
|
|
||||||
requestPermissions = { _, _ -> },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -14,12 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
|
|
@ -41,30 +39,16 @@ import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
import io.element.android.compound.theme.isDark
|
import io.element.android.compound.theme.isDark
|
||||||
import io.element.android.compound.theme.mapToTheme
|
import io.element.android.compound.theme.mapToTheme
|
||||||
import io.element.android.features.call.CallForegroundService
|
import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.CallType
|
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||||
import io.element.android.features.call.di.CallBindings
|
import io.element.android.features.call.impl.di.CallBindings
|
||||||
import io.element.android.features.call.utils.CallIntentDataParser
|
import io.element.android.features.call.impl.services.CallForegroundService
|
||||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
companion object {
|
|
||||||
private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
|
|
||||||
|
|
||||||
fun start(
|
|
||||||
context: Context,
|
|
||||||
callInputs: CallType,
|
|
||||||
) {
|
|
||||||
val intent = Intent(context, ElementCallActivity::class.java).apply {
|
|
||||||
putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
|
|
||||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||||
|
|
@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
|
|
||||||
applicationContext.bindings<CallBindings>().inject(this)
|
applicationContext.bindings<CallBindings>().inject(this)
|
||||||
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||||
|
)
|
||||||
|
|
||||||
setCallType(intent)
|
setCallType(intent)
|
||||||
|
|
||||||
|
|
@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCallType(intent: Intent?) {
|
private fun setCallType(intent: Intent?) {
|
||||||
val inputs = intent?.let {
|
val callType = intent?.let {
|
||||||
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
|
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
|
||||||
}
|
}
|
||||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
val intentUrl = intent?.dataString?.let(::parseUrl)
|
||||||
when {
|
when {
|
||||||
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
||||||
intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
|
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
|
||||||
inputs != null -> {
|
callType != null -> {
|
||||||
webViewTarget.value = inputs
|
webViewTarget.value = callType
|
||||||
presenter = presenterFactory.create(inputs, this)
|
presenter = presenterFactory.create(callType, this)
|
||||||
}
|
}
|
||||||
intentUrl != null -> {
|
intentUrl != null -> {
|
||||||
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||||
|
import io.element.android.features.call.impl.di.CallBindings
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
|
import io.element.android.features.call.impl.utils.CallState
|
||||||
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity that's displayed as a full screen intent when an incoming call is received.
|
||||||
|
*/
|
||||||
|
class IncomingCallActivity : AppCompatActivity() {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Extra key for the notification data.
|
||||||
|
*/
|
||||||
|
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var elementCallEntryPoint: ElementCallEntryPoint
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var activeCallManager: ActiveCallManager
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
applicationContext.bindings<CallBindings>().inject(this)
|
||||||
|
|
||||||
|
// Set flags so it can be displayed in the lock screen
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||||
|
if (notificationData != null) {
|
||||||
|
setContent {
|
||||||
|
IncomingCallScreen(
|
||||||
|
notificationData = notificationData,
|
||||||
|
onAnswer = ::onAnswer,
|
||||||
|
onCancel = ::onCancel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data, finish the activity
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCallManager.activeCall
|
||||||
|
.filter { it?.callState !is CallState.Ringing }
|
||||||
|
.onEach { finish() }
|
||||||
|
.launchIn(lifecycleScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAnswer(notificationData: CallNotificationData) {
|
||||||
|
elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCancel() {
|
||||||
|
val activeCall = activeCallManager.activeCall.value ?: return
|
||||||
|
activeCallManager.hungUpCall(callType = activeCall.callType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.call.impl.R
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun IncomingCallScreen(
|
||||||
|
notificationData: CallNotificationData,
|
||||||
|
onAnswer: (CallNotificationData) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
ElementTheme {
|
||||||
|
OnboardingBackground()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Bottom
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 20.dp, end = 20.dp, top = 124.dp)
|
||||||
|
.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = notificationData.senderId.value,
|
||||||
|
name = notificationData.senderName,
|
||||||
|
url = notificationData.avatarUrl,
|
||||||
|
size = AvatarSize.IncomingCall,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = notificationData.senderName ?: notificationData.senderId.value,
|
||||||
|
style = ElementTheme.typography.fontHeadingMdBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.screen_incoming_call_subtitle_android),
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ActionButton(
|
||||||
|
size = 64.dp,
|
||||||
|
onClick = { onAnswer(notificationData) },
|
||||||
|
icon = CompoundIcons.VoiceCall(),
|
||||||
|
title = stringResource(CommonStrings.action_accept),
|
||||||
|
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
|
||||||
|
borderColor = ElementTheme.colors.borderSuccessSubtle
|
||||||
|
)
|
||||||
|
|
||||||
|
ActionButton(
|
||||||
|
size = 64.dp,
|
||||||
|
onClick = onCancel,
|
||||||
|
icon = CompoundIcons.EndCall(),
|
||||||
|
title = stringResource(CommonStrings.action_reject),
|
||||||
|
backgroundColor = ElementTheme.colors.iconCriticalPrimary,
|
||||||
|
borderColor = ElementTheme.colors.borderCriticalSubtle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionButton(
|
||||||
|
size: Dp,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
backgroundColor: Color,
|
||||||
|
borderColor: Color,
|
||||||
|
contentDescription: String? = title,
|
||||||
|
borderSize: Dp = 1.33.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.width(120.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
modifier = Modifier.size(size + borderSize)
|
||||||
|
.border(borderSize, borderColor, CircleShape),
|
||||||
|
onClick = onClick,
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
contentColor = Color.White,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun IncomingCallScreenPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
IncomingCallScreen(
|
||||||
|
notificationData = CallNotificationData(
|
||||||
|
sessionId = SessionId("@alice:matrix.org"),
|
||||||
|
roomId = RoomId("!1234:matrix.org"),
|
||||||
|
eventId = EventId("\$asdadadsad:matrix.org"),
|
||||||
|
senderId = UserId("@bob:matrix.org"),
|
||||||
|
roomName = "A room",
|
||||||
|
senderName = "Bob",
|
||||||
|
avatarUrl = null,
|
||||||
|
notificationChannelId = "incoming_call",
|
||||||
|
timestamp = 0L,
|
||||||
|
),
|
||||||
|
onAnswer = {},
|
||||||
|
onCancel = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the active call state.
|
||||||
|
*/
|
||||||
|
interface ActiveCallManager {
|
||||||
|
/**
|
||||||
|
* The active call state flow, which will be updated when the active call changes.
|
||||||
|
*/
|
||||||
|
val activeCall: StateFlow<ActiveCall?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
|
||||||
|
* @param notificationData The data for the incoming call notification.
|
||||||
|
*/
|
||||||
|
fun registerIncomingCall(notificationData: CallNotificationData)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
|
||||||
|
*/
|
||||||
|
fun incomingCallTimedOut()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the active call has been hung up. It will remove any existing UI and the active call.
|
||||||
|
* @param callType The type of call that the user hung up, either an external url one or a room one.
|
||||||
|
*/
|
||||||
|
fun hungUpCall(callType: CallType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
|
||||||
|
*
|
||||||
|
* @param callType The type of call that the user joined, either an external url one or a room one.
|
||||||
|
*/
|
||||||
|
fun joinedCall(callType: CallType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultActiveCallManager @Inject constructor(
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
|
||||||
|
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
||||||
|
private val notificationManagerCompat: NotificationManagerCompat,
|
||||||
|
) : ActiveCallManager {
|
||||||
|
private var timedOutCallJob: Job? = null
|
||||||
|
|
||||||
|
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
||||||
|
|
||||||
|
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||||
|
if (activeCall.value != null) {
|
||||||
|
displayMissedCallNotification(notificationData)
|
||||||
|
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeCall.value = ActiveCall(
|
||||||
|
callType = CallType.RoomCall(
|
||||||
|
sessionId = notificationData.sessionId,
|
||||||
|
roomId = notificationData.roomId,
|
||||||
|
),
|
||||||
|
callState = CallState.Ringing(notificationData),
|
||||||
|
)
|
||||||
|
|
||||||
|
timedOutCallJob = coroutineScope.launch {
|
||||||
|
showIncomingCallNotification(notificationData)
|
||||||
|
|
||||||
|
// Wait for the ringing call to time out
|
||||||
|
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
|
||||||
|
incomingCallTimedOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun incomingCallTimedOut() {
|
||||||
|
val previousActiveCall = activeCall.value ?: return
|
||||||
|
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
|
||||||
|
activeCall.value = null
|
||||||
|
|
||||||
|
cancelIncomingCallNotification()
|
||||||
|
|
||||||
|
displayMissedCallNotification(notificationData)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hungUpCall(callType: CallType) {
|
||||||
|
if (activeCall.value?.callType != callType) {
|
||||||
|
Timber.w("Call type $callType does not match the active call type, ignoring")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelIncomingCallNotification()
|
||||||
|
timedOutCallJob?.cancel()
|
||||||
|
activeCall.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun joinedCall(callType: CallType) {
|
||||||
|
cancelIncomingCallNotification()
|
||||||
|
timedOutCallJob?.cancel()
|
||||||
|
|
||||||
|
activeCall.value = ActiveCall(
|
||||||
|
callType = callType,
|
||||||
|
callState = CallState.InCall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
|
||||||
|
val notification = ringingCallNotificationCreator.createNotification(
|
||||||
|
sessionId = notificationData.sessionId,
|
||||||
|
roomId = notificationData.roomId,
|
||||||
|
eventId = notificationData.eventId,
|
||||||
|
senderId = notificationData.senderId,
|
||||||
|
roomName = notificationData.roomName,
|
||||||
|
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
|
||||||
|
roomAvatarUrl = notificationData.avatarUrl,
|
||||||
|
notificationChannelId = notificationData.notificationChannelId,
|
||||||
|
timestamp = notificationData.timestamp
|
||||||
|
) ?: return
|
||||||
|
runCatching {
|
||||||
|
notificationManagerCompat.notify(
|
||||||
|
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
|
||||||
|
notification,
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to publish notification for incoming call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelIncomingCallNotification() {
|
||||||
|
notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayMissedCallNotification(notificationData: CallNotificationData) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
onMissedCallNotificationHandler.addMissedCallNotification(
|
||||||
|
sessionId = notificationData.sessionId,
|
||||||
|
roomId = notificationData.roomId,
|
||||||
|
eventId = notificationData.eventId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an active call.
|
||||||
|
*/
|
||||||
|
data class ActiveCall(
|
||||||
|
val callType: CallType,
|
||||||
|
val callState: CallState,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the state of an active call.
|
||||||
|
*/
|
||||||
|
sealed interface CallState {
|
||||||
|
/**
|
||||||
|
* The call is in a ringing state.
|
||||||
|
* @param notificationData The data for the incoming call notification.
|
||||||
|
*/
|
||||||
|
data class Ringing(val notificationData: CallNotificationData) : CallState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The call is in an in-call state.
|
||||||
|
*/
|
||||||
|
data object InCall : CallState
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -27,5 +27,10 @@ interface CallWidgetProvider {
|
||||||
clientId: String,
|
clientId: String,
|
||||||
languageTag: String? = null,
|
languageTag: String? = null,
|
||||||
theme: String? = null,
|
theme: String? = null,
|
||||||
): Result<Pair<MatrixWidgetDriver, String>>
|
): Result<GetWidgetResult>
|
||||||
|
|
||||||
|
data class GetWidgetResult(
|
||||||
|
val driver: MatrixWidgetDriver,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -14,17 +14,16 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.appconfig.ElementCallConfig
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -33,6 +32,7 @@ class DefaultCallWidgetProvider @Inject constructor(
|
||||||
private val matrixClientsProvider: MatrixClientProvider,
|
private val matrixClientsProvider: MatrixClientProvider,
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
|
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
|
||||||
|
private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
|
||||||
) : CallWidgetProvider {
|
) : CallWidgetProvider {
|
||||||
override suspend fun getWidget(
|
override suspend fun getWidget(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
|
|
@ -40,11 +40,16 @@ class DefaultCallWidgetProvider @Inject constructor(
|
||||||
clientId: String,
|
clientId: String,
|
||||||
languageTag: String?,
|
languageTag: String?,
|
||||||
theme: String?,
|
theme: String?,
|
||||||
): Result<Pair<MatrixWidgetDriver, String>> = runCatching {
|
): Result<CallWidgetProvider.GetWidgetResult> = runCatching {
|
||||||
val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found")
|
val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found")
|
||||||
val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL
|
val baseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
|
||||||
|
?: elementCallBaseUrlProvider.provides(sessionId)
|
||||||
|
?: ElementCallConfig.DEFAULT_BASE_URL
|
||||||
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = room.isEncrypted)
|
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = room.isEncrypted)
|
||||||
val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
|
val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
|
||||||
room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl
|
CallWidgetProvider.GetWidgetResult(
|
||||||
|
driver = room.getWidgetDriver(widgetSettings).getOrThrow(),
|
||||||
|
url = callUrl
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.call.impl.wellknown.CallWellknownAPI
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.network.RetrofitFactory
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface ElementCallBaseUrlProvider {
|
||||||
|
suspend fun provides(sessionId: SessionId): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultElementCallBaseUrlProvider @Inject constructor(
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
) : ElementCallBaseUrlProvider {
|
||||||
|
private val apiCache = mutableMapOf<SessionId, CallWellknownAPI>()
|
||||||
|
|
||||||
|
override suspend fun provides(sessionId: SessionId): String? = withContext(coroutineDispatchers.io) {
|
||||||
|
val domain = sessionId.value.substringAfter(":")
|
||||||
|
val callWellknownAPI = apiCache.getOrPut(sessionId) {
|
||||||
|
retrofitFactory.create("https://$domain")
|
||||||
|
.create(CallWellknownAPI::class.java)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
callWellknownAPI.getCallWellKnown().widgetUrl
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// Ignore Http 404, but re-throws any other exceptions
|
||||||
|
if (e.code() != HttpURLConnection.HTTP_NOT_FOUND) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
Timber.w(e, "Failed to fetch wellknown data")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||||
|
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||||
|
|
||||||
|
internal object IntentProvider {
|
||||||
|
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
|
||||||
|
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
|
||||||
|
return PendingIntentCompat.getActivity(
|
||||||
|
context,
|
||||||
|
DefaultElementCallEntryPoint.REQUEST_CODE,
|
||||||
|
createIntent(context, callType),
|
||||||
|
0,
|
||||||
|
false
|
||||||
|
)!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
|
|
@ -22,7 +22,7 @@ import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.webkit.WebViewCompat
|
import androidx.webkit.WebViewCompat
|
||||||
import androidx.webkit.WebViewFeature
|
import androidx.webkit.WebViewFeature
|
||||||
import io.element.android.features.call.BuildConfig
|
import io.element.android.features.call.impl.BuildConfig
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
class WebViewWidgetMessageInterceptor(
|
class WebViewWidgetMessageInterceptor(
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import io.element.android.features.call.data.WidgetMessage
|
import io.element.android.features.call.impl.data.WidgetMessage
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
object WidgetMessageSerializer {
|
object WidgetMessageSerializer {
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.wellknown
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example:
|
||||||
|
* <pre>
|
||||||
|
* {
|
||||||
|
* "widget_url": "https://call.server.com"
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
* .
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CallWellKnown(
|
||||||
|
@SerialName("widget_url")
|
||||||
|
val widgetUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.impl.wellknown
|
||||||
|
|
||||||
|
import retrofit2.http.GET
|
||||||
|
|
||||||
|
internal interface CallWellknownAPI {
|
||||||
|
@GET(".well-known/element/call.json")
|
||||||
|
suspend fun getCallWellKnown(): CallWellKnown
|
||||||
|
}
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Бягучы званок"</string>
|
<string name="call_foreground_service_channel_title_android">"Бягучы званок"</string>
|
||||||
<string name="call_foreground_service_message_android">"Націсніце, каб вярнуцца да званку"</string>
|
<string name="call_foreground_service_message_android">"Націсніце, каб вярнуцца да званку"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Ідзе званок"</string>
|
<string name="call_foreground_service_title_android">"☎️ Ідзе званок"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Уваходны званок Element Call"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Probíhající hovor"</string>
|
<string name="call_foreground_service_channel_title_android">"Probíhající hovor"</string>
|
||||||
<string name="call_foreground_service_message_android">"Klepněte pro návrat k hovoru"</string>
|
<string name="call_foreground_service_message_android">"Klepněte pro návrat k hovoru"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Probíhá hovor"</string>
|
<string name="call_foreground_service_title_android">"☎️ Probíhá hovor"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Příchozí Element Call"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="call_foreground_service_channel_title_android">"Käimasolev kõne"</string>
|
||||||
|
<string name="call_foreground_service_message_android">"Kõne juurde naasmiseks klõpsa"</string>
|
||||||
|
<string name="call_foreground_service_title_android">"☎️ Kõne on pooleli"</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
|
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
|
||||||
<string name="call_foreground_service_message_android">"Cliquez pour retourner à l’appel."</string>
|
<string name="call_foreground_service_message_android">"Cliquez pour retourner à l’appel."</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
|
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Folyamatban lévő hívás"</string>
|
<string name="call_foreground_service_channel_title_android">"Folyamatban lévő hívás"</string>
|
||||||
<string name="call_foreground_service_message_android">"Koppintson a híváshoz való visszatéréshez"</string>
|
<string name="call_foreground_service_message_android">"Koppintson a híváshoz való visszatéréshez"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Hívás folyamatban"</string>
|
<string name="call_foreground_service_title_android">"☎️ Hívás folyamatban"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Bejövő Element hívás"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Panggilan berlangsung"</string>
|
<string name="call_foreground_service_channel_title_android">"Panggilan berlangsung"</string>
|
||||||
<string name="call_foreground_service_message_android">"Ketuk untuk kembali ke panggilan"</string>
|
<string name="call_foreground_service_message_android">"Ketuk untuk kembali ke panggilan"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Panggilan sedang berlangsung"</string>
|
<string name="call_foreground_service_title_android">"☎️ Panggilan sedang berlangsung"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Element Call Masuk"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Chamada em curso"</string>
|
<string name="call_foreground_service_channel_title_android">"Chamada em curso"</string>
|
||||||
<string name="call_foreground_service_message_android">"Toca para voltar à chamada"</string>
|
<string name="call_foreground_service_message_android">"Toca para voltar à chamada"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Chamada em curso"</string>
|
<string name="call_foreground_service_title_android">"☎️ Chamada em curso"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"A receber chamada da Element "</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Текущий вызов"</string>
|
<string name="call_foreground_service_channel_title_android">"Текущий вызов"</string>
|
||||||
<string name="call_foreground_service_message_android">"Коснитесь, чтобы вернуться к вызову"</string>
|
<string name="call_foreground_service_message_android">"Коснитесь, чтобы вернуться к вызову"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Идёт вызов"</string>
|
<string name="call_foreground_service_title_android">"☎️ Идёт вызов"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Входящий вызов Element"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Prebiehajúci hovor"</string>
|
<string name="call_foreground_service_channel_title_android">"Prebiehajúci hovor"</string>
|
||||||
<string name="call_foreground_service_message_android">"Ťuknutím sa vrátite k hovoru"</string>
|
<string name="call_foreground_service_message_android">"Ťuknutím sa vrátite k hovoru"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Prebieha hovor"</string>
|
<string name="call_foreground_service_title_android">"☎️ Prebieha hovor"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Prichádzajúci hovor Element Call"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
|
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
|
||||||
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
|
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
|
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.features.call.impl.ui.ElementCallActivity
|
||||||
|
import io.element.android.features.call.utils.FakeActiveCallManager
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DefaultElementCallEntryPointTest {
|
||||||
|
@Test
|
||||||
|
fun `startCall - starts ElementCallActivity setup with the needed extras`() {
|
||||||
|
val entryPoint = createEntryPoint()
|
||||||
|
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
|
||||||
|
|
||||||
|
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
|
||||||
|
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
|
||||||
|
assertThat(intent.component).isEqualTo(expectedIntent.component)
|
||||||
|
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
|
||||||
|
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
|
||||||
|
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
|
||||||
|
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
|
||||||
|
|
||||||
|
entryPoint.handleIncomingCall(
|
||||||
|
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
senderId = A_USER_ID_2,
|
||||||
|
roomName = "roomName",
|
||||||
|
senderName = "senderName",
|
||||||
|
avatarUrl = "avatarUrl",
|
||||||
|
timestamp = 0,
|
||||||
|
notificationChannelId = "notificationChannelId",
|
||||||
|
)
|
||||||
|
|
||||||
|
registerIncomingCallLambda.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEntryPoint(
|
||||||
|
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||||
|
) = DefaultElementCallEntryPoint(
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
activeCallManager = activeCallManager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.call
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.call.ui.mapWebkitPermissions
|
import io.element.android.features.call.impl.ui.mapWebkitPermissions
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class MapWebkitPermissionsTest {
|
class MapWebkitPermissionsTest {
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.notifications
|
||||||
|
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
|
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
|
||||||
|
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class RingingCallNotificationCreatorTest {
|
||||||
|
@Test
|
||||||
|
fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
|
||||||
|
val notificationCreator = createRingingCallNotificationCreator(
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("No client found")) })
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = notificationCreator.createTestNotification()
|
||||||
|
|
||||||
|
assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createNotification - creates a valid notification`() = runTest {
|
||||||
|
val notificationCreator = createRingingCallNotificationCreator(
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) })
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = notificationCreator.createTestNotification()
|
||||||
|
|
||||||
|
assertThat(result).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createNotification - tries to load the avatar URL`() = runTest {
|
||||||
|
val getUserIconLambda = lambdaRecorder<String?, ImageLoader, IconCompat?> { _, _ -> null }
|
||||||
|
val notificationCreator = createRingingCallNotificationCreator(
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
|
||||||
|
notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda)
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationCreator.createTestNotification()
|
||||||
|
|
||||||
|
getUserIconLambda.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun RingingCallNotificationCreator.createTestNotification() = createNotification(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
senderId = A_USER_ID_2,
|
||||||
|
roomName = "Room",
|
||||||
|
senderDisplayName = "Johnnie Murphy",
|
||||||
|
roomAvatarUrl = "https://example.com/avatar.jpg",
|
||||||
|
notificationChannelId = "channelId",
|
||||||
|
timestamp = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createRingingCallNotificationCreator(
|
||||||
|
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
|
imageLoaderHolder: FakeImageLoaderHolder = FakeImageLoaderHolder(),
|
||||||
|
notificationBitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(),
|
||||||
|
) = RingingCallNotificationCreator(
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
matrixClientProvider = matrixClientProvider,
|
||||||
|
imageLoaderHolder = imageLoaderHolder,
|
||||||
|
notificationBitmapLoader = notificationBitmapLoader,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.features.call.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenEvents
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenNavigator
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenPresenter
|
||||||
|
import io.element.android.features.call.utils.FakeActiveCallManager
|
||||||
import io.element.android.features.call.utils.FakeCallWidgetProvider
|
import io.element.android.features.call.utils.FakeCallWidgetProvider
|
||||||
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
|
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
@ -31,6 +35,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
||||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||||
import io.element.android.services.analytics.api.ScreenTracker
|
import io.element.android.services.analytics.api.ScreenTracker
|
||||||
|
|
@ -57,11 +62,13 @@ class CallScreenPresenterTest {
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - with CallType ExternalUrl just loads the URL`() = runTest {
|
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
|
||||||
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> { }
|
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
|
||||||
|
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
|
||||||
val presenter = createCallScreenPresenter(
|
val presenter = createCallScreenPresenter(
|
||||||
callType = CallType.ExternalUrl("https://call.element.io"),
|
callType = CallType.ExternalUrl("https://call.element.io"),
|
||||||
screenTracker = FakeScreenTracker(analyticsLambda)
|
screenTracker = FakeScreenTracker(analyticsLambda),
|
||||||
|
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -72,25 +79,35 @@ class CallScreenPresenterTest {
|
||||||
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
|
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
|
||||||
assertThat(initialState.isInWidgetMode).isFalse()
|
assertThat(initialState.isInWidgetMode).isFalse()
|
||||||
analyticsLambda.assertions().isNeverCalled()
|
analyticsLambda.assertions().isNeverCalled()
|
||||||
|
joinedCallLambda.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest {
|
fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest {
|
||||||
|
val sendCallNotificationIfNeededLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val fakeRoom = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
|
||||||
|
val client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, fakeRoom)
|
||||||
|
}
|
||||||
val widgetDriver = FakeMatrixWidgetDriver()
|
val widgetDriver = FakeMatrixWidgetDriver()
|
||||||
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
|
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
|
||||||
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> { }
|
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
|
||||||
|
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
|
||||||
val presenter = createCallScreenPresenter(
|
val presenter = createCallScreenPresenter(
|
||||||
|
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
|
||||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||||
widgetDriver = widgetDriver,
|
widgetDriver = widgetDriver,
|
||||||
widgetProvider = widgetProvider,
|
widgetProvider = widgetProvider,
|
||||||
screenTracker = FakeScreenTracker(analyticsLambda)
|
screenTracker = FakeScreenTracker(analyticsLambda),
|
||||||
|
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
// Wait until the URL is loaded
|
// Wait until the URL is loaded
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
joinedCallLambda.assertions().isCalledOnce()
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
|
||||||
assertThat(initialState.isInWidgetMode).isTrue()
|
assertThat(initialState.isInWidgetMode).isTrue()
|
||||||
|
|
@ -102,6 +119,7 @@ class CallScreenPresenterTest {
|
||||||
listOf(value(MobileScreen.ScreenName.RoomCall)),
|
listOf(value(MobileScreen.ScreenName.RoomCall)),
|
||||||
listOf(value(MobileScreen.ScreenName.RoomCall))
|
listOf(value(MobileScreen.ScreenName.RoomCall))
|
||||||
)
|
)
|
||||||
|
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,6 +272,7 @@ class CallScreenPresenterTest {
|
||||||
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
|
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
|
||||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
|
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||||
screenTracker: ScreenTracker = FakeScreenTracker(),
|
screenTracker: ScreenTracker = FakeScreenTracker(),
|
||||||
): CallScreenPresenter {
|
): CallScreenPresenter {
|
||||||
val userAgentProvider = object : UserAgentProvider {
|
val userAgentProvider = object : UserAgentProvider {
|
||||||
|
|
@ -270,8 +289,9 @@ class CallScreenPresenterTest {
|
||||||
clock = clock,
|
clock = clock,
|
||||||
dispatchers = dispatchers,
|
dispatchers = dispatchers,
|
||||||
matrixClientsProvider = matrixClientsProvider,
|
matrixClientsProvider = matrixClientsProvider,
|
||||||
screenTracker = screenTracker,
|
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
|
activeCallManager = activeCallManager,
|
||||||
|
screenTracker = screenTracker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.ui
|
||||||
|
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenNavigator
|
||||||
|
|
||||||
class FakeCallScreenNavigator : CallScreenNavigator {
|
class FakeCallScreenNavigator : CallScreenNavigator {
|
||||||
var closeCalled = false
|
var closeCalled = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.utils
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||||
|
import io.element.android.features.call.impl.utils.ActiveCall
|
||||||
|
import io.element.android.features.call.impl.utils.CallState
|
||||||
|
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
|
||||||
|
import io.element.android.features.call.test.aCallNotificationData
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
|
||||||
|
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
|
||||||
|
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DefaultActiveCallManagerTest {
|
||||||
|
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
|
val callNotificationData = aCallNotificationData()
|
||||||
|
manager.registerIncomingCall(callNotificationData)
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isEqualTo(
|
||||||
|
ActiveCall(
|
||||||
|
callType = CallType.RoomCall(
|
||||||
|
sessionId = callNotificationData.sessionId,
|
||||||
|
roomId = callNotificationData.roomId,
|
||||||
|
),
|
||||||
|
callState = CallState.Ringing(callNotificationData)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
verify { notificationManagerCompat.notify(notificationId, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
|
||||||
|
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||||
|
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register existing call
|
||||||
|
val callNotificationData = aCallNotificationData()
|
||||||
|
manager.registerIncomingCall(callNotificationData)
|
||||||
|
val activeCall = manager.activeCall.value
|
||||||
|
|
||||||
|
// Now add a new call
|
||||||
|
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isEqualTo(activeCall)
|
||||||
|
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
|
||||||
|
addMissedCallNotificationLambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
|
||||||
|
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.incomingCallTimedOut()
|
||||||
|
|
||||||
|
addMissedCallNotificationLambda.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
|
||||||
|
notificationManagerCompat = notificationManagerCompat,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.registerIncomingCall(aCallNotificationData())
|
||||||
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
|
||||||
|
manager.incomingCallTimedOut()
|
||||||
|
advanceTimeBy(1)
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
addMissedCallNotificationLambda.assertions().isCalledOnce()
|
||||||
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData()
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
|
||||||
|
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
||||||
|
|
||||||
|
manager.registerIncomingCall(aCallNotificationData())
|
||||||
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
|
||||||
|
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
|
||||||
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
|
||||||
|
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
notificationManagerCompat = notificationManagerCompat,
|
||||||
|
)
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
|
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
|
||||||
|
assertThat(manager.activeCall.value).isEqualTo(
|
||||||
|
ActiveCall(
|
||||||
|
callType = CallType.RoomCall(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
),
|
||||||
|
callState = CallState.InCall,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createActiveCallManager(
|
||||||
|
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
|
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
||||||
|
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
||||||
|
) = DefaultActiveCallManager(
|
||||||
|
coroutineScope = this,
|
||||||
|
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
|
||||||
|
ringingCallNotificationCreator = RingingCallNotificationCreator(
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
matrixClientProvider = matrixClientProvider,
|
||||||
|
imageLoaderHolder = FakeImageLoaderHolder(),
|
||||||
|
notificationBitmapLoader = FakeNotificationBitmapLoader(),
|
||||||
|
),
|
||||||
|
notificationManagerCompat = notificationManagerCompat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
|
||||||
|
import io.element.android.features.call.impl.utils.ElementCallBaseUrlProvider
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
|
@ -27,7 +29,10 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
|
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
|
||||||
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -108,13 +113,42 @@ class DefaultCallWidgetProviderTest {
|
||||||
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
|
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getWidget - will use a wellknown base url if it exists`() = runTest {
|
||||||
|
val aCustomUrl = "https://custom.element.io"
|
||||||
|
val providesLambda = lambdaRecorder<SessionId, String?> { _ -> aCustomUrl }
|
||||||
|
val elementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { sessionId ->
|
||||||
|
providesLambda(sessionId)
|
||||||
|
}
|
||||||
|
val room = FakeMatrixRoom().apply {
|
||||||
|
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
|
||||||
|
givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver()))
|
||||||
|
}
|
||||||
|
val client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val settingsProvider = FakeCallWidgetSettingsProvider()
|
||||||
|
val provider = createProvider(
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
|
||||||
|
callWidgetSettingsProvider = settingsProvider,
|
||||||
|
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
|
||||||
|
)
|
||||||
|
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
|
||||||
|
assertThat(settingsProvider.providedBaseUrls).containsExactly(aCustomUrl)
|
||||||
|
providesLambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_SESSION_ID))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createProvider(
|
private fun createProvider(
|
||||||
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
|
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider()
|
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
|
||||||
|
elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
|
||||||
) = DefaultCallWidgetProvider(
|
) = DefaultCallWidgetProvider(
|
||||||
matrixClientProvider,
|
matrixClientsProvider = matrixClientProvider,
|
||||||
appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
callWidgetSettingsProvider,
|
callWidgetSettingsProvider = callWidgetSettingsProvider,
|
||||||
|
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 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 io.element.android.features.call.utils
|
||||||
|
|
||||||
|
import io.element.android.features.call.api.CallType
|
||||||
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
|
import io.element.android.features.call.impl.utils.ActiveCall
|
||||||
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FakeActiveCallManager(
|
||||||
|
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
|
||||||
|
var incomingCallTimedOutResult: () -> Unit = {},
|
||||||
|
var hungUpCallResult: (CallType) -> Unit = {},
|
||||||
|
var joinedCallResult: (CallType) -> Unit = {},
|
||||||
|
) : ActiveCallManager {
|
||||||
|
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
||||||
|
|
||||||
|
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||||
|
registerIncomingCallResult(notificationData)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun incomingCallTimedOut() {
|
||||||
|
incomingCallTimedOutResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hungUpCall(callType: CallType) {
|
||||||
|
hungUpCallResult(callType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun joinedCall(callType: CallType) {
|
||||||
|
joinedCallResult(callType)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setActiveCall(value: ActiveCall?) {
|
||||||
|
this.activeCall.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
|
import io.element.android.features.call.impl.utils.CallWidgetProvider
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
|
||||||
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
||||||
|
|
||||||
class FakeCallWidgetProvider(
|
class FakeCallWidgetProvider(
|
||||||
|
|
@ -34,8 +34,13 @@ class FakeCallWidgetProvider(
|
||||||
clientId: String,
|
clientId: String,
|
||||||
languageTag: String?,
|
languageTag: String?,
|
||||||
theme: String?
|
theme: String?
|
||||||
): Result<Pair<MatrixWidgetDriver, String>> {
|
): Result<CallWidgetProvider.GetWidgetResult> {
|
||||||
getWidgetCalled = true
|
getWidgetCalled = true
|
||||||
return Result.success(widgetDriver to url)
|
return Result.success(
|
||||||
|
CallWidgetProvider.GetWidgetResult(
|
||||||
|
driver = widgetDriver,
|
||||||
|
url = url,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue