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>