Merge branch 'release/0.4.15' into main

This commit is contained in:
ganfra 2024-06-19 14:23:48 +02:00
commit e19c72374e
1502 changed files with 10092 additions and 3996 deletions

View file

@ -9,8 +9,8 @@ on:
# Enrich gradle.properties for CI/CD
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"
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 -Dsonar.gradle.skipCompile=true
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 -Dsonar.gradle.skipCompile=true
jobs:
debug:

View file

@ -11,7 +11,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@12.3.1
uses: danger/danger-js@12.3.2
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View file

@ -8,8 +8,8 @@ on:
# Enrich gradle.properties for CI/CD
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"
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon -Dsonar.gradle.skipCompile=true
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 --no-daemon -Dsonar.gradle.skipCompile=true
jobs:
build-apk:

View file

@ -7,8 +7,8 @@ on:
- cron: "0 4 * * *"
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -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
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 --no-daemon -Dsonar.gradle.skipCompile=true
jobs:
nightly:

View file

@ -8,8 +8,8 @@ on:
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx3g" -Dkotlin.incremental=false -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 -Dsonar.gradle.skipCompile=true
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 -Dsonar.gradle.skipCompile=true
jobs:
nightlyReports:

View file

@ -10,7 +10,7 @@ on:
# Enrich gradle.properties for CI/CD
env:
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:
checkScript:
@ -33,6 +33,27 @@ jobs:
- name: Search for invalid screenshot files
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
konsist:
name: Konsist tests
@ -88,6 +109,10 @@ jobs:
uses: gradle/actions/setup-gradle@v3
with:
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
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES
- name: Upload reports
@ -187,6 +212,19 @@ jobs:
- name: Run Knit
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:
name: Project Check Suite
runs-on: ubuntu-latest
@ -207,7 +245,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@12.3.1
uses: danger/danger-js@12.3.2
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:

View file

@ -7,7 +7,7 @@ on:
# Enrich gradle.properties for CI/CD
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:
record:

View file

@ -8,7 +8,7 @@ on:
# Enrich gradle.properties for CI/CD
env:
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:
gplay:

View file

@ -62,10 +62,10 @@ if [[ -z ${REPO} ]]; then
fi
echo "Deleting previous screenshots"
./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn
./gradlew removeOldSnapshots --stacktrace --warn
echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
./gradlew recordPaparazziDebug --stacktrace --warn
echo "Committing changes"
git config http.sslVerify false

View file

@ -9,8 +9,9 @@ on:
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.incremental=false -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --warn -Dsonar.gradle.skipCompile=true
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 --warn -Dsonar.gradle.skipCompile=true
GROUP: ${{ format('sonar-{0}', github.ref) }}
jobs:
sonar:
@ -18,8 +19,8 @@ jobs:
runs-on: ubuntu-latest
# Allow all jobs on main and develop. Just one per PR.
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) }}
cancel-in-progress: true
group: ${{ format('sonar-{0}', github.ref) }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
steps:
- uses: actions/checkout@v4
with:
@ -35,8 +36,16 @@ jobs:
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build projects
run: ./gradlew assembleDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
- name: Build Gplay Debug
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
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View file

@ -10,7 +10,7 @@ on:
# Enrich gradle.properties for CI/CD
env:
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:
tests:

View file

@ -13,6 +13,7 @@
<w>onboarding</w>
<w>placeables</w>
<w>posthog</w>
<w>rageshake</w>
<w>securebackup</w>
<w>showkase</w>
<w>snackbar</w>

View file

@ -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)
=========================================

View file

