Element Call ringing notifications (#2978)

- Add `ActiveCallManager` to handle incoming and ongoing calls.
- Add ringing call notifications with full screen intents and missed call ones as part of the 'conversation' notifications.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-06-10 11:51:19 +02:00
committed by GitHub
parent b827b859dc
commit 6f8de0b2c6
186 changed files with 2686 additions and 330 deletions

View File

@@ -17,5 +17,13 @@
package io.element.android.appconfig
object ElementCallConfig {
/**
* The default base URL for the Element Call service.
*/
const val DEFAULT_BASE_URL = "https://call.element.io"
/**
* The default duration of a ringing call in seconds before it's automatically dismissed.
*/
const val RINGING_CALL_DURATION_SECONDS = 15
}

1
changelog.d/2894.feature Normal file
View File

@@ -0,0 +1 @@
Ringing call notifications and full screen ringing screen for DMs when the device is locked.

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.call.api"
}
dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.api
import android.os.Parcelable
import io.element.android.libraries.architecture.NodeInputs

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.api
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
/**
* Entry point for the call feature.
*/
interface ElementCallEntryPoint {
/**
* Start a call of the given type.
* @param callType The type of call to start.
*/
fun startCall(callType: CallType)
/**
* Handle an incoming call.
* @param callType The type of call.
* @param eventId The event id of the event that started the call.
* @param senderId The user id of the sender of the event that started the call.
* @param roomName The name of the room the call is in.
* @param senderName The name of the sender of the event that started the call.
* @param avatarUrl The avatar url of the room or DM.
* @param timestamp The timestamp of the event that started the call.
* @param notificationChannelId The id of the notification channel to use for the call notification.
*/
fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String,
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,11 +23,15 @@ plugins {
}
android {
namespace = "io.element.android.features.call"
namespace = "io.element.android.features.call.impl"
buildFeatures {
buildConfig = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
anvil {
@@ -41,12 +45,17 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
api(projects.features.call.api)
ksp(libs.showkase.processor)
testImplementation(libs.coroutines.test)
@@ -54,9 +63,12 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
}

View File

@@ -23,11 +23,17 @@
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Permissions for call foreground services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application>
<activity
@@ -70,10 +76,24 @@
</intent-filter>
</activity>
<activity android:name=".ui.IncomingCallActivity"
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:taskAffinity="io.element.android.features.call" />
<service
android:name=".CallForegroundService"
android:name=".services.CallForegroundService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
android:exported="false"
android:foregroundServiceType="phoneCall" />
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
android:exported="false"
android:enabled="true" />
</application>
</manifest>

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultElementCallEntryPoint @Inject constructor(
@ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager,
) : ElementCallEntryPoint {
companion object {
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
const val REQUEST_CODE = 2255
}
override fun startCall(callType: CallType) {
context.startActivity(IntentProvider.createIntent(context, callType))
}
override fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String,
) {
val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId,
roomId = callType.roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
senderName = senderName,
avatarUrl = avatarUrl,
timestamp = timestamp,
notificationChannelId = notificationChannelId,
)
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.data
package io.element.android.features.call.impl.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

View File

@@ -14,13 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.call.di
package io.element.android.features.call.impl.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface CallBindings {
fun inject(callActivity: ElementCallActivity)
fun inject(callActivity: IncomingCallActivity)
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.notifications
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class CallNotificationData(
val sessionId: SessionId,
val roomId: RoomId,
val eventId: EventId,
val senderId: UserId,
val roomName: String?,
val senderName: String?,
val avatarUrl: String?,
val notificationChannelId: String,
val timestamp: Long,
) : Parcelable

View File

@@ -0,0 +1,130 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.notifications
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Creates a notification for a ringing call.
*/
class RingingCallNotificationCreator @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val notificationBitmapLoader: NotificationBitmapLoader,
) {
companion object {
/**
* Request code for the decline action.
*/
const val DECLINE_REQUEST_CODE = 1
/**
* Request code for the full screen intent.
*/
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
}
suspend fun createNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderDisplayName: String,
roomAvatarUrl: String?,
notificationChannelId: String,
timestamp: Long,
): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient)
val largeIcon = notificationBitmapLoader.getUserIcon(roomAvatarUrl, imageLoader)
val caller = Person.Builder()
.setName(senderDisplayName)
.setIcon(largeIcon)
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
val declineIntent = PendingIntentCompat.getBroadcast(
context,
DECLINE_REQUEST_CODE,
Intent(context, DeclineCallBroadcastReceiver::class.java),
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,
CallNotificationData(sessionId, roomId, eventId, senderId, roomName, senderDisplayName, roomAvatarUrl, notificationChannelId, timestamp)
)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false
)
val ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
return NotificationCompat.Builder(context, notificationChannelId)
.setSmallIcon(CommonDrawables.ic_notification_small)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true))
.addPerson(caller)
.setAutoCancel(true)
.setWhen(timestamp)
.setOngoing(true)
.setShowWhen(false)
.setSound(ringtoneUri, AudioManager.STREAM_RING)
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
.setContentIntent(answerIntent)
.setDeleteIntent(declineIntent)
.setFullScreenIntent(fullScreenIntent, true)
.build()
.apply {
flags = flags.or(Notification.FLAG_INSISTENT)
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
import javax.inject.Inject
/**
* Broadcast receiver to decline the incoming call.
*/
class DeclineCallBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var activeCallManager: ActiveCallManager
override fun onReceive(context: Context, intent: Intent?) {
context.bindings<CallBindings>().inject(this)
activeCallManager.hungUpCall()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,30 +14,36 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.impl.services
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import timber.log.Timber
/**
* A foreground service that shows a notification for an ongoing call while the UI is in background.
*/
class CallForegroundService : Service() {
companion object {
fun start(context: Context) {
val intent = Intent(context, CallForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
@@ -69,7 +75,17 @@ class CallForegroundService : Service() {
.setContentText(getString(R.string.call_foreground_service_message_android))
.setContentIntent(pendingIntent)
.build()
startForeground(1, notification)
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
} else {
0
}
runCatching {
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
}.onFailure {
Timber.e(it, "Failed to start ongoing call foreground service")
}
}
override fun onDestroy() {

View File

@@ -14,11 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import io.element.android.features.call.utils.WidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents {
data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) :
CallScreenEvents
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -30,11 +30,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.CallType
import io.element.android.features.call.data.WidgetMessage
import io.element.android.features.call.utils.CallWidgetProvider
import io.element.android.features.call.utils.WidgetMessageInterceptor
import io.element.android.features.call.utils.WidgetMessageSerializer
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -65,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker,
private val appCoroutineScope: CoroutineScope,
private val activeCallManager: ActiveCallManager,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
@@ -84,6 +86,10 @@ class CallScreenPresenter @AssistedInject constructor(
LaunchedEffect(Unit) {
loadUrl(callType, urlState, callWidgetDriver)
if (callType is CallType.RoomCall) {
activeCallManager.joinedCall(callType.sessionId, callType.roomId)
}
}
when (callType) {
@@ -134,6 +140,14 @@ class CallScreenPresenter @AssistedInject constructor(
}
}
DisposableEffect(Unit) {
onDispose {
if (callType is CallType.RoomCall) {
activeCallManager.hungUpCall()
}
}
}
fun handleEvents(event: CallScreenEvents) {
when (event) {
is CallScreenEvents.Hangup -> {
@@ -193,7 +207,6 @@ class CallScreenPresenter @AssistedInject constructor(
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
matrixClientsProvider.getOrNull(it)
} ?: return@DisposableEffect onDispose { }
coroutineScope.launch {
client.syncService().syncState
.onEach { state ->

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import io.element.android.libraries.architecture.AsyncData

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.annotation.SuppressLint
import android.view.ViewGroup
@@ -34,8 +34,8 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.R
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview

View File

@@ -14,12 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.res.Configuration
import android.media.AudioAttributes
import android.media.AudioFocusRequest
@@ -41,30 +39,16 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.isDark
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings
import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.architecture.bindings
import javax.inject.Inject
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
companion object {
private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
fun start(
context: Context,
callInputs: CallType,
) {
val intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
applicationContext.bindings<CallBindings>().inject(this)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
setCallType(intent)
@@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
private fun setCallType(intent: Intent?) {
val inputs = intent?.let {
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
val callType = intent?.let {
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
}
val intentUrl = intent?.dataString?.let(::parseUrl)
when {
// Re-opened the activity but we have no url to load or a cached one, finish the activity
intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
inputs != null -> {
webViewTarget.value = inputs
presenter = presenterFactory.create(inputs, this)
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
callType != null -> {
webViewTarget.value = callType
presenter = presenterFactory.create(callType, this)
}
intentUrl != null -> {
val fallbackInputs = CallType.ExternalUrl(intentUrl)

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.ui
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallState
import io.element.android.libraries.architecture.bindings
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* Activity that's displayed as a full screen intent when an incoming call is received.
*/
class IncomingCallActivity : AppCompatActivity() {
companion object {
/**
* Extra key for the notification data.
*/
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
}
@Inject
lateinit var elementCallEntryPoint: ElementCallEntryPoint
@Inject
lateinit var activeCallManager: ActiveCallManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applicationContext.bindings<CallBindings>().inject(this)
// Set flags so it can be displayed in the lock screen
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) {
setContent {
IncomingCallScreen(
notificationData = notificationData,
onAnswer = ::onAnswer,
onCancel = ::onCancel,
)
}
} else {
// No data, finish the activity
finish()
return
}
activeCallManager.activeCall
.filter { it?.callState !is CallState.Ringing }
.onEach { finish() }
.launchIn(lifecycleScope)
}
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
}
private fun onCancel() {
activeCallManager.hungUpCall()
}
}

View File

@@ -0,0 +1,192 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun IncomingCallScreen(
notificationData: CallNotificationData,
onAnswer: (CallNotificationData) -> Unit,
onCancel: () -> Unit,
) {
ElementTheme {
OnboardingBackground()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 124.dp)
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Avatar(
avatarData = AvatarData(
id = notificationData.senderId.value,
name = notificationData.senderName,
url = notificationData.avatarUrl,
size = AvatarSize.IncomingCall,
)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = notificationData.senderName ?: notificationData.senderId.value,
style = ElementTheme.typography.fontHeadingMdBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_incoming_call_subtitle_android),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
ActionButton(
size = 64.dp,
onClick = { onAnswer(notificationData) },
icon = CompoundIcons.VoiceCall(),
title = stringResource(CommonStrings.action_accept),
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle
)
ActionButton(
size = 64.dp,
onClick = onCancel,
icon = CompoundIcons.EndCall(),
title = stringResource(CommonStrings.action_reject),
backgroundColor = ElementTheme.colors.iconCriticalPrimary,
borderColor = ElementTheme.colors.borderCriticalSubtle
)
}
}
}
}
@Composable
private fun ActionButton(
size: Dp,
onClick: () -> Unit,
icon: ImageVector,
title: String,
backgroundColor: Color,
borderColor: Color,
contentDescription: String? = title,
borderSize: Dp = 1.33.dp,
) {
Column(
modifier = Modifier.width(120.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
FilledIconButton(
modifier = Modifier.size(size + borderSize)
.border(borderSize, borderColor, CircleShape),
onClick = onClick,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor,
contentColor = Color.White,
)
) {
Icon(
modifier = Modifier.size(32.dp),
imageVector = icon,
contentDescription = contentDescription
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
)
}
}
@PreviewsDayNight
@Composable
internal fun IncomingCallScreenPreview() {
ElementPreview {
IncomingCallScreen(
notificationData = CallNotificationData(
sessionId = SessionId("@alice:matrix.org"),
roomId = RoomId("!1234:matrix.org"),
eventId = EventId("\$asdadadsad:matrix.org"),
senderId = UserId("@bob:matrix.org"),
roomName = "A room",
senderName = "Bob",
avatarUrl = null,
notificationChannelId = "incoming_call",
timestamp = 0L,
),
onAnswer = {},
onCancel = {},
)
}
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.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.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Manages the active call state.
*/
interface ActiveCallManager {
/**
* The active call state flow, which will be updated when the active call changes.
*/
val activeCall: StateFlow<ActiveCall?>
/**
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification.
*/
fun registerIncomingCall(notificationData: CallNotificationData)
/**
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/
fun incomingCallTimedOut()
/**
* Hangs up the active call and removes any associated UI.
*/
fun hungUpCall()
/**
* Called when the user joins a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param sessionId The session ID of the user joining the call.
* @param roomId The room ID of the call.
*/
fun joinedCall(sessionId: SessionId, roomId: RoomId)
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor(
private val coroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat,
) : ActiveCallManager {
private var timedOutCallJob: Job? = null
override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) {
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
return
}
activeCall.value = ActiveCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
callState = CallState.Ringing(notificationData),
)
timedOutCallJob = coroutineScope.launch {
registerIncomingCall(notificationData)
showIncomingCallNotification(notificationData)
// Wait for the call to end
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
incomingCallTimedOut()
}
}
override fun incomingCallTimedOut() {
val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null
cancelIncomingCallNotification()
coroutineScope.launch {
onMissedCallNotificationHandler.addMissedCallNotification(
sessionId = previousActiveCall.sessionId,
roomId = previousActiveCall.roomId,
eventId = notificationData.eventId,
)
}
}
override fun hungUpCall() {
cancelIncomingCallNotification()
timedOutCallJob?.cancel()
activeCall.value = null
}
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
cancelIncomingCallNotification()
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
sessionId = sessionId,
roomId = roomId,
callState = CallState.InCall,
)
// Send call notification to the room
coroutineScope.launch {
matrixClientProvider.getOrRestore(sessionId)
.getOrNull()
?.getRoom(roomId)
?.sendCallNotificationIfNeeded()
}
}
@SuppressLint("MissingPermission")
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
val notification = ringingCallNotificationCreator.createNotification(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
eventId = notificationData.eventId,
senderId = notificationData.senderId,
roomName = notificationData.roomName,
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
roomAvatarUrl = notificationData.avatarUrl,
notificationChannelId = notificationData.notificationChannelId,
timestamp = notificationData.timestamp
) ?: return
runCatching {
notificationManagerCompat.notify(
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
notification,
)
}.onFailure {
Timber.e(it, "Failed to publish notification for incoming call")
}
}
private fun cancelIncomingCallNotification() {
notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL))
}
}
/**
* Represents an active call.
*/
data class ActiveCall(
val sessionId: SessionId,
val roomId: RoomId,
val callState: CallState,
)
/**
* Represents the state of an active call.
*/
sealed interface CallState {
/**
* The call is in a ringing state.
* @param notificationData The data for the incoming call notification.
*/
data class Ringing(val notificationData: CallNotificationData) : CallState
/**
* The call is in an in-call state.
*/
data object InCall : CallState
}

View File

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

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.utils
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
return PendingIntentCompat.getActivity(
context,
DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType),
0,
false
)!!
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import android.graphics.Bitmap
import android.webkit.JavascriptInterface
@@ -22,7 +22,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.BuildConfig
import io.element.android.features.call.impl.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow
class WebViewWidgetMessageInterceptor(

View File

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

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.data.WidgetMessage
import io.element.android.features.call.impl.data.WidgetMessage
import kotlinx.serialization.json.Json
object WidgetMessageSerializer {

View File

@@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
assertThat(intent.component).isEqualTo(expectedIntent.component)
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
@Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "roomName",
senderName = "senderName",
avatarUrl = "avatarUrl",
timestamp = 0,
notificationChannelId = "notificationChannelId",
)
registerIncomingCallLambda.assertions().isCalledOnce()
}
private fun createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,
activeCallManager = activeCallManager,
)
}

View File

@@ -19,7 +19,7 @@ package io.element.android.features.call
import android.Manifest
import android.webkit.PermissionRequest
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.ui.mapWebkitPermissions
import io.element.android.features.call.impl.ui.mapWebkitPermissions
import org.junit.Test
class MapWebkitPermissionsTest {

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.notifications
import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry
import coil.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class RingingCallNotificationCreatorTest {
@Test
fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("No client found")) })
)
val result = notificationCreator.createTestNotification()
assertThat(result).isNull()
}
@Test
fun `createNotification - creates a valid notification`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) })
)
val result = notificationCreator.createTestNotification()
assertThat(result).isNotNull()
}
@Test
fun `createNotification - tries to load the avatar URL`() = runTest {
val getUserIconLambda = lambdaRecorder<String?, ImageLoader, IconCompat?> { _, _ -> null }
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda)
)
notificationCreator.createTestNotification()
getUserIconLambda.assertions().isCalledOnce()
}
private suspend fun RingingCallNotificationCreator.createTestNotification() = createNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "Room",
senderDisplayName = "Johnnie Murphy",
roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId",
timestamp = 0L,
)
private fun createRingingCallNotificationCreator(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder: FakeImageLoaderHolder = FakeImageLoaderHolder(),
notificationBitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(),
) = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = imageLoaderHolder,
notificationBitmapLoader = notificationBitmapLoader,
)
}

