diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff47a057ae..a55a2142f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - target: [ GplayDevTchapWithoutvoipWithoutpinning, FdroidDevTchapWithoutvoipWithoutpinning, GplayBtchapWithvoipWithpinning ] + target: [ GplayDevTchapWithdmvoipWithoutpinning, FdroidDevTchapWithdmvoipWithoutpinning, GplayBtchapWithvoipWithpinning ] # Allow all jobs on develop. Just one per PR. concurrency: group: ${{ github.ref == 'refs/heads/develop' && format('integration-tests-develop-{0}-{1}', matrix.target, github.sha) || format('build-debug-{0}-{1}', matrix.target, github.ref) }} @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: false matrix: - target: [ GplayBtchapWithoutvoipWithpinning, GplayTchapWithoutvoipWithpinning, FdroidBtchapWithoutvoipWithoutpinning, FdroidTchapWithoutvoipWithoutpinning, GplayBtchapWithvoipWithpinning ] + target: [ GplayBtchapWithdmvoipWithpinning, GplayTchapWithdmvoipWithpinning, FdroidBtchapWithdmvoipWithoutpinning, FdroidTchapWithdmvoipWithoutpinning, GplayBtchapWithvoipWithpinning ] concurrency: # Tchap: custom group group: ${{ github.ref == 'refs/head/main' && format('build-release-apk-main-{0}-{1}', github.sha, matrix.target) || startsWith(github.ref, 'refs/tags/tchap_v') && format('build-release-apk-tag-{0}-{1}', github.sha, matrix.target) }} @@ -82,8 +82,8 @@ jobs: needs: release env: # Tchap: only generate exodus report for the following target - TARGET_NAME: "GplayTchapWithoutvoipWithpinning" - TARGET_FOLDER: "gplayTchapWithoutvoipWithpinning" + TARGET_NAME: "GplayTchapWithdmvoipWithpinning" + TARGET_FOLDER: "gplayTchapWithdmvoipWithpinning" steps: - name: Obtain apk from artifact id: download diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 39d8682a7f..ed0fb8b412 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -55,8 +55,8 @@ jobs: - name: Run lint # Not always, if ktlint or detekt fail, avoid running the long lint check. run: | - ./gradlew vector-app:lintGplayBtchapWithoutvoipWithpinningRelease $CI_GRADLE_ARG_PROPERTIES - ./gradlew vector-app:lintFdroidBtchapWithoutvoipWithoutpinningRelease $CI_GRADLE_ARG_PROPERTIES + ./gradlew vector-app:lintGplayBtchapWithdmvoipWithpinningRelease $CI_GRADLE_ARG_PROPERTIES + ./gradlew vector-app:lintFdroidBtchapWithdmvoipWithoutpinningRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() uses: actions/upload-artifact@v3 diff --git a/.github/workflows/sanity_test.yml b/.github/workflows/sanity_test.yml index 911ab62b1c..459900c403 100644 --- a/.github/workflows/sanity_test.yml +++ b/.github/workflows/sanity_test.yml @@ -70,7 +70,7 @@ jobs: touch emulator.log chmod 777 emulator.log adb logcat >> emulator.log & - ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayBtchapWithoutvoipWithoutpinningDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots && exit 1 + ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayBtchapWithdmvoipWithoutpinningDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots && exit 1 - name: Upload Test Report Log uses: actions/upload-artifact@v2 if: always() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 63ceecd211..5fccf57829 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,7 +74,7 @@ jobs: disable-animations: true # emulator-build: 7425822 script: | - ./gradlew gatherGplayTchapWithoutvoipWithpinningDebugStringTemplates $CI_GRADLE_ARG_PROPERTIES + ./gradlew gatherGplayTchapWithdmvoipWithpinningDebugStringTemplates $CI_GRADLE_ARG_PROPERTIES ./gradlew unitTestsWithCoverage $CI_GRADLE_ARG_PROPERTIES ./gradlew instrumentationTestsWithCoverage $CI_GRADLE_ARG_PROPERTIES ./gradlew generateCoverageReport $CI_GRADLE_ARG_PROPERTIES diff --git a/TCHAP_CHANGES.md b/TCHAP_CHANGES.md index b3f987dec3..5f7bb6545a 100644 --- a/TCHAP_CHANGES.md +++ b/TCHAP_CHANGES.md @@ -1,3 +1,11 @@ +Changes in Tchap 2.10.0 (2024-01-15) +==================================== + +Features ✨ +---------- + - Activation des appels audios dans les DM. ([#993](https://github.com/tchapgouv/tchap-android/issues/993)) + + Changes in Tchap 2.9.8 (2023-12-22) =================================== diff --git a/coverage.gradle b/coverage.gradle index 520a137be5..97b954bd6f 100644 --- a/coverage.gradle +++ b/coverage.gradle @@ -82,11 +82,11 @@ task unitTestsWithCoverage(type: GradleBuild) { // the 7.1.3 android gradle plugin has a bug where enableTestCoverage generates invalid coverage startParameter.projectProperties.coverage = "false" // Tchap: Specify variant for every module - tasks = [/*':matrix-sdk-android:testDebugUnitTest',*/ ':vector:testGplayBtchapWithoutvoipWithoutpinningDebugUnitTest', ':vector-app:testGplayBtchapWithoutvoipWithoutpinningDebugUnitTest', ':vector-config:testBtchapWithoutvoipDebugUnitTest'] + tasks = [/*':matrix-sdk-android:testDebugUnitTest',*/ ':vector:testGplayBtchapWithdmvoipWithoutpinningDebugUnitTest', ':vector-app:testGplayBtchapWithdmvoipWithoutpinningDebugUnitTest', ':vector-config:testBtchapWithdmvoipDebugUnitTest'] } task instrumentationTestsWithCoverage(type: GradleBuild) { startParameter.projectProperties.coverage = "true" startParameter.projectProperties['android.testInstrumentationRunnerArguments.notPackage'] = 'im.vector.app.ui' - tasks = [':vector-app:connectedGplayBtchapWithoutvoipWithoutpinningDebugAndroidTest', ':vector:connectedGplayBtchapWithoutvoipWithoutpinningDebugAndroidTest'/*, 'matrix-sdk-android:connectedDebugAndroidTest'*/] + tasks = [':vector-app:connectedGplayBtchapWithdmvoipWithoutpinningDebugAndroidTest', ':vector:connectedGplayBtchapWithdmvoipWithoutpinningDebugAndroidTest'/*, 'matrix-sdk-android:connectedDebugAndroidTest'*/] } diff --git a/towncrier.toml b/towncrier.toml index 55e99ce1c9..fe681993ab 100644 --- a/towncrier.toml +++ b/towncrier.toml @@ -1,5 +1,5 @@ [tool.towncrier] - version = "2.9.8" + version = "2.10.0" directory = "changelog.d" filename = "TCHAP_CHANGES.md" name = "Changes in Tchap" diff --git a/vector-app/build.gradle b/vector-app/build.gradle index f07102f6d7..72eef653a8 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -33,11 +33,11 @@ knit { // Note: 2 digits max for each value ext.versionMajor = 2 -ext.versionMinor = 9 +ext.versionMinor = 10 // Note: even values are reserved for regular release, odd values for hotfix release. // When creating a hotfix, you should decrease the value, since the current value // is the value for the next regular release. -ext.versionPatch = 8 +ext.versionPatch = 0 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -350,21 +350,12 @@ android { buildConfigField "String", "FLAVOR_DESCRIPTION", "\"FDroid\"" } - withvoip { - dimension "voip" - } - - withoutvoip { - dimension "voip" - } + withvoip { dimension "voip" } + withdmvoip { dimension "voip" } + withoutvoip { dimension "voip" } - withpinning { - dimension "pinning" - } - - withoutpinning { - dimension "pinning" - } + withpinning { dimension "pinning" } + withoutpinning { dimension "pinning" } devTchap { dimension "target" @@ -470,6 +461,24 @@ configurations { gplayBtchapWithoutvoipWithoutpinningDebugImplementation gplayDevTchapWithoutvoipWithoutpinningDebugImplementation gplayTchapWithoutvoipWithoutpinningDebugImplementation + + + fdroidBtchapWithdmvoipWithpinningDebugImplementation + fdroidDevTchapWithdmvoipWithpinningDebugImplementation + fdroidTchapWithdmvoipWithpinningDebugImplementation + + gplayBtchapWithdmvoipWithpinningDebugImplementation + gplayDevTchapWithdmvoipWithpinningDebugImplementation + gplayTchapWithdmvoipWithpinningDebugImplementation + + + fdroidBtchapWithdmvoipWithoutpinningDebugImplementation + fdroidDevTchapWithdmvoipWithoutpinningDebugImplementation + fdroidTchapWithdmvoipWithoutpinningDebugImplementation + + gplayBtchapWithdmvoipWithoutpinningDebugImplementation + gplayDevTchapWithdmvoipWithoutpinningDebugImplementation + gplayTchapWithdmvoipWithoutpinningDebugImplementation } dependencies { @@ -592,6 +601,45 @@ dependencies { fdroidDevTchapWithoutvoipWithpinningDebugImplementation(libs.flipper.flipper) fdroidDevTchapWithoutvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + gplayBtchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipper) + gplayBtchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + gplayTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipper) + gplayTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + gplayDevTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipper) + gplayDevTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + + fdroidBtchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipper) + fdroidBtchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + fdroidTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipper) + fdroidTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + fdroidDevTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipper) + fdroidDevTchapWithdmvoipWithoutpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + + gplayBtchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipper) + gplayBtchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + gplayTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipper) + gplayTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + gplayDevTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipper) + gplayDevTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + fdroidBtchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipper) + fdroidBtchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + fdroidTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipper) + fdroidTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + + fdroidDevTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipper) + fdroidDevTchapWithdmvoipWithpinningDebugImplementation(libs.flipper.flipperNetworkPlugin) + debugImplementation 'com.facebook.soloader:soloader:0.10.4' debugImplementation "com.kgurgul.flipper:flipper-realm-android:2.2.0" diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt index 177feb9005..f433bcc199 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt @@ -22,6 +22,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import im.vector.app.R import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.VectorFeatures @@ -32,7 +33,7 @@ class NewRoomRobot( private val labsPreferences: LabFeaturesPreferences ) { private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val features: VectorFeatures = DefaultVectorFeatures(BooleanProvider(context.resources)) + private val features: VectorFeatures = DefaultVectorFeatures(StringArrayProvider(context.resources), BooleanProvider(context.resources)) fun createNewRoom(block: CreateNewRoomRobot.() -> Unit) { clickOn(R.string.create_new_room) diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt index 81638928a9..8b2705465c 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt @@ -30,6 +30,7 @@ import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaEditTextInteractions.writeTo import im.vector.app.R import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.debug.features.DebugFeatureKeys @@ -40,9 +41,10 @@ class OnboardingRobot { // Tchap: Override default feature values for tests private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val defaultVectorFeatures = DebugVectorFeatures(context, DefaultVectorFeatures(BooleanProvider(context.resources))).apply { - override(true, DebugFeatureKeys.tchapIsKeyBackupEnabled) - } + private val defaultVectorFeatures = + DebugVectorFeatures(context, DefaultVectorFeatures(StringArrayProvider(context.resources), BooleanProvider(context.resources))).apply { + override(true, DebugFeatureKeys.tchapIsKeyBackupEnabled) + } fun crawl() { waitUntilViewVisible(withId(R.id.loginSplashSubmit)) diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt index a067b7d348..6d560cffb7 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt @@ -28,6 +28,7 @@ import com.adevinta.android.barista.interaction.BaristaDrawerInteractions.openDr import com.adevinta.android.barista.internal.viewaction.ClickChildAction import im.vector.app.R import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.espresso.tools.waitUntilDialogVisible import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.DefaultVectorFeatures @@ -37,7 +38,7 @@ import org.hamcrest.Matchers class SpaceRobot(private val labsPreferences: LabFeaturesPreferences) { private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val features: VectorFeatures = DefaultVectorFeatures(BooleanProvider(context.resources)) + private val features: VectorFeatures = DefaultVectorFeatures(StringArrayProvider(context.resources), BooleanProvider(context.resources)) fun createSpace(isFirstSpace: Boolean, block: SpaceCreateRobot.() -> Unit) { if (labsPreferences.isNewAppLayoutEnabled) { diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt b/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt index 6948adc8b4..0a18214038 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt @@ -23,6 +23,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.DefaultVectorOverrides import im.vector.app.features.VectorFeatures @@ -43,8 +44,8 @@ interface FeaturesModule { companion object { @Provides - fun providesDefaultVectorFeatures(booleanProvider: BooleanProvider): DefaultVectorFeatures { - return DefaultVectorFeatures(booleanProvider) + fun providesDefaultVectorFeatures(stringArrayProvider: StringArrayProvider, booleanProvider: BooleanProvider): DefaultVectorFeatures { + return DefaultVectorFeatures(stringArrayProvider, booleanProvider) } @Provides diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt index f4be52ce1c..684a284418 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt @@ -40,7 +40,7 @@ class DebugVectorFeatures( private val dataStore = context.dataStore - override fun tchapIsVoipSupported() = vectorFeatures.tchapIsVoipSupported() + override fun tchapIsVoipSupported(homeServerUrl: String) = vectorFeatures.tchapIsVoipSupported(homeServerUrl) override fun tchapIsCrossSigningEnabled() = vectorFeatures.tchapIsCrossSigningEnabled() diff --git a/vector-app/src/main/java/im/vector/app/VectorApplication.kt b/vector-app/src/main/java/im/vector/app/VectorApplication.kt index 93e60b5098..01344ee479 100644 --- a/vector-app/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector-app/src/main/java/im/vector/app/VectorApplication.kt @@ -134,11 +134,9 @@ class VectorApplication : vectorUncaughtExceptionHandler.activate() // Remove Log handler statically added by Jitsi - if (vectorFeatures.tchapIsVoipSupported()) { - Timber.forest() - .filter { it::class.java.name == "org.jitsi.meet.sdk.log.JitsiMeetDefaultLogHandler" } - .forEach { Timber.uproot(it) } - } + Timber.forest() + .filter { it::class.java.name == "org.jitsi.meet.sdk.log.JitsiMeetDefaultLogHandler" } + .forEach { Timber.uproot(it) } if (buildMeta.isDebug) { Timber.plant(Timber.DebugTree()) diff --git a/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt b/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt index 9c838dd103..ce218888c6 100644 --- a/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt +++ b/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt @@ -21,6 +21,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.DefaultVectorOverrides import im.vector.app.features.VectorFeatures @@ -31,8 +32,8 @@ import im.vector.app.features.VectorOverrides object FeaturesModule { @Provides - fun providesFeatures(booleanProvider: BooleanProvider): VectorFeatures { - return DefaultVectorFeatures(booleanProvider) + fun providesFeatures(stringArrayProvider: StringArrayProvider, booleanProvider: BooleanProvider): VectorFeatures { + return DefaultVectorFeatures(stringArrayProvider, booleanProvider) } @Provides diff --git a/vector-config/build.gradle b/vector-config/build.gradle index 1d3f549c43..c65050ac78 100644 --- a/vector-config/build.gradle +++ b/vector-config/build.gradle @@ -19,6 +19,7 @@ android { productFlavors { withvoip { dimension "voip" } + withdmvoip { dimension "voip" } withoutvoip { dimension "voip" } devTchap { dimension "target" } diff --git a/vector-config/src/btchap/res/values/config-features.xml b/vector-config/src/btchap/res/values/config-features.xml index f6fda74684..e30415e59d 100755 --- a/vector-config/src/btchap/res/values/config-features.xml +++ b/vector-config/src/btchap/res/values/config-features.xml @@ -3,4 +3,6 @@ true true false + + diff --git a/vector-config/src/devTchap/res/values/config-features.xml b/vector-config/src/devTchap/res/values/config-features.xml index f6fda74684..e30415e59d 100755 --- a/vector-config/src/devTchap/res/values/config-features.xml +++ b/vector-config/src/devTchap/res/values/config-features.xml @@ -3,4 +3,6 @@ true true false + + diff --git a/vector-config/src/tchap/res/values/config-features.xml b/vector-config/src/tchap/res/values/config-features.xml index f6fda74684..f9439b9f00 100755 --- a/vector-config/src/tchap/res/values/config-features.xml +++ b/vector-config/src/tchap/res/values/config-features.xml @@ -3,4 +3,8 @@ true true false + + + agent.dinum.tchap.gouv.fr + diff --git a/vector-config/src/withdmvoip/res/values/config-features.xml b/vector-config/src/withdmvoip/res/values/config-features.xml new file mode 100755 index 0000000000..6f4b43e696 --- /dev/null +++ b/vector-config/src/withdmvoip/res/values/config-features.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/vector/build.gradle b/vector/build.gradle index 7a80872199..88f43e6b72 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -92,6 +92,7 @@ android { } withvoip { dimension "voip" } + withdmvoip { dimension "voip" } withoutvoip { dimension "voip" } withpinning { dimension "pinning" } @@ -272,6 +273,7 @@ dependencies { // Tchap: Manage jitsi lib // WebRTC withoutvoipImplementation('org.jitsi:webrtc:111.0.1') + withdmvoipImplementation('org.jitsi:webrtc:111.0.1') // Jitsi withvoipApi('org.jitsi.react:jitsi-meet-sdk:8.1.1') { exclude group: 'com.google.firebase' diff --git a/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt b/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt index c531c7b8da..12a3a9b8aa 100644 --- a/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt +++ b/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt @@ -20,6 +20,7 @@ import android.os.Build import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.TestBuildVersionSdkIntProvider import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.features.DefaultVectorFeatures import io.mockk.every import io.mockk.spyk @@ -30,7 +31,13 @@ class VoiceRecorderProviderTests { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider() - private val provider = spyk(VoiceRecorderProvider(context, DefaultVectorFeatures(BooleanProvider(context.resources)), buildVersionSdkIntProvider)) + private val provider = spyk( + VoiceRecorderProvider( + context, + DefaultVectorFeatures(StringArrayProvider(context.resources), BooleanProvider(context.resources)), + buildVersionSdkIntProvider + ) + ) @Test fun provideVoiceRecorderOnAndroidQAndCodecReturnsQRecorder() { diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index a364398449..209c0fba3c 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -20,12 +20,13 @@ import im.vector.app.R import im.vector.app.config.Config import im.vector.app.config.OnboardingVariant import im.vector.app.core.resources.BooleanProvider +import im.vector.app.core.resources.StringArrayProvider import im.vector.app.features.settings.VectorPreferences import javax.inject.Inject interface VectorFeatures { - fun tchapIsVoipSupported(): Boolean + fun tchapIsVoipSupported(homeServerUrl: String): Boolean fun tchapIsCrossSigningEnabled(): Boolean fun tchapIsKeyBackupEnabled(): Boolean fun tchapIsThreadEnabled(): Boolean @@ -55,9 +56,13 @@ interface VectorFeatures { } class DefaultVectorFeatures @Inject constructor( + private val stringArrayProvider: StringArrayProvider, private val booleanProvider: BooleanProvider ) : VectorFeatures { - override fun tchapIsVoipSupported() = booleanProvider.getBoolean(R.bool.tchap_is_voip_supported) + override fun tchapIsVoipSupported(homeServerUrl: String) = booleanProvider.getBoolean(R.bool.tchap_is_voip_supported) && + stringArrayProvider.getStringArray(R.array.tchap_is_voip_supported_homeservers).let { homeServerUrls -> + homeServerUrls.isEmpty() || homeServerUrls.any { homeServerUrl.contains(it) } + } override fun tchapIsCrossSigningEnabled() = booleanProvider.getBoolean(R.bool.tchap_is_cross_signing_enabled) override fun tchapIsKeyBackupEnabled() = booleanProvider.getBoolean(R.bool.tchap_is_key_backup_enabled) override fun tchapIsThreadEnabled() = booleanProvider.getBoolean(R.bool.tchap_is_thread_enabled) diff --git a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt index 11a43bcd0c..a60158cf11 100644 --- a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt @@ -104,6 +104,7 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment state.canInvite R.id.open_matrix_apps -> false // Tchap: there are no matrix apps // Tchap: check if voip is enabled - R.id.voice_call -> vectorFeatures.tchapIsVoipSupported() && (state.isCallOptionAvailable() || state.hasActiveElementCallWidget()) - // Tchap: check if voip is enabled - R.id.video_call -> vectorFeatures.tchapIsVoipSupported() && - (state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined) + R.id.video_call -> vectorPreferences.developerMode() && vectorFeatures.tchapIsVoipSupported(session.sessionParams.homeServerUrl) + R.id.voice_call -> vectorFeatures.tchapIsVoipSupported(session.sessionParams.homeServerUrl) && + (state.isCallOptionAvailable() || state.hasActiveElementCallWidget()) // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ - R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined + R.id.join_conference -> vectorFeatures.tchapIsVoipSupported(session.sessionParams.homeServerUrl) && !state.isCallOptionAvailable() && + state.jitsiState.confId != null && !state.jitsiState.hasJoined R.id.search -> state.isSearchAvailable() R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() R.id.dev_tools -> vectorPreferences.developerMode() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index 35f5af2e44..5ea450ba32 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -147,7 +147,7 @@ class CallItemFactory @Inject constructor( val userOfInterest = roomSummary.toMatrixItem() val attributes = messageItemAttributesFactory.create(null, informationData, callback, reactionsSummaryEvents).let { CallTileTimelineItem.Attributes( - isVoipSupported = vectorFeatures.tchapIsVoipSupported(), + isVoipSupported = vectorFeatures.tchapIsVoipSupported(session.sessionParams.homeServerUrl), callId = callId, callKind = callKind, callStatus = callStatus, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt index 8477c16d8f..b8dc6be9fc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt @@ -26,6 +26,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.JitsiWidgetEvents import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_ +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.widgets.model.WidgetContent import org.matrix.android.sdk.api.session.widgets.model.WidgetType @@ -34,6 +35,7 @@ import javax.inject.Inject class WidgetItemFactory @Inject constructor( private val vectorFeatures: VectorFeatures, + private val session: Session, private val informationDataFactory: MessageInformationDataFactory, private val noticeItemFactory: NoticeItemFactory, private val avatarSizeProvider: AvatarSizeProvider, @@ -78,7 +80,7 @@ class WidgetItemFactory @Inject constructor( CallTileTimelineItem.CallStatus.ENDED } val attributes = CallTileTimelineItem.Attributes( - isVoipSupported = vectorFeatures.tchapIsVoipSupported(), + isVoipSupported = vectorFeatures.tchapIsVoipSupported(session.sessionParams.homeServerUrl), callId = jitsiWidgetEventsGroup.callId, callKind = CallTileTimelineItem.CallKind.CONFERENCE, callStatus = callStatus, diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 7cf681e2d6..501af90711 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -994,8 +994,8 @@ class VectorPreferences @Inject constructor( * The user does not allow screenshots of the application. */ fun useFlagSecure(): Boolean { - // Tchap: Screenshot is allowed for Gplay Pre-prod and Dev versions only. - return BuildConfig.FLAVOR_store != "gplay" || BuildConfig.FLAVOR_target == "tchap" + // Tchap: Screenshot is allowed when developer mode is enabled or for Gplay Pre-prod and Dev versions only. + return (!developerMode() && (BuildConfig.FLAVOR_store != "gplay" || BuildConfig.FLAVOR_target == "tchap")) } /** Whether the keyboard should disable personalized learning. */ diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt index 43230b7dd3..4c5d296ab5 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt @@ -22,7 +22,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.spyk -class FakeVectorFeatures : VectorFeatures by spyk(DefaultVectorFeatures(mockk())) { +class FakeVectorFeatures : VectorFeatures by spyk(DefaultVectorFeatures(mockk(), mockk())) { fun givenPersonalisationEnabled() { every { isOnboardingPersonalizeEnabled() } returns true diff --git a/vector/src/withdmvoip/assets/open_source_licenses.html b/vector/src/withdmvoip/assets/open_source_licenses.html new file mode 100755 index 0000000000..6b6a486510 --- /dev/null +++ b/vector/src/withdmvoip/assets/open_source_licenses.html @@ -0,0 +1,482 @@ + + + + + +
+