@ -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/).
[<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
<!--- TOC -->
@ -25,13 +27,13 @@ Learn more about why we are building Element X in our blog post: [https://elemen
* [Contributing](#contributing)
* [Build instructions](#build-instructions)
* [Support](#support)
* [Copyright & License](#copyright-&-license)
* [Copyright and License](#copyright-and-license)
<!--- END -->
## Screenshots
Here are some early screenshots of the application:
Here are some screenshots of the application:
<!--
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
-->
|<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
@ -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).
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

View file

@ -223,7 +223,6 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir, logger)
implementation(projects.features.call)
implementation(projects.features.migration.api)
implementation(projects.anvilannotations)
implementation(projects.appnav)

View file

@ -20,11 +20,11 @@ import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
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.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ContributesTo(AppScope::class)
interface AppBindings {

View file

@ -22,7 +22,9 @@ import androidx.preference.PreferenceManager
import androidx.startup.Initializer
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.rageshake.api.reporter.BugReporter
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.TracingFilterConfigurations
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
@ -36,31 +38,27 @@ class TracingInitializer : Initializer<Unit> {
val tracingService = appBindings.tracingService()
val bugReporter = appBindings.bugReporter()
Timber.plant(tracingService.createTimberTree())
val tracingConfiguration = if (BuildConfig.DEBUG) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val store = SharedPreferencesTracingConfigurationStore(prefs)
val builder = TargetLogLevelMapBuilder(store)
val tracingConfiguration = if (BuildConfig.BUILD_TYPE == BuildType.RELEASE.name) {
TracingConfiguration(
filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()),
writesToLogcat = true,
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
filterConfiguration = TracingFilterConfigurations.release,
writesToLogcat = false,
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
)
} else {
val config = if (BuildConfig.BUILD_TYPE == "nightly") {
TracingFilterConfigurations.nightly
} else {
TracingFilterConfigurations.release
}
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val store = SharedPreferencesTracingConfigurationStore(prefs)
val builder = TargetLogLevelMapBuilder(
tracingConfigurationStore = store,
defaultConfig = if (BuildConfig.BUILD_TYPE == BuildType.NIGHTLY.name) {
TracingFilterConfigurations.nightly
} else {
TracingFilterConfigurations.debug
}
)
TracingConfiguration(
filterConfiguration = config,
writesToLogcat = false,
writesToFilesConfiguration = WriteToFilesConfiguration.Enabled(
directory = bugReporter.logDirectory().absolutePath,
filenamePrefix = "logs",
filenameSuffix = null,
// Keep a minimum of 1 week of log files.
numberOfFiles = 7 * 24,
)
filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()),
writesToLogcat = BuildConfig.DEBUG,
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
)
}
bugReporter.setCurrentTracingFilter(tracingConfiguration.filterConfiguration.filter)
@ -69,5 +67,15 @@ class TracingInitializer : Initializer<Unit> {
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()
}

View file

@ -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>

View file

@ -6,6 +6,7 @@
<locale android:name="de"/>
<locale android:name="en"/>
<locale android:name="es"/>
<locale android:name="et"/>
<locale android:name="fr"/>
<locale android:name="hu"/>
<locale android:name="in"/>

View file

@ -40,9 +40,4 @@ object ApplicationConfig {
* For Element, the value is "Element". We use the same name for desktop and mobile for now.
*/
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
}

View file

@ -17,5 +17,13 @@
package io.element.android.appconfig
object ElementCallConfig {
/**
* The default base URL for the Element Call service.
*/
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
}

View file

@ -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
}

View file

@ -67,6 +67,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.login.impl)
testImplementation(projects.tests.testutils)

View file

@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode
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.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
@ -102,6 +103,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@ -157,6 +159,11 @@ class LoggedInFlowNode @AssistedInject constructor(
}
)
observeSyncStateAndNetworkStatus()
setupSendingQueue()
}
private fun setupSendingQueue() {
sendingQueue.launchIn(lifecycleScope)
}
@OptIn(FlowPreview::class)
@ -231,7 +238,12 @@ class LoggedInFlowNode @AssistedInject constructor(
return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
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 -> {
val callback = object : RoomListEntryPoint.Callback {

View file

@ -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.
runBlocking {
sessionIds.forEach { sessionId ->
restore(sessionId)
getOrRestore(sessionId)
}
}
}

View file

@ -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
}

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -35,11 +36,22 @@ class LoggedInNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun navigateToNotificationTroubleshoot()
}
private fun navigateToNotificationTroubleshoot() {
plugins<Callback>().forEach {
it.navigateToNotificationTroubleshoot()
}
}
@Composable
override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present()
LoggedInView(
state = loggedInState,
navigateToNotificationTroubleshoot = ::navigateToNotificationTroubleshoot,
modifier = modifier
)
}