View File

@@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.CallType
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.CallScreenEvents
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
@@ -254,6 +258,7 @@ class CallScreenPresenterTest {
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
@@ -270,8 +275,9 @@ class CallScreenPresenterTest {
clock = clock,
dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider,
screenTracker = screenTracker,
appCoroutineScope = this,
activeCallManager = activeCallManager,
screenTracker = screenTracker,
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,191 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import androidx.core.app.NotificationManagerCompat
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.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.FakeMatrixRoom
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest {
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
@Test
fun `registerIncomingCall - when there is an already active call does nothing`() = runTest {
val manager = createActiveCallManager()
// 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?.roomId).isNotEqualTo(A_ROOM_ID_2)
}
@Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
)
manager.incomingCallTimedOut()
addMissedCallNotificationLambda.assertions().isNeverCalled()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
notificationManagerCompat = notificationManagerCompat,
)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
manager.incomingCallTimedOut()
assertThat(manager.activeCall.value).isNull()
runCurrent()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - removes existing call`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
manager.hungUpCall()
assertThat(manager.activeCall.value).isNull()
verify { 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 sendCallNotifyLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val room = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotifyLambda)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val manager = createActiveCallManager(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
notificationManagerCompat = notificationManagerCompat,
)
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(A_SESSION_ID, A_ROOM_ID)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
callState = CallState.InCall,
)
)
runCurrent()
sendCallNotifyLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
private fun TestScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
) = DefaultActiveCallManager(
coroutineScope = this,
matrixClientProvider = matrixClientProvider,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = FakeImageLoaderHolder(),
notificationBitmapLoader = FakeNotificationBitmapLoader(),
),
notificationManagerCompat = notificationManagerCompat,
)
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
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.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var incomingCallTimedOutResult: () -> Unit = {},
var hungUpCallResult: () -> Unit = {},
var joinedCallResult: (SessionId, RoomId) -> Unit = { _, _ -> },
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) {
registerIncomingCallResult(notificationData)
}
override fun incomingCallTimedOut() {
incomingCallTimedOutResult()
}
override fun hungUpCall() {
hungUpCallResult()
}
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
joinedCallResult(sessionId, roomId)
}
fun setActiveCall(value: ActiveCall?) {
this.activeCall.value = value
}
}

View File

@@ -16,6 +16,7 @@
package io.element.android.features.call.utils
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver

View File

@@ -16,6 +16,7 @@
package io.element.android.features.call.utils
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.call.test"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
api(projects.features.call.api)
implementation(projects.features.call.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.test
import io.element.android.features.call.impl.notifications.CallNotificationData
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.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
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.A_USER_NAME
fun aCallNotificationData(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID_2,
roomName: String = A_ROOM_NAME,
senderName: String? = A_USER_NAME,
avatarUrl: String? = AN_AVATAR_URL,
notificationChannelId: String = "channel_id",
timestamp: Long = 0L,
): CallNotificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
senderName = senderName,
avatarUrl = avatarUrl,
notificationChannelId = notificationChannelId,
timestamp = timestamp,
)

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
class FakeElementCallEntryPoint(
var startCallResult: (CallType) -> Unit = {},
var handleIncomingCallResult: (CallType.RoomCall, EventId, UserId, String?, String?, String?, String) -> Unit = { _, _, _, _, _, _, _ -> }
) : ElementCallEntryPoint {
override fun startCall(callType: CallType) {
startCallResult(callType)
}
override fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String
) {
handleIncomingCallResult(callType, eventId, senderId, roomName, senderName, avatarUrl, notificationChannelId)
}
}

View File

@@ -39,7 +39,7 @@ dependencies {
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
implementation(projects.appconfig)
implementation(projects.features.call)
implementation(projects.features.call.api)
implementation(projects.features.location.api)
implementation(projects.features.poll.api)
implementation(projects.libraries.androidutils)

View File

@@ -16,7 +16,6 @@
package io.element.android.features.messages.impl
import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -31,8 +30,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.CallType
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
@@ -58,7 +57,6 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
@@ -78,11 +76,11 @@ import kotlinx.parcelize.Parcelize
class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
private val matrixClient: MatrixClient,
private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val analyticsService: AnalyticsService,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
@@ -188,12 +186,12 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onJoinCallClick(roomId: RoomId) {
val inputs = CallType.RoomCall(
val callType = CallType.RoomCall(
sessionId = matrixClient.sessionId,
roomId = roomId,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
ElementCallActivity.start(context, inputs)
elementCallEntryPoint.startCall(callType)
}
}
val inputs = MessagesNode.Inputs(

View File

@@ -173,6 +173,7 @@ class TimelineControllerTest {
matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline))
val sut = TimelineController(matrixRoom)
sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID)
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
}

View File

@@ -56,7 +56,7 @@ dependencies {
api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)
implementation(libs.coil.compose)
implementation(projects.features.call)
implementation(projects.features.call.api)
implementation(projects.features.createroom.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.userprofile.shared)

View File

@@ -16,7 +16,6 @@
package io.element.android.features.roomdetails.impl
import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -30,8 +29,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.CallType
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
@@ -46,7 +45,6 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -62,8 +60,8 @@ import kotlinx.parcelize.Parcelize
class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
@@ -147,7 +145,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
roomId = room.roomId,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
ElementCallActivity.start(context, inputs)
elementCallEntryPoint.startCall(inputs)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
@@ -195,7 +193,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
}
override fun onStartCall(dmRoomId: RoomId) {
ElementCallActivity.start(context, CallType.RoomCall(sessionId = room.sessionId, roomId = dmRoomId))
elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId))
}
}
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback)

View File

@@ -70,7 +70,10 @@ private fun Content(desktopApplicationName: String) {
add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2)))
add(
annotatedTextWithBold(
text = stringResource(R.string.screen_create_new_recovery_key_list_item_3),
text = stringResource(
id = R.string.screen_create_new_recovery_key_list_item_3,
stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
),
boldText = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
)
)

View File

@@ -46,7 +46,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call)
implementation(projects.features.call.api)
api(projects.features.userprofile.api)
api(projects.features.userprofile.shared)
implementation(libs.coil.compose)

View File

@@ -16,7 +16,6 @@
package io.element.android.features.userprofile.impl
import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -29,8 +28,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.CallType
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
@@ -40,7 +39,6 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -53,7 +51,7 @@ import kotlinx.parcelize.Parcelize
class UserProfileFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionIdHolder: CurrentSessionIdHolder,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
@@ -84,7 +82,7 @@ class UserProfileFlowNode @AssistedInject constructor(
}
override fun onStartCall(dmRoomId: RoomId) {
ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId))
}
}
val params = UserProfileNode.UserProfileInputs(userId = inputs<UserProfileEntryPoint.Params>().userId)

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
IncomingCall(140.dp),
RoomHeader(96.dp),
RoomListItem(52.dp),

View File

@@ -332,5 +332,10 @@ interface MatrixRoom : Closeable {
*/
suspend fun getPermalinkFor(eventId: EventId): Result<String>
/**
* Send an Element Call started notification if needed.
*/
suspend fun sendCallNotificationIfNeeded(): Result<Unit>
override fun close() = destroy()
}

View File

@@ -72,6 +72,7 @@ object EventType {
const val CALL_NEGOTIATE = "m.call.negotiate"
const val CALL_REJECT = "m.call.reject"
const val CALL_HANGUP = "m.call.hangup"
const val CALL_NOTIFY = "m.call.notify"
// This type is not processed by the client, just sent to the server
const val CALL_REPLACES = "m.call.replaces"
@@ -94,6 +95,7 @@ object EventType {
type == CALL_SELECT_ANSWER ||
type == CALL_NEGOTIATE ||
type == CALL_REJECT ||
type == CALL_REPLACES
type == CALL_REPLACES ||
type == CALL_NOTIFY
}
}

View File

@@ -596,6 +596,10 @@ class RustMatrixRoom(
innerRoom.matrixToEventPermalink(eventId.value)
}
override suspend fun sendCallNotificationIfNeeded(): Result<Unit> = runCatching {
innerRoom.sendCallNotificationIfNeeded()
}
private fun createTimeline(
timeline: InnerTimeline,
isLive: Boolean,

View File

@@ -86,6 +86,7 @@ class FakeMatrixRoom(
override val liveTimeline: Timeline = FakeTimeline(),
private var roomPermalinkResult: () -> Result<String> = { Result.success("room link") },
private var eventPermalinkResult: (EventId) -> Result<String> = { Result.success("event link") },
var sendCallNotificationIfNeededResult: () -> Result<Unit> = { Result.success(Unit) },
canRedactOwn: Boolean = false,
canRedactOther: Boolean = false,
) : MatrixRoom {
@@ -528,6 +529,10 @@ class FakeMatrixRoom(
theme: String?,
): Result<String> = generateWidgetWebViewUrlResult
override suspend fun sendCallNotificationIfNeeded(): Result<Unit> {
return sendCallNotificationIfNeededResult()
}
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
fun givenRoomMembersState(state: MatrixRoomMembersState) {

View File

@@ -24,6 +24,7 @@ android {
dependencies {
implementation(libs.androidx.corektx)
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushproviders.api)
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api.notifications
import android.graphics.Bitmap
import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader
interface NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap?
/**
* Get icon of a user.
* Before Android P, this does nothing because the icon won't be used
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat?
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,13 +14,12 @@
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
package io.element.android.libraries.push.api.notifications
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
import kotlin.math.abs
class NotificationIdProvider @Inject constructor() {
object NotificationIdProvider {
fun getSummaryNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID
}
@@ -41,16 +40,30 @@ class NotificationIdProvider @Inject constructor() {
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
}
fun getCallNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + ROOM_CALL_NOTIFICATION_ID
}
fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int {
return type.id * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID
}
private fun getOffset(sessionId: SessionId): Int {
// Compute a int from a string with a low risk of collision.
return abs(sessionId.value.hashCode() % 100_000) * 10
}
companion object {
private const val FALLBACK_NOTIFICATION_ID = -1
private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
}
private const val FALLBACK_NOTIFICATION_ID = -1
private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
private const val ROOM_CALL_NOTIFICATION_ID = 3
private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4
}
enum class ForegroundServiceType(val id: Int) {
INCOMING_CALL(1),
ONGOING_CALL(2),
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.api.notifications
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
/**
* Handles missed calls by creating a new notification.
*/
interface OnMissedCallNotificationHandler {
/**
* Adds a missed call notification.
*/
suspend fun addMissedCallNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
)
}

View File

@@ -55,6 +55,7 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.troubleshoot.api)
implementation(projects.features.call.api)
api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api)
api(projects.libraries.push.api)
@@ -76,6 +77,7 @@ dependencies {
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.call.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl)
testImplementation(projects.services.toolbox.test)

View File

@@ -22,6 +22,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import javax.inject.Inject
interface ActiveNotificationsProvider {
@@ -37,7 +38,6 @@ interface ActiveNotificationsProvider {
@ContributesBinding(AppScope::class)
class DefaultActiveNotificationsProvider @Inject constructor(
private val notificationManager: NotificationManagerCompat,
private val notificationIdProvider: NotificationIdProvider,
) : ActiveNotificationsProvider {
override fun getAllNotifications(): List<StatusBarNotification> {
return notificationManager.activeNotifications
@@ -48,22 +48,22 @@ class DefaultActiveNotificationsProvider @Inject constructor(
}
override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId)
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
val summaryId = notificationIdProvider.getSummaryNotificationId(sessionId)
val summaryId = NotificationIdProvider.getSummaryNotificationId(sessionId)
return getNotificationsForSession(sessionId).find { it.id == summaryId }
}

View File

@@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
@@ -51,6 +52,7 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -150,8 +152,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
}
NotificationContent.MessageLike.CallAnswer,
NotificationContent.MessageLike.CallCandidates,
NotificationContent.MessageLike.CallHangup,
is NotificationContent.MessageLike.CallNotify -> { // TODO CallNotify will be handled separately in the future
NotificationContent.MessageLike.CallHangup -> {
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}")
null
}
@@ -172,6 +173,44 @@ class DefaultNotifiableEventResolver @Inject constructor(
senderAvatarPath = senderAvatarUrl,
)
}
is NotificationContent.MessageLike.CallNotify -> {
if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp)) {
NotifiableRingingCallEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
roomName = roomDisplayName,
editedEventId = null,
canBeReplaced = true,
timestamp = this.timestamp,
isRedacted = false,
isUpdated = false,
description = stringProvider.getString(R.string.notification_incoming_call),
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
roomAvatarUrl = roomAvatarUrl,
callNotifyType = content.type,
senderId = content.senderId,
senderAvatarUrl = senderAvatarUrl,
)
} else {
// Create a simple message notification event
buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.senderId,
roomId = roomId,
eventId = eventId,
noisy = true,
timestamp = this.timestamp,
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
body = "☎️ ${stringProvider.getString(R.string.notification_incoming_call)}",
roomName = roomDisplayName,
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
type = EventType.CALL_NOTIFY,
)
}
}
NotificationContent.MessageLike.KeyVerificationAccept,
NotificationContent.MessageLike.KeyVerificationCancel,
NotificationContent.MessageLike.KeyVerificationDone,
@@ -334,7 +373,8 @@ private fun buildNotifiableMessageEvent(
outGoingMessage: Boolean = false,
outGoingMessageFailed: Boolean = false,
isRedacted: Boolean = false,
isUpdated: Boolean = false
isUpdated: Boolean = false,
type: String = EventType.MESSAGE,
) = NotifiableMessageEvent(
sessionId = sessionId,
senderId = senderId,
@@ -356,5 +396,6 @@ private fun buildNotifiableMessageEvent(
outGoingMessage = outGoingMessage,
outGoingMessageFailed = outGoingMessageFailed,
isRedacted = isRedacted,
isUpdated = isUpdated
isUpdated = isUpdated,
type = type,
)

View File

@@ -24,23 +24,27 @@ import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import timber.log.Timber
import javax.inject.Inject
class NotificationBitmapLoader @Inject constructor(
@ContributesBinding(AppScope::class)
class DefaultNotificationBitmapLoader @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider,
) {
) : NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
if (path == null) {
return null
}
@@ -67,7 +71,7 @@ class NotificationBitmapLoader @Inject constructor(
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
if (path == null || sdkIntProvider.get() < Build.VERSION_CODES.P) {
return null
}

View File

@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
@@ -53,7 +54,6 @@ private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.
class DefaultNotificationDrawerManager @Inject constructor(
private val notificationManager: NotificationManagerCompat,
private val notificationRenderer: NotificationRenderer,
private val notificationIdProvider: NotificationIdProvider,
private val appNavigationStateService: AppNavigationStateService,
coroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
@@ -124,7 +124,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
* Clear all known message events for a [sessionId].
*/
override fun clearAllMessagesEvents(sessionId: SessionId) {
notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
notificationManager.cancel(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
@@ -142,7 +142,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
* Can also be called when a notification for this room is dismissed by the user.
*/
override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
notificationManager.cancel(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
@@ -165,7 +165,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
* Clear the notifications for a single event.
*/
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
val id = notificationIdProvider.getRoomEventNotificationId(sessionId)
val id = NotificationIdProvider.getRoomEventNotificationId(sessionId)
notificationManager.cancel(eventId.value, id)
clearSummaryNotificationIfNeeded(sessionId)
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
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.push.api.notifications.OnMissedCallNotificationHandler
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultOnMissedCallNotificationHandler @Inject constructor(
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val notifiableEventResolver: NotifiableEventResolver,
) : OnMissedCallNotificationHandler {
override suspend fun addMissedCallNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
) {
// Resolve the event and add a notification for it, at this point it should no longer be a ringing one
val notifiableEvent = notifiableEventResolver.resolveEvent(sessionId, roomId, eventId)
notifiableEvent?.let { defaultNotificationDrawerManager.onNotifiableEventReceived(it) }
}
}

View File

@@ -49,6 +49,8 @@ interface NotificationDataFactory {
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification>
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification>
fun createSummaryNotification(
@@ -130,6 +132,8 @@ class DefaultNotificationDataFactory @Inject constructor(
}
}
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(

View File

@@ -19,10 +19,12 @@ package io.element.android.libraries.push.impl.notifications
import coil.ImageLoader
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import timber.log.Timber
import javax.inject.Inject
@@ -30,7 +32,6 @@ import javax.inject.Inject
private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.NotificationLoggerTag)
class NotificationRenderer @Inject constructor(
private val notificationIdProvider: NotificationIdProvider,
private val notificationDisplayer: NotificationDisplayer,
private val notificationDataFactory: NotificationDataFactory,
) {
@@ -59,14 +60,14 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId)
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId)
)
}
roomNotifications.forEach { notificationData ->
notificationDisplayer.showNotificationMessage(
tag = notificationData.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
@@ -76,7 +77,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
@@ -87,7 +88,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
@@ -98,7 +99,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Showing fallback notification")
notificationDisplayer.showNotificationMessage(
tag = "FALLBACK",
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = fallbackNotifications.first().notification
)
}
@@ -108,7 +109,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Updating summary notification")
notificationDisplayer.showNotificationMessage(
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId),
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId),
notification = summaryNotification.notification
)
}
@@ -127,6 +128,8 @@ private fun List<NotifiableEvent>.groupByType(): GroupedNotificationEvents {
is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType())
is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType())
is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType())
// Nothing should be done for ringing call events as they're not handled here
is NotifiableRingingCallEvent -> {}
}
}
return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents)

View File

@@ -23,6 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError

View File

@@ -19,10 +19,15 @@ package io.element.android.libraries.push.impl.notifications.channels
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.RingtoneManager
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
@@ -30,15 +35,51 @@ import io.element.android.libraries.push.impl.R
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
// Legacy channel
private const val CALL_NOTIFICATION_CHANNEL_ID_V2 = "CALL_NOTIFICATION_CHANNEL_ID_V2"
internal const val CALL_NOTIFICATION_CHANNEL_ID_V3 = "CALL_NOTIFICATION_CHANNEL_ID_V3"
internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID"
/**
* on devices >= android O, we need to define a channel for each notifications.
*/
interface NotificationChannels {
/**
* Get the channel for incoming call.
* @param ring true if the device should ring when receiving the call.
*/
fun getChannelForIncomingCall(ring: Boolean): String
/**
* Get the channel for messages.
* @param noisy true if the notification should have sound and vibration.
*/
fun getChannelIdForMessage(noisy: Boolean): String
/**
* Get the channel for test notifications.
*/
fun getChannelIdForTest(): String
}
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@SingleIn(AppScope::class)
class NotificationChannels @Inject constructor(
@ContributesBinding(AppScope::class)
class DefaultNotificationChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val stringProvider: StringProvider,
) {
) : NotificationChannels {
init {
createNotificationChannels()
}
@@ -75,6 +116,9 @@ class NotificationChannels @Inject constructor(
}
}
// Migration - Create new call channel
notificationManager.deleteNotificationChannel(CALL_NOTIFICATION_CHANNEL_ID_V2)
/**
* Default notification importance: shows everywhere, makes noise, but does not visually
* intrude.
@@ -123,46 +167,52 @@ class NotificationChannels @Inject constructor(
}
)
// Register a channel for incoming and in progress call notifications with no ringing
notificationManager.createNotificationChannel(
NotificationChannel(
CALL_NOTIFICATION_CHANNEL_ID,
CALL_NOTIFICATION_CHANNEL_ID_V3,
stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" },
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = stringProvider.getString(R.string.notification_channel_call)
setSound(null, null)
enableVibration(true)
enableLights(true)
lightColor = accentColor
}
)
// Register a channel for incoming call notifications which will ring the device when received
val ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder(
RINGING_CALL_NOTIFICATION_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_MAX,
)
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" })
.setVibrationEnabled(true)
.setSound(
ringtoneUri,
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_RING)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
)
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls))
.setLightsEnabled(true)
.setLightColor(accentColor)
.build()
)
}
private fun getChannel(channelId: String): NotificationChannel? {
return notificationManager.getNotificationChannel(channelId)
override fun getChannelForIncomingCall(ring: Boolean): String {
return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID_V3
}
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return getChannel(notificationChannel)
}
fun getChannelIdForMessage(noisy: Boolean): String {
override fun getChannelIdForMessage(noisy: Boolean): String {
return if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
}
fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
companion object {
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
private const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2"
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
}

View File

@@ -35,9 +35,10 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
@@ -129,12 +130,16 @@ class DefaultNotificationCreator @Inject constructor(
val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing)
val containsMissedCall = events.any { it.type == EventType.CALL_NOTIFY }
val channelId = if (containsMissedCall) {
notificationChannels.getChannelForIncomingCall(false)
} else {
notificationChannels.getChannelIdForMessage(noisy = roomInfo.shouldBing)
}
val builder = if (existingNotification != null) {
NotificationCompat.Builder(context, existingNotification)
} else {
NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(roomInfo.isUpdated)
// A category allows groups of notifications to be ranked and filtered per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
@@ -210,6 +215,11 @@ class DefaultNotificationCreator @Inject constructor(
setLargeIcon(largeIcon)
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
// If any of the events are of call notify type it means a missed call, set the category to the right value
if (events.any { it.type == EventType.CALL_NOTIFY }) {
setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
}
}
.setTicker(tickerText)
.build()
@@ -343,7 +353,6 @@ class DefaultNotificationCreator @Inject constructor(
.setWhen(lastMessageTimestamp)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
// set content text to support devices running API level < 24
.setGroup(currentUser.userId.value)
// set this notification as the summary for the group
.setGroupSummary(true)

View File

@@ -51,9 +51,9 @@ data class NotifiableMessageEvent(
val outGoingMessage: Boolean = false,
val outGoingMessageFailed: Boolean = false,
override val isRedacted: Boolean = false,
override val isUpdated: Boolean = false
) : NotifiableEvent {
override val isUpdated: Boolean = false,
val type: String = EventType.MESSAGE
) : NotifiableEvent {
override val description: String = body ?: ""
// Example of value:
@@ -69,9 +69,16 @@ fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationSta
val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
null -> false
else -> appNavigationState.isInForeground &&
sessionId == currentSessionId &&
roomId == currentRoomId &&
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
else -> {
// Never ignore ringing call notifications
if (this is NotifiableRingingCallEvent) {
false
} else {
appNavigationState.isInForeground &&
sessionId == currentSessionId &&
roomId == currentRoomId &&
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
}
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.model
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.api.notification.CallNotifyType
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
data class NotifiableRingingCallEvent(
override val sessionId: SessionId,
override val roomId: RoomId,
override val eventId: EventId,
override val editedEventId: EventId?,
override val description: String?,
override val canBeReplaced: Boolean,
override val isRedacted: Boolean,
override val isUpdated: Boolean,
val roomName: String?,
val senderId: UserId,
val senderDisambiguatedDisplayName: String?,
val senderAvatarUrl: String?,
val roomAvatarUrl: String? = null,
val callNotifyType: CallNotifyType,
val timestamp: Long,
) : NotifiableEvent {
companion object {
fun shouldRing(callNotifyType: CallNotifyType, timestamp: Long): Boolean {
val timeout = 10.seconds.inWholeMilliseconds
val elapsed = Instant.now().toEpochMilli() - timestamp
// Only ring if the type is RING and the elapsed time is less than the timeout
return callNotifyType == CallNotifyType.RING && elapsed < timeout
}
}
}

View File

@@ -17,11 +17,15 @@
package io.element.android.libraries.push.impl.push
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.test.DefaultTestPush
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
import io.element.android.libraries.pushproviders.api.PushData
@@ -44,6 +48,8 @@ class DefaultPushHandler @Inject constructor(
private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val diagnosticPushHandler: DiagnosticPushHandler,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
) : PushHandler {
/**
* Called when message is received.
@@ -91,19 +97,33 @@ class DefaultPushHandler @Inject constructor(
return
}
val userPushStore = userPushStoreFactory.getOrCreate(userId)
if (userPushStore.getNotificationEnabledForDevice().first()) {
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
if (areNotificationsEnabled) {
val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
if (notifiableEvent == null) {
Timber.w("Unable to get a notification data")
return
when (notifiableEvent) {
null -> Timber.tag(loggerTag.value).w("Unable to get a notification data")
is NotifiableRingingCallEvent -> handleRingingCallEvent(notifiableEvent)
else -> onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
}
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
} else {
// TODO We need to check if this is an incoming call
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
}
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
}
}
private fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
eventId = notifiableEvent.eventId,
senderId = notifiableEvent.senderId,
roomName = notifiableEvent.roomName,
senderName = notifiableEvent.senderDisambiguatedDisplayName,
avatarUrl = notifiableEvent.roomAvatarUrl,
timestamp = notifiableEvent.timestamp,
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
)
}
}

View File

@@ -3,6 +3,7 @@
<string name="notification_channel_call">"Call"</string>
<string name="notification_channel_listening_for_events">"Listening for events"</string>
<string name="notification_channel_noisy">"Noisy notifications"</string>
<string name="notification_channel_ringing_calls">"Ringing calls"</string>
<string name="notification_channel_silent">"Silent notifications"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d message"</item>
@@ -13,6 +14,7 @@
<item quantity="other">"%d notifications"</item>
</plurals>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_incoming_call">"Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string>
<string name="notification_invitation_action_reject">"Reject"</string>

View File

@@ -25,6 +25,7 @@ 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.A_SESSION_ID_2
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
@@ -33,6 +34,8 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultActiveNotificationsProviderTest {
private val notificationIdProvider = NotificationIdProvider
@Test
fun `getAllNotifications with no active notifications returns empty list`() {
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = emptyList())
@@ -43,7 +46,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getAllNotifications with active notifications returns all`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
@@ -57,7 +59,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getNotificationsForSession returns only notifications for that session id`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
@@ -71,7 +72,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getMembershipNotificationsForSession returns only membership notifications for that session id`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value,),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
@@ -89,7 +89,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getMessageNotificationsForRoom returns only message notifications for those session and room ids`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
@@ -117,7 +116,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getMembershipNotificationsForRoom returns only membership notifications for those session and room ids`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
@@ -145,7 +143,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getSummaryNotification returns only the summary notification for that session id if it exists`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
@@ -172,7 +169,6 @@ class DefaultActiveNotificationsProviderTest {
}
return DefaultActiveNotificationsProvider(
notificationManager = notificationManager,
notificationIdProvider = NotificationIdProvider(),
)
}
}

View File

@@ -18,12 +18,15 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@@ -48,7 +51,9 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeNotificatio
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest
@@ -58,6 +63,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class DefaultNotifiableEventResolverTest {
@Test
@@ -479,6 +485,109 @@ class DefaultNotifiableEventResolverTest {
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - ringing`() = runTest {
val timestamp = DefaultSystemClock().epochMillis()
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
createNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.RING
),
timestamp = timestamp,
)
)
)
val expectedResult = NotifiableRingingCallEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = null,
editedEventId = null,
description = "Incoming call",
timestamp = timestamp,
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = "Bob",
senderAvatarUrl = null,
callNotifyType = CallNotifyType.RING,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - ring but timed out displays the same as notify`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
createNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.RING
),
timestamp = 0L,
)
)
)
val expectedResult = NotifiableMessageEvent(
sessionId = A_SESSION_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
noisy = true,
timestamp = 0L,
senderDisambiguatedDisplayName = "Bob",
senderId = UserId("@bob:server.org"),
body = "\uFE0F Incoming call",
roomId = A_ROOM_ID,
threadId = null,
roomName = null,
roomIsDirect = false,
canBeReplaced = false,
isRedacted = false,
imageUriString = null,
type = EventType.CALL_NOTIFY,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - notify`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
createNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.NOTIFY
)
)
)
)
val expectedResult = NotifiableMessageEvent(
sessionId = A_SESSION_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
noisy = true,
timestamp = A_TIMESTAMP,
senderDisambiguatedDisplayName = "Bob",
senderId = UserId("@bob:server.org"),
body = "\uFE0F Incoming call",
roomId = A_ROOM_ID,
threadId = null,
roomName = null,
roomIsDirect = false,
canBeReplaced = false,
isRedacted = false,
imageUriString = null,
type = EventType.CALL_NOTIFY,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve null cases`() {
testNull(NotificationContent.MessageLike.CallAnswer)
@@ -558,6 +667,7 @@ class DefaultNotifiableEventResolverTest {
content: NotificationContent,
isDirect: Boolean = false,
hasMention: Boolean = false,
timestamp: Long = A_TIMESTAMP,
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
@@ -570,7 +680,7 @@ class DefaultNotifiableEventResolverTest {
isDirect = isDirect,
isEncrypted = false,
isNoisy = false,
timestamp = A_TIMESTAMP,
timestamp = timestamp,
content = content,
hasMention = hasMention,
)

View File

@@ -28,12 +28,13 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
@@ -164,7 +165,7 @@ class DefaultNotificationDrawerManagerTest {
val notificationManager = mockk<NotificationManagerCompat> {
every { cancel(any(), any()) } returns Unit
}
val summaryId = NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)
val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)
val activeNotificationsProvider = FakeActiveNotificationsProvider(
mutableListOf(
mockk {
@@ -198,7 +199,6 @@ class DefaultNotificationDrawerManagerTest {
return DefaultNotificationDrawerManager(
notificationManager = notificationManager,
notificationRenderer = NotificationRenderer(
notificationIdProvider = NotificationIdProvider(),
notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)),
notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = FakeNotificationCreator(),
@@ -208,7 +208,6 @@ class DefaultNotificationDrawerManagerTest {
stringProvider = FakeStringProvider(),
),
),
notificationIdProvider = NotificationIdProvider(),
appNavigationStateService = appNavigationStateService,
coroutineScope = this,
matrixClientProvider = matrixClientProvider,

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
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.FakeMatrixClientProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultOnMissedCallNotificationHandlerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `addMissedCallNotification - should add missed call notification`() = runTest {
val childScope = CoroutineScope(coroutineContext + SupervisorJob())
val dataFactory = FakeNotificationDataFactory(
messageEventToNotificationsResult = lambdaRecorder { _, _, _ -> emptyList() }
)
val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler(
defaultNotificationDrawerManager = DefaultNotificationDrawerManager(
notificationManager = mockk(relaxed = true),
notificationRenderer = NotificationRenderer(
notificationDisplayer = FakeNotificationDisplayer(),
notificationDataFactory = dataFactory,
),
appNavigationStateService = FakeAppNavigationStateService(),
coroutineScope = childScope,
matrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = FakeActiveNotificationsProvider(),
),
notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult = { _, _, _ -> aNotifiableMessageEvent() }),
)
defaultOnMissedCallNotificationHandler.addMissedCallNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
runCurrent()
dataFactory.messageEventToNotificationsResult.assertions().isCalledOnce()
// Cancel the coroutine scope so the test can finish
childScope.cancel()
}
}

View File

@@ -25,8 +25,8 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@@ -212,7 +212,7 @@ fun createRoomGroupMessageCreator(
sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O),
): RoomGroupMessageCreator {
val context = RuntimeEnvironment.getApplication() as Context
val bitmapLoader = NotificationBitmapLoader(
val bitmapLoader = DefaultNotificationBitmapLoader(
context = RuntimeEnvironment.getApplication(),
sdkIntProvider = sdkIntProvider,
)

View File

@@ -23,13 +23,13 @@ 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.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -37,6 +37,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
private val MY_AVATAR_URL: String? = null
private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID)
private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID)
private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)

Some files were not shown because too many files have changed in this diff Show More