diff --git a/library/ui-strings/src/main/res/values-ru/strings_sc.xml b/library/ui-strings/src/main/res/values-ru/strings_sc.xml index d14411c18f5..4e510848b74 100644 --- a/library/ui-strings/src/main/res/values-ru/strings_sc.xml +++ b/library/ui-strings/src/main/res/values-ru/strings_sc.xml @@ -180,4 +180,4 @@ Очистить выделение при прокрутке Разрешить резервный сервер звонков ⚠️ Эта настройка по умолчанию (если не изменена конфигурацией Вашего домашнего сервера) включает доступ к \"Scalar\", менеджеру интеграций от Element. К сожалению, он является проприетарным, т.е. его исходый код не открытый и не может быть проверен пользователями или разработчиками SchildiChat. - \ No newline at end of file + diff --git a/library/ui-strings/src/main/res/values/strings_sc.xml b/library/ui-strings/src/main/res/values/strings_sc.xml index 148d267d8cf..9adfff72804 100644 --- a/library/ui-strings/src/main/res/values/strings_sc.xml +++ b/library/ui-strings/src/main/res/values/strings_sc.xml @@ -239,6 +239,8 @@ Will use %s as assist when your homeserver does not offer one (your IP address will be seen by the stun server during a call ) + Ring for group calls + Open App ⚠️ This setting by default (unless overridden by your homeserver\'s configuration) enables access to \"scalar\", Element\'s integration manager which is unfortunately proprietary, i.e. its source code is not open and can not be checked by the public or the SchildiChat developers. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 9a96b4b7e81..af86500eac4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -23,7 +23,9 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult +import org.matrix.android.sdk.api.session.events.model.EventType.IS_JITSI_CALL import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent +import org.matrix.android.sdk.api.session.room.model.JitsiEventContent import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent @@ -498,6 +500,9 @@ fun Event.getPollContent(): MessagePollContent? { return getClearContent().toModel() } +fun Event.isJitsiEvent() = content?.toModel()?.type?.lowercase() == IS_JITSI_CALL && + content.toModel()?.name?.lowercase() == IS_JITSI_CALL + fun Event.supportsNotification() = this.getClearType() in EventType.MESSAGE + EventType.POLL_START.values + diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 811831929c6..03ff1d10fb9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -111,6 +111,9 @@ object EventType { // Relation Events const val REACTION = "m.reaction" + // Jitsi call + const val IS_JITSI_CALL = "jitsi" + // Poll val POLL_START = StableUnstableId(stable = "m.poll.start", unstable = "org.matrix.msc3381.poll.start") val POLL_RESPONSE = StableUnstableId(stable = "m.poll.response", unstable = "org.matrix.msc3381.poll.response") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/JitsiEventContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/JitsiEventContent.kt new file mode 100644 index 00000000000..956c343ac96 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/JitsiEventContent.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.room.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Class representing the Jitsi call state event. + */ +@JsonClass(generateAdapter = true) +data class JitsiEventContent( + @Json(name = "type") val type: String? = null, + @Json(name = "name") val name: String? = null, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt index 434512e2d3d..e5917129e91 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt @@ -65,6 +65,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor( EventType.MESSAGE, EventType.REDACTION, EventType.ENCRYPTED, + EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_MEMBER -> true else -> false } diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt index 09b2babfe73..a4c7309e5cf 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt @@ -70,6 +70,9 @@ class DebugVectorFeatures( override fun isVoiceBroadcastEnabled(): Boolean = read(DebugFeatureKeys.voiceBroadcastEnabled) ?: vectorFeatures.isVoiceBroadcastEnabled() + override fun isJitsiCallNotificationEnabled(): Boolean = read(DebugFeatureKeys.jitsiCallNotificationsEnabled) + ?: vectorFeatures.isJitsiCallNotificationEnabled() + override fun isUnverifiedSessionsAlertEnabled(): Boolean = read(DebugFeatureKeys.unverifiedSessionsAlertEnabled) ?: vectorFeatures.isUnverifiedSessionsAlertEnabled() @@ -134,4 +137,5 @@ object DebugFeatureKeys { val newAppLayoutEnabled = booleanPreferencesKey("new-app-layout-enabled") val voiceBroadcastEnabled = booleanPreferencesKey("voice-broadcast-enabled") val unverifiedSessionsAlertEnabled = booleanPreferencesKey("unverified-sessions-alert-enabled") + val jitsiCallNotificationsEnabled = booleanPreferencesKey("jitsi-call-notifications-enabled") } diff --git a/vector-config/src/main/res/values/config-settings_sc.xml b/vector-config/src/main/res/values/config-settings_sc.xml new file mode 100644 index 00000000000..b0bf97489cb --- /dev/null +++ b/vector-config/src/main/res/values/config-settings_sc.xml @@ -0,0 +1,4 @@ + + + false + diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonEntryPoint.kt b/vector/src/main/java/im/vector/app/core/di/SingletonEntryPoint.kt index 1209545338a..ee7fa54254a 100644 --- a/vector/src/main/java/im/vector/app/core/di/SingletonEntryPoint.kt +++ b/vector/src/main/java/im/vector/app/core/di/SingletonEntryPoint.kt @@ -16,6 +16,7 @@ import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.navigation.Navigator +import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.pin.PinLocker import im.vector.app.features.rageshake.BugReporter import im.vector.app.features.session.SessionListener @@ -32,6 +33,8 @@ interface SingletonEntryPoint { fun avatarRenderer(): AvatarRenderer + fun notificationUtils(): NotificationUtils + fun activeSessionHolder(): ActiveSessionHolder fun unrecognizedCertificateDialog(): UnrecognizedCertificateDialog diff --git a/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt b/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt index d51bf70fe27..7aeee6de8c2 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt @@ -11,6 +11,7 @@ package im.vector.app.core.services import android.content.Context import android.content.Intent import android.os.Binder +import android.os.PowerManager import android.support.v4.media.session.MediaSessionCompat import android.view.KeyEvent import androidx.core.app.NotificationManagerCompat @@ -75,6 +76,8 @@ class CallAndroidService : VectorAndroidService() { } } + private var wakeLock: PowerManager.WakeLock? = null + override fun onCreate() { super.onCreate() notificationManager = NotificationManagerCompat.from(this) @@ -87,6 +90,9 @@ class CallAndroidService : VectorAndroidService() { callRingPlayerIncoming?.stop() callRingPlayerOutgoing?.stop() mediaSession?.release() + if (wakeLock?.isHeld == true) { + wakeLock?.release() + } mediaSession = null } @@ -142,6 +148,11 @@ class CallAndroidService : VectorAndroidService() { */ private fun displayIncomingCallNotification(intent: Intent) { Timber.tag(loggerTag.value).v("displayIncomingCallNotification $intent") + + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SchildiChat-android:CallWakeLock") + wakeLock!!.acquire(60 * 1000L) + val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" val call = callManager.getCallById(callId) ?: return Unit.also { handleUnexpectedState(callId) @@ -179,6 +190,7 @@ class CallAndroidService : VectorAndroidService() { notificationManager.notify(callId.hashCode(), notification) } knownCalls[callId] = callInformation + wakeLock?.release() } private fun handleCallTerminated(intent: Intent) { diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt index 1eb9c9a0e1d..5bcec5699cc 100644 --- a/vector/src/main/java/im/vector/app/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt @@ -33,6 +33,7 @@ import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.threads.ThreadsActivity import im.vector.app.features.location.live.map.LiveLocationMapViewActivity import im.vector.app.features.notifications.NotificationDrawerManager +import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.pin.UnlockedActivity import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository import im.vector.app.features.pin.lockscreen.pincode.PinCodeHelper @@ -79,7 +80,9 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity private const val EXTRA_NEXT_INTENT = "EXTRA_NEXT_INTENT" private const val EXTRA_INIT_SESSION = "EXTRA_INIT_SESSION" private const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" + private const val EXTRA_CALL_ID = "EXTRA_CALL_ID" private const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT" + private const val ACTION_ROOM_DETAILS_JITSI_CALL = "ROOM_DETAILS_JITSI_CALL" // Special action to clear cache and/or clear credentials fun restartApp(activity: Activity, args: MainActivityArgs) { @@ -110,6 +113,20 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity } } + fun getCallIntent( + context: Context, + roomId: String, + callId: String, + ): Intent { + return Intent(context, MainActivity::class.java).apply { + action = ACTION_ROOM_DETAILS_JITSI_CALL + flags = Intent.FLAG_ACTIVITY_NEW_TASK + + putExtra(EXTRA_ROOM_ID, roomId) + putExtra(EXTRA_CALL_ID, callId) + } + } + val allowList = listOf( HomeActivity::class.java.name, MainActivity::class.java.name, @@ -128,6 +145,7 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity private lateinit var args: MainActivityArgs @Inject lateinit var notificationDrawerManager: NotificationDrawerManager + @Inject lateinit var notificationUtils: NotificationUtils @Inject lateinit var uiStateRepository: UiStateRepository @Inject lateinit var shortcutsHandler: ShortcutsHandler @Inject lateinit var pinCodeHelper: PinCodeHelper @@ -195,6 +213,17 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity setResult(RESULT_OK) finish() } else if (intent.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { + startSyncing() + val roomId = intent.getStringExtra(EXTRA_ROOM_ID) + if (roomId?.isNotEmpty() == true) { + navigator.openRoom(this, roomId, trigger = ViewRoom.Trigger.Shortcut) + } + finish() + } else if (intent.action == ACTION_ROOM_DETAILS_JITSI_CALL) { + val callId = intent.getStringExtra(EXTRA_CALL_ID).orEmpty() + + notificationUtils.cancelNotificationMessage(callId, NotificationDrawerManager.JITSI_CALL_NOTIFICATION_ID) + startSyncing() val roomId = intent.getStringExtra(EXTRA_ROOM_ID) if (roomId?.isNotEmpty() == true) { diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index 6fdd5283e99..df9109e12e8 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -32,6 +32,7 @@ interface VectorFeatures { */ fun isNewAppLayoutFeatureEnabled(): Boolean fun isVoiceBroadcastEnabled(): Boolean + fun isJitsiCallNotificationEnabled(): Boolean fun isUnverifiedSessionsAlertEnabled(): Boolean } @@ -49,5 +50,6 @@ class DefaultVectorFeatures : VectorFeatures { override fun forceUsageOfOpusEncoder(): Boolean = false override fun isNewAppLayoutFeatureEnabled(): Boolean = true override fun isVoiceBroadcastEnabled(): Boolean = true + override fun isJitsiCallNotificationEnabled(): Boolean = true override fun isUnverifiedSessionsAlertEnabled(): Boolean = true } diff --git a/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt b/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt index 65175f9119f..5af09340340 100644 --- a/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt +++ b/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt @@ -11,7 +11,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import im.vector.app.core.extensions.singletonEntryPoint +import im.vector.app.core.services.CallAndroidService import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.notifications.NotificationDrawerManager import timber.log.Timber class CallHeadsUpActionReceiver : BroadcastReceiver() { @@ -24,9 +26,11 @@ class CallHeadsUpActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { val webRtcCallManager = context.singletonEntryPoint().webRtcCallManager() + val notificationUtils = context.singletonEntryPoint().notificationUtils() when (intent?.getIntExtra(EXTRA_CALL_ACTION_KEY, 0)) { CALL_ACTION_REJECT -> { val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return + notificationUtils.cancelNotificationMessage(callId, NotificationDrawerManager.JITSI_CALL_NOTIFICATION_ID) onCallRejectClicked(webRtcCallManager, callId) } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/JitsiNotificationsUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/JitsiNotificationsUtils.kt new file mode 100644 index 00000000000..7d1d0582cc8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/JitsiNotificationsUtils.kt @@ -0,0 +1,121 @@ +/* + * 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 im.vector.app.features.notifications + +import android.app.Notification +import android.app.PendingIntent +import android.app.TaskStackBuilder +import android.content.Context +import android.media.RingtoneManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat +import im.vector.app.R +import im.vector.app.core.platform.PendingIntentCompat +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.MainActivity +import im.vector.app.features.home.HomeActivity +import im.vector.app.features.notifications.NotificationUtils.Companion.CALL_NOTIFICATION_CHANNEL_ID +import im.vector.app.features.notifications.NotificationUtils.Companion.SILENT_NOTIFICATION_CHANNEL_ID +import im.vector.app.features.themes.ThemeUtils +import im.vector.lib.core.utils.timer.Clock +import im.vector.lib.strings.CommonStrings +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JitsiNotificationsUtils @Inject constructor( + private val context: Context, + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils, + private val clock: Clock, + ) { + /** + * Build an incoming jitsi call notification. + * This notification starts the VectorHomeActivity which is in charge of centralizing the incoming call flow. + * + * @param callId id of the jitsi call + * @param signalingRoomId id of the room + * @param title title of the notification + * @param fromBg true if the app is in background when posting the notification + * @return the call notification. + */ + fun buildIncomingJitsiCallNotification( + callId: String, + signalingRoomId: String, + title: String, + fromBg: Boolean, + ): Notification { + val accentColor = ContextCompat.getColor(context, im.vector.lib.ui.styles.R.color.notification_accent_color) + val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + val builder = NotificationCompat.Builder(context, notificationChannel) + .setContentTitle(notificationUtils.ensureTitleNotEmpty(title)) + .apply { + setContentText(stringProvider.getString(CommonStrings.incoming_video_call)) + setSmallIcon(R.drawable.ic_call_answer_video) + } + .setCategory(NotificationCompat.CATEGORY_CALL) + .setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary)) + .setLights(accentColor, 500, 500) + .setOngoing(true) + .setDefaults(NotificationCompat.DEFAULT_SOUND) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + + val contentIntent = MainActivity.getCallIntent( + context = context, + roomId = signalingRoomId, + callId = callId, + ) + + val contentPendingIntent = PendingIntent.getActivity( + context, + clock.epochMillis().toInt(), + contentIntent, + PendingIntentCompat.FLAG_IMMUTABLE + ) + + val answerCallPendingIntent = TaskStackBuilder.create(context) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) + .addNextIntent(contentIntent) + .getPendingIntent(clock.epochMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE) + + val rejectCallPendingIntent = notificationUtils.buildRejectCallPendingIntent(callId) + + builder.addAction( + NotificationCompat.Action( + IconCompat.createWithResource(context, R.drawable.ic_call_hangup) + .setTint(ThemeUtils.getColor(context, android.R.attr.colorError)), + notificationUtils.getActionText(CommonStrings.call_notification_reject, android.R.attr.colorError), + rejectCallPendingIntent + ) + ) + + builder.addAction( + NotificationCompat.Action( + R.drawable.ic_call_answer, + notificationUtils.getActionText(CommonStrings.call_notification_open_app_action, android.R.attr.colorPrimary), + answerCallPendingIntent + ) + ) + if (fromBg) { + // Compat: Display the incoming call notification on the lock screen + builder.priority = NotificationCompat.PRIORITY_HIGH + builder.setFullScreenIntent(contentPendingIntent, true) + } + return builder.build() + } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt index 23071f39878..88ddf7f679c 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt @@ -32,17 +32,29 @@ class NotifiableEventProcessor @Inject constructor( .also { Timber.d("notification message removed due to being read") } else -> KEEP } + is NotifiableJitsiEvent -> { + if (it.isReceived != true) { + KEEP + } else { + REMOVE + } + } is SimpleNotifiableEvent -> when (it.type) { EventType.REDACTION -> REMOVE else -> KEEP } } - ProcessedEvent(type, it) + + val updatedEvent = if (it is NotifiableJitsiEvent) it.updateReceivedStatus() else it + ProcessedEvent(type, updatedEvent) } val removedEventsDiff = renderedEvents.filter { renderedEvent -> queuedEvents.none { it.eventId == renderedEvent.event.eventId } - }.map { ProcessedEvent(REMOVE, it.event) } + }.map { + val updatedEvent = if (it.event is NotifiableJitsiEvent) it.event.updateReceivedStatus() else it.event + ProcessedEvent(REMOVE, updatedEvent) + } return removedEventsDiff + processedEvents } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt index 871a3a5d02a..5caa80f7cf0 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import org.matrix.android.sdk.api.session.events.model.isEdition import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isJitsiEvent import org.matrix.android.sdk.api.session.events.model.supportsNotification import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom @@ -73,6 +74,9 @@ class NotifiableEventResolver @Inject constructor( event.supportsNotification() || event.type == EventType.ENCRYPTED -> { resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) } + event.isJitsiEvent() -> { + resolveJitsiEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) + } else -> { // If the event can be displayed, display it as is Timber.w("NotifiableEventResolver Received an unsupported event matching a bing rule") @@ -126,6 +130,51 @@ class NotifiableEventResolver @Inject constructor( } } + private fun resolveJitsiEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableJitsiEvent?{ + // The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) + val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/) + + return if (room != null) { + val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString() + val roomName = room.roomSummary()?.displayName.orEmpty() + val senderDisplayName = event.senderInfo.disambiguatedDisplayName + + NotifiableJitsiEvent( + eventId = event.root.eventId.orEmpty(), + editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, + timestamp = event.root.originServerTs ?: 0, + noisy = isNoisy, + senderName = senderDisplayName, + senderId = event.root.senderId, + body = body, + roomId = event.root.roomId!!, + threadId = event.root.getRootThreadEventId(), + roomName = roomName, + roomIsDirect = room.roomSummary()?.isDirect ?: false, + roomAvatarPath = session.contentUrlResolver() + .resolveThumbnail( + room.roomSummary()?.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE + ), + senderAvatarPath = session.contentUrlResolver() + .resolveThumbnail( + event.senderInfo.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE + ), + matrixID = session.myUserId, + soundName = null, + isReceived = null, + ) + } else { + null + } + } + fun TimelineEvent.getCaption(): String? = (getLastMessageContent() as? MessageWithAttachmentContent)?.getCaption() fun TimelineEvent.getFilename(): String? = (getLastMessageContent() as? MessageWithAttachmentContent)?.getFileName() diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableJitsiEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableJitsiEvent.kt new file mode 100644 index 00000000000..ca43d31f319 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableJitsiEvent.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2019 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 im.vector.app.features.notifications + +import org.matrix.android.sdk.api.session.events.model.EventType + +data class NotifiableJitsiEvent( + override val eventId: String, + override val editedEventId: String?, + override val canBeReplaced: Boolean, + val noisy: Boolean, + val timestamp: Long, + val senderName: String?, + val senderId: String?, + val body: String?, + val roomId: String, + val threadId: String?, + val roomName: String?, + val roomIsDirect: Boolean = false, + val roomAvatarPath: String? = null, + val senderAvatarPath: String? = null, + val matrixID: String? = null, + val soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + val outGoingMessage: Boolean = false, + val outGoingMessageFailed: Boolean = false, + var isReceived: Boolean? = null, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent { + + val type: String = EventType.MESSAGE + val description: String = body ?: "" + val title: String = senderName ?: "" + + fun updateReceivedStatus() = this.copy( + isReceived = when (isReceived) { + null -> false + false -> true + true -> true + } + ) +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt index c58a7f1ef42..90fd710260d 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt @@ -231,5 +231,6 @@ class NotificationDrawerManager @Inject constructor( const val ROOM_MESSAGES_NOTIFICATION_ID = 1 const val ROOM_EVENT_NOTIFICATION_ID = 2 const val ROOM_INVITATION_NOTIFICATION_ID = 3 + const val JITSI_CALL_NOTIFICATION_ID = 4 } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt index 349020aa4fc..e62dd4b0e2b 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt @@ -27,6 +27,7 @@ data class NotificationEventQueue( is InviteNotifiableEvent -> it.copy(isRedacted = true) is NotifiableMessageEvent -> it.copy(isRedacted = true) is SimpleNotifiableEvent -> it.copy(isRedacted = true) + is NotifiableJitsiEvent -> it.copy(isRedacted = true) } } } @@ -108,6 +109,7 @@ data class NotificationEventQueue( is InviteNotifiableEvent -> with.copy(isUpdated = true) is NotifiableMessageEvent -> with.copy(isUpdated = true) is SimpleNotifiableEvent -> with.copy(isUpdated = true) + is NotifiableJitsiEvent -> with.copy(isUpdated = true) } ) } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt index 17c75edf137..eafdf7e0c3c 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt @@ -11,13 +11,37 @@ import android.app.Notification import javax.inject.Inject private typealias ProcessedMessageEvents = List> +private typealias ProcessedJitsiEvents = List> class NotificationFactory @Inject constructor( private val notificationUtils: NotificationUtils, private val roomGroupMessageCreator: RoomGroupMessageCreator, - private val summaryGroupMessageCreator: SummaryGroupMessageCreator + private val summaryGroupMessageCreator: SummaryGroupMessageCreator, + private val jitsiNotificationsUtils: JitsiNotificationsUtils ) { + fun Map.toNotifications(): List { + return map { (roomId, events) -> + if (events.all { it.event.isReceived == true }) { + return emptyList() + } + + val eventToShow = events.first { it.event.isReceived == false } + + JitsiNotification.IncomingCall( + roomId = roomId, + eventId = eventToShow.event.eventId, + roomName = eventToShow.event.roomName.orEmpty(), + notification = jitsiNotificationsUtils.buildIncomingJitsiCallNotification( + callId = eventToShow.event.eventId.ifEmpty { roomId }, + signalingRoomId = roomId, + title = eventToShow.event.roomName.orEmpty(), + fromBg = true, + ) + ) + } + } + fun Map.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List { return map { (roomId, events) -> when { @@ -108,6 +132,15 @@ sealed interface RoomNotification { } } +sealed interface JitsiNotification { + data class IncomingCall( + val roomId: String, + val eventId: String, + val roomName: String, + val notification: Notification, + ) : JitsiNotification +} + sealed interface OneShotNotification { data class Removed(val key: String) : OneShotNotification data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt index 890fe9515c5..1c3ea0720b8 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt @@ -6,19 +6,20 @@ */ package im.vector.app.features.notifications -import android.content.Context import androidx.annotation.WorkerThread +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.JITSI_CALL_NOTIFICATION_ID import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID import im.vector.app.features.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID +import im.vector.app.features.settings.VectorPreferences import timber.log.Timber import javax.inject.Inject class NotificationRenderer @Inject constructor( private val notificationDisplayer: NotificationDisplayer, private val notificationFactory: NotificationFactory, - private val appContext: Context + private val vectorPreferences: VectorPreferences, ) { @WorkerThread @@ -29,9 +30,10 @@ class NotificationRenderer @Inject constructor( useCompleteNotificationFormat: Boolean, eventsToProcess: List> ) { - val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() + val (roomEvents, simpleEvents, invitationEvents, jitsiEvents) = eventsToProcess.groupByType() with(notificationFactory) { val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) + val jitsiNotifications = jitsiEvents.toNotifications() val invitationNotifications = invitationEvents.toNotifications(myUserId) val simpleNotifications = simpleEvents.toNotifications(myUserId) val summaryNotification = createSummaryNotification( @@ -60,6 +62,21 @@ class NotificationRenderer @Inject constructor( } } + Timber.d("Jitsi call notifications count = ${jitsiNotifications.size}") + if (vectorPreferences.isJitsiCallNotificationEnabled()) { + jitsiNotifications.forEach { wrapper -> + when (wrapper) { + is JitsiNotification.IncomingCall -> { + Timber.d("Updating jitsi call notification ${wrapper.eventId} for room ${wrapper.roomName}") + if (wrapper.eventId.isNotEmpty() || wrapper.roomId.isNotEmpty()) { + val tag = wrapper.eventId.ifEmpty { wrapper.roomId } + notificationDisplayer.showNotificationMessage(tag, JITSI_CALL_NOTIFICATION_ID, wrapper.notification) + } + } + } + } + } + invitationNotifications.forEach { wrapper -> when (wrapper) { is OneShotNotification.Removed -> { @@ -99,6 +116,7 @@ private fun List>.groupByType(): GroupedNotifica val roomIdToEventMap: MutableMap>> = LinkedHashMap() val simpleEvents: MutableList> = ArrayList() val invitationEvents: MutableList> = ArrayList() + val roomIdToJitsiEventMap: MutableMap>> = LinkedHashMap() forEach { when (val event = it.event) { is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) @@ -106,10 +124,14 @@ private fun List>.groupByType(): GroupedNotifica val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } roomEvents.add(it.castedToEventType()) } + is NotifiableJitsiEvent -> { + val jitsiEvents = roomIdToJitsiEventMap.getOrPut(event.roomId) { ArrayList() } + jitsiEvents.add(it.castedToEventType()) + } is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) } } - return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents) + return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, roomIdToJitsiEventMap) } @Suppress("UNCHECKED_CAST") @@ -118,5 +140,6 @@ private fun ProcessedEvent.castedToEventT data class GroupedNotificationEvents( val roomEvents: Map>>, val simpleEvents: List>, - val invitationEvents: List> + val invitationEvents: List>, + val jitsiEvents: Map>>, ) diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt old mode 100755 new mode 100644 index 657665372b8..ead3868e2d8 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -18,8 +18,12 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas +import android.media.AudioAttributes +import android.media.RingtoneManager import android.net.Uri import android.os.Build +import android.os.PowerManager +import android.provider.Settings import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan @@ -96,7 +100,7 @@ class NotificationUtils @Inject constructor( private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID" const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" - private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2" + const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2" @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) @@ -197,9 +201,14 @@ class NotificationUtils @Inject constructor( ) .apply { description = stringProvider.getString(CommonStrings.call) - setSound(null, null) + enableVibration(true) enableLights(true) lightColor = accentColor + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + setSound(Settings.System.DEFAULT_RINGTONE_URI, audioAttributes) }) } @@ -351,7 +360,7 @@ class NotificationUtils @Inject constructor( ) if (fromBg) { // Compat: Display the incoming call notification on the lock screen - builder.priority = NotificationCompat.PRIORITY_HIGH + builder.priority = NotificationCompat.PRIORITY_MAX builder.setFullScreenIntent(contentPendingIntent, true) } return builder.build() @@ -453,7 +462,7 @@ class NotificationUtils @Inject constructor( return builder.build() } - private fun buildRejectCallPendingIntent(callId: String): PendingIntent { + fun buildRejectCallPendingIntent(callId: String): PendingIntent { val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply { putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ID, callId) putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT) @@ -1077,14 +1086,14 @@ class NotificationUtils @Inject constructor( setting == NotificationManager.INTERRUPTION_FILTER_ALARMS } - private fun getActionText(@StringRes stringRes: Int, @AttrRes colorRes: Int): Spannable { + fun getActionText(@StringRes stringRes: Int, @AttrRes colorRes: Int): Spannable { return SpannableString(context.getText(stringRes)).apply { val foregroundColorSpan = ForegroundColorSpan(ThemeUtils.getColor(context, colorRes)) setSpan(foregroundColorSpan, 0, length, 0) } } - private fun ensureTitleNotEmpty(title: String?): CharSequence { + fun ensureTitleNotEmpty(title: String?): CharSequence { if (title.isNullOrBlank()) { return buildMeta.applicationName } diff --git a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt index e337a8a1f2e..bb1fab716ce 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt @@ -71,7 +71,7 @@ class RoomGroupMessageCreator @Inject constructor( largeIcon = largeBitmap, lastMessageTimestamp, userDisplayName, - tickerText + tickerText, ), meta ) diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index ff4a0e0b8a6..1eddd816f49 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -76,6 +76,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_LABS_NEW_SESSION_MANAGER_KEY = "SETTINGS_LABS_NEW_SESSION_MANAGER_KEY" const val SETTINGS_LABS_CLIENT_INFO_RECORDING_KEY = "SETTINGS_LABS_CLIENT_INFO_RECORDING_KEY" const val SETTINGS_LABS_VOICE_BROADCAST_KEY = "SETTINGS_LABS_VOICE_BROADCAST_KEY" + const val SETTINGS_LABS_JITSI_CALL_NOTIFICATION_KEY = "SETTINGS_LABS_JITSI_CALL_NOTIFICATION_KEY" const val SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY" @@ -1588,6 +1589,11 @@ class VectorPreferences @Inject constructor( ) } + fun isJitsiCallNotificationEnabled(): Boolean { + return vectorFeatures.isJitsiCallNotificationEnabled() && + defaultPrefs.getBoolean(SETTINGS_LABS_JITSI_CALL_NOTIFICATION_KEY, getDefault(im.vector.app.config.R.bool.settings_labs_enable_jitsi_call_notifications_default)) + } + fun showIpAddressInSessionManagerScreens(): Boolean { return defaultPrefs.getBoolean( SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 0b2c6f67c7e..752a41aa376 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -202,5 +202,11 @@ android:title="@string/labs_enable_voice_broadcast_title" app:isPreferenceVisible="@bool/settings_labs_enable_voice_broadcast_visible" /> + + diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt index e1769a29c1f..d41257abddd 100644 --- a/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt +++ b/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt @@ -8,6 +8,7 @@ package im.vector.app.features.notifications import im.vector.app.features.notifications.ProcessedEvent.Type +import im.vector.app.test.fakes.FakeJitsiNotificationUtils import im.vector.app.test.fakes.FakeNotificationUtils import im.vector.app.test.fakes.FakeRoomGroupMessageCreator import im.vector.app.test.fakes.FakeSummaryGroupMessageCreator @@ -31,11 +32,13 @@ class NotificationFactoryTest { private val notificationUtils = FakeNotificationUtils() private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val jitsiNotificationUtils = FakeJitsiNotificationUtils() private val notificationFactory = NotificationFactory( notificationUtils.instance, roomGroupMessageCreator.instance, - summaryGroupMessageCreator.instance + summaryGroupMessageCreator.instance, + jitsiNotificationUtils.instance, ) @Test diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeJitsiNotificationUtils.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeJitsiNotificationUtils.kt new file mode 100644 index 00000000000..3bd7f20347a --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeJitsiNotificationUtils.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package im.vector.app.test.fakes + +import android.app.Notification +import im.vector.app.features.notifications.InviteNotifiableEvent +import im.vector.app.features.notifications.JitsiNotificationsUtils +import im.vector.app.features.notifications.NotificationUtils +import im.vector.app.features.notifications.SimpleNotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeJitsiNotificationUtils { + + val instance = mockk() +}