Tchap Android

+

Third Party Licenses

+
+ +
    +
  • + glide +
    + Copyright 2014 Google, Inc. All rights reserved. +
  • +
+
+License for everything not in third_party and not otherwise marked:
+
+Copyright 2014 Google, Inc. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are
+permitted provided that the following conditions are met:
+
+   1. Redistributions of source code must retain the above copyright notice, this list of
+         conditions and the following disclaimer.
+
+   2. Redistributions in binary form must reproduce the above copyright notice, this list
+         of conditions and the following disclaimer in the documentation and/or other materials
+         provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY GOOGLE, INC. ``AS IS'' AND ANY EXPRESS OR IMPLIED
+WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GOOGLE, INC. OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those of the
+authors and should not be interpreted as representing official policies, either expressed
+or implied, of Google, Inc.
+---------------------------------------------------------------------------------------------
+License for third_party/disklrucache:
+
+Copyright 2012 Jake Wharton
+Copyright 2011 The Android Open Source Project
+
+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.
+---------------------------------------------------------------------------------------------
+License for third_party/gif_decoder:
+
+Copyright (c) 2013 Xcellent Creations, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+---------------------------------------------------------------------------------------------
+License for third_party/gif_encoder/AnimatedGifEncoder.java and
+third_party/gif_encoder/LZWEncoder.java:
+
+No copyright asserted on the source code of this class. May be used for any
+purpose, however, refer to the Unisys LZW patent for restrictions on use of
+the associated LZWEncoder class. Please forward any corrections to
+kweiner@fmsware.com.
+
+-----------------------------------------------------------------------------
+License for third_party/gif_encoder/NeuQuant.java
+
+Copyright (c) 1994 Anthony Dekker
+
+NEUQUANT Neural-Net quantization algorithm by Anthony Dekker, 1994. See
+"Kohonen neural networks for optimal colour quantization" in "Network:
+Computation in Neural Systems" Vol. 5 (1994) pp 351-367. for a discussion of
+the algorithm.
+
+Any party obtaining a copy of these files from the author, directly or
+indirectly, is granted, free of charge, a full and unrestricted irrevocable,
+world-wide, paid up, royalty-free, nonexclusive right and license to deal in
+this software and documentation files (the "Software"), including without
+limitation the rights to use, copy, modify, merge, publish, distribute,
+sublicense, and/or sell copies of the Software, and to permit persons who
+receive copies from any such party to do so, with the only requirement being
+that this copyright notice remain intact.
+
+ +
    +
  • + Stetho +
    + Copyright (c) 2015, Facebook, Inc. All rights reserved. +
  • +
+
+BSD License
+
+For Stetho software
+
+Copyright (c) 2015, Facebook, Inc. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+* Neither the name Facebook nor the names of its contributors may be used to
+endorse or promote products derived from this software without specific
+prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ + +

+ Apache License +
+ Version 2.0, January 2004 +

+
    +
  • + Matrix SDK +
    + Copyright (c) 2016 OpenMarket Ltd +
    + Copyright (c) 2016-2017 Vector Creations Ltd +
    + Copyright (c) 2018-2019 New Vector Ltd +
    + Copyright (c) 2019 The Matrix.org Foundation C.I.C +
  • + +
  • + Retrofit +
    + Copyright 2013 Square, Inc. +
  • +
  • + okhttp +
    + Copyright 2016 Square, Inc. +
  • +
  • + ShortcutBadger +
    + Copyright 2014 Leo Lin +
  • +
  • + html-textview +
    + Copyright (C) 2013-2015 Dominik Schürmann +
    + Copyright (C) 2013-2015 Juha Kuitunen +
    + Copyright (C) 2013 Mohammed Lakkadshaw +
    + Copyright (C) 2007 The Android Open Source Project +
  • +
  • + anddown +
    + Copyright (c) 2016 CommonsWare, LLC +
  • +
  • + zip4j +
    + Copyright 2010 Srikanth Reddy Lingala +
  • +
  • + SwipeBack +
    + Copyright 2015 Eric Liu +
  • +
  • + Libphonenumber +
    + Copyright 2017 Google +
  • +
  • + Butter Knife +
    + Copyright 2013 Jake Wharton +
  • +
  • + Realm +
    + Copyright (c) 2011-2017 Realm Inc +
  • +
  • + FloatingActionButton +
    + Copyright (C) 2014 Jerzy Chalupski +
  • +
  • + Spanny +
    + Copyright 2015 Pavlovsky Ivan +
  • +
  • + PhotoView +
    + Copyright 2018 Chris Banes +
  • +
  • + Epoxy +
    + Copyright 2016 Airbnb, Inc. +
  • +
  • + Anko +
    + Copyright 2016 JetBrains s.r.o. +
  • +
+
+Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+ + +
    +
  • + Tapadoo/Alerter +
    + Tapadoo/Alerter is licensed under the MIT License + Copyright 2017 Tapadoo, Dublin. +
  • +