View file

@ -18,15 +18,20 @@ package io.element.android.appnav.loggedin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor
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.core.log.logger.LoggerTag
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.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.SessionVerifiedStatus
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 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 javax.inject.Inject
private val pusherTag = LoggerTag("Pusher", LoggerTag.PushLoggerTag)
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor,
@ -49,36 +60,26 @@ class LoggedInPresenter @Inject constructor(
) : Presenter<LoggedInState> {
@Composable
override fun present(): LoggedInState {
val isVerified by remember {
sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
val coroutineScope = rememberCoroutineScope()
val ignoreRegistrationError by remember {
pushService.ignoreRegistrationError(matrixClient.sessionId)
}.collectAsState(initial = false)
LaunchedEffect(isVerified) {
if (isVerified) {
// Ensure pusher is registered
val currentPushProvider = pushService.getCurrentPushProvider()
val result = if (currentPushProvider == null) {
// Register with the first available push provider
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, pushProvider, distributor)
} else {
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)
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
sessionVerificationService.sessionVerifiedStatus
.onEach { sessionVerifiedStatus ->
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> Unit
SessionVerifiedStatus.Verified -> {
ensurePusherIsRegistered(pusherRegistrationState)
}
SessionVerifiedStatus.NotVerified -> {
pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified())
}
}
}
result.onFailure {
Timber.e(it, "Failed to register pusher")
}
}
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState()
val showSyncSpinner by remember {
@ -86,14 +87,86 @@ class LoggedInPresenter @Inject constructor(
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
}
}
val verificationState by sessionVerificationService.sessionVerifiedStatus.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
LaunchedEffect(verificationState, recoveryState) {
reportCryptoStatusToAnalytics(verificationState, recoveryState)
LaunchedEffect(Unit) {
combine(
sessionVerificationService.sessionVerifiedStatus,
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(
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)
}
}
)
}

View file

@ -16,6 +16,11 @@
package io.element.android.appnav.loggedin
import io.element.android.libraries.architecture.AsyncData
data class LoggedInState(
val showSyncSpinner: Boolean,
val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean,
val eventSink: (LoggedInEvents) -> Unit,
)

View file

@ -17,18 +17,23 @@
package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(false),
aLoggedInState(true),
// Add other state here
aLoggedInState(),
aLoggedInState(showSyncSpinner = true),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
)
}
fun aLoggedInState(
showSyncSpinner: Boolean = true,
showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
eventSink = {},
)

View file

@ -22,13 +22,19 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.PreviewsDayNight
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoggedInView(
state: LoggedInState,
navigateToNotificationTroubleshoot: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
@ -41,12 +47,53 @@ fun LoggedInView(
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
@Composable
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
LoggedInView(
state = state
state = state,
navigateToNotificationTroubleshoot = {},
)
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -40,7 +40,7 @@ class MatrixClientsHolderTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
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.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}

View file

@ -18,23 +18,39 @@ package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
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.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.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.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
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.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.tests.testutils.WarmUpRule
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 org.junit.Rule
import org.junit.Test
@ -51,6 +67,8 @@ class LoggedInPresenterTest {
}.test {
val initialState = awaitItem()
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.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
skipItems(4)
skipItems(2)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
// ensure a sync status change does not trigger a new capture
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
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(
roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = FakePushService(),
sessionVerificationService = FakeSessionVerificationService(),
pushService = pushService,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
encryptionService = encryptionService
)

View file

@ -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)),
)
}
}

View file

@ -0,0 +1,2 @@
Main changes in this version: Ringing call notifications.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -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>

View file

@ -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>

View file

@ -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");
* you may not use this file except in compliance with the License.
@ -14,13 +14,18 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
data object Retry : RetrySendMenuEvents
data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.call.api"
}
dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}

View file

