First Commit
This commit is contained in:
@@ -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>
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+49
@@ -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,
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
+32
@@ -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
|
||||
+148
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+96
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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,
|
||||
)
|
||||
+21
@@ -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,
|
||||
)
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
+46
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -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
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+281
@@ -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()
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
+40
@@ -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,
|
||||
)
|
||||
}
|
||||
+277
@@ -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) {}
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
}
|
||||
+299
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
}
|
||||
+400
@@ -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
|
||||
}
|
||||
+98
@@ -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"
|
||||
+28
@@ -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,
|
||||
)
|
||||
}
|
||||
+23
@@ -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() }
|
||||
}
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
)!!
|
||||
}
|
||||
}
|
||||
+15
@@ -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()
|
||||
}
|
||||
+521
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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)
|
||||
}
|
||||
}
|
||||
+174
@@ -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) }
|
||||
}
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
+27
@@ -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 à l’appel."</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 l’utilisation d’accessoires Bluetooth dans cette version d’Android. 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>
|
||||
+79
@@ -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,
|
||||
)
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
}
|
||||
+24
@@ -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()
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+21
@@ -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()
|
||||
}
|
||||
+144
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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,
|
||||
)
|
||||
}
|
||||
+419
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
+44
@@ -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)")
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+226
@@ -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"
|
||||
}
|
||||
}
|
||||
+480
@@ -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,
|
||||
)
|
||||
}
|
||||
+141
@@ -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,
|
||||
)
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+38
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user