First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.call.impl"
buildFeatures {
buildConfig = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
defaultConfig {
buildConfigFieldStr(
name = "SENTRY_DSN",
value = System.getenv("ELEMENT_CALL_SENTRY_DSN")
?: readLocalProperty("features.call.sentry.dsn")
?: ""
)
buildConfigFieldStr(
name = "POSTHOG_USER_ID",
value = System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
?: readLocalProperty("features.call.posthog.userid")
?: ""
)
buildConfigFieldStr(
name = "POSTHOG_API_HOST",
value = System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
?: readLocalProperty("features.call.posthog.api.host")
?: ""
)
buildConfigFieldStr(
name = "POSTHOG_API_KEY",
value = System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
?: readLocalProperty("features.call.posthog.api.key")
?: ""
)
buildConfigFieldStr(
name = "RAGESHAKE_URL",
value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
?: readLocalProperty("features.call.regeshake.url")
?: ""
)
}
}
setupDependencyInjection()
dependencies {
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
implementation(libs.element.call.embedded)
api(projects.features.call.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixmedia.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.test)
}
@@ -0,0 +1,93 @@
<!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Permissions for call foreground services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application>
<activity
android:name=".ui.ElementCallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="true"
android:label="@string/element_call"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:taskAffinity="io.element.android.features.call">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://call.element.io/.well-known/assetlinks.json -->
<data android:host="call.element.io" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:host="call" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.element.call" />
</intent-filter>
</activity>
<activity
android:name=".ui.IncomingCallActivity"
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
android:taskAffinity="io.element.android.features.call" />
<service
android:name=".services.CallForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone" />
<receiver
android:name=".receivers.DeclineCallBroadcastReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(AppScope::class)
class DefaultElementCallEntryPoint(
@ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager,
) : ElementCallEntryPoint {
companion object {
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
const val REQUEST_CODE = 2255
}
override fun startCall(callType: CallType) {
context.startActivity(IntentProvider.createIntent(context, callType))
}
override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String,
textContent: String?,
) {
val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId,
roomId = callType.roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
senderName = senderName,
avatarUrl = avatarUrl,
timestamp = timestamp,
expirationTimestamp = expirationTimestamp,
notificationChannelId = notificationChannelId,
textContent = textContent,
)
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class WidgetMessage(
@SerialName("api") val direction: Direction,
@SerialName("widgetId") val widgetId: String,
@SerialName("requestId") val requestId: String,
@SerialName("action") val action: Action,
@SerialName("data") val data: JsonElement? = null,
) {
@Serializable
enum class Direction {
@SerialName("fromWidget")
FromWidget,
@SerialName("toWidget")
ToWidget
}
@Serializable
enum class Action {
@SerialName("io.element.join")
Join,
@SerialName("im.vector.hangup")
HangUp,
@SerialName("io.element.close")
Close,
@SerialName("send_event")
SendEvent,
@SerialName("content_loaded")
ContentLoaded,
}
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.impl.ui.IncomingCallActivity
@ContributesTo(AppScope::class)
interface CallBindings {
fun inject(callActivity: ElementCallActivity)
fun inject(callActivity: IncomingCallActivity)
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.notifications
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class CallNotificationData(
val sessionId: SessionId,
val roomId: RoomId,
val eventId: EventId,
val senderId: UserId,
val roomName: String?,
val senderName: String?,
val avatarUrl: String?,
val notificationChannelId: String,
val timestamp: Long,
val textContent: String?,
// Expiration timestamp in millis since epoch
val expirationTimestamp: Long,
) : Parcelable
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.notifications
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import kotlin.time.Duration.Companion.seconds
/**
* Creates a notification for a ringing call.
*/
@Inject
class RingingCallNotificationCreator(
@ApplicationContext private val context: Context,
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val notificationBitmapLoader: NotificationBitmapLoader,
) {
companion object {
/**
* Request code for the decline action.
*/
const val DECLINE_REQUEST_CODE = 1
/**
* Request code for the full screen intent.
*/
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
}
suspend fun createNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderDisplayName: String,
roomAvatarUrl: String?,
notificationChannelId: String,
timestamp: Long,
expirationTimestamp: Long,
textContent: String?,
): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient)
val userIcon = notificationBitmapLoader.getUserIcon(
avatarData = AvatarData(
id = roomId.value,
name = roomName,
url = roomAvatarUrl,
size = AvatarSize.RoomDetailsHeader,
),
imageLoader = imageLoader,
)
val caller = Person.Builder()
.setName(senderDisplayName)
.setIcon(userIcon)
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
val notificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
senderName = senderDisplayName,
avatarUrl = roomAvatarUrl,
notificationChannelId = notificationChannelId,
timestamp = timestamp,
textContent = textContent,
expirationTimestamp = expirationTimestamp,
)
val declineIntent = PendingIntentCompat.getBroadcast(
context,
DECLINE_REQUEST_CODE,
Intent(context, DeclineCallBroadcastReceiver::class.java).apply {
putExtra(DeclineCallBroadcastReceiver.EXTRA_NOTIFICATION_DATA, notificationData)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)!!
val fullScreenIntent = PendingIntentCompat.getActivity(
context,
FULL_SCREEN_INTENT_REQUEST_CODE,
Intent(context, IncomingCallActivity::class.java).apply {
putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false
)
return NotificationCompat.Builder(context, notificationChannelId)
.setSmallIcon(CommonDrawables.ic_notification)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true))
.addPerson(caller)
.setAutoCancel(true)
.setWhen(timestamp)
.setOngoing(true)
.setShowWhen(false)
// If textContent is null, the content text is set by the style (will be "Incoming call")
.setContentText(textContent)
.setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING)
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
.setContentIntent(answerIntent)
.setDeleteIntent(declineIntent)
.setFullScreenIntent(fullScreenIntent, true)
.build()
.apply {
flags = flags.or(Notification.FLAG_INSISTENT)
}
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvents {
data class SetPipController(val pipController: PipController) : PictureInPictureEvents
data object EnterPictureInPicture : PictureInPictureEvents
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.call.impl.utils.PipController
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag
import kotlinx.coroutines.launch
import timber.log.Timber
private val loggerTag = LoggerTag("PiP")
@Inject
class PictureInPicturePresenter(
pipSupportProvider: PipSupportProvider,
) : Presenter<PictureInPictureState> {
private val isPipSupported = pipSupportProvider.isPipSupported()
private var pipView: PipView? = null
@Composable
override fun present(): PictureInPictureState {
val coroutineScope = rememberCoroutineScope()
var isInPictureInPicture by remember { mutableStateOf(false) }
var pipController by remember { mutableStateOf<PipController?>(null) }
fun handleEvent(event: PictureInPictureEvents) {
when (event) {
is PictureInPictureEvents.SetPipController -> {
pipController = event.pipController
}
PictureInPictureEvents.EnterPictureInPicture -> {
coroutineScope.launch {
switchToPip(pipController)
}
}
is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
isInPictureInPicture = event.isInPip
if (event.isInPip) {
pipController?.enterPip()
} else {
pipController?.exitPip()
}
}
}
}
return PictureInPictureState(
supportPip = isPipSupported,
isInPictureInPicture = isInPictureInPicture,
eventSink = ::handleEvent,
)
}
fun setPipView(pipView: PipView?) {
if (isPipSupported) {
Timber.tag(loggerTag.value).d("Setting PiP params")
this.pipView = pipView
pipView?.setPipParams()
} else {
Timber.tag(loggerTag.value).d("setPipView: PiP is not supported")
}
}
/**
* Enters Picture-in-Picture mode, if allowed by Element Call.
*/
private suspend fun switchToPip(pipController: PipController?) {
if (isPipSupported) {
if (pipController == null) {
Timber.tag(loggerTag.value).w("webPipApi is not available")
}
if (pipController == null || pipController.canEnterPip()) {
Timber.tag(loggerTag.value).d("Switch to PiP mode")
pipView?.enterPipMode()
?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
} else {
Timber.tag(loggerTag.value).w("Cannot enter PiP mode, hangup the call")
pipView?.hangUp()
}
}
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
data class PictureInPictureState(
val supportPip: Boolean,
val isInPictureInPicture: Boolean,
val eventSink: (PictureInPictureEvents) -> Unit,
)
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
fun aPictureInPictureState(
supportPip: Boolean = false,
isInPictureInPicture: Boolean = false,
eventSink: (PictureInPictureEvents) -> Unit = {},
): PictureInPictureState {
return PictureInPictureState(
supportPip = supportPip,
isInPictureInPicture = isInPictureInPicture,
eventSink = eventSink,
)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.annotations.ApplicationContext
interface PipSupportProvider {
@ChecksSdkIntAtLeast(Build.VERSION_CODES.O)
fun isPipSupported(): Boolean
}
@ContributesBinding(AppScope::class)
class DefaultPipSupportProvider(
@ApplicationContext private val context: Context,
) : PipSupportProvider {
override fun isPipSupported(): Boolean {
val isSupportedByTheOs = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE).orFalse()
return isSupportedByTheOs
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
interface PipView {
fun setPipParams()
fun enterPipMode(): Boolean
fun hangUp()
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.IntentCompat
import dev.zacsweers.metro.Inject
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Broadcast receiver to decline the incoming call.
*/
class DeclineCallBroadcastReceiver : BroadcastReceiver() {
companion object {
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
}
@Inject
lateinit var activeCallManager: ActiveCallManager
@AppCoroutineScope
@Inject lateinit var appCoroutineScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
?: return
context.bindings<CallBindings>().inject(this)
appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
}
}
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.services
import android.Manifest
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import timber.log.Timber
/**
* A foreground service that shows a notification for an ongoing call while the UI is in background.
*/
class CallForegroundService : Service() {
companion object {
fun start(context: Context) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
val intent = Intent(context, CallForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
} else {
Timber.w("Microphone permission is not granted, cannot start the call foreground service")
}
}
fun stop(context: Context) {
val intent = Intent(context, CallForegroundService::class.java)
context.stopService(intent)
}
}
private lateinit var notificationManagerCompat: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
notificationManagerCompat = NotificationManagerCompat.from(this)
val foregroundServiceChannel = NotificationChannelCompat.Builder(
"call_foreground_service_channel",
NotificationManagerCompat.IMPORTANCE_LOW,
).setName(
getString(R.string.call_foreground_service_channel_title_android).ifEmpty { "Ongoing call" }
).build()
notificationManagerCompat.createNotificationChannel(foregroundServiceChannel)
val callActivityIntent = Intent(this, ElementCallActivity::class.java)
val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false)
val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id)
.setSmallIcon(CommonDrawables.ic_notification)
.setContentTitle(getString(R.string.call_foreground_service_title_android))
.setContentText(getString(R.string.call_foreground_service_message_android))
.setContentIntent(pendingIntent)
.build()
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
0
}
runCatchingExceptions {
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
}.onFailure {
Timber.e(it, "Failed to start ongoing call foreground service")
}
}
override fun onDestroy() {
super.onDestroy()
stopForeground(STOP_FOREGROUND_REMOVE)
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents {
data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
data class OnWebViewError(val description: String?) : CallScreenEvents
}
@@ -0,0 +1,281 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
@AssistedInject
class CallScreenPresenter(
@Assisted private val callType: CallType,
@Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider,
userAgentProvider: UserAgentProvider,
private val clock: SystemClock,
private val dispatchers: CoroutineDispatchers,
private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker,
private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val widgetMessageSerializer: WidgetMessageSerializer,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
}
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
@Composable
override fun present(): CallScreenState {
val coroutineScope = rememberCoroutineScope()
val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) }
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isWidgetLoaded by rememberSaveable { mutableStateOf(false) }
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag()
val theme = if (ElementTheme.isLightTheme) "light" else "dark"
DisposableEffect(Unit) {
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
fetchRoomCallUrl(
inputs = callType,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
languageTag = languageTag,
theme = theme,
)
}
onDispose {
appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
}
}
when (callType) {
is CallType.ExternalUrl -> {
// No analytics yet for external calls
}
is CallType.RoomCall -> {
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
}
}
HandleMatrixClientSyncState()
callWidgetDriver.value?.let { driver ->
LaunchedEffect(Unit) {
driver.incomingMessages
.onEach {
// Relay message to the WebView
messageInterceptor.value?.sendMessage(it)
}
.launchIn(this)
driver.run()
}
}
messageInterceptor.value?.let { interceptor ->
LaunchedEffect(Unit) {
interceptor.interceptedMessages
.onEach {
// We are receiving messages from the WebView, consider that the application is loaded
ignoreWebViewError = true
// Relay message to Widget Driver
callWidgetDriver.value?.send(it)
val parsedMessage = parseMessage(it)
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
if (parsedMessage.action == WidgetMessage.Action.Close) {
close(callWidgetDriver.value, navigator)
} else if (parsedMessage.action == WidgetMessage.Action.ContentLoaded) {
isWidgetLoaded = true
}
}
}
.launchIn(this)
}
LaunchedEffect(Unit) {
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.")
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = ""
}
}
}
fun handleEvent(event: CallScreenEvents) {
when (event) {
is CallScreenEvents.Hangup -> {
val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isWidgetLoaded) {
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor)
isWidgetLoaded = false
coroutineScope.launch {
// Wait for a couple of seconds to receive the hangup message
// If we don't get it in time, we close the screen anyway
delay(2.seconds)
close(callWidgetDriver.value, navigator)
}
} else {
coroutineScope.launch {
close(callWidgetDriver.value, navigator)
}
}
}
is CallScreenEvents.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor
}
is CallScreenEvents.OnWebViewError -> {
if (!ignoreWebViewError) {
webViewError = event.description.orEmpty()
}
// Else ignore the error, give a chance the Element Call to recover by itself.
}
}
}
return CallScreenState(
urlState = urlState.value,
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvent,
)
}
private suspend fun fetchRoomCallUrl(
inputs: CallType,
urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
languageTag: String?,
theme: String?,
) {
urlState.runCatchingUpdatingState {
when (inputs) {
is CallType.ExternalUrl -> {
inputs.url
}
is CallType.RoomCall -> {
val result = callWidgetProvider.getWidget(
sessionId = inputs.sessionId,
roomId = inputs.roomId,
clientId = UUID.randomUUID().toString(),
languageTag = languageTag,
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url
}
}
}
}
@Composable
private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) {
val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
}
coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
client.syncService.syncState
.collect { state ->
if (state != SyncState.Running) {
appForegroundStateService.updateIsInCallState(true)
}
}
}
onDispose {
Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
// Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false)
}
}
}
private fun parseMessage(message: String): WidgetMessage? {
return widgetMessageSerializer.deserialize(message).getOrNull()
}
private fun sendHangupMessage(widgetId: String, messageInterceptor: WidgetMessageInterceptor) {
val message = WidgetMessage(
direction = WidgetMessage.Direction.ToWidget,
widgetId = widgetId,
requestId = "widgetapi-${clock.epochMillis()}",
action = WidgetMessage.Action.HangUp,
data = null,
)
messageInterceptor.sendMessage(widgetMessageSerializer.serialize(message))
}
private fun CoroutineScope.close(widgetDriver: MatrixWidgetDriver?, navigator: CallScreenNavigator) = launch(dispatchers.io) {
navigator.close()
widgetDriver?.close()
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import io.element.android.libraries.architecture.AsyncData
data class CallScreenState(
val urlState: AsyncData<String>,
val webViewError: String?,
val userAgent: String,
val isCallActive: Boolean,
val isInWidgetMode: Boolean,
val eventSink: (CallScreenEvents) -> Unit,
)
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
override val values: Sequence<CallScreenState>
get() = sequenceOf(
aCallScreenState(),
aCallScreenState(urlState = AsyncData.Loading()),
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
aCallScreenState(webViewError = "Error details from WebView"),
)
}
internal fun aCallScreenState(
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
webViewError: String? = null,
userAgent: String = "",
isCallActive: Boolean = true,
isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState {
return CallScreenState(
urlState = urlState,
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode,
eventSink = eventSink,
)
}
@@ -0,0 +1,277 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
import io.element.android.features.call.impl.utils.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
typealias RequestPermissionCallback = (Array<String>) -> Unit
interface CallScreenNavigator {
fun close()
}
@Composable
internal fun CallScreenView(
state: CallScreenState,
pipState: PictureInPictureState,
onConsoleMessage: (ConsoleMessage) -> Unit,
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
modifier: Modifier = Modifier,
) {
fun handleBack() {
if (pipState.supportPip) {
pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture)
} else {
state.eventSink(CallScreenEvents.Hangup)
}
}
Scaffold(
modifier = modifier,
) { padding ->
BackHandler {
handleBack()
}
if (state.webViewError != null) {
ErrorDialog(
content = buildString {
append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
},
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
invalidAudioDeviceReason?.let {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
invalidAudioDeviceReason = null
}
}
CallWebView(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.fillMaxSize(),
url = state.urlState,
userAgent = state.userAgent,
onPermissionsRequest = { request ->
val androidPermissions = mapWebkitPermissions(request.resources)
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
},
onConsoleMessage = onConsoleMessage,
onCreateWebView = { webView ->
webView.addBackHandler(onBackPressed = ::handleBack)
val interceptor = WebViewWidgetMessageInterceptor(
webView = webView,
onUrlLoaded = { url ->
webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
},
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(
webView = webView,
coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
},
onDestroyWebView = {
// Reset audio mode
webViewAudioManager?.onCallStopped()
}
)
when (state.urlState) {
AsyncData.Uninitialized,
is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
is AsyncData.Failure -> {
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog(
content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
}
is AsyncData.Success -> Unit
}
}
}
}
@Composable
private fun InvalidAudioDeviceDialog(
invalidAudioDeviceReason: InvalidAudioDeviceReason,
onDismiss: () -> Unit,
) {
ErrorDialog(
content = when (invalidAudioDeviceReason) {
InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED -> {
stringResource(R.string.call_invalid_audio_device_bluetooth_devices_disabled)
}
},
onSubmit = onDismiss,
)
}
@Composable
private fun CallWebView(
url: AsyncData<String>,
userAgent: String,
onPermissionsRequest: (PermissionRequest) -> Unit,
onConsoleMessage: (ConsoleMessage) -> Unit,
onCreateWebView: (WebView) -> Unit,
onDestroyWebView: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Text("WebView - can't be previewed")
}
} else {
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
onCreateWebView(this)
setup(
userAgent = userAgent,
onPermissionsRequested = onPermissionsRequest,
onConsoleMessage = onConsoleMessage,
)
}
},
update = { webView ->
if (url is AsyncData.Success && webView.url != url.data) {
webView.loadUrl(url.data)
}
},
onRelease = { webView ->
onDestroyWebView(webView)
webView.destroy()
}
)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.setup(
userAgent: String,
onPermissionsRequested: (PermissionRequest) -> Unit,
onConsoleMessage: (ConsoleMessage) -> Unit,
) {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
with(settings) {
javaScriptEnabled = true
allowContentAccess = true
allowFileAccess = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
@Suppress("DEPRECATION")
databaseEnabled = true
loadsImagesAutomatically = true
userAgentString = userAgent
}
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
onPermissionsRequested(request)
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
onConsoleMessage(consoleMessage)
return true
}
}
}
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
addJavascriptInterface(
object {
@Suppress("unused")
@JavascriptInterface
fun onBackPressed() = onBackPressed()
},
"backHandler"
)
}
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview(
@PreviewParameter(CallScreenStateProvider::class) state: CallScreenState,
) = ElementPreview {
CallScreenView(
state = state,
pipState = aPictureInPictureState(),
requestPermissions = { _, _ -> },
onConsoleMessage = {},
)
}
@PreviewsDayNight
@Composable
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import io.element.android.features.call.api.CallType
import io.element.android.libraries.matrix.api.core.SessionId
fun CallType.getSessionId(): SessionId? {
return when (this) {
is CallType.ExternalUrl -> null
is CallType.RoomCall -> sessionId
}
}
@@ -0,0 +1,299 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import android.Manifest
import android.app.PictureInPictureParams
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.view.WindowManager
import android.webkit.PermissionRequest
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.IntentCompat
import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber
private val loggerTag = LoggerTag("ElementCallActivity")
class ElementCallActivity :
AppCompatActivity(),
CallScreenNavigator,
PipView {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
@Inject lateinit var buildMeta: BuildMeta
@Inject lateinit var audioFocus: AudioFocus
@Inject lateinit var consoleMessageLogger: ConsoleMessageLogger
private lateinit var presenter: Presenter<CallScreenState>
private var requestPermissionCallback: RequestPermissionCallback? = null
private val requestPermissionsLauncher = registerPermissionResultLauncher()
private val webViewTarget = mutableStateOf<CallType?>(null)
private var eventSink: ((CallScreenEvents) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindings<CallBindings>().inject(this)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
setCallType(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
if (!::presenter.isInitialized) {
return
}
pictureInPicturePresenter.setPipView(this)
Timber.d("Created ElementCallActivity with call type: ${webViewTarget.value}")
setContent {
val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState)
val colors by remember(webViewTarget.value?.getSessionId()) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId())
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
// Note when not in WidgetMode, isCallActive will never be true, so consider the call is active
if (state.isCallActive || !state.isInWidgetMode) {
setCallIsActive()
}
}
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {
consoleMessageLogger.log("ElementCall", it)
},
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
}
}
}
private fun setCallIsActive() {
audioFocus.requestAudioFocus(
requester = AudioFocusRequester.ElementCall,
onFocusLost = {
// If the audio focus is lost, we do not stop the call.
Timber.tag(loggerTag.value).w("Audio focus lost")
}
)
CallForegroundService.start(this)
}
@Composable
private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
val pipEventSink by rememberUpdatedState(pipState.eventSink)
DisposableEffect(Unit) {
val listener = Runnable {
if (requestPermissionCallback != null) {
Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
}
}
addOnUserLeaveHintListener(listener)
onDispose {
removeOnUserLeaveHintListener(listener)
}
}
DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
eventSink?.invoke(CallScreenEvents.Hangup)
}
}
addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
onDispose {
removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setCallType(intent)
}
override fun onDestroy() {
super.onDestroy()
audioFocus.releaseAudioFocus()
CallForegroundService.stop(this)
pictureInPicturePresenter.setPipView(null)
}
override fun finish() {
// Also remove the task from recents
finishAndRemoveTask()
}
override fun close() {
finish()
}
private fun setCallType(intent: Intent?) {
val callType = intent?.let {
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
}
val currentCallType = webViewTarget.value
if (currentCallType == null) {
if (callType == null) {
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
finish()
} else {
Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
webViewTarget.value = callType
presenter = presenterFactory.create(callType, this)
}
} else {
if (callType == null) {
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
} else if (callType != currentCallType) {
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
setIntent(intent)
recreate()
} else {
// Starting the same call again, should not happen, the UI is preventing this. But maybe when using external links.
Timber.tag(loggerTag.value).d("Starting the same call again, do nothing")
}
}
}
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val callback = requestPermissionCallback ?: return@registerForActivityResult
val permissionsToGrant = mutableListOf<String>()
permissions.forEach { (permission, granted) ->
if (granted) {
val webKitPermission = when (permission) {
Manifest.permission.CAMERA -> PermissionRequest.RESOURCE_VIDEO_CAPTURE
Manifest.permission.RECORD_AUDIO -> PermissionRequest.RESOURCE_AUDIO_CAPTURE
else -> return@forEach
}
permissionsToGrant.add(webKitPermission)
}
}
callback(permissionsToGrant.toTypedArray())
requestPermissionCallback = null
}
}
@RequiresApi(Build.VERSION_CODES.O)
override fun setPipParams() {
setPictureInPictureParams(getPictureInPictureParams())
}
@RequiresApi(Build.VERSION_CODES.O)
override fun enterPipMode(): Boolean {
return if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
enterPictureInPictureMode(getPictureInPictureParams())
} else {
false
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getPictureInPictureParams(): PictureInPictureParams {
return PictureInPictureParams.Builder()
// Portrait for calls seems more appropriate
.setAspectRatio(Rational(3, 5))
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true)
}
}
.build()
}
override fun hangUp() {
eventSink?.invoke(CallScreenEvents.Hangup)
}
}
internal fun mapWebkitPermissions(permissions: Array<String>): List<String> {
return permissions.mapNotNull { permission ->
when (permission) {
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA
else -> null
}
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
/**
* Activity that's displayed as a full screen intent when an incoming call is received.
*/
class IncomingCallActivity : AppCompatActivity() {
companion object {
/**
* Extra key for the notification data.
*/
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
}
@Inject
lateinit var elementCallEntryPoint: ElementCallEntryPoint
@Inject
lateinit var activeCallManager: ActiveCallManager
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
@Inject
lateinit var enterpriseService: EnterpriseService
@Inject
lateinit var buildMeta: BuildMeta
@AppCoroutineScope
@Inject lateinit var appCoroutineScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindings<CallBindings>().inject(this)
// Set flags so it can be displayed in the lock screen
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) {
setContent {
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = notificationData.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
IncomingCallScreen(
notificationData = notificationData,
onAnswer = ::onAnswer,
onCancel = ::onCancel,
)
}
}
} else {
// No data, finish the activity
finish()
return
}
activeCallManager.activeCall
.filter { it?.callState !is CallState.Ringing }
.onEach { finish() }
.launchIn(lifecycleScope)
}
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
}
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = activeCall.callType)
}
}
}
@@ -0,0 +1,185 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun IncomingCallScreen(
notificationData: CallNotificationData,
onAnswer: (CallNotificationData) -> Unit,
onCancel: () -> Unit,
) {
OnboardingBackground()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 124.dp)
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Avatar(
avatarData = AvatarData(
id = notificationData.senderId.value,
name = notificationData.senderName,
url = notificationData.avatarUrl,
size = AvatarSize.IncomingCall,
),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = notificationData.senderName ?: notificationData.senderId.value,
style = ElementTheme.typography.fontHeadingMdBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_incoming_call_subtitle_android),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
ActionButton(
size = 64.dp,
onClick = { onAnswer(notificationData) },
icon = CompoundIcons.VoiceCallSolid(),
title = stringResource(CommonStrings.action_accept),
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle
)
ActionButton(
size = 64.dp,
onClick = onCancel,
icon = CompoundIcons.EndCall(),
title = stringResource(CommonStrings.action_reject),
backgroundColor = ElementTheme.colors.iconCriticalPrimary,
borderColor = ElementTheme.colors.borderCriticalSubtle
)
}
}
}
@Composable
private fun ActionButton(
size: Dp,
onClick: () -> Unit,
icon: ImageVector,
title: String,
backgroundColor: Color,
borderColor: Color,
contentDescription: String? = title,
borderSize: Dp = 1.33.dp,
) {
Column(
modifier = Modifier.width(120.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
FilledIconButton(
modifier = Modifier
.size(size + borderSize)
.border(borderSize, borderColor, CircleShape),
onClick = onClick,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor,
contentColor = Color.White,
)
) {
Icon(
modifier = Modifier.size(32.dp),
imageVector = icon,
contentDescription = contentDescription
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
)
}
}
@PreviewsDayNight
@Composable
internal fun IncomingCallScreenPreview() = ElementPreview {
IncomingCallScreen(
notificationData = CallNotificationData(
sessionId = SessionId("@alice:matrix.org"),
roomId = RoomId("!1234:matrix.org"),
eventId = EventId("\$asdadadsad:matrix.org"),
senderId = UserId("@bob:matrix.org"),
roomName = "A room",
senderName = "Bob",
avatarUrl = null,
notificationChannelId = "incoming_call",
timestamp = 0L,
textContent = null,
expirationTimestamp = 1000L,
),
onAnswer = {},
onCancel = {},
)
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
interface LanguageTagProvider {
@Composable
fun provideLanguageTag(): String?
}
@ContributesBinding(AppScope::class)
class DefaultLanguageTagProvider : LanguageTagProvider {
@Composable
override fun provideLanguageTag(): String? {
return LocalConfiguration.current.locales.get(0)?.toLanguageTag()
}
}
@@ -0,0 +1,400 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import kotlin.math.min
/**
* Manages the active call state.
*/
interface ActiveCallManager {
/**
* The active call state flow, which will be updated when the active call changes.
*/
val activeCall: StateFlow<ActiveCall?>
/**
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification.
*/
suspend fun registerIncomingCall(notificationData: CallNotificationData)
/**
* Called when the active call has been hung up. It will remove any existing UI and the active call.
* @param callType The type of call that the user hung up, either an external url one or a room one.
*/
suspend fun hungUpCall(callType: CallType)
/**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param callType The type of call that the user joined, either an external url one or a room one.
*/
suspend fun joinedCall(callType: CallType)
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager(
@ApplicationContext context: Context,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat,
private val matrixClientProvider: MatrixClientProvider,
private val defaultCurrentCallService: DefaultCurrentCallService,
private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock,
) : ActiveCallManager {
private val tag = "ActiveCallManager"
private var timedOutCallJob: Job? = null
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
override val activeCall = MutableStateFlow<ActiveCall?>(null)
private val mutex = Mutex()
init {
observeRingingCall()
observeCurrentCall()
}
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
mutex.withLock {
val ringDuration =
min(
notificationData.expirationTimestamp - systemClock.epochMillis(),
ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L
)
if (ringDuration < 0) {
// Should already have stopped ringing, ignore.
Timber.tag(tag).d("Received timed-out incoming ringing call for room id: ${notificationData.roomId}, cancel ringing")
return
}
appForegroundStateService.updateHasRingingCall(true)
Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}, ringDuration(ms): $ringDuration")
if (activeCall.value != null) {
displayMissedCallNotification(notificationData)
Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
return
}
activeCall.value = ActiveCall(
callType = CallType.RoomCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
),
callState = CallState.Ringing(notificationData),
)
timedOutCallJob = coroutineScope.launch {
setUpCoil(notificationData.sessionId)
showIncomingCallNotification(notificationData)
// Wait for the ringing call to time out
delay(timeMillis = ringDuration)
incomingCallTimedOut(displayMissedCallNotification = true)
}
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
if (activeWakeLock?.isHeld == false) {
Timber.tag(tag).d("Acquiring partial wakelock")
activeWakeLock.acquire(ringDuration)
}
}
}
@OptIn(DelicateCoilApi::class)
private suspend fun setUpCoil(sessionId: SessionId) {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
// Ensure that the image loader is set, else the IncomingCallActivity will not be able to render the caller avatar
SingletonImageLoader.setUnsafe(imageLoaderHolder.get(matrixClient))
}
/**
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
Timber.tag(tag).d("Incoming call timed out")
val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after timeout")
activeWakeLock.release()
}
cancelIncomingCallNotification()
if (displayMissedCallNotification) {
displayMissedCallNotification(notificationData)
}
}
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Hung up call: $callType")
val currentActiveCall = activeCall.value ?: run {
Timber.tag(tag).w("No active call, ignoring hang up")
return
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return
}
if (currentActiveCall.callState is CallState.Ringing) {
// Decline the call
val notificationData = currentActiveCall.callState.notificationData
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
?.getRoom(notificationData.roomId)
?.declineCall(notificationData.eventId)
}
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after hang up")
activeWakeLock.release()
}
timedOutCallJob?.cancel()
activeCall.value = null
}
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call")
activeWakeLock.release()
}
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
callType = callType,
callState = CallState.InCall,
)
}
@SuppressLint("MissingPermission")
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
Timber.tag(tag).d("Displaying ringing call notification")
val notification = ringingCallNotificationCreator.createNotification(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
eventId = notificationData.eventId,
senderId = notificationData.senderId,
roomName = notificationData.roomName,
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
roomAvatarUrl = notificationData.avatarUrl,
notificationChannelId = notificationData.notificationChannelId,
timestamp = notificationData.timestamp,
textContent = notificationData.textContent,
expirationTimestamp = notificationData.expirationTimestamp,
) ?: return
runCatchingExceptions {
notificationManagerCompat.notify(
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
notification,
)
}.onFailure {
Timber.e(it, "Failed to publish notification for incoming call")
}
}
private fun cancelIncomingCallNotification() {
appForegroundStateService.updateHasRingingCall(false)
Timber.tag(tag).d("Ringing call notification cancelled")
notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL))
}
private fun displayMissedCallNotification(notificationData: CallNotificationData) {
Timber.tag(tag).d("Displaying missed call notification")
coroutineScope.launch {
onMissedCallNotificationHandler.addMissedCallNotification(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
eventId = notificationData.eventId,
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeRingingCall() {
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val ringingInfo = activeCall.callState as CallState.Ringing
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
val room = client.getRoom(callType.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
// If we have declined from another phone we want to stop ringing.
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
.filter { decliner ->
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
// only want to listen if the call was declined from another of my sessions,
// (we are ringing for an incoming call in a DM)
decliner == client.sessionId
}
}
.onEach { decliner ->
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
// Remove the active call and cancel the notification
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
activeWakeLock.release()
}
cancelIncomingCallNotification()
}
.launchIn(coroutineScope)
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
// has joined the call from another session.
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
room.roomInfoFlow.map {
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
}
}
// We only want to check if the room active call status changes
.distinctUntilChanged()
// Skip the first one, we're not interested in it (if the check below passes, it had to be active anyway)
.drop(1)
.onEach { (roomHasActiveCall, userIsInTheCall) ->
if (!roomHasActiveCall) {
// The call was cancelled
timedOutCallJob?.cancel()
incomingCallTimedOut(displayMissedCallNotification = true)
} else if (userIsInTheCall) {
// The user joined the call from another session
timedOutCallJob?.cancel()
incomingCallTimedOut(displayMissedCallNotification = false)
}
}
.launchIn(coroutineScope)
}
private fun observeCurrentCall() {
activeCall
.onEach { value ->
if (value == null) {
defaultCurrentCallService.onCallEnded()
} else {
when (value.callState) {
is CallState.Ringing -> {
// Nothing to do
}
is CallState.InCall -> {
when (val callType = value.callType) {
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
}
}
}
}
.launchIn(coroutineScope)
}
}
/**
* Represents an active call.
*/
data class ActiveCall(
val callType: CallType,
val callState: CallState,
)
/**
* Represents the state of an active call.
*/
sealed interface CallState {
/**
* The call is in a ringing state.
* @param notificationData The data for the incoming call notification.
*/
data class Ringing(val notificationData: CallNotificationData) : CallState
/**
* The call is in an in-call state.
*/
data object InCall : CallState
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.Inject
@Inject
class CallIntentDataParser {
private val validHttpSchemes = sequenceOf("https")
private val knownHosts = sequenceOf(
"call.element.io",
)
fun parse(data: String?): String? {
val parsedUrl = data?.toUri() ?: return null
val scheme = parsedUrl.scheme
return when {
scheme in validHttpSchemes -> parsedUrl
scheme == "element" && parsedUrl.host == "call" -> {
parsedUrl.getUrlParameter()
}
scheme == "io.element.call" && parsedUrl.host == null -> {
parsedUrl.getUrlParameter()
}
// This should never be possible, but we still need to take into account the possibility
else -> null
}
?.takeIf { it.host in knownHosts }
?.withCustomParameters()
}
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
urlParameter.toUri().takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}
}
}
/**
* Ensure the uri has the following parameters and value in the fragment:
* - appPrompt=false
* - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview.
*/
private fun Uri.withCustomParameters(): String {
val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery()
queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it))
}
// Remove the existing fragment parameters, and build the new fragment
val currentFragment = fragment ?: ""
// Reset the current fragment
builder.fragment("")
val queryFragmentPosition = currentFragment.lastIndexOf("?")
val newFragment = if (queryFragmentPosition == -1) {
// No existing query, build it.
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
} else {
buildString {
append(currentFragment.substring(0, queryFragmentPosition + 1))
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
// Replace the existing parameters
val newQueryFragment = queryFragment
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
append(newQueryFragment)
// Ensure the parameters are there
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
if (newQueryFragment.isNotEmpty()) {
append("&")
}
append("$APP_PROMPT_PARAMETER=false")
}
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
append("&$CONFINE_TO_ROOM_PARAMETER=true")
}
}
}
// We do not want to encode the Fragment part, so append it manually
return builder.build().toString() + "#" + newFragment
}
private const val APP_PROMPT_PARAMETER = "appPrompt"
private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
interface CallWidgetProvider {
suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
clientId: String,
languageTag: String?,
theme: String?,
): Result<GetWidgetResult>
data class GetWidgetResult(
val driver: MatrixWidgetDriver,
val url: String,
)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.call.impl.BuildConfig
import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider
@ContributesBinding(AppScope::class)
class DefaultCallAnalyticCredentialsProvider : CallAnalyticCredentialsProvider {
override val posthogUserId: String? = BuildConfig.POSTHOG_USER_ID.takeIf { it.isNotBlank() }
override val posthogApiHost: String? = BuildConfig.POSTHOG_API_HOST.takeIf { it.isNotBlank() }
override val posthogApiKey: String? = BuildConfig.POSTHOG_API_KEY.takeIf { it.isNotBlank() }
override val rageshakeSubmitUrl: String? = BuildConfig.RAGESHAKE_URL.takeIf { it.isNotBlank() }
override val sentryDsn: String? = BuildConfig.SENTRY_DSN.takeIf { it.isNotBlank() }
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import kotlinx.coroutines.flow.firstOrNull
private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html"
@ContributesBinding(AppScope::class)
class DefaultCallWidgetProvider(
private val matrixClientsProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
private val activeRoomsHolder: ActiveRoomsHolder,
) : CallWidgetProvider {
override suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
clientId: String,
languageTag: String?,
theme: String?,
): Result<CallWidgetProvider.GetWidgetResult> = runCatchingExceptions {
val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow()
val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
?: matrixClient.getJoinedRoom(roomId)
?: error("Room not found")
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val roomInfo = room.info()
val isEncrypted = roomInfo.isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(
baseUrl = baseUrl,
encrypted = isEncrypted,
direct = room.isDm(),
hasActiveCall = roomInfo.hasRoomCall,
)
val callUrl = room.generateWidgetWebViewUrl(
widgetSettings = widgetSettings,
clientId = clientId,
languageTag = languageTag,
theme = theme,
).getOrThrow()
val driver = room.getWidgetDriver(widgetSettings).getOrThrow()
CallWidgetProvider.GetWidgetResult(
driver = driver,
url = callUrl,
)
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import kotlinx.coroutines.flow.MutableStateFlow
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCurrentCallService : CurrentCallService {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) {
currentCall.value = call
}
fun onCallEnded() {
currentCall.value = CurrentCall.None
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
return PendingIntentCompat.getActivity(
context,
DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType),
PendingIntent.FLAG_CANCEL_CURRENT,
false
)!!
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
interface PipController {
suspend fun canEnterPip(): Boolean
fun enterPip()
fun exitPip()
}
@@ -0,0 +1,521 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.PowerManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.seconds
/**
* This class manages the audio devices for a WebView.
*
* It listens for audio device changes and updates the WebView with the available devices.
* It also handles the selection of the audio device by the user in the WebView and the default audio device based on the device type.
*
* See also: [Element Call controls docs.](https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#audio-devices)
*/
class WebViewAudioManager(
private val webView: WebView,
private val coroutineScope: CoroutineScope,
private val onInvalidAudioDeviceAdded: (InvalidAudioDeviceReason) -> Unit,
) {
private val json by lazy {
Json {
encodeDefaults = true
explicitNulls = false
}
}
/**
* Whether to disable bluetooth audio devices. This must be done on Android versions lower than Android 12,
* since the WebView approach breaks when using the legacy Bluetooth audio APIs.
*/
private val disableBluetoothAudioDevices = Build.VERSION.SDK_INT < Build.VERSION_CODES.S
/**
* This flag indicates whether the WebView audio is enabled or not. By default, it is enabled.
*/
private val isWebViewAudioEnabled = AtomicBoolean(true)
/**
* The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
*/
private val wantedDeviceTypes = listOf(
// Paired bluetooth device with microphone
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
// USB devices which can play or record audio
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
// Wired audio devices
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
// The built-in speaker of the device
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
// The built-in earpiece of the device
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
)
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
/**
* This wake lock is used to turn off the screen when the proximity sensor is triggered during a call,
* if the selected audio device is the built-in earpiece.
*/
private val proximitySensorWakeLock by lazy {
webView.context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock")
}
/**
* Used to ensure that only one coroutine can access the proximity sensor wake lock at a time, preventing re-acquiring or re-releasing it.
*/
private val proximitySensorMutex = Mutex()
/**
* This listener tracks the current communication device and updates the WebView when it changes.
*/
@get:RequiresApi(Build.VERSION_CODES.S)
private val commsDeviceChangedListener by lazy {
AudioManager.OnCommunicationDeviceChangedListener { device ->
if (device != null && device.id == expectedNewCommunicationDeviceId) {
expectedNewCommunicationDeviceId = null
Timber.d("Audio device changed, type: ${device.type}")
updateSelectedAudioDeviceInWebView(device.id.toString())
} else if (device != null && device.id != expectedNewCommunicationDeviceId) {
// We were expecting a device change but it didn't happen, so we should retry
val expectedDeviceId = expectedNewCommunicationDeviceId
if (expectedDeviceId != null) {
// Remove the expected id so we only retry once
expectedNewCommunicationDeviceId = null
audioManager.selectAudioDevice(expectedDeviceId.toString())
}
} else {
Timber.d("Audio device cleared")
expectedNewCommunicationDeviceId = null
audioManager.selectAudioDevice(null)
}
}
}
/**
* This callback is used to listen for audio device changes coming from the OS.
*/
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
val validNewDevices = addedDevices.orEmpty().filter { it.type in wantedDeviceTypes && it.isSink }
if (validNewDevices.isEmpty()) return
// We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list
val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }
setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
// This should automatically switch to a new device if it has a higher priority than the current one
selectDefaultAudioDevice(audioDevices)
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
// Update the available devices
setAvailableAudioDevices()
// Unless the removed device is the current one, we don't need to do anything else
val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId }
if (!removedCurrentDevice) return
val previousDevice = previousSelectedDevice
if (previousDevice != null) {
previousSelectedDevice = null
// If we have a previous device, we should select it again
audioManager.selectAudioDevice(previousDevice.id.toString())
} else {
// If we don't have a previous device, we should select the default one
selectDefaultAudioDevice()
}
}
}
/**
* The currently used audio device id.
*/
private var currentDeviceId: Int? = null
/**
* When a new audio device is selected but not yet set as the communication device by the OS, this id is used to check if the device is the expected one.
*/
private var expectedNewCommunicationDeviceId: Int? = null
/**
* Previously selected device, used to restore the selection when the selected device is removed.
*/
private var previousSelectedDevice: AudioDeviceInfo? = null
private var hasRegisteredCallbacks = false
/**
* Marks if the WebView audio is in call mode or not.
*/
val isInCallMode = AtomicBoolean(false)
init {
// Apparently, registering the javascript interface takes a while, so registering and immediately using it doesn't work
// We register it ahead of time to avoid this issue
registerWebViewDeviceSelectedCallback()
}
/**
* Call this method when the call starts to enable in-call audio mode.
*
* It'll set the audio mode to [AudioManager.MODE_IN_COMMUNICATION] if possible, register the audio device callback and set the available audio devices.
*/
fun onCallStarted() {
if (!isInCallMode.compareAndSet(false, true)) {
Timber.w("Audio: tried to enable webview in-call audio mode while already in it")
return
}
Timber.d("Audio: enabling webview in-call audio mode")
audioManager.mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Set 'voice call' mode so volume keys actually control the call volume
AudioManager.MODE_IN_COMMUNICATION
} else {
// Workaround for Android 12 and lower, otherwise changing the audio device doesn't work
AudioManager.MODE_NORMAL
}
setWebViewAndroidNativeBridge()
}
/**
* Call this method when the call stops to disable in-call audio mode.
*
* It's the counterpart of [onCallStarted], and should be called as a pair with it once the call has ended.
*/
fun onCallStopped() {
if (!isInCallMode.compareAndSet(true, false)) {
Timber.w("Audio: tried to disable webview in-call audio mode while already disabled")
return
}
coroutineScope.launch {
proximitySensorMutex.withLock {
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
}
}
}
audioManager.mode = AudioManager.MODE_NORMAL
if (!hasRegisteredCallbacks) {
Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks")
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener)
}
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
/**
* Registers the WebView audio device selected callback.
*
* This should be called when the WebView is created to ensure that the callback is set before any audio device selection is made.
*/
private fun registerWebViewDeviceSelectedCallback() {
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
onAudioDeviceSelected = { selectedDeviceId ->
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
audioManager.selectAudioDevice(selectedDeviceId)
},
onAudioPlaybackStarted = {
coroutineScope.launch(Dispatchers.Main) {
// Even with the callback, it seems like starting the audio takes a bit on the webview side,
// so we add an extra delay here to make sure it's ready
delay(2.seconds)
// Calling this ahead of time makes the default audio device to not use the right audio stream
setAvailableAudioDevices()
// Registering the audio devices changed callback will also set the default audio device
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), commsDeviceChangedListener)
}
hasRegisteredCallbacks = true
}
}
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
}
/**
* Assigns the callback in the WebView to be called when the user selects an audio device.
*
* It should be called with some delay after [registerWebViewDeviceSelectedCallback].
*/
private fun setWebViewAndroidNativeBridge() {
Timber.d("Adding callback in controls.onAudioPlaybackStarted")
webView.evaluateJavascript("controls.onAudioPlaybackStarted = () => { androidNativeBridge.onTrackReady(); };", null)
Timber.d("Adding callback in controls.onOutputDeviceSelect")
webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { androidNativeBridge.setOutputDevice(id); };", null)
}
/**
* Returns the list of available audio devices.
*
* On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback.
*/
private fun listAudioDevices(): List<AudioDeviceInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.availableCommunicationDevices
} else {
val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }
}
}
/**
* Sets the available audio devices in the WebView.
*
* @param devices The list of audio devices to set. If not provided, it will use the current list of audio devices.
*/
private fun setAvailableAudioDevices(
devices: List<SerializableAudioDevice> = listAudioDevices().map(SerializableAudioDevice::fromAudioDeviceInfo),
) {
Timber.d("Updating available audio devices")
val deviceList = json.encodeToString(devices)
webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", {
Timber.d("Audio: setAvailableOutputDevices result: $it")
})
}
/**
* Selects the default audio device based on the available devices.
*
* @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
*/
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
val selectedDevice = availableDevices
.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
}
}
expectedNewCommunicationDeviceId = selectedDevice?.id
audioManager.selectAudioDevice(selectedDevice)
selectedDevice?.let {
updateSelectedAudioDeviceInWebView(it.id.toString())
} ?: run {
Timber.w("Audio: unable to select default audio device")
}
}
/**
* Updates the WebView's UI to reflect the selected audio device.
*
* @param deviceId The id of the selected audio device.
*/
private fun updateSelectedAudioDeviceInWebView(deviceId: String) {
coroutineScope.launch(Dispatchers.Main) {
webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null)
}
}
/**
* Selects the audio device on the OS based on the provided device id.
*
* It will select the device only if it is available in the list of audio devices.
*
* @param device The id of the audio device to select.
*/
private fun AudioManager.selectAudioDevice(device: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val audioDevice = availableCommunicationDevices.find { it.id.toString() == device }
selectAudioDevice(audioDevice)
} else {
val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val audioDevice = rawAudioDevices.find { it.id.toString() == device }
selectAudioDevice(audioDevice)
}
}
/**
* Selects the audio device on the OS based on the provided device info.
*
* @param device The info of the audio device to select, or none to clear the selected device.
*/
@Suppress("DEPRECATION")
private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
currentDeviceId = device?.id
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (device != null) {
Timber.d("Setting communication device: ${device.id} - ${deviceName(device.type, device.productName.toString())}")
setCommunicationDevice(device)
} else {
audioManager.clearCommunicationDevice()
}
} else {
// On Android 11 and lower, we don't have the concept of communication devices
// We have to call the right methods based on the device type
if (device != null) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) {
Timber.w("Bluetooth audio devices are disabled on this Android version")
setAudioEnabled(false)
onInvalidAudioDeviceAdded(InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED)
return
}
setAudioEnabled(true)
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else {
isSpeakerphoneOn = false
isBluetoothScoOn = false
}
}
expectedNewCommunicationDeviceId = null
coroutineScope.launch {
proximitySensorMutex.withLock {
@Suppress("WakeLock", "WakeLockTimeout")
if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
// If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
proximitySensorWakeLock?.acquire()
} else if (proximitySensorWakeLock?.isHeld == true) {
// If the device is no longer the earpiece, we need to release the wake lock
proximitySensorWakeLock?.release()
}
}
}
}
/**
* Sets whether the audio is enabled for Element Call in the WebView.
* It will only perform the change if the audio state has changed.
*/
private fun setAudioEnabled(enabled: Boolean) {
coroutineScope.launch(Dispatchers.Main) {
Timber.d("Setting audio enabled in Element Call: $enabled")
if (isWebViewAudioEnabled.getAndSet(enabled) != enabled) {
webView.evaluateJavascript("controls.setAudioEnabled($enabled);", null)
}
}
}
}
/**
* This class is used to handle the audio device selection in the WebView.
* It listens for the audio device selection event and calls the callback with the selected device ID.
*/
private class AndroidWebViewAudioBridge(
private val onAudioDeviceSelected: (String) -> Unit,
private val onAudioPlaybackStarted: () -> Unit,
) {
@JavascriptInterface
fun setOutputDevice(id: String) {
Timber.d("Audio device selected in webview, id: $id")
onAudioDeviceSelected(id)
}
@JavascriptInterface
fun onTrackReady() {
// This method can be used to notify the WebView that the audio track is ready
// It can be used to start playing audio or to update the UI
Timber.d("Audio track is ready")
onAudioPlaybackStarted()
}
}
private fun deviceName(type: Int, name: String): String {
// TODO maybe translate these?
val typePart = when (type) {
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth"
AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB accessory"
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB device"
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB headset"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired headset"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired headphones"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in speaker"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Built-in earpiece"
else -> "Unknown"
}
return if (isBuiltIn(type)) {
typePart
} else {
"$typePart - $name"
}
}
private fun isBuiltIn(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_BUILTIN_MIC,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> true
else -> false
}
enum class InvalidAudioDeviceReason {
BT_AUDIO_DEVICE_DISABLED,
}
/**
* This class is used to serialize the audio device information to JSON.
*/
@Suppress("unused")
@Serializable
internal data class SerializableAudioDevice(
val id: String,
val name: String,
@Transient val type: Int = 0,
// These have to be part of the constructor for the JSON serializer to pick them up
val isEarpiece: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
val isSpeaker: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
val isExternalHeadset: Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
) {
companion object {
fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): SerializableAudioDevice {
return SerializableAudioDevice(
id = audioDeviceInfo.id.toString(),
name = deviceName(type = audioDeviceInfo.type, name = audioDeviceInfo.productName.toString()),
type = audioDeviceInfo.type,
)
}
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.webkit.WebView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Documentation about the `controls` command can be found here:
* https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#picture-in-picture
*/
class WebViewPipController(
private val webView: WebView,
) : PipController {
override suspend fun canEnterPip(): Boolean {
return suspendCoroutine { continuation ->
webView.evaluateJavascript("controls.canEnterPip()") { result ->
// Note if the method is not available, it will return "null"
continuation.resume(result == "true" || result == "null")
}
}
}
override fun enterPip() {
webView.evaluateJavascript("controls.enablePip()", null)
}
override fun exitPip() {
webView.evaluateJavascript("controls.disablePip()", null)
}
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.graphics.Bitmap
import android.net.http.SslError
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.net.toUri
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow
import timber.log.Timber
class WebViewWidgetMessageInterceptor(
private val webView: WebView,
private val onUrlLoaded: (String) -> Unit,
private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor {
companion object {
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this
// 'listenerName' so they can both receive the data from the WebView when
// `${LISTENER_NAME}.postMessage(...)` is called
const val LISTENER_NAME = "elementX"
}
// It's important to have extra capacity here to make sure we don't drop any messages
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 10)
init {
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context))
.build()
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// Due to https://github.com/element-hq/element-x-android/issues/4097
// we need to supply a logging implementation that correctly includes
// objects in log lines.
view.evaluateJavascript(
"""
function logFn(consoleLogFn, ...args) {
consoleLogFn(
args.map(
a => typeof a === "string" ? a : JSON.stringify(a)
).join(' ')
);
};
globalThis.console.debug = logFn.bind(null, console.debug);
globalThis.console.log = logFn.bind(null, console.log);
globalThis.console.info = logFn.bind(null, console.info);
globalThis.console.warn = logFn.bind(null, console.warn);
globalThis.console.error = logFn.bind(null, console.error);
""".trimIndent(),
null
)
// We inject this JS code when the page starts loading to attach a message listener to the window.
// This listener will receive both messages:
// - EC widget API -> Element X (message.data.api == "fromWidget")
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
view.evaluateJavascript(
"""
window.addEventListener('message', function(event) {
let message = {data: event.data, origin: event.origin}
if (message.data.response && message.data.api == "toWidget"
|| !message.data.response && message.data.api == "fromWidget") {
let json = JSON.stringify(event.data)
${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG }}
$LISTENER_NAME.postMessage(json);
} else {
${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG }}
}
});
""".trimIndent(),
null
)
}
override fun onPageFinished(view: WebView, url: String) {
onUrlLoaded(url)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")
// Only propagate the error if it happens while loading the current page
if (view?.url == request?.url.toString()) {
onError(error?.description.toString())
}
super.onReceivedError(view, request, error)
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}")
// Only propagate the error if it happens while loading the current page
if (view?.url == request?.url.toString()) {
onError(errorResponse?.statusCode.toString())
}
super.onReceivedHttpError(view, request, errorResponse)
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
Timber.e("onReceivedSslError error: ${error?.primaryError}")
// Only propagate the error if it happens while loading the current page
if (view?.url == error?.url.toString()) {
onError(error?.toString())
}
super.onReceivedSslError(view, handler, error)
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(request.url)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(url.toUri())
}
}
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
onMessageReceived(message.data)
}
// Use WebMessageListener if supported, otherwise use JavascriptInterface
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
LISTENER_NAME,
setOf("*"),
webMessageListener
)
} else {
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
}
}, LISTENER_NAME)
}
}
override fun sendMessage(message: String) {
webView.evaluateJavascript("postMessage($message, '*')", null)
}
private fun onMessageReceived(json: String?) {
// Here is where we would handle the messages from the WebView, passing them to the Rust SDK
json?.let { interceptedMessages.tryEmit(it) }
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import kotlinx.coroutines.flow.Flow
interface WidgetMessageInterceptor {
val interceptedMessages: Flow<String>
fun sendMessage(message: String)
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.Inject
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.runCatchingExceptions
@Inject
class WidgetMessageSerializer(
private val json: JsonProvider,
) {
fun deserialize(message: String): Result<WidgetMessage> {
return runCatchingExceptions { json().decodeFromString(WidgetMessage.serializer(), message) }
}
fun serialize(message: WidgetMessage): String {
return json().encodeToString(WidgetMessage.serializer(), message)
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Бягучы званок"</string>
<string name="call_foreground_service_message_android">"Націсніце, каб вярнуцца да званку"</string>
<string name="call_foreground_service_title_android">"☎️ Ідзе званок"</string>
<string name="screen_incoming_call_subtitle_android">"Уваходны званок Element Call"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Probíhající hovor"</string>
<string name="call_foreground_service_message_android">"Klepněte pro návrat k hovoru"</string>
<string name="call_foreground_service_title_android">"☎️ Probíhá hovor"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nepodporuje používání Bluetooth zvukových zařízení v této verzi systému Android. Vyberte jiné zvukové zařízení."</string>
<string name="screen_incoming_call_subtitle_android">"Příchozí Element Call"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Galwad cyfredol"</string>
<string name="call_foreground_service_message_android">"Tapio i ddychwelyd i\'r alwad"</string>
<string name="call_foreground_service_title_android">"☎️ Galwad ar y gweill"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Nid yw Element Call yn cefnogi defnyddio dyfeisiau sain Bluetooth yn y fersiwn Android hon. Dewiswch ddyfais sain wahanol."</string>
<string name="screen_incoming_call_subtitle_android">"Galwad Element"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Igangværende opkald"</string>
<string name="call_foreground_service_message_android">"Tryk for at vende tilbage til opkaldet"</string>
<string name="call_foreground_service_title_android">"☎️ Opkald i gang"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call understøtter desværre ikke brug af Bluetooth-lydenheder i denne Android-version. Vælg venligst en anden lydenhed."</string>
<string name="screen_incoming_call_subtitle_android">"Indgående Element opkald"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Laufender Anruf"</string>
<string name="call_foreground_service_message_android">"Tippen, um zum Anruf zurückzukehren"</string>
<string name="call_foreground_service_title_android">"☎️ Anruf läuft"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call unterstützt in dieser Android-Version keine Bluetooth Geräte. Bitte wähle ein anderes Audiogerät."</string>
<string name="screen_incoming_call_subtitle_android">"Eingehender Element Call"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Συνεχής κλήση"</string>
<string name="call_foreground_service_message_android">"Πάτα για να επιστρέψεις στην κλήση"</string>
<string name="call_foreground_service_title_android">"☎️ Κλήση σε εξέλιξη"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Το Element Call δεν υποστηρίζει τη χρήση συσκευών ήχου Bluetooth σε αυτήν την έκδοση Android. Επέλεξε μια διαφορετική συσκευή ήχου."</string>
<string name="screen_incoming_call_subtitle_android">"Εισερχόμενη κλήση Element"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Llamada en curso"</string>
<string name="call_foreground_service_message_android">"Pulsa para regresar a la llamada"</string>
<string name="call_foreground_service_title_android">"☎️ Llamada en curso"</string>
<string name="screen_incoming_call_subtitle_android">"Llamada de Element Call entrante"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Pooleliolev kõne"</string>
<string name="call_foreground_service_message_android">"Kõne juurde naasmiseks klõpsa"</string>
<string name="call_foreground_service_title_android">"☎️ Kõne on pooleli"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade."</string>
<string name="screen_incoming_call_subtitle_android">"Sissetulev Element Calli kõne"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Abian den deia"</string>
<string name="call_foreground_service_message_android">"Sakatu deira itzultzeko."</string>
<string name="call_foreground_service_title_android">"☎️ Deia abian"</string>
<string name="screen_incoming_call_subtitle_android">"Element deia jasotzen"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"تماس خروجی"</string>
<string name="call_foreground_service_message_android">"زدن برای بازگشت به تماس"</string>
<string name="call_foreground_service_title_android">"تماس در جریان ☎️"</string>
<string name="screen_incoming_call_subtitle_android">"تماس المنتی ورودی"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Käynnissä oleva puhelu"</string>
<string name="call_foreground_service_message_android">"Palaa puheluun napauttamalla"</string>
<string name="call_foreground_service_title_android">"☎️ Puhelu käynnissä"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ei tue Bluetooth-äänilaitteiden käyttöä tässä Android-versiossa. Valitse toinen äänilaite."</string>
<string name="screen_incoming_call_subtitle_android">"Saapuva Element Call -puhelu"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
<string name="call_foreground_service_message_android">"Cliquez pour retourner à lappel."</string>
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ne prend pas en charge lutilisation daccessoires Bluetooth dans cette version dAndroid. Sélectionnez une autre sortie audio."</string>
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Folyamatban lévő hívás"</string>
<string name="call_foreground_service_message_android">"Koppintson a híváshoz való visszatéréshez"</string>
<string name="call_foreground_service_title_android">"☎️ Hívás folyamatban"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Az Element Call nem támogatja a Bluetooth hangeszközök használatát ebben az Android-verzióban. Válasszon másik hangeszközt."</string>
<string name="screen_incoming_call_subtitle_android">"Bejövő Element hívás"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Panggilan berlangsung"</string>
<string name="call_foreground_service_message_android">"Ketuk untuk kembali ke panggilan"</string>
<string name="call_foreground_service_title_android">"☎️ Panggilan sedang berlangsung"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call tidak mendukung penggunaan perangkat audio Bluetooth di versi Android ini. Silakan pilih perangkat audio yang berbeda."</string>
<string name="screen_incoming_call_subtitle_android">"Element Call Masuk"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Chiamata in corso"</string>
<string name="call_foreground_service_message_android">"Tocca per tornare alla chiamata"</string>
<string name="call_foreground_service_title_android">"☎️ Chiamata in corso"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call non supporta l\'uso di dispositivi audio Bluetooth in questa versione di Android. Seleziona un dispositivo audio diverso."</string>
<string name="screen_incoming_call_subtitle_android">"Chiamata Element Call in arrivo"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"მიმდინარე ზარი"</string>
<string name="call_foreground_service_message_android">"დააწკაპუნეთ ზარში დასაბრუნებლად"</string>
<string name="call_foreground_service_title_android">"☎️ ზარი მიმდინარეობს"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"수신 중인 통화"</string>
<string name="call_foreground_service_message_android">"탭해서 통화로 돌아가기"</string>
<string name="call_foreground_service_title_android">"☎️ 통화 진행 중"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call은 이 Android 버전에서 Bluetooth 오디오 장치 사용을 지원하지 않습니다. 다른 오디오 장치를 선택하세요."</string>
<string name="screen_incoming_call_subtitle_android">"Element 전화 수신"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Pågående samtale"</string>
<string name="call_foreground_service_message_android">"Trykk for å gå tilbake til samtalen"</string>
<string name="call_foreground_service_title_android">"☎️ Samtale pågår"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call støtter ikke bruk av Bluetooth-lydenheter i denne Android-versjonen. Velg en annen lydenhet."</string>
<string name="screen_incoming_call_subtitle_android">"Innkommende Element-anrop"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Actieve oproep"</string>
<string name="call_foreground_service_message_android">"Tik om terug te gaan naar het gesprek"</string>
<string name="call_foreground_service_title_android">"☎️ In gesprek"</string>
<string name="screen_incoming_call_subtitle_android">"Inkomende Element-oproep"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Połączenie w trakcie"</string>
<string name="call_foreground_service_message_android">"Stuknij, aby wrócić do rozmowy"</string>
<string name="call_foreground_service_title_android">"☎️ Rozmowa w toku"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nie obsługuje korzystania z urządzeń audio Bluetooth w tej wersji Androida. Wybierz inne urządzenie audio."</string>
<string name="screen_incoming_call_subtitle_android">"Przychodzące połączenie Element"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Chamada em andamento"</string>
<string name="call_foreground_service_message_android">"Toque para retornar à chamada"</string>
<string name="call_foreground_service_title_android">"☎️ Chamada em andamento"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"O Element Call não tem suporte a dispositivos de áudio Bluetooth nesta versão do Android. Por favor, selecione um dispositivo de áudio diferente."</string>
<string name="screen_incoming_call_subtitle_android">"Chamada do Element recebida"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Chamada em curso"</string>
<string name="call_foreground_service_message_android">"Toca para voltar à chamada"</string>
<string name="call_foreground_service_title_android">"☎️ Chamada em curso"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"As chamadas do Element não permitem o uso de dispositivos de áudio Bluetooth nesta versão do Android. Por favor, seleciona outro dispositivo."</string>
<string name="screen_incoming_call_subtitle_android">"A receber chamada da Element"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Apel în curs"</string>
<string name="call_foreground_service_message_android">"Atingeți pentru a reveni la apel."</string>
<string name="call_foreground_service_title_android">"☎️ Apel în curs"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nu permite utilizarea dispozitivelor audio Bluetooth în această versiune Android. Vă rugăm să selectați un alt dispozitiv audio."</string>
<string name="screen_incoming_call_subtitle_android">"Primiți un apel Element Call"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Текущий вызов"</string>
<string name="call_foreground_service_message_android">"Коснитесь, чтобы вернуться к вызову"</string>
<string name="call_foreground_service_title_android">"☎️ Идёт вызов"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Функция Element Call не поддерживает использование аудиоустройств Bluetooth в данной версии Android. Пожалуйста, выберите другое аудиоустройство."</string>
<string name="screen_incoming_call_subtitle_android">"Входящий вызов Element"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Prebiehajúci hovor"</string>
<string name="call_foreground_service_message_android">"Ťuknutím sa vrátite k hovoru"</string>
<string name="call_foreground_service_title_android">"☎️ Prebieha hovor"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nepodporuje používanie zvukových zariadení Bluetooth v tejto verzii systému Android. Vyberte iné zvukové zariadenie."</string>
<string name="screen_incoming_call_subtitle_android">"Prichádzajúci hovor Element Call"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Pågående samtal"</string>
<string name="call_foreground_service_message_android">"Tryck för att återgå till samtalet"</string>
<string name="call_foreground_service_title_android">"☎️ Samtal pågår"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call stöder inte användning av Bluetooth-ljudenheter i den här Android-versionen. Välj en annan ljudenhet."</string>
<string name="screen_incoming_call_subtitle_android">"Inkommande Element Call"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Devam eden çağrı"</string>
<string name="call_foreground_service_message_android">"Aramaya geri dönmek için dokunun"</string>
<string name="call_foreground_service_title_android">"☎️ Çağrı devam ediyor"</string>
<string name="screen_incoming_call_subtitle_android">"Gelen Element Call"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Поточний виклик"</string>
<string name="call_foreground_service_message_android">"Торкніться, щоб повернутися до виклику"</string>
<string name="call_foreground_service_title_android">"☎️ Триває виклик"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call не підтримує використання аудіопристроїв Bluetooth у цій версії Android. Виберіть інший аудіопристрій."</string>
<string name="screen_incoming_call_subtitle_android">"Вхідний виклик Element"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"جاری مکالمہ"</string>
<string name="call_foreground_service_message_android">"مکالمہ پر واپس جانے کے لیے تھپتھپائیں"</string>
<string name="call_foreground_service_title_android">"☎️ مکالمہ جاری ہے"</string>
<string name="screen_incoming_call_subtitle_android">"ورودی ایلیمنٹ کال"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Davom etayotgan qo\'ng\'iroq"</string>
<string name="call_foreground_service_message_android">"Qo\'ng\'iroqqa qaytish uchun bosing"</string>
<string name="call_foreground_service_title_android">"☎️ Qoʻngʻiroq davom etmoqda"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ushbu Android versiyasida Bluetooth audio qurilmalaridan foydalanishni qoʻllab-quvvatlamaydi. Iltimos, boshqa audio qurilmani tanlang."</string>
<string name="screen_incoming_call_subtitle_android">"Kiruvchi element qoʻngʻirogʻi"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"進行中的通話"</string>
<string name="call_foreground_service_message_android">"點擊以返回到通話頁面"</string>
<string name="call_foreground_service_title_android">"☎️ 通話中"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支援在此 Android 版本中使用藍牙音訊裝置。請選取其他音訊裝置。"</string>
<string name="screen_incoming_call_subtitle_android">"Element 來電"</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"通话进行中"</string>
<string name="call_foreground_service_message_android">"点按即可返回通话"</string>
<string name="call_foreground_service_title_android">"☎️ 通话中"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其他音频设备。"</string>
<string name="screen_incoming_call_subtitle_android">"Element 来电"</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string translatable="false" name="element_call">Element Call</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device."</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
assertThat(intent.component).isEqualTo(expectedIntent.component)
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "roomName",
senderName = "senderName",
avatarUrl = "avatarUrl",
timestamp = 0,
expirationTimestamp = 0,
notificationChannelId = "notificationChannelId",
textContent = "textContent",
)
advanceTimeBy(1.seconds)
registerIncomingCallLambda.assertions().isCalledOnce()
}
private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,
activeCallManager = activeCallManager,
)
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call
import android.Manifest
import android.webkit.PermissionRequest
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.mapWebkitPermissions
import org.junit.Test
class MapWebkitPermissionsTest {
@Test
fun `given Webkit's RESOURCE_AUDIO_CAPTURE returns Android's RECORD_AUDIO permission`() {
val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE))
assertThat(permission).isEqualTo(listOf(Manifest.permission.RECORD_AUDIO))
}
@Test
fun `given Webkit's RESOURCE_VIDEO_CAPTURE returns Android's CAMERA permission`() {
val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
assertThat(permission).isEqualTo(listOf(Manifest.permission.CAMERA))
}
@Test
fun `given any other permission, it returns nothing`() {
val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID))
assertThat(permission).isEmpty()
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController
import io.element.android.tests.testutils.lambda.lambdaError
class FakePipController(
private val canEnterPipResult: () -> Boolean = { lambdaError() },
private val enterPipResult: () -> Unit = { lambdaError() },
private val exitPipResult: () -> Unit = { lambdaError() },
) : PipController {
override suspend fun canEnterPip(): Boolean = canEnterPipResult()
override fun enterPip() = enterPipResult()
override fun exitPip() = exitPipResult()
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
class FakePipSupportProvider(
private val isPipSupported: Boolean
) : PipSupportProvider {
override fun isPipSupported() = isPipSupported
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
import io.element.android.tests.testutils.lambda.lambdaError
class FakePipView(
private val setPipParamsResult: () -> Unit = { lambdaError() },
private val enterPipModeResult: () -> Boolean = { lambdaError() },
private val handUpResult: () -> Unit = { lambdaError() }
) : PipView {
override fun setPipParams() = setPipParamsResult()
override fun enterPipMode(): Boolean = enterPipModeResult()
override fun hangUp() = handUpResult()
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.pip
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PictureInPicturePresenterTest {
@Test
fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse()
}
presenter.setPipView(null)
}
@Test
fun `when pip is supported, the state value supportPip is true`() = runTest {
val presenter = createPictureInPicturePresenter(
supportPip = true,
pipView = FakePipView(setPipParamsResult = { }),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue()
}
}
@Test
fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest {
val enterPipModeResult = lambdaRecorder<Boolean> { true }
val presenter = createPictureInPicturePresenter(
supportPip = true,
pipView = FakePipView(
setPipParamsResult = { },
enterPipModeResult = enterPipModeResult,
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse()
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
}
}
@Test
fun `with webPipApi, when entering pip is supported, but web deny it, the call is finished`() = runTest {
val handUpResult = lambdaRecorder<Unit> { }
val presenter = createPictureInPicturePresenter(
supportPip = true,
pipView = FakePipView(
setPipParamsResult = { },
handUpResult = handUpResult
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false })))
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
handUpResult.assertions().isCalledOnce()
}
}
@Test
fun `with webPipApi, when entering pip is supported, and web allows it, the state value isInPictureInPicture is true`() = runTest {
val enterPipModeResult = lambdaRecorder<Boolean> { true }
val enterPipResult = lambdaRecorder<Unit> { }
val exitPipResult = lambdaRecorder<Unit> { }
val presenter = createPictureInPicturePresenter(
supportPip = true,
pipView = FakePipView(
setPipParamsResult = { },
enterPipModeResult = enterPipModeResult
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(
PictureInPictureEvents.SetPipController(
FakePipController(
canEnterPipResult = { true },
enterPipResult = enterPipResult,
exitPipResult = exitPipResult,
)
)
)
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce()
enterPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
enterPipResult.assertions().isCalledOnce()
// User stops pip
exitPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
exitPipResult.assertions().isCalledOnce()
}
}
private fun createPictureInPicturePresenter(
supportPip: Boolean = true,
pipView: PipView? = FakePipView()
): PictureInPicturePresenter {
return PictureInPicturePresenter(
pipSupportProvider = FakePipSupportProvider(supportPip),
).apply {
setPipView(pipView)
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.notifications
import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class RingingCallNotificationCreatorTest {
@Test
fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("No client found")) })
)
val result = notificationCreator.createTestNotification()
assertThat(result).isNull()
}
@Test
fun `createNotification - creates a valid notification`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) })
)
val result = notificationCreator.createTestNotification()
assertThat(result).isNotNull()
}
@Test
fun `createNotification - tries to load the avatar URL`() = runTest {
val getUserIconLambda = lambdaRecorder<AvatarData, ImageLoader, IconCompat?> { _, _ -> null }
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda)
)
notificationCreator.createTestNotification()
getUserIconLambda.assertions().isCalledOnce()
}
private suspend fun RingingCallNotificationCreator.createTestNotification() = createNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "Room",
senderDisplayName = "Johnnie Murphy",
roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId",
timestamp = 0L,
expirationTimestamp = 20L,
textContent = "textContent",
)
private fun createRingingCallNotificationCreator(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder: FakeImageLoaderHolder = FakeImageLoaderHolder(),
notificationBitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(),
) = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = imageLoaderHolder,
notificationBitmapLoader = notificationBitmapLoader,
)
}
@@ -0,0 +1,419 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.CallScreenEvents
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.analytics.test.FakeScreenTracker
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class CallScreenPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse()
assertThat(initialState.isCallActive).isFalse()
analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(1)
joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
// Wait until the WidgetDriver is loaded
skipItems(1)
assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java)
}
}
@Test
fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
// And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message")
assertThat(messageInterceptor.sentMessages).containsExactly("A message")
// And incoming message from the WebView is passed to the Widget Driver
messageInterceptor.givenInterceptedMessage("A reply")
assertThat(widgetDriver.sentMessages).containsExactly("A reply")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup)
// Let background coroutines run and the widget drive be received
runCurrent()
assertThat(navigator.closeCalled).isTrue()
assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run
advanceTimeBy(1.seconds)
runCurrent()
assertThat(navigator.closeCalled).isTrue()
assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a received 'content loaded' action makes the call to be active`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage(
"""
{
"action":"content_loaded",
"api":"fromWidget",
"widgetId":"1",
"requestId":"1"
}
""".trimIndent()
)
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.isCallActive).isTrue()
}
}
@Test
fun `present - if in room mode and no join action is received an error is displayed`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
skipItems(2)
// Wait for the timeout to trigger
advanceTimeBy(10.seconds)
val finalState = awaitItem()
assertThat(finalState.isCallActive).isFalse()
// The error dialog that will force the user to leave the call is displayed
assertThat(finalState.webViewError).isNotNull()
assertThat(finalState.webViewError).isEmpty()
}
}
@Test
fun `present - automatically sets the isInCall state when starting the call and disposing the screen`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(SyncState.Idle).apply {
this.startSyncLambda = startSyncLambda
}
val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
screenTracker = FakeScreenTracker {},
appForegroundStateService = appForegroundStateService,
)
val hasRun = Mutex(true)
val job = launch {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.collect {
hasRun.unlock()
}
}
appForegroundStateService.isInCall.test {
// The initial isInCall state will always be false
assertThat(awaitItem()).isFalse()
// Wait until the call starts
hasRun.lock()
// Then it'll be true once the call is active
assertThat(awaitItem()).isTrue()
// If we dispose the screen
job.cancelAndJoin()
// The isInCall state is now false
assertThat(awaitItem()).isFalse()
// And there are no more events
ensureAllEventsConsumed()
}
}
@Test
fun `present - error from WebView are updating the state`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isEqualTo("A Webview error")
}
}
@Test
fun `present - error from WebView are ignored if Element Call is loaded`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
val messageInterceptor = FakeWidgetMessageInterceptor()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
// Emit a message
messageInterceptor.givenInterceptedMessage("A message")
// WebView emits an error, but it will be ignored
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createCallScreenPresenter(
callType: CallType,
navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
override fun provide(): String {
return "Test"
}
}
val clock = SystemClock { 0 }
return CallScreenPresenter(
callType = callType,
navigator = navigator,
callWidgetProvider = widgetProvider,
userAgentProvider = userAgentProvider,
clock = clock,
dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider,
activeCallManager = activeCallManager,
screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
appCoroutineScope = backgroundScope,
widgetMessageSerializer = WidgetMessageSerializer(DefaultJsonProvider()),
)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.getSessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import org.junit.Test
class CallTypeTest {
@Test
fun `getSessionId returns null for ExternalUrl`() {
assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull()
}
@Test
fun `getSessionId returns the sessionId for RoomCall`() {
assertThat(
CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
).getSessionId()
).isEqualTo(A_SESSION_ID)
}
@Test
fun `ExternalUrl stringification does not contain the URL`() {
assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl")
}
@Test
fun `RoomCall stringification does not contain the URL`() {
assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID).toString())
.isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID)")
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import io.element.android.features.call.impl.ui.CallScreenNavigator
class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false
private set
override fun close() {
closeCalled = true
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import androidx.compose.runtime.Composable
import io.element.android.features.call.impl.ui.LanguageTagProvider
class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider {
@Composable
override fun provideLanguageTag() = languageTag
}
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.CallIntentDataParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
class CallIntentDataParserTest {
private val callIntentDataParser = CallIntentDataParser()
@Test
fun `a null data returns null`() {
val url: String? = null
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `empty data returns null`() {
doTest("", null)
}
@Test
fun `invalid data returns null`() {
doTest("!", null)
}
@Test
fun `data with no scheme returns null`() {
doTest("test", null)
}
@Test
fun `Element Call http urls returns null`() {
doTest("http://call.element.io", null)
doTest("http://call.element.io/some-actual-call?with=parameters", null)
}
@Test
fun `Element Call urls with unknown host returns null`() {
// Check valid host first, should not return null
doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true")
// Unknown host should return null
doTest("https://unknown.io", null)
doTest("https://call.unknown.io", null)
doTest("https://call.element.com", null)
doTest("https://call.element.io.tld", null)
}
@Test
fun `Element Call urls will be returned as is`() {
doTest(
url = "https://call.element.io",
expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url param gets url extracted`() {
doTest(
url = VALID_CALL_URL_WITH_PARAM,
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
doTest("http://app.element.io", null)
doTest("https://app.element.io", null)
doTest("http://", null)
doTest("https://", null)
}
@Test
fun `Element Call url with no url returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?no_url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no call host returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://no-call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no data returns null`() {
val url = "element://call?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with no data returns null`() {
val url = "io.element.call:/?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element invalid scheme returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "bad.scheme:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with url extra param appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
)
}
@Test
fun `Element Call url with url fragment gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with other params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment query`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
private fun doTest(url: String, expectedResult: String?) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
// Test embedded url, scheme 1
val encodedUrl = URLEncoder.encode(url, "utf-8")
val urlScheme1 = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
// Test embedded url, scheme 2
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
companion object {
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
}
}
@@ -0,0 +1,480 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.plantTestTimber
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest {
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
),
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
val manager = createActiveCallManager(
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
)
// Register existing call
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
val activeCall = manager.activeCall.value
// Now add a new call
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1)
addMissedCallNotificationLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
}
@Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
)
manager.incomingCallTimedOut(displayMissedCallNotification = true)
addMissedCallNotificationLambda.assertions().isNeverCalled()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
notificationManagerCompat = notificationManagerCompat,
)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = mockk<JoinedRoom>(relaxed = true)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `Decline event - Declining from another session should stop ringing`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = FakeJoinedRoom()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
runCurrent()
// Simulate declined from other session
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
runCurrent()
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `Decline event - Should ignore decline for other notification events`() = runTest {
plantTestTimber()
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = FakeJoinedRoom()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
runCurrent()
// Simulate declined for another notification event
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
runCurrent()
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
),
callState = CallState.InCall,
)
)
runCurrent()
verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `observeRingingCalls - will cancel the active ringing call if the call is cancelled`() = runTest {
val room = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo())
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
manager.registerIncomingCall(aCallNotificationData())
// Call is active (the other user join the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
// Call is cancelled (the other user left the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest {
val room = FakeBaseRoom().apply {
givenRoomInfo(aRoomInfo())
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
// No matrix client
manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
// No room
client.givenGetRoomResult(A_ROOM_ID, null)
matrixClientProvider.getClient = { Result.success(client) }
manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `IncomingCall - rings no longer than expiration time`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val clock = FakeSystemClock()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val eventTimestamp = A_FAKE_TIMESTAMP
// The call should not ring more than 30 seconds after the initial event was sent
val expirationTimestamp = eventTimestamp + 30_000
val callNotificationData = aCallNotificationData(
timestamp = eventTimestamp,
expirationTimestamp = expirationTimestamp,
)
// suppose it took 10s to be notified
clock.epochMillisResult = eventTimestamp + 10_000
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
),
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
// advance by 21s it should have stopped ringing
advanceTimeBy(21_000)
runCurrent()
verify { notificationManagerCompat.cancel(any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `IncomingCall - ignore expired ring lifetime`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val clock = FakeSystemClock()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val eventTimestamp = A_FAKE_TIMESTAMP
// The call should not ring more than 30 seconds after the initial event was sent
val expirationTimestamp = eventTimestamp + 30_000
val callNotificationData = aCallNotificationData(
timestamp = eventTimestamp,
expirationTimestamp = expirationTimestamp,
)
// suppose it took 35s to be notified
clock.epochMillisResult = eventTimestamp + 35_000
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isNull()
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify(exactly = 0) { notificationManagerCompat.notify(notificationId, any()) }
}
private fun setupShadowPowerManager() {
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
}
}
private fun TestScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
systemClock: FakeSystemClock = FakeSystemClock(),
) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = backgroundScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = FakeImageLoaderHolder(),
notificationBitmapLoader = FakeNotificationBitmapLoader(),
),
notificationManagerCompat = notificationManagerCompat,
matrixClientProvider = matrixClientProvider,
defaultCurrentCallService = DefaultCurrentCallService(),
appForegroundStateService = FakeAppForegroundStateService(),
imageLoaderHolder = FakeImageLoaderHolder(),
systemClock = systemClock,
)
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultCallWidgetProviderTest {
@Test
fun `getWidget - fails if the session does not exist`() = runTest {
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.failure(Exception("Session not found")) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - fails if the room does not exist`() = runTest {
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, null)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - fails if it can't generate the URL for the widget`() = runTest {
val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.failure(Exception("Can't generate URL for widget")) }
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - fails if it can't get the widget driver`() = runTest {
val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.failure(Exception("Can't get a widget driver")) }
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
}
@Test
fun `getWidget - returns a widget driver when all steps are successful`() = runTest {
val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull()
}
@Test
fun `getWidget - reuses the active room if possible`() = runTest {
val client = FakeMatrixClient().apply {
// No room from the client
givenGetRoomResult(A_ROOM_ID, null)
}
val activeRoomsHolder = DefaultActiveRoomsHolder().apply {
// A current active room with the same room id
addRoom(
FakeJoinedRoom(
baseRoom = FakeBaseRoom(roomId = A_ROOM_ID),
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
)
}
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
activeRoomsHolder = activeRoomsHolder
)
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isSuccess).isTrue()
}
@Test
fun `getWidget - will use a custom base url if it exists`() = runTest {
val room = FakeJoinedRoom(
generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") },
getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) },
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val preferencesStore = InMemoryAppPreferencesStore().apply {
setCustomElementCallBaseUrl("https://custom.element.io")
}
val settingsProvider = FakeCallWidgetSettingsProvider()
val provider = createProvider(
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
callWidgetSettingsProvider = settingsProvider,
appPreferencesStore = preferencesStore,
)
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
}
private fun createProvider(
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
) = DefaultCallWidgetProvider(
matrixClientsProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,
callWidgetSettingsProvider = callWidgetSettingsProvider,
activeRoomsHolder = activeRoomsHolder,
)
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var hungUpCallResult: (CallType) -> Unit = {},
var joinedCallResult: (CallType) -> Unit = {},
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
registerIncomingCallResult(notificationData)
}
override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType)
}
override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType)
}
fun setActiveCall(value: ActiveCall?) {
this.activeCall.value = value
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
class FakeCallWidgetProvider(
private val widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
private val url: String = "https://call.element.io",
) : CallWidgetProvider {
var getWidgetCalled = false
private set
override suspend fun getWidget(
sessionId: SessionId,
roomId: RoomId,
clientId: String,
languageTag: String?,
theme: String?
): Result<CallWidgetProvider.GetWidgetResult> {
getWidgetCalled = true
return Result.success(
CallWidgetProvider.GetWidgetResult(
driver = widgetDriver,
url = url,
)
)
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
val sentMessages = mutableListOf<String>()
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
override fun sendMessage(message: String) {
sentMessages += message
}
fun givenInterceptedMessage(message: String) {
interceptedMessages.tryEmit(message)
}
}