@ -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");
* you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.api
import android.os.Parcelable
import io.element.android.libraries.architecture.NodeInputs

View file

@ -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,
)
}

View file

@ -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");
* you may not use this file except in compliance with the License.
@ -23,11 +23,15 @@ plugins {
}
android {
namespace = "io.element.android.features.call"
namespace = "io.element.android.features.call.impl"
buildFeatures {
buildConfig = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
anvil {
@ -41,12 +45,18 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.call.api)
ksp(libs.showkase.processor)
testImplementation(libs.coroutines.test)
@ -54,9 +64,12 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
}

View file

@ -23,11 +23,17 @@
android:name="android.hardware.microphone"
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.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<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_PHONE_CALL" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application>
<activity
@ -70,10 +76,24 @@
</intent-filter>
</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
android:name=".CallForegroundService"
android:name=".services.CallForegroundService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
android:exported="false"
android:foregroundServiceType="phoneCall" />
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
android:exported="false"
android:enabled="true" />
</application>
</manifest>

View file

@ -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)
}
}

View file

@ -14,7 +14,7 @@
* 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.Serializable

View file

@ -14,13 +14,17 @@
* 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 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
@ContributesTo(AppScope::class)
interface CallBindings {
fun inject(callActivity: ElementCallActivity)
fun inject(callActivity: IncomingCallActivity)
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
}

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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))
}
}

View file

@ -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");
* you may not use this file except in compliance with the License.
@ -14,30 +14,36 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.impl.services
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
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.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() {
companion object {
fun start(context: Context) {
val intent = Intent(context, CallForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
@ -69,7 +75,17 @@ class CallForegroundService : Service() {
.setContentText(getString(R.string.call_foreground_service_message_android))
.setContentIntent(pendingIntent)
.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() {

View file

@ -14,11 +14,12 @@
* 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 {
data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) :
CallScreenEvents
}

View file

@ -14,7 +14,7 @@
* 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.DisposableEffect
@ -30,23 +30,25 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.CallType
import io.element.android.features.call.data.WidgetMessage
import io.element.android.features.call.utils.CallWidgetProvider
import io.element.android.features.call.utils.WidgetMessageInterceptor
import io.element.android.features.call.utils.WidgetMessageSerializer
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
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.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
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.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -65,6 +67,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker,
private val appCoroutineScope: CoroutineScope,
private val activeCallManager: ActiveCallManager,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
@ -73,6 +76,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
private var notifiedCallStart = false
@Composable
override fun present(): CallScreenState {
@ -82,8 +86,15 @@ class CallScreenPresenter @AssistedInject constructor(
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
loadUrl(callType, urlState, callWidgetDriver)
DisposableEffect(Unit) {
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
loadUrl(callType, urlState, callWidgetDriver)
}
onDispose {
activeCallManager.hungUpCall(callType)
}
}
when (callType) {
@ -159,28 +170,28 @@ class CallScreenPresenter @AssistedInject constructor(
urlState = urlState.value,
userAgent = userAgent,
isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvents,
eventSink = { handleEvents(it) },
)
}
private fun CoroutineScope.loadUrl(
private suspend fun loadUrl(
inputs: CallType,
urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
) = launch {
) {
urlState.runCatchingUpdatingState {
when (inputs) {
is CallType.ExternalUrl -> {
inputs.url
}
is CallType.RoomCall -> {
val (driver, url) = callWidgetProvider.getWidget(
val result = callWidgetProvider.getWidget(
sessionId = inputs.sessionId,
roomId = inputs.roomId,
clientId = UUID.randomUUID().toString(),
).getOrThrow()
callWidgetDriver.value = driver
url
callWidgetDriver.value = result.driver
result.url
}
}
}
@ -193,15 +204,15 @@ class CallScreenPresenter @AssistedInject constructor(
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
matrixClientsProvider.getOrNull(it)
} ?: return@DisposableEffect onDispose { }
coroutineScope.launch {
client.syncService().syncState
.onEach { state ->
if (state != SyncState.Running) {
.collect { state ->
if (state == SyncState.Running) {
client.notifyCallStartIfNeeded(callType.roomId)
} else {
client.syncService().startSync()
}
}
.collect()
}
onDispose {
// 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? {
return WidgetMessageSerializer.deserialize(message).getOrNull()
}

View file

@ -14,7 +14,7 @@
* 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

View file

@ -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,
)
}

View file

@ -14,7 +14,7 @@
* 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.view.ViewGroup
@ -32,17 +32,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.R
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
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.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
typealias RequestPermissionCallback = (Array<String>) -> Unit
@ -91,6 +95,17 @@ internal fun CallScreenView(
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
@Composable
internal fun CallScreenViewPreview() {
ElementPreview {
CallScreenView(
state = CallScreenState(
urlState = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
isInWidgetMode = false,
userAgent = "",
eventSink = {},
),
requestPermissions = { _, _ -> },
)
}
internal fun CallScreenViewPreview(
@PreviewParameter(CallScreenStateProvider::class) state: CallScreenState,
) = ElementPreview {
CallScreenView(
state = state,
requestPermissions = { _, _ -> },
)
}

View file

@ -14,12 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.res.Configuration
import android.media.AudioAttributes
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.isDark
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings
import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import javax.inject.Inject
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 presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
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)
@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
private fun setCallType(intent: Intent?) {
val inputs = intent?.let {
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
val callType = intent?.let {
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
}
val intentUrl = intent?.dataString?.let(::parseUrl)
when {
// 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()
inputs != null -> {
webViewTarget.value = inputs
presenter = presenterFactory.create(inputs, this)
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
callType != null -> {
webViewTarget.value = callType
presenter = presenterFactory.create(callType, this)
}
intentUrl != null -> {
val fallbackInputs = CallType.ExternalUrl(intentUrl)

View file

@ -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)
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import android.net.Uri
import javax.inject.Inject

View file

@ -14,7 +14,7 @@
* 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.SessionId
@ -27,5 +27,10 @@ interface CallWidgetProvider {
clientId: String,
languageTag: String? = null,
theme: String? = null,
): Result<Pair<MatrixWidgetDriver, String>>
): Result<GetWidgetResult>
data class GetWidgetResult(
val driver: MatrixWidgetDriver,
val url: String,
)
}

View file

@ -14,17 +14,16 @@
* 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 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.matrix.api.MatrixClientProvider
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.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 javax.inject.Inject
@ -33,6 +32,7 @@ class DefaultCallWidgetProvider @Inject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val elementCallBaseUrlProvider: ElementCallBaseUrlProvider,
) : CallWidgetProvider {
override suspend fun getWidget(
sessionId: SessionId,
@ -40,11 +40,16 @@ class DefaultCallWidgetProvider @Inject constructor(
clientId: String,
languageTag: 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 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 callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl
CallWidgetProvider.GetWidgetResult(
driver = room.getWidgetDriver(widgetSettings).getOrThrow(),
url = callUrl
)
}
}

View file

@ -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
}
}
}

View file

@ -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
)!!
}
}

View file

@ -14,7 +14,7 @@
* 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.webkit.JavascriptInterface
@ -22,7 +22,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.BuildConfig
import io.element.android.features.call.impl.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow
class WebViewWidgetMessageInterceptor(

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import kotlinx.coroutines.flow.Flow

View file

@ -14,9 +14,9 @@
* 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
object WidgetMessageSerializer {

View file

@ -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,
)

View file

@ -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
}

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Бягучы званок"</string>
<string name="call_foreground_service_message_android">"Націсніце, каб вярнуцца да званку"</string>
<string name="call_foreground_service_title_android">"☎️ Ідзе званок"</string>
<string name="screen_incoming_call_subtitle_android">"Уваходны званок Element Call"</string>
</resources>

View file

@ -3,4 +3,5 @@
<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_title_android">"☎️ Probíhá hovor"</string>
<string name="screen_incoming_call_subtitle_android">"Příchozí Element Call"</string>
</resources>

View file

@ -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>

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
<string name="call_foreground_service_message_android">"Cliquez pour retourner à lappel."</string>
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
</resources>

View file

@ -3,4 +3,5 @@
<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_title_android">"☎️ Hívás folyamatban"</string>
<string name="screen_incoming_call_subtitle_android">"Bejövő Element hívás"</string>
</resources>

View file

@ -3,4 +3,5 @@
<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_title_android">"☎️ Panggilan sedang berlangsung"</string>
<string name="screen_incoming_call_subtitle_android">"Element Call Masuk"</string>
</resources>

View file

@ -3,4 +3,5 @@
<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_title_android">"☎️ Chamada em curso"</string>
<string name="screen_incoming_call_subtitle_android">"A receber chamada da Element "</string>
</resources>

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Текущий вызов"</string>
<string name="call_foreground_service_message_android">"Коснитесь, чтобы вернуться к вызову"</string>
<string name="call_foreground_service_title_android">"☎️ Идёт вызов"</string>
<string name="screen_incoming_call_subtitle_android">"Входящий вызов Element"</string>
</resources>

View file

@ -3,4 +3,5 @@
<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_title_android">"☎️ Prebieha hovor"</string>
<string name="screen_incoming_call_subtitle_android">"Prichádzajúci hovor Element Call"</string>
</resources>

View file

@ -3,4 +3,5 @@
<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_title_android">"☎️ Call in progress"</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>

View file

@ -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,
)
}

View file

@ -19,7 +19,7 @@ package io.element.android.features.call
import android.Manifest
import android.webkit.PermissionRequest
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
class MapWebkitPermissionsTest {

View file

@ -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,
)
}

View file

@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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.FakeWidgetMessageInterceptor
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.FakeMatrixClient
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.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
@ -57,11 +62,13 @@ class CallScreenPresenterTest {
val warmUpRule = WarmUpRule()
@Test
fun `present - with CallType ExternalUrl just loads the URL`() = runTest {
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> { }
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
screenTracker = FakeScreenTracker(analyticsLambda)
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -72,25 +79,35 @@ class CallScreenPresenterTest {
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.isInWidgetMode).isFalse()
analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce()
}
}
@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 widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> { }
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda)
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
assertThat(initialState.isInWidgetMode).isTrue()
@ -102,6 +119,7 @@ class CallScreenPresenterTest {
listOf(value(MobileScreen.ScreenName.RoomCall)),
listOf(value(MobileScreen.ScreenName.RoomCall))
)
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
}
}
@ -254,6 +272,7 @@ class CallScreenPresenterTest {
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
@ -270,8 +289,9 @@ class CallScreenPresenterTest {
clock = clock,
dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider,
screenTracker = screenTracker,
appCoroutineScope = this,
activeCallManager = activeCallManager,
screenTracker = screenTracker,
)
}
}

View file

@ -16,6 +16,8 @@
package io.element.android.features.call.ui
import io.element.android.features.call.impl.ui.CallScreenNavigator
class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false
private set

View file

@ -17,6 +17,7 @@
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.CallIntentDataParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

View file

@ -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,
)
}

View file

@ -17,8 +17,10 @@
package io.element.android.features.call.utils
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.core.SessionId
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_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.widget.FakeCallWidgetSettingsProvider
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.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -108,13 +113,42 @@ class DefaultCallWidgetProviderTest {
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(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider()
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
elementCallBaseUrlProvider: ElementCallBaseUrlProvider = FakeElementCallBaseUrlProvider { _ -> null },
) = DefaultCallWidgetProvider(
matrixClientProvider,
appPreferencesStore,
callWidgetSettingsProvider,
matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider,
elementCallBaseUrlProvider = elementCallBaseUrlProvider,
)
}

View file

@ -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
}
}

View file

@ -16,9 +16,9 @@
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.SessionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
class FakeCallWidgetProvider(
@ -34,8 +34,13 @@ class FakeCallWidgetProvider(
clientId: String,
languageTag: String?,
theme: String?
): Result<Pair<MatrixWidgetDriver, String>> {
): Result<CallWidgetProvider.GetWidgetResult> {
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