+
+
+Copyright 2017 Tapadoo, Dublin.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+ + diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/CameraEventsHandlerAdapter.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/CameraEventsHandlerAdapter.kt new file mode 100644 index 0000000000..5307ca6a1d --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/CameraEventsHandlerAdapter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call + +import org.webrtc.CameraVideoCapturer +import timber.log.Timber + +open class CameraEventsHandlerAdapter : CameraVideoCapturer.CameraEventsHandler { + override fun onCameraError(p0: String?) { + Timber.v("## VOIP onCameraError $p0") + } + + override fun onCameraOpening(p0: String?) { + Timber.v("## VOIP onCameraOpening $p0") + } + + override fun onCameraDisconnected() { + Timber.v("## VOIP onCameraOpening") + } + + override fun onCameraFreezed(p0: String?) { + Timber.v("## VOIP onCameraFreezed $p0") + } + + override fun onFirstFrameAvailable() { + Timber.v("## VOIP onFirstFrameAvailable") + } + + override fun onCameraClosed() { + Timber.v("## VOIP onCameraClosed") + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/VectorCallActivity.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/VectorCallActivity.kt new file mode 100644 index 0000000000..a96097a830 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/VectorCallActivity.kt @@ -0,0 +1,743 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call + +import android.app.Activity +import android.app.KeyguardManager +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.graphics.Color +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.util.Rational +import android.view.MenuItem +import android.view.View +import android.view.WindowManager +import androidx.activity.result.ActivityResult +import androidx.annotation.StringRes +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.util.Consumer +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Mavericks +import com.airbnb.mvrx.viewModel +import com.airbnb.mvrx.withState +import com.google.android.material.card.MaterialCardView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.platform.VectorMenuProvider +import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL +import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.registerForPermissionsResult +import im.vector.app.databinding.ActivityCallBinding +import im.vector.app.features.call.dialpad.CallDialPadBottomSheet +import im.vector.app.features.call.dialpad.DialPadFragment +import im.vector.app.features.call.transfer.CallTransferActivity +import im.vector.app.features.call.utils.EglUtils +import im.vector.app.features.call.webrtc.ScreenCaptureAndroidService +import im.vector.app.features.call.webrtc.ScreenCaptureServiceConnection +import im.vector.app.features.call.webrtc.WebRtcCall +import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.RoomDetailActivity +import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.lib.core.utils.compat.getParcelableExtraCompat +import io.github.hyuwah.draggableviewlib.DraggableView +import io.github.hyuwah.draggableviewlib.setupDraggable +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState +import org.matrix.android.sdk.api.session.call.TurnServerResponse +import org.matrix.android.sdk.api.session.room.model.call.EndCallReason +import org.webrtc.EglBase +import org.webrtc.RendererCommon +import org.webrtc.ScreenCapturerAndroid +import timber.log.Timber +import javax.inject.Inject + +@Parcelize +data class CallArgs( + val signalingRoomId: String, + val callId: String, + val participantUserId: String, + val isIncomingCall: Boolean, + val isVideoCall: Boolean +) : Parcelable + +private val loggerTag = LoggerTag("VectorCallActivity", LoggerTag.VOIP) + +@AndroidEntryPoint +class VectorCallActivity : + VectorBaseActivity(), + CallControlsView.InteractionListener, + VectorMenuProvider { + + override fun getBinding() = ActivityCallBinding.inflate(layoutInflater) + + @Inject lateinit var callManager: WebRtcCallManager + @Inject lateinit var avatarRenderer: AvatarRenderer + @Inject lateinit var screenCaptureServiceConnection: ScreenCaptureServiceConnection + + private val callViewModel: VectorCallViewModel by viewModel() + + private val dialPadCallback = object : DialPadFragment.Callback { + override fun onDigitAppended(digit: String) { + callViewModel.handle(VectorCallViewActions.SendDtmfDigit(digit)) + } + } + + private var rootEglBase: EglBase? = null + private var pipDraggrableView: DraggableView? = null + private var otherCallDraggableView: DraggableView? = null + + var surfaceRenderersAreInitialized = false + + override fun doBeforeSetContentView() { + setContentView(R.layout.activity_call) + } + + override fun onCreate(savedInstanceState: Bundle?) { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.BLACK + super.onCreate(savedInstanceState) + addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) + + Timber.tag(loggerTag.value).v("EXTRA_MODE is ${intent.getStringExtra(EXTRA_MODE)}") + if (intent.getStringExtra(EXTRA_MODE) == INCOMING_RINGING) { + turnScreenOnAndKeyguardOff() + } + if (savedInstanceState != null) { + (supportFragmentManager.findFragmentByTag(FRAGMENT_DIAL_PAD_TAG) as? CallDialPadBottomSheet)?.callback = dialPadCallback + } + setupToolbar(views.callToolbar) + configureCallViews() + + callViewModel.onEach { + renderState(it) + } + + callViewModel.onAsync(VectorCallViewState::callState) { + if (it is CallState.Ended) { + handleCallEnded(it) + } + } + + callViewModel.observeViewEvents { + handleViewEvents(it) + } + + callViewModel.onEach(VectorCallViewState::callId, VectorCallViewState::isVideoCall) { _, isVideoCall -> + if (isVideoCall) { + if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, this, permissionCameraLauncher, R.string.permissions_rationale_msg_camera_and_audio)) { + setupRenderersIfNeeded() + } + } else { + if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, this, permissionCameraLauncher, R.string.permissions_rationale_msg_record_audio)) { + setupRenderersIfNeeded() + } + } + } + + // Bind to service in case of user killed the app while there is an ongoing call + bindToScreenCaptureService() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + intent?.takeIf { it.hasExtra(Mavericks.KEY_ARG) } + ?.let { intent.getParcelableExtraCompat(Mavericks.KEY_ARG) } + ?.let { + callViewModel.handle(VectorCallViewActions.SwitchCall(it)) + } + this.intent = intent + } + + override fun getMenuRes() = R.menu.vector_call + + override fun onUserLeaveHint() { + enterPictureInPictureIfRequired() + } + + override fun onBackPressed() { + if (!enterPictureInPictureIfRequired()) { + @Suppress("DEPRECATION") + super.onBackPressed() + } + } + + private fun enterPictureInPictureIfRequired(): Boolean = withState(callViewModel) { + if (!it.isVideoCall) { + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val aspectRatio = Rational(resources.getDimensionPixelSize(R.dimen.call_pip_width), resources.getDimensionPixelSize(R.dimen.call_pip_height)) + val params = PictureInPictureParams.Builder() + .setAspectRatio(aspectRatio) + .build() + renderPiPMode(it) + enterPictureInPictureMode(params) + } else { + false + } + } + + private fun isInPictureInPictureModeSafe(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode + } + + private val pictureInPictureModeChangedInfoConsumer = Consumer { + withState(callViewModel) { + renderState(it) + } + } + + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_call_open_chat -> { + returnToChat() + true + } + android.R.id.home -> { + // We check here as we want PiP in some cases + @Suppress("DEPRECATION") + onBackPressed() + true + } + else -> false + } + } + + override fun onDestroy() { + detachRenderersIfNeeded() + turnScreenOffAndKeyguardOn() + removeOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) + screenCaptureServiceConnection.unbind() + super.onDestroy() + } + + private fun detachRenderersIfNeeded() { + val callId = withState(callViewModel) { it.callId } + callManager.getCallById(callId)?.detachRenderers(listOf(views.pipRenderer, views.fullscreenRenderer)) + if (surfaceRenderersAreInitialized) { + views.pipRenderer.release() + views.fullscreenRenderer.release() + surfaceRenderersAreInitialized = false + } + } + + private fun renderState(state: VectorCallViewState) { + Timber.tag(loggerTag.value).v("renderState call $state") + if (state.callState is Fail) { + finish() + return + } + if (isInPictureInPictureModeSafe()) { + renderPiPMode(state) + } else { + renderFullScreenMode(state) + } + } + + private fun renderFullScreenMode(state: VectorCallViewState) { + views.callToolbar.isVisible = true + views.callControlsView.isVisible = true + views.callControlsView.updateForState(state) + val callState = state.callState.invoke() + views.callActionText.setOnClickListener(null) + views.callActionText.isVisible = false + views.smallIsHeldIcon.isVisible = false + when (callState) { + is CallState.Idle, + is CallState.CreateOffer, + is CallState.LocalRinging, + is CallState.Dialing -> { + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isVisible = true + toolbar?.setSubtitle(R.string.call_ringing) + configureCallInfo(state) + } + is CallState.Answering -> { + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isVisible = true + toolbar?.setSubtitle(R.string.call_connecting) + configureCallInfo(state) + } + is CallState.Connected -> { + toolbar?.subtitle = state.formattedDuration + if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { + if (state.isLocalOnHold || state.isRemoteOnHold) { + views.smallIsHeldIcon.isVisible = true + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isVisible = true + configureCallInfo(state, blurAvatar = true) + if (state.isRemoteOnHold) { + views.callActionText.setText(R.string.call_resume_action) + views.callActionText.isVisible = true + views.callActionText.setOnClickListener { callViewModel.handle(VectorCallViewActions.ToggleHoldResume) } + toolbar?.setSubtitle(R.string.call_held_by_you) + } else { + views.callActionText.isInvisible = true + state.callInfo?.opponentUserItem?.let { + toolbar?.subtitle = getString(R.string.call_held_by_user, it.getBestName()) + } + } + } else if (state.transferee !is VectorCallViewState.TransfereeState.NoTransferee) { + val transfereeName = if (state.transferee is VectorCallViewState.TransfereeState.KnownTransferee) { + state.transferee.name + } else { + getString(R.string.call_transfer_unknown_person) + } + views.callActionText.text = getString(R.string.call_transfer_transfer_to_title, transfereeName) + views.callActionText.isVisible = true + views.callActionText.setOnClickListener { callViewModel.handle(VectorCallViewActions.TransferCall) } + configureCallInfo(state) + } else { + configureCallInfo(state) + if (state.isVideoCall) { + views.fullscreenRenderer.isVisible = true + views.pipRendererWrapper.isVisible = true + views.callInfoGroup.isVisible = false + views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null + } else { + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isVisible = true + } + } + } else { + // This state is not final, if you change network, new candidates will be sent + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isVisible = true + configureCallInfo(state) + toolbar?.setSubtitle(R.string.call_connecting) + } + } + is CallState.Ended -> { + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isVisible = true + toolbar?.setSubtitle(R.string.call_ended) + configureCallInfo(state) + } + else -> { + views.fullscreenRenderer.isVisible = false + views.pipRendererWrapper.isVisible = false + views.callInfoGroup.isInvisible = true + } + } + } + + private fun renderPiPMode(state: VectorCallViewState) { + val callState = state.callState.invoke() + views.callToolbar.isVisible = false + views.callControlsView.isVisible = false + views.pipRendererWrapper.isVisible = false + views.pipRenderer.isVisible = false + views.callActionText.isVisible = false + when (callState) { + is CallState.Idle, + is CallState.CreateOffer, + is CallState.LocalRinging, + is CallState.Dialing, + is CallState.Answering -> { + views.fullscreenRenderer.isVisible = false + views.callInfoGroup.isVisible = false + } + is CallState.Connected -> { + if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { + if (state.isLocalOnHold || state.isRemoteOnHold) { + views.smallIsHeldIcon.isVisible = true + views.fullscreenRenderer.isVisible = false + views.callInfoGroup.isVisible = true + configureCallInfo(state, blurAvatar = true) + } else { + configureCallInfo(state) + views.fullscreenRenderer.isVisible = true + views.callInfoGroup.isVisible = false + } + } else { + views.callInfoGroup.isVisible = false + } + } + else -> { + views.fullscreenRenderer.isVisible = false + views.callInfoGroup.isVisible = false + } + } + } + + private fun handleCallEnded(callState: CallState.Ended) { + if (isInPictureInPictureModeSafe()) { + val startIntent = Intent(this, VectorCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + } + startActivity(startIntent) + } + when (callState.reason) { + EndCallReason.USER_BUSY -> { + showEndCallDialog(R.string.call_ended_user_busy_title, R.string.call_ended_user_busy_description) + } + EndCallReason.INVITE_TIMEOUT -> { + showEndCallDialog(R.string.call_ended_invite_timeout_title, R.string.call_error_user_not_responding) + } + else -> { + finish() + } + } + } + + private fun showEndCallDialog(@StringRes title: Int, @StringRes description: Int) { + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(description) + .setNegativeButton(R.string.ok, null) + .setOnDismissListener { + finish() + } + .show() + } + + private fun configureCallInfo(state: VectorCallViewState, blurAvatar: Boolean = false) { + state.callInfo?.opponentUserItem?.let { + val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen_blur) + avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter, addPlaceholder = false) + if (state.transferee is VectorCallViewState.TransfereeState.NoTransferee) { + views.participantNameText.setTextOrHide(null) + toolbar?.title = if (state.isVideoCall) { + getString(R.string.video_call_with_participant, it.getBestName()) + } else { + getString(R.string.audio_call_with_participant, it.getBestName()) + } + } else { + views.participantNameText.setTextOrHide(getString(R.string.call_transfer_consulting_with, it.getBestName())) + } + if (blurAvatar) { + avatarRenderer.renderBlur(it, views.otherMemberAvatar, sampling = 2, rounded = true, colorFilter = colorFilter, addPlaceholder = true) + } else { + avatarRenderer.render(it, views.otherMemberAvatar) + } + } + if (state.otherKnownCallInfo?.opponentUserItem == null || isInPictureInPictureModeSafe()) { + views.otherKnownCallLayout.isVisible = false + } else { + val otherCall = callManager.getCallById(state.otherKnownCallInfo.callId) + val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen_blur) + avatarRenderer.renderBlur( + matrixItem = state.otherKnownCallInfo.opponentUserItem, + imageView = views.otherKnownCallAvatarView, + sampling = 20, + rounded = true, + colorFilter = colorFilter, + addPlaceholder = true + ) + views.otherKnownCallLayout.isVisible = true + views.otherSmallIsHeldIcon.isVisible = otherCall?.let { it.isLocalOnHold || it.isRemoteOnHold }.orFalse() + } + } + + private fun configureCallViews() { + views.callControlsView.interactionListener = this + views.otherKnownCallLayout.setOnClickListener { + withState(callViewModel) { + val otherCall = callManager.getCallById(it.otherKnownCallInfo?.callId ?: "") ?: return@withState + val callArgs = CallArgs( + signalingRoomId = otherCall.nativeRoomId, + callId = otherCall.callId, + participantUserId = otherCall.mxCall.opponentUserId, + isIncomingCall = !otherCall.mxCall.isOutgoing, + isVideoCall = otherCall.mxCall.isVideoCall + ) + callViewModel.handle(VectorCallViewActions.SwitchCall(callArgs)) + } + } + views.pipRendererWrapper.setOnClickListener { + callViewModel.handle(VectorCallViewActions.ToggleCamera) + } + pipDraggrableView = views.pipRendererWrapper.setupDraggable() + .setStickyMode(DraggableView.Mode.STICKY_XY) + .build() + + otherCallDraggableView = views.otherKnownCallLayout.setupDraggable() + .setStickyMode(DraggableView.Mode.STICKY_XY) + .build() + } + + private val permissionCameraLauncher = registerForPermissionsResult { allGranted, _ -> + if (allGranted) { + setupRenderersIfNeeded() + } else { + // TODO display something + finish() + } + } + + private fun setupRenderersIfNeeded() { + detachRenderersIfNeeded() + rootEglBase = EglUtils.rootEglBase ?: return Unit.also { + Timber.tag(loggerTag.value).v("rootEglBase is null") + finish() + } + + // Init Picture in Picture renderer + views.pipRenderer.apply { + init(rootEglBase!!.eglBaseContext, null) + setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_BALANCED) + setEnableHardwareScaler(true) + setZOrderMediaOverlay(true) + } + // Init Full Screen renderer + views.fullscreenRenderer.init(rootEglBase!!.eglBaseContext, null) + views.fullscreenRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) + views.fullscreenRenderer.setEnableHardwareScaler(true /* enabled */) + + val callId = withState(callViewModel) { it.callId } + callManager.getCallById(callId)?.also { webRtcCall -> + webRtcCall.attachViewRenderers(views.pipRenderer, views.fullscreenRenderer, intent.getStringExtra(EXTRA_MODE)) + intent.removeExtra(EXTRA_MODE) + } + surfaceRenderersAreInitialized = true + } + + private fun handleViewEvents(event: VectorCallViewEvents?) { + Timber.tag(loggerTag.value).v("handleViewEvents $event") + when (event) { + is VectorCallViewEvents.ConnectionTimeout -> { + onErrorTimoutConnect(event.turn) + } + is VectorCallViewEvents.ShowDialPad -> { + CallDialPadBottomSheet.newInstance(false).apply { + callback = dialPadCallback + }.show(supportFragmentManager, FRAGMENT_DIAL_PAD_TAG) + } + is VectorCallViewEvents.ShowCallTransferScreen -> { + val callId = withState(callViewModel) { it.callId } + navigator.openCallTransfer(this, callTransferActivityResultLauncher, callId) + } + is VectorCallViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) + is VectorCallViewEvents.ShowScreenSharingPermissionDialog -> handleShowScreenSharingPermissionDialog() + is VectorCallViewEvents.StopScreenSharingService -> handleStopScreenSharingService() + else -> Unit + } + } + + private val callTransferActivityResultLauncher = registerStartForActivityResult { activityResult -> + when (activityResult.resultCode) { + Activity.RESULT_CANCELED -> { + callViewModel.handle(VectorCallViewActions.CallTransferSelectionCancelled) + } + Activity.RESULT_OK -> { + CallTransferActivity.getCallTransferResult(activityResult.data) + ?.let { callViewModel.handle(VectorCallViewActions.CallTransferSelectionResult(it)) } + } + } + } + + private fun onErrorTimoutConnect(turn: TurnServerResponse?) { + Timber.tag(loggerTag.value).d("onErrorTimoutConnect $turn") + // TODO ask to use default stun, etc... + MaterialAlertDialogBuilder(this) + .setTitle(R.string.call_failed_no_connection) + .setMessage(R.string.call_failed_no_connection_description) + .setNegativeButton(R.string.ok) { _, _ -> + callViewModel.handle(VectorCallViewActions.EndCall) + } + .show() + } + + override fun didTapAudioSettings() { + CallSoundDeviceChooserBottomSheet().show(supportFragmentManager, "SoundDeviceChooser") + } + + override fun didAcceptIncomingCall() { + callViewModel.handle(VectorCallViewActions.AcceptCall) + } + + override fun didDeclineIncomingCall() { + callViewModel.handle(VectorCallViewActions.DeclineCall) + } + + override fun didEndCall() { + callViewModel.handle(VectorCallViewActions.EndCall) + } + + override fun didTapToggleMute() { + callViewModel.handle(VectorCallViewActions.ToggleMute) + } + + override fun didTapToggleVideo() { + callViewModel.handle(VectorCallViewActions.ToggleVideo) + } + + private fun returnToChat() { + val roomId = withState(callViewModel) { it.roomId } + val args = TimelineArgs(roomId) + val intent = RoomDetailActivity.newIntent(this, args, false).apply { + flags = FLAG_ACTIVITY_CLEAR_TOP + } + startActivity(intent) + // is it needed? + finish() + } + + override fun didTapMore() { + CallControlsBottomSheet().show(supportFragmentManager, "Controls") + } + + // Needed to let you answer call when phone is locked + private fun turnScreenOnAndKeyguardOff() { + Timber.tag(loggerTag.value).v("turnScreenOnAndKeyguardOff") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } + + with(getSystemService()!!) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + requestDismissKeyguard(this@VectorCallActivity, null) + } + } + } + + private fun turnScreenOffAndKeyguardOn() { + Timber.tag(loggerTag.value).v("turnScreenOnAndKeyguardOn") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } else { + @Suppress("DEPRECATION") + window.clearFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } + } + + private val screenSharingPermissionActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // We need to start a foreground service with a sticky notification during screen sharing + startScreenSharingService(activityResult) + } else { + startScreenSharing(activityResult) + } + } + } + + private fun startScreenSharing(activityResult: ActivityResult) { + val videoCapturer = ScreenCapturerAndroid(activityResult.data, object : MediaProjection.Callback() { + override fun onStop() { + Timber.i("User revoked the screen capturing permission") + } + }) + callViewModel.handle(VectorCallViewActions.StartScreenSharing(videoCapturer)) + } + + private fun startScreenSharingService(activityResult: ActivityResult) { + ContextCompat.startForegroundService( + this, + Intent(this, ScreenCaptureAndroidService::class.java) + ) + bindToScreenCaptureService(activityResult) + } + + private fun bindToScreenCaptureService(activityResult: ActivityResult? = null) { + screenCaptureServiceConnection.bind(object : ScreenCaptureServiceConnection.Callback { + override fun onServiceConnected() { + activityResult?.let { startScreenSharing(it) } + } + }) + } + + private fun handleShowScreenSharingPermissionDialog() { + getSystemService()?.let { + navigator.openScreenSharingPermissionDialog(it.createScreenCaptureIntent(), screenSharingPermissionActivityResultLauncher) + } + } + + private fun handleStopScreenSharingService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + screenCaptureServiceConnection.stopScreenCapturing() + } + } + + companion object { + private const val EXTRA_MODE = "EXTRA_MODE" + private const val FRAGMENT_DIAL_PAD_TAG = "FRAGMENT_DIAL_PAD_TAG" + + const val OUTGOING_CREATED = "OUTGOING_CREATED" + const val INCOMING_RINGING = "INCOMING_RINGING" + const val INCOMING_ACCEPT = "INCOMING_ACCEPT" + + fun newIntent(context: Context, call: WebRtcCall, mode: String?): Intent { + val callArgs = CallArgs(call.nativeRoomId, call.callId, call.mxCall.opponentUserId, !call.mxCall.isOutgoing, call.mxCall.isVideoCall) + return Intent(context, VectorCallActivity::class.java).apply { + // what could be the best flags? + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(Mavericks.KEY_ARG, callArgs) + putExtra(EXTRA_MODE, mode) + } + } + + fun newIntent( + context: Context, + callId: String, + signalingRoomId: String, + otherUserId: String, + isIncomingCall: Boolean, + isVideoCall: Boolean, + mode: String? + ): Intent { + val callArgs = CallArgs(signalingRoomId, callId, otherUserId, isIncomingCall, isVideoCall) + return Intent(context, VectorCallActivity::class.java).apply { + // what could be the best flags? + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(Mavericks.KEY_ARG, callArgs) + putExtra(EXTRA_MODE, mode) + } + } + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/conference/ConferenceEvent.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/ConferenceEvent.kt new file mode 100644 index 0000000000..7151e922d5 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/ConferenceEvent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import android.content.Context +import androidx.lifecycle.LifecycleObserver + +sealed class ConferenceEvent(open val data: Map) { + data class Terminated(override val data: Map) : ConferenceEvent(data) + data class WillJoin(override val data: Map) : ConferenceEvent(data) + data class Joined(override val data: Map) : ConferenceEvent(data) + + fun extractConferenceUrl(): String? { + return null + } +} + +class ConferenceEventEmitter(private val context: Context) { fun emitConferenceEnded() {} } + +class ConferenceEventObserver(private val context: Context, private val onBroadcastEvent: (ConferenceEvent) -> Unit) : LifecycleObserver diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt new file mode 100644 index 0000000000..95c6be8325 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel + +class JitsiCallViewModel @AssistedInject constructor( + @Assisted initialState: JitsiCallViewState +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: JitsiCallViewState): JitsiCallViewModel + } + + override fun handle(action: EmptyAction) { + // No op + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + const val ENABLE_VIDEO_OPTION = "ENABLE_VIDEO_OPTION" + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiCallViewState.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiCallViewState.kt new file mode 100644 index 0000000000..7ecd44b9b0 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiCallViewState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.session.widgets.model.Widget + +data class JitsiCallViewState( + val roomId: String = "", + val widgetId: String = "", + val enableVideo: Boolean = false, + val widget: Async = Uninitialized +) : MavericksState { + + constructor(args: VectorJitsiActivity.Args) : this( + roomId = args.roomId, + widgetId = args.widgetId, + enableVideo = args.enableVideo + ) +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiService.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiService.kt new file mode 100644 index 0000000000..0e6e0fe0f8 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/JitsiService.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.raw.wellknown.getElementWellknown +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.widgets.model.Widget +import org.matrix.android.sdk.api.session.widgets.model.WidgetType +import javax.inject.Inject + +class JitsiService @Inject constructor( + private val session: Session, + private val rawService: RawService, + private val stringProvider: StringProvider) { + + suspend fun createJitsiWidget(roomId: String, withVideo: Boolean): Widget { + // Build data for a jitsi widget + val widgetId: String = WidgetType.Jitsi.preferred + "_" + session.myUserId + "_" + System.currentTimeMillis() + val preferredJitsiDomain = tryOrNull { + rawService.getElementWellknown(session.sessionParams) + ?.jitsiServer + ?.preferredDomain + } + val jitsiDomain = preferredJitsiDomain ?: stringProvider.getString(R.string.preferred_jitsi_domain) + + val widgetEventContent = mapOf( + "type" to WidgetType.Jitsi.legacy, + "data" to mapOf( + "domain" to jitsiDomain, + "isAudioOnly" to !withVideo + ), + "creatorUserId" to session.myUserId, + "id" to widgetId, + "name" to "jitsi" + ) + + return session.widgetService().createRoomWidget(roomId, widgetId, widgetEventContent) + } + + @Suppress("UNUSED_PARAMETER") + fun extractJitsiWidgetData(widget: Widget): JitsiWidgetData? = null +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt new file mode 100644 index 0000000000..3111b7ce8f --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import com.airbnb.mvrx.Mavericks +import im.vector.app.databinding.ActivityJitsiBinding +import kotlinx.parcelize.Parcelize + +class VectorJitsiActivity : Activity() { + + @Parcelize + data class Args( + val roomId: String, + val widgetId: String, + val enableVideo: Boolean + ) : Parcelable + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityJitsiBinding.inflate(layoutInflater) + setContentView(binding.root) + } + + companion object { + fun newIntent(context: Context, roomId: String, widgetId: String, enableVideo: Boolean): Intent { + return Intent(context, VectorJitsiActivity::class.java).apply { + putExtra(Mavericks.KEY_ARG, Args(roomId, widgetId, enableVideo)) + } + } + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/utils/EglUtils.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/utils/EglUtils.kt new file mode 100644 index 0000000000..045124a900 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/utils/EglUtils.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.features.call.utils + +import org.webrtc.EglBase +import timber.log.Timber + +/** + * The root [EglBase] instance shared by the entire application for + * the sake of reducing the utilization of system resources (such as EGL + * contexts) + * by performing a runtime check. + */ +object EglUtils { + + // TODO how do we release that? + + /** + * Lazily creates and returns the one and only [EglBase] which will + * serve as the root for all contexts that are needed. + */ + @get:Synchronized var rootEglBase: EglBase? = null + get() { + if (field == null) { + val configAttributes = EglBase.CONFIG_PLAIN + try { + field = EglBase.createEgl14(configAttributes) + ?: EglBase.createEgl10(configAttributes) // Fall back to EglBase10. + } catch (ex: Throwable) { + Timber.e(ex, "Failed to create EglBase") + } + } + return field + } + private set + + val rootEglBaseContext: EglBase.Context? + get() { + val eglBase = rootEglBase + return eglBase?.eglBaseContext + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/utils/PeerConnectionExt.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/utils/PeerConnectionExt.kt new file mode 100644 index 0000000000..978b984dce --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/utils/PeerConnectionExt.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.utils + +import im.vector.app.features.call.webrtc.SdpObserverAdapter +import org.webrtc.MediaConstraints +import org.webrtc.PeerConnection +import org.webrtc.SessionDescription +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +suspend fun PeerConnection.awaitCreateOffer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont -> + createOffer(object : SdpObserverAdapter() { + override fun onCreateSuccess(p0: SessionDescription?) { + super.onCreateSuccess(p0) + cont.resume(p0) + } + + override fun onCreateFailure(p0: String?) { + super.onCreateFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + }, mediaConstraints) +} + +suspend fun PeerConnection.awaitCreateAnswer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont -> + createAnswer(object : SdpObserverAdapter() { + override fun onCreateSuccess(p0: SessionDescription?) { + super.onCreateSuccess(p0) + cont.resume(p0) + } + + override fun onCreateFailure(p0: String?) { + super.onCreateFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + }, mediaConstraints) +} + +suspend fun PeerConnection.awaitSetLocalDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont -> + setLocalDescription(object : SdpObserverAdapter() { + override fun onSetFailure(p0: String?) { + super.onSetFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + + override fun onSetSuccess() { + super.onSetSuccess() + cont.resume(Unit) + } + }, sessionDescription) +} + +suspend fun PeerConnection.awaitSetRemoteDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont -> + setRemoteDescription(object : SdpObserverAdapter() { + override fun onSetFailure(p0: String?) { + super.onSetFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + + override fun onSetSuccess() { + super.onSetSuccess() + cont.resume(Unit) + } + }, sessionDescription) +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/utils/WebRtcMapping.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/utils/WebRtcMapping.kt new file mode 100644 index 0000000000..2b0d5281d2 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/utils/WebRtcMapping.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.utils + +import org.matrix.android.sdk.api.session.room.model.call.CallCandidate +import org.matrix.android.sdk.api.session.room.model.call.SdpType +import org.webrtc.IceCandidate +import org.webrtc.SessionDescription + +fun List.mapToCallCandidate() = map { + CallCandidate( + sdpMid = it.sdpMid, + sdpMLineIndex = it.sdpMLineIndex, + candidate = it.sdp + ) +} + +fun SdpType.asWebRTC(): SessionDescription.Type { + return if (this == SdpType.OFFER) { + SessionDescription.Type.OFFER + } else { + SessionDescription.Type.ANSWER + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt new file mode 100644 index 0000000000..99c26c5ebe --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.RtpReceiver +import timber.log.Timber + +private val loggerTag = LoggerTag("PeerConnectionObserver", LoggerTag.VOIP) + +class PeerConnectionObserver(private val webRtcCall: WebRtcCall) : PeerConnection.Observer { + + override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { + Timber.tag(loggerTag.value).v("StreamObserver onConnectionChange: $newState") + when (newState) { + /** + * Every ICE transport used by the connection is either in use (state "connected" or "completed") + * or is closed (state "closed"); in addition, at least one transport is either "connected" or "completed" + */ + PeerConnection.PeerConnectionState.CONNECTED -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.CONNECTED) + } + /** + * One or more of the ICE transports on the connection is in the "failed" state. + */ + PeerConnection.PeerConnectionState.FAILED -> { + // This can be temporary, e.g when other ice not yet received... + // webRtcCall.mxCall.state = CallState.ERROR + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.FAILED) + } + /** + * At least one of the connection's ICE transports (RTCIceTransports or RTCDtlsTransports) are in the "new" state, + * and none of them are in one of the following states: "connecting", "checking", "failed", or "disconnected", + * or all of the connection's transports are in the "closed" state. + */ + PeerConnection.PeerConnectionState.NEW, + /** + * One or more of the ICE transports are currently in the process of establishing a connection; + * that is, their RTCIceConnectionState is either "checking" or "connected", and no transports are in the "failed" state + */ + PeerConnection.PeerConnectionState.CONNECTING -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.CONNECTING) + } + /** + * The RTCPeerConnection is closed. + * This value was in the RTCSignalingState enum (and therefore found by reading the value of the signalingState) + * property until the May 13, 2016 draft of the specification. + */ + PeerConnection.PeerConnectionState.CLOSED -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.CLOSED) + } + /** + * At least one of the ICE transports for the connection is in the "disconnected" state and none of + * the other transports are in the state "failed", "connecting", or "checking". + */ + PeerConnection.PeerConnectionState.DISCONNECTED -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.DISCONNECTED) + } + null -> { + } + } + } + + override fun onIceCandidate(iceCandidate: IceCandidate) { + Timber.tag(loggerTag.value).v("StreamObserver onIceCandidate: $iceCandidate") + webRtcCall.onIceCandidate(iceCandidate) + } + + override fun onDataChannel(dc: DataChannel) { + Timber.tag(loggerTag.value).v("StreamObserver onDataChannel: ${dc.state()}") + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) { + Timber.tag(loggerTag.value).v("StreamObserver onIceConnectionReceivingChange: $receiving") + } + + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState) { + Timber.tag(loggerTag.value).v("StreamObserver onIceConnectionChange IceConnectionState:$newState") + when (newState) { + /** + * the ICE agent is gathering addresses or is waiting to be given remote candidates through + * calls to RTCPeerConnection.addIceCandidate() (or both). + */ + PeerConnection.IceConnectionState.NEW -> { + } + /** + * The ICE agent has been given one or more remote candidates and is checking pairs of local and remote candidates + * against one another to try to find a compatible match, but has not yet found a pair which will allow + * the peer connection to be made. It's possible that gathering of candidates is also still underway. + */ + PeerConnection.IceConnectionState.CHECKING -> { + } + + /** + * A usable pairing of local and remote candidates has been found for all components of the connection, + * and the connection has been established. + * It's possible that gathering is still underway, and it's also possible that the ICE agent is still checking + * candidates against one another looking for a better connection to use. + */ + PeerConnection.IceConnectionState.CONNECTED -> { + } + /** + * Checks to ensure that components are still connected failed for at least one component of the RTCPeerConnection. + * This is a less stringent test than "failed" and may trigger intermittently and resolve just as spontaneously on less reliable networks, + * or during temporary disconnections. When the problem resolves, the connection may return to the "connected" state. + */ + PeerConnection.IceConnectionState.DISCONNECTED -> { + } + /** + * The ICE candidate has checked all candidates pairs against one another and has failed to find + * compatible matches for all components of the connection. + * It is, however, possible that the ICE agent did find compatible connections for some components. + */ + PeerConnection.IceConnectionState.FAILED -> { + webRtcCall.onRenegotiationNeeded(restartIce = true) + } + /** + * The ICE agent has finished gathering candidates, has checked all pairs against one another, and has found a connection for all components. + */ + PeerConnection.IceConnectionState.COMPLETED -> { + } + /** + * The ICE agent for this RTCPeerConnection has shut down and is no longer handling requests. + */ + PeerConnection.IceConnectionState.CLOSED -> { + } + } + } + + override fun onAddStream(stream: MediaStream) { + Timber.tag(loggerTag.value).v("StreamObserver onAddStream: $stream") + webRtcCall.onAddStream(stream) + } + + override fun onRemoveStream(stream: MediaStream) { + Timber.tag(loggerTag.value).v("StreamObserver onRemoveStream") + webRtcCall.onRemoveStream() + } + + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) { + Timber.tag(loggerTag.value).v("StreamObserver onIceGatheringChange: $newState") + } + + override fun onSignalingChange(newState: PeerConnection.SignalingState) { + Timber.tag(loggerTag.value).v("StreamObserver onSignalingChange: $newState") + } + + override fun onIceCandidatesRemoved(candidates: Array) { + Timber.tag(loggerTag.value).v("StreamObserver onIceCandidatesRemoved: ${candidates.contentToString()}") + } + + override fun onRenegotiationNeeded() { + Timber.tag(loggerTag.value).v("StreamObserver onRenegotiationNeeded") + webRtcCall.onRenegotiationNeeded(restartIce = false) + } + + /** + * This happens when a new track of any kind is added to the media stream. + * This event is fired when the browser adds a track to the stream + * (such as when a RTCPeerConnection is renegotiated or a stream being captured using HTMLMediaElement.captureStream() + * gets a new set of tracks because the media element being captured loaded a new source. + */ + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) { + Timber.tag(loggerTag.value).v("StreamObserver onAddTrack") + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/ScreenCaptureAndroidService.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/ScreenCaptureAndroidService.kt new file mode 100644 index 0000000000..ab2a39ace8 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/ScreenCaptureAndroidService.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.extensions.startForegroundCompat +import im.vector.app.core.services.VectorAndroidService +import im.vector.app.features.notifications.NotificationUtils +import im.vector.lib.core.utils.timer.Clock +import javax.inject.Inject + +@AndroidEntryPoint +class ScreenCaptureAndroidService : VectorAndroidService() { + + @Inject lateinit var notificationUtils: NotificationUtils + @Inject lateinit var clock: Clock + private val binder = LocalBinder() + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + showStickyNotification() + + return START_STICKY + } + + private fun showStickyNotification() { + val notificationId = clock.epochMillis().toInt() + val notification = notificationUtils.buildScreenSharingNotification() + startForegroundCompat(notificationId, notification) + } + + override fun onBind(intent: Intent?): IBinder { + return binder + } + + fun stopService() { + stopSelf() + } + + inner class LocalBinder : Binder() { + fun getService(): ScreenCaptureAndroidService = this@ScreenCaptureAndroidService + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt new file mode 100644 index 0000000000..41105ddafb --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import javax.inject.Inject + +class ScreenCaptureServiceConnection @Inject constructor( + private val context: Context +) : ServiceConnection { + + interface Callback { + fun onServiceConnected() + } + + private var isBound = false + private var screenCaptureAndroidService: ScreenCaptureAndroidService? = null + private var callback: Callback? = null + + fun bind(callback: Callback) { + this.callback = callback + + if (isBound) { + callback.onServiceConnected() + } else { + Intent(context, ScreenCaptureAndroidService::class.java).also { intent -> + context.bindService(intent, this, 0) + } + } + } + + fun unbind() { + callback = null + } + + fun stopScreenCapturing() { + screenCaptureAndroidService?.stopService() + } + + override fun onServiceConnected(className: ComponentName, binder: IBinder) { + screenCaptureAndroidService = (binder as ScreenCaptureAndroidService.LocalBinder).getService() + isBound = true + callback?.onServiceConnected() + } + + override fun onServiceDisconnected(className: ComponentName) { + isBound = false + screenCaptureAndroidService = null + callback = null + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/SdpObserverAdapter.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/SdpObserverAdapter.kt new file mode 100644 index 0000000000..24d0e7b1f8 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/SdpObserverAdapter.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import timber.log.Timber + +open class SdpObserverAdapter : SdpObserver { + override fun onSetFailure(p0: String?) { + Timber.e("## SdpObserver: onSetFailure $p0") + } + + override fun onSetSuccess() { + Timber.v("## SdpObserver: onSetSuccess") + } + + override fun onCreateSuccess(p0: SessionDescription?) { + Timber.v("## SdpObserver: onCreateSuccess $p0") + } + + override fun onCreateFailure(p0: String?) { + Timber.e("## SdpObserver: onCreateFailure $p0") + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCall.kt new file mode 100644 index 0000000000..9c65d94a94 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -0,0 +1,1086 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import android.content.Context +import android.hardware.camera2.CameraManager +import androidx.core.content.getSystemService +import im.vector.app.core.services.CallAndroidService +import im.vector.app.core.utils.PublishDataSource +import im.vector.app.core.utils.TextUtils.formatDuration +import im.vector.app.features.call.CameraEventsHandlerAdapter +import im.vector.app.features.call.CameraProxy +import im.vector.app.features.call.CameraType +import im.vector.app.features.call.CaptureFormat +import im.vector.app.features.call.VectorCallActivity +import im.vector.app.features.call.lookup.sipNativeLookup +import im.vector.app.features.call.utils.asWebRTC +import im.vector.app.features.call.utils.awaitCreateAnswer +import im.vector.app.features.call.utils.awaitCreateOffer +import im.vector.app.features.call.utils.awaitSetLocalDescription +import im.vector.app.features.call.utils.awaitSetRemoteDescription +import im.vector.app.features.call.utils.mapToCallCandidate +import im.vector.app.features.session.coroutineScope +import im.vector.lib.core.utils.flow.chunk +import im.vector.lib.core.utils.timer.CountUpTimer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.call.CallIdGenerator +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState +import org.matrix.android.sdk.api.session.call.TurnServerResponse +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallAssertedIdentityContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.EndCallReason +import org.matrix.android.sdk.api.session.room.model.call.SdpType +import org.threeten.bp.Duration +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.Camera1Enumerator +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpSender +import org.webrtc.RtpTransceiver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoCapturer +import org.webrtc.VideoSource +import org.webrtc.VideoTrack +import timber.log.Timber +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Provider +import kotlin.coroutines.CoroutineContext + +private const val STREAM_ID = "userMedia" +private const val AUDIO_TRACK_ID = "${STREAM_ID}a0" +private const val VIDEO_TRACK_ID = "${STREAM_ID}v0" +private const val SCREEN_TRACK_ID = "${STREAM_ID}s0" +private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints() +private const val INVITE_TIMEOUT_IN_MS = 60_000L + +private val loggerTag = LoggerTag("WebRtcCall", LoggerTag.VOIP) + +class WebRtcCall( + val mxCall: MxCall, + // This is where the call is placed from an ui perspective. + // In case of virtual room, it can differs from the signalingRoomId. + val nativeRoomId: String, + private val rootEglBase: EglBase?, + private val context: Context, + private val dispatcher: CoroutineContext, + private val sessionProvider: Provider, + private val peerConnectionFactoryProvider: Provider, + private val onCallBecomeActive: (WebRtcCall) -> Unit, + private val onCallEnded: (String, EndCallReason, Boolean) -> Unit +) : MxCall.StateListener { + + interface Listener : MxCall.StateListener { + fun onCaptureStateChanged() {} + fun onCameraChanged() {} + fun onHoldUnhold() {} + fun assertedIdentityChanged() {} + fun onTick(formattedDuration: String) {} + override fun onStateUpdate(call: MxCall) {} + } + + private val listeners = CopyOnWriteArrayList() + + private val sessionScope: CoroutineScope? + get() = sessionProvider.get()?.coroutineScope + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + val callId = mxCall.callId + + // room where call signaling is placed. In case of virtual room it can differs from the nativeRoomId. + val signalingRoomId = mxCall.roomId + + private var peerConnection: PeerConnection? = null + private var localAudioSource: AudioSource? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoSource: VideoSource? = null + private var localVideoTrack: VideoTrack? = null + private var remoteAudioTrack: AudioTrack? = null + private var remoteVideoTrack: VideoTrack? = null + + // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example + private var makingOffer: Boolean = false + private var ignoreOffer: Boolean = false + + private var videoCapturer: VideoCapturer? = null + + private val availableCamera = ArrayList() + private var cameraInUse: CameraProxy? = null + private var currentCaptureFormat: CaptureFormat = CaptureFormat.HD + private var cameraAvailabilityCallback: CameraManager.AvailabilityCallback? = null + + private var videoSender: RtpSender? = null + private var screenSender: RtpSender? = null + + private val timer = CountUpTimer(intervalInMs = 1000L).apply { + tickListener = CountUpTimer.TickListener { milliseconds -> + val formattedDuration = formatDuration(Duration.ofMillis(milliseconds)) + listeners.forEach { + tryOrNull { it.onTick(formattedDuration) } + } + } + } + + private var inviteTimeout: Deferred? = null + + // Mute status + var micMuted = false + private set + var videoMuted = false + private set + var isRemoteOnHold = false + private set + var isLocalOnHold = false + private set + + // This value is used to track localOnHold when changing remoteOnHold value + private var wasLocalOnHold = false + var remoteAssertedIdentity: CallAssertedIdentityContent.AssertedIdentity? = null + private set + + var offerSdp: CallInviteContent.Offer? = null + + var videoCapturerIsInError = false + set(value) { + field = value + listeners.forEach { + tryOrNull { it.onCaptureStateChanged() } + } + } + private var localSurfaceRenderers: MutableList> = ArrayList() + private var remoteSurfaceRenderers: MutableList> = ArrayList() + + private val localIceCandidateSource = PublishDataSource() + private var localIceCandidateJob: Job? = null + + private val remoteCandidateSource: MutableSharedFlow = MutableSharedFlow(replay = Int.MAX_VALUE) + private var remoteIceCandidateJob: Job? = null + + init { + setupLocalIceCanditate() + mxCall.addListener(this) + } + + private fun setupLocalIceCanditate() { + sessionScope?.let { + localIceCandidateJob = localIceCandidateSource.stream() + .chunk(300) + .onEach { + // omit empty :/ + if (it.isNotEmpty()) { + Timber.tag(loggerTag.value).v("Sending local ice candidates to call") + // it.forEach { peerConnection?.addIceCandidate(it) } + mxCall.sendLocalCallCandidates(it.mapToCallCandidate()) + } + }.launchIn(it) + } + } + + fun onIceCandidate(iceCandidate: IceCandidate) = localIceCandidateSource.post(iceCandidate) + + fun onRenegotiationNeeded(restartIce: Boolean) { + sessionScope?.launch(dispatcher) { + if (mxCall.state != CallState.CreateOffer && mxCall.opponentVersion == 0) { + Timber.tag(loggerTag.value).v("Opponent does not support renegotiation: ignoring onRenegotiationNeeded event") + return@launch + } + val constraints = MediaConstraints() + if (restartIce) { + constraints.mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) + } + val peerConnection = peerConnection ?: return@launch + Timber.tag(loggerTag.value).v("creating offer...") + makingOffer = true + try { + val sessionDescription = peerConnection.awaitCreateOffer(constraints) ?: return@launch + peerConnection.awaitSetLocalDescription(sessionDescription) + if (peerConnection.iceGatheringState() == PeerConnection.IceGatheringState.GATHERING) { + // Allow a short time for initial candidates to be gathered + delay(200) + } + if (mxCall.state is CallState.Ended) { + return@launch + } + if (mxCall.state == CallState.CreateOffer) { + // send offer to peer + mxCall.offerSdp(sessionDescription.description) + inviteTimeout = async { + delay(INVITE_TIMEOUT_IN_MS) + endCall(EndCallReason.INVITE_TIMEOUT) + } + } else { + mxCall.negotiate(sessionDescription.description, SdpType.OFFER) + } + } catch (failure: Throwable) { + // Need to handle error properly. + Timber.tag(loggerTag.value).v("Failure while creating offer") + } finally { + makingOffer = false + } + } + } + + fun durationMillis(): Int { + return timer.elapsedTime().toInt() + } + + fun formattedDuration(): String { + return formatDuration( + Duration.ofMillis(timer.elapsedTime()) + ) + } + + private fun createPeerConnection(turnServerResponse: TurnServerResponse?) { + val peerConnectionFactory = peerConnectionFactoryProvider.get() ?: return + val iceServers = mutableListOf().apply { + turnServerResponse?.let { server -> + server.uris?.forEach { uri -> + add( + PeerConnection + .IceServer + .builder(uri) + .setUsername(server.username) + .setPassword(server.password) + .createIceServer() + ) + } + } + } + Timber.tag(loggerTag.value).v("creating peer connection...with iceServers $iceServers ") + val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + } + peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, PeerConnectionObserver(this)) + } + + /** + * Without consultation. + */ + fun transferToUser(targetUserId: String, targetRoomId: String?) { + sessionScope?.launch(dispatcher) { + mxCall.transfer( + targetUserId = targetUserId, + targetRoomId = targetRoomId, + createCallId = CallIdGenerator.generate(), + awaitCallId = null + ) + terminate(EndCallReason.REPLACED) + } + } + + /** + * With consultation. + */ + fun transferToCall(transferTargetCall: WebRtcCall) { + sessionScope?.launch(dispatcher) { + val newCallId = CallIdGenerator.generate() + transferTargetCall.mxCall.transfer( + targetUserId = mxCall.opponentUserId, + targetRoomId = null, + createCallId = null, + awaitCallId = newCallId + ) + mxCall.transfer( + targetUserId = transferTargetCall.mxCall.opponentUserId, + targetRoomId = null, + createCallId = newCallId, + awaitCallId = null + ) + terminate(EndCallReason.REPLACED) + transferTargetCall.terminate(EndCallReason.REPLACED) + } + } + + fun acceptIncomingCall() { + sessionScope?.launch { + Timber.tag(loggerTag.value).v("acceptIncomingCall from state ${mxCall.state}") + if (mxCall.state == CallState.LocalRinging) { + internalAcceptIncomingCall() + } + } + } + + /** + * Sends a DTMF digit to the other party. + * @param digit The digit (nb. string - '#' and '*' are dtmf too) + */ + fun sendDtmfDigit(digit: String) { + sessionScope?.launch { + for (sender in peerConnection?.senders.orEmpty()) { + if (sender.track()?.kind() == "audio" && sender.dtmf()?.canInsertDtmf() == true) { + try { + sender.dtmf()?.insertDtmf(digit, 100, 70) + return@launch + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).v("Fail to send Dtmf digit") + } + } + } + } + } + + fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) { + sessionScope?.launch(dispatcher) { + Timber.tag(loggerTag.value).v("attachViewRenderers localRenderer $localViewRenderer / $remoteViewRenderer") + localSurfaceRenderers.addIfNeeded(localViewRenderer) + remoteSurfaceRenderers.addIfNeeded(remoteViewRenderer) + when (mode) { + VectorCallActivity.INCOMING_ACCEPT -> { + internalAcceptIncomingCall() + } + VectorCallActivity.INCOMING_RINGING -> { + // wait until accepted to create peer connection + // TODO eventually we could already display local stream in PIP? + } + VectorCallActivity.OUTGOING_CREATED -> { + setupOutgoingCall() + } + else -> { + // sink existing tracks (configuration change, e.g screen rotation) + attachViewRenderersInternal() + } + } + } + } + + private suspend fun attachViewRenderersInternal() = withContext(dispatcher) { + // render local video in pip view + localSurfaceRenderers.forEach { renderer -> + renderer.get()?.let { pipSurface -> + pipSurface.setMirror(cameraInUse?.type == CameraType.FRONT) + // no need to check if already added, addSink is checking that + localVideoTrack?.addSink(pipSurface) + } + } + + // If remote track exists, then sink it to surface + remoteSurfaceRenderers.forEach { renderer -> + renderer.get()?.let { participantSurface -> + remoteVideoTrack?.addSink(participantSurface) + } + } + } + + fun detachRenderers(renderers: List?) { + sessionScope?.launch(dispatcher) { + detachRenderersInternal(renderers) + } + } + + private suspend fun detachRenderersInternal(renderers: List?) = withContext(dispatcher) { + Timber.tag(loggerTag.value).v("detachRenderers") + if (renderers.isNullOrEmpty()) { + // remove all sinks + localSurfaceRenderers.forEach { + if (it.get() != null) localVideoTrack?.removeSink(it.get()) + } + remoteSurfaceRenderers.forEach { + if (it.get() != null) remoteVideoTrack?.removeSink(it.get()) + } + localSurfaceRenderers.clear() + remoteSurfaceRenderers.clear() + } else { + renderers.forEach { + localSurfaceRenderers.removeIfNeeded(it) + remoteSurfaceRenderers.removeIfNeeded(it) + // no need to check if it's in the track, removeSink is doing it + localVideoTrack?.removeSink(it) + remoteVideoTrack?.removeSink(it) + } + } + } + + private suspend fun setupOutgoingCall() = withContext(dispatcher) { + tryOrNull { + onCallBecomeActive(this@WebRtcCall) + } + val turnServer = getTurnServer() + mxCall.state = CallState.CreateOffer + // 1. Create RTCPeerConnection + createPeerConnection(turnServer) + // 2. Access camera (if video call) + microphone, create local stream + createLocalStream() + attachViewRenderersInternal() + Timber.tag(loggerTag.value).v("remoteCandidateSource $remoteCandidateSource") + remoteIceCandidateJob = remoteCandidateSource + .onEach { + Timber.tag(loggerTag.value).v("adding remote ice candidate $it") + peerConnection?.addIceCandidate(it) + } + .catch { + Timber.tag(loggerTag.value).v("failed to add remote ice candidate $it") + } + .launchIn(this) + // Now we wait for negotiation callback + } + + private suspend fun internalAcceptIncomingCall() = withContext(dispatcher) { + tryOrNull { + onCallBecomeActive(this@WebRtcCall) + } + val turnServerResponse = getTurnServer() + // Update service state + withContext(Dispatchers.Main) { + CallAndroidService.onPendingCall( + context = context, + callId = mxCall.callId + ) + } + // 1) create peer connection + createPeerConnection(turnServerResponse) + + // create sdp using offer, and set remote description + // the offer has beed stored when invite was received + val offerSdp = offerSdp?.sdp?.let { + SessionDescription(SessionDescription.Type.OFFER, it) + } + if (offerSdp == null) { + Timber.tag(loggerTag.value).v("We don't have any offer to process") + return@withContext + } + Timber.tag(loggerTag.value).v("Offer sdp for invite: ${offerSdp.description}") + try { + peerConnection?.awaitSetRemoteDescription(offerSdp) + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).v("Failure putting remote description") + endCall(reason = EndCallReason.UNKWOWN_ERROR) + return@withContext + } + // 2) Access camera + microphone, create local stream + createLocalStream() + attachViewRenderersInternal() + + // create a answer, set local description and send via signaling + createAnswer()?.also { + mxCall.accept(it.description) + } + Timber.tag(loggerTag.value).v("remoteCandidateSource $remoteCandidateSource") + remoteIceCandidateJob = remoteCandidateSource + .onEach { + Timber.tag(loggerTag.value).v("adding remote ice candidate $it") + peerConnection?.addIceCandidate(it) + }.catch { + Timber.tag(loggerTag.value).v("failed to add remote ice candidate $it") + }.launchIn(this) + } + + private suspend fun getTurnServer(): TurnServerResponse? { + return tryOrNull { + sessionProvider.get()?.callSignalingService()?.getTurnServer() + } + } + + private fun createLocalStream() { + val peerConnectionFactory = peerConnectionFactoryProvider.get() ?: return + Timber.tag(loggerTag.value).v("Create local stream for call ${mxCall.callId}") + configureAudioTrack(peerConnectionFactory) + // add video track if needed + if (mxCall.isVideoCall) { + configureVideoTrack(peerConnectionFactory) + } + updateMuteStatus() + } + + private fun configureAudioTrack(peerConnectionFactory: PeerConnectionFactory) { + val audioSource = peerConnectionFactory.createAudioSource(DEFAULT_AUDIO_CONSTRAINTS) + val audioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, audioSource) + audioTrack.setEnabled(true) + Timber.tag(loggerTag.value).v("Add audio track $AUDIO_TRACK_ID to call ${mxCall.callId}") + peerConnection?.addTrack(audioTrack, listOf(STREAM_ID)) + localAudioSource = audioSource + localAudioTrack = audioTrack + } + + private fun configureVideoTrack(peerConnectionFactory: PeerConnectionFactory) { + val cameraIterator = if (Camera2Enumerator.isSupported(context)) { + Camera2Enumerator(context) + } else { + Camera1Enumerator(false) + } + // I don't realy know how that works if there are 2 front or 2 back cameras + val frontCamera = cameraIterator.deviceNames + ?.firstOrNull { cameraIterator.isFrontFacing(it) } + ?.let { + CameraProxy(it, CameraType.FRONT).also { availableCamera.add(it) } + } + + val backCamera = cameraIterator.deviceNames + ?.firstOrNull { cameraIterator.isBackFacing(it) } + ?.let { + CameraProxy(it, CameraType.BACK).also { availableCamera.add(it) } + } + + val camera = frontCamera?.also { cameraInUse = frontCamera } + ?: backCamera?.also { cameraInUse = backCamera } + ?: null.also { cameraInUse = null } + + listeners.forEach { + tryOrNull { it.onCameraChanged() } + } + + if (camera != null) { + val videoCapturer = cameraIterator.createCapturer(camera.name, object : CameraEventsHandlerAdapter() { + override fun onFirstFrameAvailable() { + super.onFirstFrameAvailable() + videoCapturerIsInError = false + } + + override fun onCameraClosed() { + super.onCameraClosed() + Timber.tag(loggerTag.value).v("onCameraClosed") + // This could happen if you open the camera app in chat + // We then register in order to restart capture as soon as the camera is available again + videoCapturerIsInError = true + val cameraManager = context.getSystemService() + cameraAvailabilityCallback = object : CameraManager.AvailabilityCallback() { + override fun onCameraUnavailable(cameraId: String) { + super.onCameraUnavailable(cameraId) + Timber.tag(loggerTag.value).v("On camera unavailable: $cameraId") + } + + override fun onCameraAccessPrioritiesChanged() { + super.onCameraAccessPrioritiesChanged() + Timber.tag(loggerTag.value).v("onCameraAccessPrioritiesChanged") + } + + override fun onCameraAvailable(cameraId: String) { + Timber.tag(loggerTag.value).v("On camera available: $cameraId") + if (cameraId == camera.name) { + videoCapturer?.startCapture(currentCaptureFormat.width, currentCaptureFormat.height, currentCaptureFormat.fps) + cameraManager?.unregisterAvailabilityCallback(this) + } + } + } + cameraManager?.registerAvailabilityCallback(cameraAvailabilityCallback!!, null) + } + }) + + val videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast) + val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase!!.eglBaseContext) + Timber.tag(loggerTag.value).v("Local video source created") + + videoCapturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver) + // HD + videoCapturer.startCapture(currentCaptureFormat.width, currentCaptureFormat.height, currentCaptureFormat.fps) + this.videoCapturer = videoCapturer + + val videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource) + Timber.tag(loggerTag.value).v("Add video track $VIDEO_TRACK_ID to call ${mxCall.callId}") + videoTrack.setEnabled(true) + videoSender = peerConnection?.addTrack(videoTrack, listOf(STREAM_ID)) + localVideoSource = videoSource + localVideoTrack = videoTrack + } + } + + fun setCaptureFormat(format: CaptureFormat) { + sessionScope?.launch(dispatcher) { + Timber.tag(loggerTag.value).v("setCaptureFormat $format") + videoCapturer?.changeCaptureFormat(format.width, format.height, format.fps) + currentCaptureFormat = format + } + } + + private fun updateMuteStatus() { + val micShouldBeMuted = micMuted || isRemoteOnHold + localAudioTrack?.setEnabled(!micShouldBeMuted) + remoteAudioTrack?.setEnabled(!isRemoteOnHold) + val vidShouldBeMuted = videoMuted || isRemoteOnHold + localVideoTrack?.setEnabled(!vidShouldBeMuted) + remoteVideoTrack?.setEnabled(!isRemoteOnHold) + } + + /** + * Indicates whether we are 'on hold' to the remote party (ie. if true, + * they cannot hear us). Note that this will return true when we put the + * remote on hold too due to the way hold is implemented (since we don't + * wish to play hold music when we put a call on hold, we use 'inactive' + * rather than 'sendonly') + * @returns true if the other party has put us on hold + */ + private fun computeIsLocalOnHold(): Boolean { + if (mxCall.state !is CallState.Connected) return false + var callOnHold = true + // We consider a call to be on hold only if *all* the tracks are on hold + // (is this the right thing to do?) + for (transceiver in peerConnection?.transceivers ?: emptyList()) { + val trackOnHold = transceiver.currentDirection == RtpTransceiver.RtpTransceiverDirection.INACTIVE || + transceiver.currentDirection == RtpTransceiver.RtpTransceiverDirection.RECV_ONLY + if (!trackOnHold) callOnHold = false + } + return callOnHold + } + + fun updateRemoteOnHold(onHold: Boolean) { + sessionScope?.launch(dispatcher) { + if (isRemoteOnHold == onHold) return@launch + val direction: RtpTransceiver.RtpTransceiverDirection + if (onHold) { + wasLocalOnHold = isLocalOnHold + isRemoteOnHold = true + isLocalOnHold = true + direction = RtpTransceiver.RtpTransceiverDirection.SEND_ONLY + } else { + isRemoteOnHold = false + isLocalOnHold = wasLocalOnHold + onCallBecomeActive(this@WebRtcCall) + direction = RtpTransceiver.RtpTransceiverDirection.SEND_RECV + } + for (transceiver in peerConnection?.transceivers ?: emptyList()) { + transceiver.sender.track()?.setEnabled(!onHold) + transceiver.receiver.track()?.setEnabled(!onHold) + transceiver.direction = direction + } + updateMuteStatus() + listeners.forEach { + tryOrNull { it.onHoldUnhold() } + } + } + } + + fun muteCall(muted: Boolean) { + sessionScope?.launch(dispatcher) { + micMuted = muted + updateMuteStatus() + } + } + + fun enableVideo(enabled: Boolean) { + sessionScope?.launch(dispatcher) { + videoMuted = !enabled + updateMuteStatus() + } + } + + fun canSwitchCamera(): Boolean { + return availableCamera.size > 1 + } + + private fun getOppositeCameraIfAny(): CameraProxy? { + val currentCamera = cameraInUse ?: return null + return if (currentCamera.type == CameraType.FRONT) { + availableCamera.firstOrNull { it.type == CameraType.BACK } + } else { + availableCamera.firstOrNull { it.type == CameraType.FRONT } + } + } + + fun switchCamera() { + sessionScope?.launch(dispatcher) { + Timber.tag(loggerTag.value).v("switchCamera") + if (mxCall.state is CallState.Connected && mxCall.isVideoCall) { + val oppositeCamera = getOppositeCameraIfAny() ?: return@launch + (videoCapturer as? CameraVideoCapturer)?.switchCamera( + object : CameraVideoCapturer.CameraSwitchHandler { + // Invoked on success. |isFrontCamera| is true if the new camera is front facing. + override fun onCameraSwitchDone(isFrontCamera: Boolean) { + Timber.tag(loggerTag.value).v("onCameraSwitchDone isFront $isFrontCamera") + cameraInUse = oppositeCamera + localSurfaceRenderers.forEach { + it.get()?.setMirror(isFrontCamera) + } + listeners.forEach { + tryOrNull { it.onCameraChanged() } + } + } + + override fun onCameraSwitchError(errorDescription: String?) { + Timber.tag(loggerTag.value).v("onCameraSwitchError isFront $errorDescription") + } + }, oppositeCamera.name + ) + } + } + } + + private suspend fun createAnswer(): SessionDescription? { + Timber.tag(loggerTag.value).w("createAnswer") + val peerConnection = peerConnection ?: return null + val constraints = MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", if (mxCall.isVideoCall) "true" else "false")) + } + return try { + val localDescription = peerConnection.awaitCreateAnswer(constraints) ?: return null + peerConnection.awaitSetLocalDescription(localDescription) + localDescription + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).v("Fail to create answer") + null + } + } + + fun currentCameraType(): CameraType? { + return cameraInUse?.type + } + + fun currentCaptureFormat(): CaptureFormat { + return currentCaptureFormat + } + + fun startSharingScreen(videoCapturer: VideoCapturer) { + val factory = peerConnectionFactoryProvider.get() ?: return + + this.videoCapturer = videoCapturer + + val localMediaStream = factory.createLocalMediaStream(STREAM_ID) + val videoSource = factory.createVideoSource(videoCapturer.isScreencast) + + startCapturingScreen(videoCapturer, videoSource) + + removeLocalSurfaceRenderers() + + showScreenLocally(factory, videoSource, localMediaStream) + + videoSender?.let { removeStream(it) } + + screenSender = peerConnection?.addTrack(localVideoTrack, listOf(STREAM_ID)) + } + + fun stopSharingScreen() { + localVideoTrack?.setEnabled(false) + screenSender?.let { removeStream(it) } + if (mxCall.isVideoCall) { + peerConnectionFactoryProvider.get()?.let { configureVideoTrack(it) } + } + updateMuteStatus() + sessionScope?.launch(dispatcher) { attachViewRenderersInternal() } + } + + private fun removeStream(sender: RtpSender) { + peerConnection?.removeTrack(sender) + } + + private fun showScreenLocally(factory: PeerConnectionFactory, videoSource: VideoSource?, localMediaStream: MediaStream?) { + localVideoTrack = factory.createVideoTrack(SCREEN_TRACK_ID, videoSource).apply { setEnabled(true) } + localMediaStream?.addTrack(localVideoTrack) + localSurfaceRenderers.forEach { it.get()?.let { localVideoTrack?.addSink(it) } } + } + + private fun removeLocalSurfaceRenderers() { + localSurfaceRenderers.forEach { it.get()?.let { localVideoTrack?.removeSink(it) } } + } + + private fun startCapturingScreen(videoCapturer: VideoCapturer, videoSource: VideoSource) { + val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase!!.eglBaseContext) + videoCapturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver) + videoCapturer.startCapture(currentCaptureFormat.width, currentCaptureFormat.height, currentCaptureFormat.fps) + } + + /** + * Returns true if the user is sharing the screen, false otherwise. + */ + fun isSharingScreen(): Boolean { + return localVideoTrack?.enabled().orFalse() && localVideoTrack?.id() == SCREEN_TRACK_ID + } + + private suspend fun release() { + listeners.clear() + mxCall.removeListener(this) + timer.stop() + timer.tickListener = null + detachRenderersInternal(null) + videoCapturer?.stopCapture() + videoCapturer?.dispose() + videoCapturer = null + remoteIceCandidateJob?.cancel() + localIceCandidateJob?.cancel() + peerConnection?.close() + peerConnection?.dispose() + localAudioSource?.dispose() + localVideoSource?.dispose() + localAudioSource = null + localAudioTrack = null + localVideoSource = null + localVideoTrack = null + remoteAudioTrack = null + remoteVideoTrack = null + cameraAvailabilityCallback = null + } + + fun onAddStream(stream: MediaStream) { + sessionScope?.launch(dispatcher) { + // reportError("Weird-looking stream: " + stream); + if (stream.audioTracks.size > 1 || stream.videoTracks.size > 1) { + Timber.tag(loggerTag.value).e("StreamObserver weird looking stream: $stream") + // TODO maybe do something more?? + endCall(EndCallReason.UNKWOWN_ERROR) + return@launch + } + if (stream.audioTracks.size == 1) { + val remoteAudioTrack = stream.audioTracks.first() + remoteAudioTrack.setEnabled(true) + this@WebRtcCall.remoteAudioTrack = remoteAudioTrack + } + if (stream.videoTracks.size == 1) { + val remoteVideoTrack = stream.videoTracks.first() + remoteVideoTrack.setEnabled(true) + this@WebRtcCall.remoteVideoTrack = remoteVideoTrack + // sink to renderer if attached + remoteSurfaceRenderers.forEach { it.get()?.let { remoteVideoTrack.addSink(it) } } + } + } + } + + fun onRemoveStream() { + sessionScope?.launch(dispatcher) { + remoteSurfaceRenderers + .mapNotNull { it.get() } + .forEach { remoteVideoTrack?.removeSink(it) } + remoteVideoTrack = null + remoteAudioTrack = null + } + } + + fun endCall(reason: EndCallReason = EndCallReason.USER_HANGUP, sendSignaling: Boolean = true) { + sessionScope?.launch(dispatcher) { + if (mxCall.state is CallState.Ended) { + return@launch + } + val reject = mxCall.state is CallState.LocalRinging + terminate(reason, reject) + if (sendSignaling) { + if (reject) { + mxCall.reject() + } else { + mxCall.hangUp(reason) + } + } + } + } + + private suspend fun terminate(reason: EndCallReason? = null, rejected: Boolean = false) = withContext(dispatcher) { + // Close tracks ASAP + localVideoTrack?.setEnabled(false) + localVideoTrack?.setEnabled(false) + cameraAvailabilityCallback?.let { cameraAvailabilityCallback -> + val cameraManager = context.getSystemService()!! + cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback) + } + inviteTimeout?.cancel() + inviteTimeout = null + mxCall.state = CallState.Ended(reason ?: EndCallReason.USER_HANGUP) + release() + onCallEnded(callId, reason ?: EndCallReason.USER_HANGUP, rejected) + } + + // Call listener + + fun onCallIceCandidateReceived(iceCandidatesContent: CallCandidatesContent) { + sessionScope?.launch(dispatcher) { + iceCandidatesContent.candidates.forEach { + if (it.sdpMid.isNullOrEmpty() || it.candidate.isNullOrEmpty()) { + return@forEach + } + Timber.tag(loggerTag.value).v("onCallIceCandidateReceived for call ${mxCall.callId} sdp: ${it.candidate}") + val iceCandidate = IceCandidate(it.sdpMid, it.sdpMLineIndex, it.candidate) + remoteCandidateSource.emit(iceCandidate) + } + } + } + + fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { + inviteTimeout?.cancel() + inviteTimeout = null + sessionScope?.launch(dispatcher) { + Timber.tag(loggerTag.value).v("onCallAnswerReceived ${callAnswerContent.callId}") + val sdp = SessionDescription(SessionDescription.Type.ANSWER, callAnswerContent.answer.sdp) + try { + peerConnection?.awaitSetRemoteDescription(sdp) + } catch (failure: Throwable) { + endCall(EndCallReason.UNKWOWN_ERROR) + return@launch + } + if (mxCall.opponentPartyId?.hasValue().orFalse()) { + mxCall.selectAnswer() + } + } + } + + fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) { + sessionScope?.launch(dispatcher) { + val description = callNegotiateContent.description + val type = description?.type + val sdpText = description?.sdp + if (type == null || sdpText == null) { + Timber.tag(loggerTag.value).i("Ignoring invalid m.call.negotiate event") + return@launch + } + val peerConnection = peerConnection ?: return@launch + // Politeness always follows the direction of the call: in a glare situation, + // we pick either the inbound or outbound call, so one side will always be + // inbound and one outbound + val polite = !mxCall.isOutgoing + // Here we follow the perfect negotiation logic from + // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation + val offerCollision = description.type == SdpType.OFFER && + (makingOffer || peerConnection.signalingState() != PeerConnection.SignalingState.STABLE) + + ignoreOffer = !polite && offerCollision + if (ignoreOffer) { + Timber.tag(loggerTag.value).i("Ignoring colliding negotiate event because we're impolite") + return@launch + } + val prevOnHold = computeIsLocalOnHold() + try { + val sdp = SessionDescription(type.asWebRTC(), sdpText) + peerConnection.awaitSetRemoteDescription(sdp) + if (type == SdpType.OFFER) { + createAnswer()?.also { + mxCall.negotiate(it.description, SdpType.ANSWER) + } + } + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).e(failure, "Failed to complete negotiation") + } + val nowOnHold = computeIsLocalOnHold() + wasLocalOnHold = nowOnHold + if (prevOnHold != nowOnHold) { + isLocalOnHold = nowOnHold + listeners.forEach { + tryOrNull { it.onHoldUnhold() } + } + } + } + } + + fun onCallHangupReceived(callHangupContent: CallHangupContent) { + sessionScope?.launch(dispatcher) { + terminate(callHangupContent.reason) + } + } + + fun onCallRejectReceived(callRejectContent: CallRejectContent) { + sessionScope?.launch(dispatcher) { + terminate(callRejectContent.reason, true) + } + } + + fun onCallSelectedAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) { + sessionScope?.launch(dispatcher) { + val selectedPartyId = callSelectAnswerContent.selectedPartyId + if (selectedPartyId != mxCall.ourPartyId) { + Timber.i("Got select_answer for party ID $selectedPartyId: we are party ID ${mxCall.ourPartyId}.") + // The other party has picked somebody else's answer + terminate() + } + } + } + + fun onCallAssertedIdentityReceived(callAssertedIdentityContent: CallAssertedIdentityContent) { + sessionScope?.launch(dispatcher) { + val session = sessionProvider.get() ?: return@launch + val newAssertedIdentity = callAssertedIdentityContent.assertedIdentity ?: return@launch + if (newAssertedIdentity.id == null && newAssertedIdentity.displayName == null) { + Timber.tag(loggerTag.value).v("Asserted identity received with no relevant information, skip") + return@launch + } + remoteAssertedIdentity = newAssertedIdentity + if (newAssertedIdentity.id != null) { + val nativeUserId = session.sipNativeLookup(newAssertedIdentity.id!!).firstOrNull()?.userId + if (nativeUserId != null) { + val resolvedUser = tryOrNull { + session.userService().resolveUser(nativeUserId) + } + if (resolvedUser != null) { + remoteAssertedIdentity = newAssertedIdentity.copy( + id = nativeUserId, + avatarUrl = resolvedUser.avatarUrl, + displayName = resolvedUser.displayName + ) + } else { + remoteAssertedIdentity = newAssertedIdentity.copy(id = nativeUserId) + } + } + } + listeners.forEach { + tryOrNull { it.assertedIdentityChanged() } + } + } + } + + // MxCall.StateListener + + override fun onStateUpdate(call: MxCall) { + val state = call.state + if (state is CallState.Connected && state.iceConnectionState == MxPeerConnectionState.CONNECTED) { + timer.resume() + } else { + timer.pause() + } + listeners.forEach { + tryOrNull { it.onStateUpdate(call) } + } + } +} + +private fun MutableList>.addIfNeeded(renderer: SurfaceViewRenderer?) { + if (renderer == null) return + val exists = any { + it.get() == renderer + } + if (!exists) { + add(WeakReference(renderer)) + } +} + +private fun MutableList>.removeIfNeeded(renderer: SurfaceViewRenderer?) { + if (renderer == null) return + removeAll { + it.get() == renderer + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCallExt.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCallExt.kt new file mode 100644 index 0000000000..de6d5acb20 --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCallExt.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem + +fun WebRtcCall.getOpponentAsMatrixItem(session: Session): MatrixItem? { + return session.getRoom(nativeRoomId)?.let { room -> + val roomSummary = room.roomSummary() ?: return@let null + // Fallback to RoomSummary if there is no other member. + if (roomSummary.otherMemberIds.isEmpty().orFalse()) { + roomSummary.toMatrixItem() + } else { + val userId = roomSummary.otherMemberIds.first() + return room.membershipService().getRoomMember(userId)?.toMatrixItem() + } + } +} diff --git a/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt new file mode 100644 index 0000000000..074779c6cc --- /dev/null +++ b/vector/src/withdmvoip/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import im.vector.app.ActiveSessionDataSource +import im.vector.app.core.pushers.UnifiedPushHelper +import im.vector.app.core.services.CallAndroidService +import im.vector.app.features.analytics.AnalyticsTracker +import im.vector.app.features.analytics.plan.CallEnded +import im.vector.app.features.analytics.plan.CallStarted +import im.vector.app.features.call.VectorCallActivity +import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.lookup.CallProtocolsChecker +import im.vector.app.features.call.lookup.CallUserMapper +import im.vector.app.features.call.utils.EglUtils +import im.vector.app.features.call.vectorCallService +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.logger.LoggerTag +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.call.CallListener +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallAssertedIdentityContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.EndCallReason +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.PeerConnectionFactory +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manage peerConnectionFactory & Peer connections outside of activity lifecycle to resist configuration changes. + * Use app context + */ +private val loggerTag = LoggerTag("WebRtcCallManager", LoggerTag.VOIP) + +@Singleton +class WebRtcCallManager @Inject constructor( + private val context: Context, + private val activeSessionDataSource: ActiveSessionDataSource, + private val analyticsTracker: AnalyticsTracker, + private val unifiedPushHelper: UnifiedPushHelper, + private val voipConfig: VoipConfig, +) : CallListener, + DefaultLifecycleObserver { + + private val currentSession: Session? + get() = activeSessionDataSource.currentValue?.orNull() + + private val protocolsChecker: CallProtocolsChecker? + get() = currentSession?.vectorCallService?.protocolChecker + + private val callUserMapper: CallUserMapper? + get() = currentSession?.vectorCallService?.userMapper + + private val sessionScope: CoroutineScope? + get() = currentSession?.coroutineScope + + interface Listener { + fun onCallEnded(callId: String) = Unit + fun onCurrentCallChange(call: WebRtcCall?) = Unit + fun onAudioDevicesChange() = Unit + } + + val supportedPSTNProtocol: String? + get() = protocolsChecker?.supportedPSTNProtocol + + val supportsPSTNProtocol: Boolean + get() = supportedPSTNProtocol != null + + val supportsVirtualRooms: Boolean + get() = protocolsChecker?.supportVirtualRooms.orFalse() + + fun addProtocolsCheckerListener(listener: CallProtocolsChecker.Listener) { + protocolsChecker?.addListener(listener) + } + + fun removeProtocolsCheckerListener(listener: CallProtocolsChecker.Listener) { + protocolsChecker?.removeListener(listener) + } + + private val currentCallsListeners = CopyOnWriteArrayList() + + fun addListener(listener: Listener) { + currentCallsListeners.add(listener) + } + + fun removeListener(listener: Listener) { + currentCallsListeners.remove(listener) + } + + val audioManager = CallAudioManager(context) { + currentCallsListeners.forEach { + tryOrNull { it.onAudioDevicesChange() } + } + }.apply { + setMode(CallAudioManager.Mode.DEFAULT) + } + + private var peerConnectionFactory: PeerConnectionFactory? = null + private val executor = Executors.newSingleThreadExecutor() + private val dispatcher = executor.asCoroutineDispatcher() + + private val rootEglBase by lazy { EglUtils.rootEglBase } + + private var isInBackground: Boolean = true + + override fun onResume(owner: LifecycleOwner) { + isInBackground = false + } + + override fun onPause(owner: LifecycleOwner) { + isInBackground = true + } + + /** + * The current call is the call we interacted with whatever his state (connected,resumed, held...) + * As soon as we interact with an other call, it replaces this one and put it on held if not already. + */ + var currentCall: AtomicReference = AtomicReference(null) + private fun AtomicReference.setAndNotify(newValue: WebRtcCall?) { + set(newValue) + currentCallsListeners.forEach { + tryOrNull { it.onCurrentCallChange(newValue) } + } + } + + private val advertisedCalls = HashSet() + private val callsByCallId = ConcurrentHashMap() + private val callsByRoomId = ConcurrentHashMap>() + + // Calls started as an attended transfer, ie. with the intention of transferring another + // call with a different party to this one. + // callId (target) -> call (transferee) + private val transferees = ConcurrentHashMap() + + fun getCallById(callId: String): WebRtcCall? { + return callsByCallId[callId] + } + + fun getCallsByRoomId(roomId: String): List { + return callsByRoomId[roomId] ?: emptyList() + } + + fun getTransfereeForCallId(callId: String): WebRtcCall? { + return transferees[callId] + } + + fun getCurrentCall(): WebRtcCall? { + return currentCall.get() + } + + fun getCalls(): List { + return callsByCallId.values.toList() + } + + fun checkForProtocolsSupportIfNeeded() { + protocolsChecker?.checkProtocols() + } + + /** + * @return a set of all advertised call during the lifetime of the app. + */ + fun getAdvertisedCalls() = advertisedCalls + + fun headSetButtonTapped() { + Timber.tag(loggerTag.value).v("headSetButtonTapped") + val call = getCurrentCall() ?: return + if (call.mxCall.state is CallState.LocalRinging) { + call.acceptIncomingCall() + } + if (call.mxCall.state is CallState.Connected) { + // end call? + call.endCall() + } + } + + private fun createPeerConnectionFactoryIfNeeded() { + if (peerConnectionFactory != null) return + Timber.tag(loggerTag.value).v("createPeerConnectionFactory") + val eglBaseContext = rootEglBase?.eglBaseContext ?: return Unit.also { + Timber.tag(loggerTag.value).e("No EGL BASE") + } + + Timber.tag(loggerTag.value).v("PeerConnectionFactory.initialize") + PeerConnectionFactory.initialize( + PeerConnectionFactory + .InitializationOptions.builder(context.applicationContext) + .createInitializationOptions() + ) + + val options = PeerConnectionFactory.Options() + val defaultVideoEncoderFactory = DefaultVideoEncoderFactory( + eglBaseContext, + /* enableIntelVp8Encoder */ + true, + /* enableH264HighProfile */ + true + ) + val defaultVideoDecoderFactory = DefaultVideoDecoderFactory(eglBaseContext) + Timber.tag(loggerTag.value).v("PeerConnectionFactory.createPeerConnectionFactory ...") + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(options) + .setVideoEncoderFactory(defaultVideoEncoderFactory) + .setVideoDecoderFactory(defaultVideoDecoderFactory) + .createPeerConnectionFactory() + } + + private fun onCallActive(call: WebRtcCall) { + Timber.tag(loggerTag.value).v("WebRtcPeerConnectionManager onCall active: ${call.mxCall.callId}") + val currentCall = getCurrentCall().takeIf { it != call } + currentCall?.updateRemoteOnHold(onHold = true) + audioManager.setMode(if (call.mxCall.isVideoCall) CallAudioManager.Mode.VIDEO_CALL else CallAudioManager.Mode.AUDIO_CALL) + call.trackCallStarted() + this.currentCall.setAndNotify(call) + } + + private fun onCallEnded(callId: String, endCallReason: EndCallReason, rejected: Boolean) { + Timber.tag(loggerTag.value).v("onCall ended: $callId") + val webRtcCall = callsByCallId.remove(callId) ?: return Unit.also { + Timber.tag(loggerTag.value).v("On call ended for unknown call $callId") + } + webRtcCall.trackCallEnded() + CallAndroidService.onCallTerminated(context, callId, endCallReason, rejected) + callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall) + callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall) + transferees.remove(callId) + if (currentCall.get()?.callId == callId) { + val otherCall = getCalls().lastOrNull() + currentCall.setAndNotify(otherCall) + } + tryOrNull { + currentCallsListeners.forEach { it.onCallEnded(callId) } + } + // There is no active calls + if (getCurrentCall() == null) { + Timber.tag(loggerTag.value).v("Dispose peerConnectionFactory as there is no need to keep one") + peerConnectionFactory?.dispose() + peerConnectionFactory = null + audioManager.setMode(CallAudioManager.Mode.DEFAULT) + // did we start background sync? so we should stop it + if (isInBackground) { + if (!unifiedPushHelper.isBackgroundSync()) { + currentSession?.syncService()?.stopAnyBackgroundSync() + } else { + // for fdroid we should not stop, it should continue syncing + // maybe we should restore default timeout/delay though? + } + } + } + } + + suspend fun startOutgoingCall(nativeRoomId: String, otherUserId: String, isVideoCall: Boolean, transferee: WebRtcCall? = null) { + val signalingRoomId = callUserMapper?.getOrCreateVirtualRoomForRoom(nativeRoomId, otherUserId) ?: nativeRoomId + Timber.tag(loggerTag.value).v("startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall") + if (getCallsByRoomId(nativeRoomId).isNotEmpty()) { + Timber.tag(loggerTag.value).w("you already have a call in this room") + return + } + if (getCurrentCall() != null && getCurrentCall()?.mxCall?.state !is CallState.Connected || getCalls().size >= 2) { + Timber.tag(loggerTag.value).w("cannot start outgoing call") + // Just ignore, maybe we could answer from other session? + return + } + executor.execute { + createPeerConnectionFactoryIfNeeded() + } + getCurrentCall()?.updateRemoteOnHold(onHold = true) + val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return + val webRtcCall = createWebRtcCall(mxCall, nativeRoomId) + currentCall.setAndNotify(webRtcCall) + if (transferee != null) { + transferees[webRtcCall.callId] = transferee + } + CallAndroidService.onOutgoingCallRinging( + context = context.applicationContext, + callId = mxCall.callId + ) + + // start the activity now + context.startActivity(VectorCallActivity.newIntent(context, webRtcCall, VectorCallActivity.OUTGOING_CREATED)) + } + + override fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent) { + Timber.tag(loggerTag.value).v("onCallIceCandidateReceived for call ${mxCall.callId}") + val call = callsByCallId[iceCandidatesContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallIceCandidateReceived for non active call? ${iceCandidatesContent.callId}") + } + call.onCallIceCandidateReceived(iceCandidatesContent) + } + + private fun createWebRtcCall(mxCall: MxCall, nativeRoomId: String): WebRtcCall { + val webRtcCall = WebRtcCall( + mxCall = mxCall, + nativeRoomId = nativeRoomId, + rootEglBase = rootEglBase, + context = context, + dispatcher = dispatcher, + peerConnectionFactoryProvider = { + createPeerConnectionFactoryIfNeeded() + peerConnectionFactory + }, + sessionProvider = { currentSession }, + onCallBecomeActive = this::onCallActive, + onCallEnded = this::onCallEnded + ) + advertisedCalls.add(mxCall.callId) + callsByCallId[mxCall.callId] = webRtcCall + callsByRoomId.getOrPut(nativeRoomId) { ArrayList(1) } + .add(webRtcCall) + callsByRoomId.getOrPut(mxCall.roomId) { ArrayList(1) } + .add(webRtcCall) + if (getCurrentCall() == null) { + currentCall.setAndNotify(webRtcCall) + } + return webRtcCall + } + + fun endCallForRoom(roomId: String) { + callsByRoomId[roomId]?.firstOrNull()?.endCall() + } + + override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) { + Timber.tag(loggerTag.value).v("onCallInviteReceived callId ${mxCall.callId}") + val nativeRoomId = callUserMapper?.nativeRoomForVirtualRoom(mxCall.roomId) ?: mxCall.roomId + if (getCallsByRoomId(nativeRoomId).isNotEmpty()) { + Timber.tag(loggerTag.value).w("you already have a call in this room") + return + } + if ((getCurrentCall() != null && getCurrentCall()?.mxCall?.state !is CallState.Connected) || getCalls().size >= 2) { + Timber.tag(loggerTag.value).w("receiving incoming call but cannot handle it") + // Just ignore, maybe we could answer from other session? + return + } + createWebRtcCall(mxCall, nativeRoomId).apply { + offerSdp = callInviteContent.offer + } + // Start background service with notification + CallAndroidService.onIncomingCallRinging( + context = context, + callId = mxCall.callId, + isInBackground = isInBackground + ) + // If this is received while in background, the app will not sync, + // and thus won't be able to received events. For example if the call is + // accepted on an other session this device will continue ringing + if (isInBackground) { + if (!unifiedPushHelper.isBackgroundSync()) { + // only for push version as fdroid version is already doing it? + currentSession?.syncService()?.startAutomaticBackgroundSync(30, 0) + } else { + // Maybe increase sync freq? but how to set back to default values? + } + } + } + + override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { + val call = callsByCallId[callAnswerContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallAnswerReceived for non active call? ${callAnswerContent.callId}") + } + val mxCall = call.mxCall + // Update service state + CallAndroidService.onPendingCall( + context = context, + callId = mxCall.callId + ) + call.onCallAnswerReceived(callAnswerContent) + } + + override fun onCallHangupReceived(callHangupContent: CallHangupContent) { + val call = callsByCallId[callHangupContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallHangupReceived for non active call? ${callHangupContent.callId}") + } + call.onCallHangupReceived(callHangupContent) + } + + override fun onCallRejectReceived(callRejectContent: CallRejectContent) { + val call = callsByCallId[callRejectContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallRejectReceived for non active call? ${callRejectContent.callId}") + } + call.onCallRejectReceived(callRejectContent) + } + + override fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) { + val call = callsByCallId[callSelectAnswerContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallSelectAnswerReceived for non active call? ${callSelectAnswerContent.callId}") + } + call.onCallSelectedAnswerReceived(callSelectAnswerContent) + } + + override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) { + val call = callsByCallId[callNegotiateContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallNegotiateReceived for non active call? ${callNegotiateContent.callId}") + } + call.onCallNegotiateReceived(callNegotiateContent) + } + + override fun onCallManagedByOtherSession(callId: String) { + Timber.tag(loggerTag.value).v("onCallManagedByOtherSession: $callId") + val call = callsByCallId[callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallManagedByOtherSession for non active call? $callId") + } + call.endCall(EndCallReason.ANSWERED_ELSEWHERE, sendSignaling = false) + } + + override fun onCallAssertedIdentityReceived(callAssertedIdentityContent: CallAssertedIdentityContent) { + if (!voipConfig.handleCallAssertedIdentityEvents) { + return + } + val call = callsByCallId[callAssertedIdentityContent.callId] + ?: return Unit.also { + Timber.tag(loggerTag.value).w("onCallAssertedIdentityReceived for non active call? ${callAssertedIdentityContent.callId}") + } + call.onCallAssertedIdentityReceived(callAssertedIdentityContent) + } + + /** + * Analytics. + */ + private fun WebRtcCall.trackCallStarted() { + analyticsTracker.capture( + CallStarted( + isVideo = mxCall.isVideoCall, + numParticipants = 2, + placed = mxCall.isOutgoing + ) + ) + } + + private fun WebRtcCall.trackCallEnded() { + analyticsTracker.capture( + CallEnded( + durationMs = durationMillis(), + isVideo = mxCall.isVideoCall, + numParticipants = 2, + placed = mxCall.isOutgoing + ) + ) + } +} diff --git a/vector/src/withdmvoip/res/layout/activity_call.xml b/vector/src/withdmvoip/res/layout/activity_call.xml new file mode 100644 index 0000000000..5734e5f92a --- /dev/null +++ b/vector/src/withdmvoip/res/layout/activity_call.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +