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 4867354fd4
commit 30a1367714
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
186 changed files with 2686 additions and 330 deletions

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

@ -1,56 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
import kotlin.math.abs
class NotificationIdProvider @Inject constructor() {
fun getSummaryNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID
}
fun getRoomMessagesNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + ROOM_MESSAGES_NOTIFICATION_ID
}
fun getRoomEventNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + ROOM_EVENT_NOTIFICATION_ID
}
fun getRoomInvitationNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
}
fun getFallbackNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + FALLBACK_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
}
}

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)

View file

@ -19,12 +19,13 @@ package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
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 org.junit.Test
class NotificationIdProviderTest {
@Test
fun `test notification id provider`() {
val sut = NotificationIdProvider()
val sut = NotificationIdProvider
val offsetForASessionId = 305_410
assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 0)
assertThat(sut.getRoomMessagesNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 1)

View file

@ -20,8 +20,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
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.api.notifications.NotificationIdProvider
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.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
@ -31,6 +31,7 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
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.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -61,10 +62,9 @@ class NotificationRendererTest {
activeNotificationsProvider = FakeActiveNotificationsProvider(),
stringProvider = FakeStringProvider(),
)
private val notificationIdProvider = NotificationIdProvider()
private val notificationIdProvider = NotificationIdProvider
private val notificationRenderer = NotificationRenderer(
notificationIdProvider = notificationIdProvider,
notificationDisplayer = notificationDisplayer,
notificationDataFactory = notificationDataFactory,
)

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.impl.notifications.channels
class FakeNotificationChannels(
var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" },
var channelIdForMessage: (noisy: Boolean) -> String = { _ -> "" },
var channelIdForTest: () -> String = { "" }
) : NotificationChannels {
override fun getChannelForIncomingCall(ring: Boolean): String {
return channelForIncomingCall(ring)
}
override fun getChannelIdForMessage(noisy: Boolean): String {
return channelIdForMessage(noisy)
}
override fun getChannelIdForTest(): String {
return channelIdForTest()
}
}

View file

@ -0,0 +1,83 @@
/*
* 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.channels
import android.app.NotificationChannel
import android.os.Build
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class NotificationChannelsTest {
@Test
@Config(sdk = [Build.VERSION_CODES.O])
fun `init - creates notification channels and migrates old ones`() {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
createNotificationChannels(notificationManager = notificationManager)
verify { notificationManager.createNotificationChannel(any<NotificationChannelCompat>()) }
verify { notificationManager.createNotificationChannel(any<NotificationChannel>()) }
verify { notificationManager.deleteNotificationChannel(any<String>()) }
}
@Test
fun `getChannelForIncomingCall - returns the right channel`() {
val notificationChannels = createNotificationChannels()
val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true)
assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID)
val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false)
assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID_V3)
}
@Test
fun `getChannelIdForMessage - returns the right channel`() {
val notificationChannels = createNotificationChannels()
assertThat(notificationChannels.getChannelIdForMessage(noisy = true)).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID)
assertThat(notificationChannels.getChannelIdForMessage(noisy = false)).isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID)
}
@Test
fun `getChannelIdForTest - returns the right channel`() {
val notificationChannels = createNotificationChannels()
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID)
}
private fun createNotificationChannels(
notificationManager: NotificationManagerCompat = mockk(relaxed = true),
) = DefaultNotificationChannels(
context = InstrumentationRegistry.getInstrumentation().targetContext,
notificationManager = notificationManager,
stringProvider = FakeStringProvider(),
)
}

View file

@ -29,18 +29,20 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
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.DefaultNotificationChannels
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
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.SimpleNotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
@ -284,7 +286,7 @@ fun createNotificationCreator(
context: Context = RuntimeEnvironment.getApplication(),
buildMeta: BuildMeta = aBuildMeta(),
notificationChannels: NotificationChannels = createNotificationChannels(),
bitmapLoader: NotificationBitmapLoader = NotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)),
bitmapLoader: NotificationBitmapLoader = DefaultNotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)),
): NotificationCreator {
return DefaultNotificationCreator(
context = context,
@ -327,5 +329,5 @@ fun createNotificationCreator(
fun createNotificationChannels(): NotificationChannels {
val context = RuntimeEnvironment.getApplication()
return NotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider(""))
return DefaultNotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider(""))
}

View file

@ -1,55 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.fake
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.test.FakeImageLoaderEngine
import org.robolectric.RuntimeEnvironment
@OptIn(ExperimentalCoilApi::class)
class FakeImageLoader {
private val coilRequests = mutableListOf<Any>()
private var cache: ImageLoader? = null
fun getImageLoader(): ImageLoader {
return cache ?: ImageLoader.Builder(RuntimeEnvironment.getApplication())
.components {
val engine = FakeImageLoaderEngine.Builder()
.intercept(
predicate = {
coilRequests.add(it)
true
},
drawable = ColorDrawable(Color.BLUE)
)
.build()
add(engine)
}
.build()
.also {
cache = it
}
}
fun getCoilRequests(): List<Any> {
return coilRequests.toList()
}
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.fake
import coil.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
class FakeImageLoaderHolder : ImageLoaderHolder {
private val fakeImageLoader = FakeImageLoader()
override fun get(client: MatrixClient): ImageLoader {
return fakeImageLoader.getImageLoader()
}
}

View file

@ -46,7 +46,7 @@ class FakeNotificationDataFactory(
var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var simpleEventToNotificationsResult: LambdaOneParamRecorder<List<SimpleNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, List<OneShotNotification>> =
lambdaRecorder { _ -> emptyList() },
lambdaRecorder { _ -> emptyList() },
) : NotificationDataFactory {
override suspend fun toNotifications(messages: List<NotifiableMessageEvent>, currentUser: MatrixUser, imageLoader: ImageLoader): List<RoomNotification> {
return messageEventToNotificationsResult(messages, currentUser, imageLoader)
@ -64,6 +64,8 @@ class FakeNotificationDataFactory(
return simpleEventToNotificationsResult(simpleEvents)
}
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallbackEventToNotificationsResult(fallback)
}

View file

@ -18,8 +18,8 @@ package io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
import io.element.android.libraries.push.impl.notifications.NotificationIdProvider
import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
@ -51,7 +51,7 @@ class FakeNotificationDisplayer(
fun verifySummaryCancelled(times: Int = 1) {
cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence(
listOf(value(null), value(NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)))
listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)))
)
}
}

View file

@ -21,11 +21,16 @@ 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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
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_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
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.libraries.push.impl.notifications.model.SimpleNotifiableEvent
fun aSimpleNotifiableEvent(
@ -79,6 +84,7 @@ fun aNotifiableMessageEvent(
threadId: ThreadId? = null,
isRedacted: Boolean = false,
timestamp: Long = 0,
type: String = EventType.MESSAGE,
) = NotifiableMessageEvent(
sessionId = sessionId,
eventId = eventId,
@ -94,5 +100,34 @@ fun aNotifiableMessageEvent(
roomIsDirect = false,
canBeReplaced = false,
isRedacted = isRedacted,
imageUriString = null
imageUriString = null,
type = type,
)
fun anNotifiableCallEvent(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID_2,
senderName: String? = null,
roomAvatarUrl: String? = AN_AVATAR_URL,
senderAvatarUrl: String? = AN_AVATAR_URL,
callNotifyType: CallNotifyType = CallNotifyType.NOTIFY,
timestamp: Long = 0L,
) = NotifiableRingingCallEvent(
sessionId = sessionId,
eventId = eventId,
roomId = roomId,
roomName = "a room name",
editedEventId = null,
description = "description",
timestamp = timestamp,
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = senderName,
senderId = senderId,
roomAvatarUrl = roomAvatarUrl,
senderAvatarUrl = senderAvatarUrl,
callNotifyType = callNotifyType,
)

View file

@ -19,11 +19,16 @@
package io.element.android.libraries.push.impl.push
import app.cash.turbine.test
import io.element.android.features.call.api.CallType
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
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 io.element.android.libraries.matrix.api.timeline.item.event.EventType
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_SECRET
@ -31,7 +36,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.test.DefaultTestPush
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
@ -47,6 +54,7 @@ import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
class DefaultPushHandlerTest {
@Test
@ -220,6 +228,55 @@ class DefaultPushHandlerTest {
.isNeverCalled()
}
@Test
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val handleIncomingCallLambda = lambdaRecorder<CallType.RoomCall, EventId, UserId, String?, String?, String?, String, Unit> { _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
notifiableEventResult = { _, _, _ -> anNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()) },
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
)
defaultPushHandler.handle(aPushData)
handleIncomingCallLambda.assertions().isCalledOnce()
}
@Test
fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest {
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val handleIncomingCallLambda = lambdaRecorder<CallType.RoomCall, EventId, UserId, String?, String?, String?, String, Unit> { _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = { _, _, _ -> aNotifiableMessageEvent(type = EventType.CALL_NOTIFY) },
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
)
defaultPushHandler.handle(aPushData)
handleIncomingCallLambda.assertions().isNeverCalled()
onNotifiableEventReceived.assertions().isCalledOnce()
}
@Test
fun `when diagnostic PushData is received, the diagnostic push handler is informed `() =
runTest {
@ -249,6 +306,8 @@ class DefaultPushHandlerTest {
buildMeta: BuildMeta = aBuildMeta(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
): DefaultPushHandler {
return DefaultPushHandler(
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived),
@ -263,6 +322,8 @@ class DefaultPushHandlerTest {
buildMeta = buildMeta,
matrixAuthenticationService = matrixAuthenticationService,
diagnosticPushHandler = diagnosticPushHandler,
elementCallEntryPoint = elementCallEntryPoint,
notificationChannels = notificationChannels,
)
}
}