): PushData {
+ val pushDataFirebase = PushDataFirebase(
+ eventId = message["event_id"],
+ roomId = message["room_id"],
+ unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } },
+ clientSecret = message["cs"],
+ )
+ return pushDataFirebase.toPushData()
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt
new file mode 100644
index 00000000000..bcf48bab15b
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.firebase
+
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.asEventId
+import io.element.android.libraries.matrix.api.core.asRoomId
+import io.element.android.libraries.push.impl.push.PushData
+
+/**
+ * In this case, the format is:
+ *
+ * {
+ * "event_id":"$anEventId",
+ * "room_id":"!aRoomId",
+ * "unread":"1",
+ * "prio":"high",
+ * "cs":""
+ * }
+ *
+ * .
+ */
+data class PushDataFirebase(
+ val eventId: String?,
+ val roomId: String?,
+ var unread: Int?,
+ val clientSecret: String?
+)
+
+fun PushDataFirebase.toPushData() = PushData(
+ eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(),
+ roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(),
+ unread = unread,
+ clientSecret = clientSecret,
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt
new file mode 100644
index 00000000000..8769baa9478
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.firebase
+
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.push.impl.PushersManager
+import io.element.android.libraries.push.impl.log.pushLoggerTag
+import io.element.android.libraries.push.impl.push.PushHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("Firebase", pushLoggerTag)
+
+class VectorFirebaseMessagingService : FirebaseMessagingService() {
+ @Inject lateinit var pushersManager: PushersManager
+ @Inject lateinit var pushParser: FirebasePushParser
+ @Inject lateinit var pushHandler: PushHandler
+
+ private val coroutineScope = CoroutineScope(SupervisorJob())
+
+ override fun onCreate() {
+ super.onCreate()
+ applicationContext.bindings().inject(this)
+ }
+
+ override fun onNewToken(token: String) {
+ Timber.tag(loggerTag.value).d("New Firebase token")
+ coroutineScope.launch {
+ pushersManager.onNewFirebaseToken(token)
+ }
+ }
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ Timber.tag(loggerTag.value).d("New Firebase message")
+ coroutineScope.launch {
+ pushParser.parse(message.data).let {
+ pushHandler.handle(it)
+ }
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt
new file mode 100644
index 00000000000..aef87e7df32
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.firebase
+
+import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.libraries.di.AppScope
+
+@ContributesTo(AppScope::class)
+interface VectorFirebaseMessagingServiceBindings {
+ fun inject(service: VectorFirebaseMessagingService)
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt
new file mode 100644
index 00000000000..52abb3f6a48
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.intent
+
+import android.content.Intent
+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
+
+interface IntentProvider {
+ /**
+ * Provide an intent to start the application.
+ */
+ fun getMainIntent(): Intent
+
+ fun getIntent(
+ sessionId: SessionId,
+ roomId: RoomId?,
+ threadId: ThreadId?,
+ ): Intent
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt
new file mode 100644
index 00000000000..3fa613d097b
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.log
+
+import io.element.android.libraries.core.log.logger.LoggerTag
+
+internal val pushLoggerTag = LoggerTag("Push")
+internal val notificationLoggerTag = LoggerTag("Notification", pushLoggerTag)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt
new file mode 100644
index 00000000000..a24f088998e
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.push.impl.notifications.model.NotifiableEvent
+import javax.inject.Inject
+
+class FilteredEventDetector @Inject constructor(
+ //private val activeSessionDataSource: ActiveSessionDataSource
+) {
+
+ /**
+ * Returns true if the given event should be ignored.
+ * Used to skip notifications if a non expected message is received.
+ */
+ fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean {
+ /* TODO EAx
+ val session = activeSessionDataSource.currentValue?.orNull() ?: return false
+
+ if (notifiableEvent is NotifiableMessageEvent) {
+ val room = session.getRoom(notifiableEvent.roomId) ?: return false
+ val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
+ return timelineEvent.shouldBeIgnored()
+ }
+
+ */
+ return false
+ }
+
+ /**
+ * Whether the timeline event should be ignored.
+ */
+ /*
+ private fun TimelineEvent.shouldBeIgnored(): Boolean {
+ if (root.isVoiceMessage()) {
+ val audioEvent = root.asMessageAudioEvent()
+ // if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
+ return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
+ }
+
+ return false
+ }
+ */
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
new file mode 100644
index 00000000000..1b1fe2723e0
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2021 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.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.SimpleNotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
+import io.element.android.services.appnavstate.api.AppNavigationState
+import timber.log.Timber
+import javax.inject.Inject
+
+private typealias ProcessedEvents = List>
+
+class NotifiableEventProcessor @Inject constructor(
+ private val outdatedDetector: OutdatedEventDetector,
+) {
+
+ fun process(
+ queuedEvents: List,
+ appNavigationState: AppNavigationState?,
+ renderedEvents: ProcessedEvents,
+ ): ProcessedEvents {
+ val processedEvents = queuedEvents.map {
+ val type = when (it) {
+ is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
+ is NotifiableMessageEvent -> when {
+ it.shouldIgnoreMessageEventInRoom(appNavigationState) -> {
+ ProcessedEvent.Type.REMOVE
+ .also { Timber.d("notification message removed due to currently viewing the same room or thread") }
+ }
+ outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE
+ .also { Timber.d("notification message removed due to being read") }
+ else -> ProcessedEvent.Type.KEEP
+ }
+ is SimpleNotifiableEvent -> when (it.type) {
+ /*EventType.REDACTION*/ "m.room.redaction" -> ProcessedEvent.Type.REMOVE
+ else -> ProcessedEvent.Type.KEEP
+ }
+ }
+ ProcessedEvent(type, it)
+ }
+
+ val removedEventsDiff = renderedEvents.filter { renderedEvent ->
+ queuedEvents.none { it.eventId == renderedEvent.event.eventId }
+ }.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) }
+
+ return removedEventsDiff + processedEvents
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
new file mode 100644
index 00000000000..5ae39e11029
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.core.log.logger.LoggerTag
+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.NotificationData
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
+import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
+import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.push.impl.log.pushLoggerTag
+import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
+import io.element.android.services.toolbox.api.strings.StringProvider
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import timber.log.Timber
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("NotifiableEventResolver", pushLoggerTag)
+
+/**
+ * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
+ * It is used as a bridge between the Event Thread and the NotificationDrawerManager.
+ * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that,
+ * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk.
+ */
+class NotifiableEventResolver @Inject constructor(
+ private val stringProvider: StringProvider,
+ // private val noticeEventFormatter: NoticeEventFormatter,
+ // private val displayableEventFormatter: DisplayableEventFormatter,
+ private val clock: SystemClock,
+ private val matrixAuthenticationService: MatrixAuthenticationService,
+ private val buildMeta: BuildMeta,
+) {
+
+ suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
+ // Restore session
+ val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null
+ // TODO EAx, no need for a session?
+ val notificationData = session.let {// TODO Use make the app crashes
+ it.notificationService().getNotification(
+ userId = sessionId,
+ roomId = roomId,
+ eventId = eventId,
+ )
+ }.fold(
+ {
+ it
+ },
+ {
+ Timber.tag(loggerTag.value).e(it, "Unable to resolve event.")
+ null
+ }
+ ).orDefault(roomId, eventId)
+
+ return notificationData.asNotifiableEvent(sessionId, roomId, eventId)
+ }
+}
+
+private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent {
+ return NotifiableMessageEvent(
+ sessionId = userId,
+ roomId = roomId,
+ eventId = eventId,
+ editedEventId = null,
+ canBeReplaced = true,
+ noisy = false,
+ timestamp = System.currentTimeMillis(),
+ senderName = null,
+ senderId = null,
+ body = "$eventId in $roomId",
+ imageUriString = null,
+ threadId = null,
+ roomName = null,
+ roomIsDirect = false,
+ roomAvatarPath = null,
+ senderAvatarPath = null,
+ soundName = null,
+ outGoingMessage = false,
+ outGoingMessageFailed = false,
+ isRedacted = false,
+ isUpdated = false
+ )
+}
+
+/**
+ * TODO This is a temporary method for EAx.
+ */
+private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
+ return this ?: NotificationData(
+ item = MatrixTimelineItem.Event(
+ event = EventTimelineItem(
+ uniqueIdentifier = eventId.value,
+ eventId = eventId,
+ isEditable = false,
+ isLocal = false,
+ isOwn = false,
+ isRemote = false,
+ localSendState = null,
+ reactions = emptyList(),
+ sender = UserId(""),
+ senderProfile = ProfileTimelineDetails.Unavailable,
+ timestamp = System.currentTimeMillis(),
+ content = MessageContent(
+ body = eventId.value,
+ inReplyTo = null,
+ isEdited = false,
+ type = TextMessageType(
+ body = eventId.value,
+ formatted = null
+ )
+ )
+ ),
+ ),
+ title = roomId.value,
+ subtitle = eventId.value,
+ isNoisy = false,
+ avatarUrl = null,
+ )
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt
new file mode 100644
index 00000000000..b3f0b1e0f26
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt
@@ -0,0 +1,40 @@
+/*
+ * 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
+
+data class NotificationAction(
+ val shouldNotify: Boolean,
+ val highlight: Boolean,
+ val soundName: String?
+)
+
+/*
+fun List.toNotificationAction(): NotificationAction {
+ var shouldNotify = false
+ var highlight = false
+ var sound: String? = null
+ forEach { action ->
+ when (action) {
+ is Action.Notify -> shouldNotify = true
+ is Action.DoNotNotify -> shouldNotify = false
+ is Action.Highlight -> highlight = action.highlight
+ is Action.Sound -> sound = action.sound
+ }
+ }
+ return NotificationAction(shouldNotify, highlight, sound)
+}
+ */
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt
new file mode 100644
index 00000000000..56054bbef87
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 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.core.meta.BuildMeta
+import javax.inject.Inject
+
+/**
+ * Util class for creating notifications.
+ * Note: Cannot inject ColorProvider in the constructor, because it requires an Activity
+ */
+
+data class NotificationActionIds @Inject constructor(
+ private val buildMeta: BuildMeta,
+) {
+ val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION"
+ val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION"
+ val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION"
+ val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION"
+ val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
+ val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
+ val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
+ val tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_ACTION"
+ val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
+ val push = "${buildMeta.applicationId}.PUSH"
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt
new file mode 100644
index 00000000000..7bd76f9f427
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt
@@ -0,0 +1,95 @@
+/*
+ * 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 android.content.Context
+import android.graphics.Bitmap
+import android.os.Build
+import androidx.annotation.WorkerThread
+import androidx.core.graphics.drawable.IconCompat
+import io.element.android.libraries.di.ApplicationContext
+import timber.log.Timber
+import javax.inject.Inject
+
+class NotificationBitmapLoader @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ /**
+ * Get icon of a room.
+ */
+ @WorkerThread
+ fun getRoomBitmap(path: String?): Bitmap? {
+ if (path == null) {
+ return null
+ }
+ return loadRoomBitmap(path)
+ }
+
+ @WorkerThread
+ private fun loadRoomBitmap(path: String): Bitmap? {
+ return try {
+ null
+ /* TODO Notification
+ Glide.with(context)
+ .asBitmap()
+ .load(path)
+ .format(DecodeFormat.PREFER_ARGB_8888)
+ .signature(ObjectKey("room-icon-notification"))
+ .submit()
+ .get()
+ */
+ } catch (e: Exception) {
+ Timber.e(e, "decodeFile failed")
+ null
+ }
+ }
+
+ /**
+ * Get icon of a user.
+ * Before Android P, this does nothing because the icon won't be used
+ */
+ @WorkerThread
+ fun getUserIcon(path: String?): IconCompat? {
+ if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ return null
+ }
+
+ return loadUserIcon(path)
+ }
+
+ @WorkerThread
+ private fun loadUserIcon(path: String): IconCompat? {
+ return try {
+ null
+ /* TODO Notification
+ val bitmap = Glide.with(context)
+ .asBitmap()
+ .load(path)
+ .transform(CircleCrop())
+ .format(DecodeFormat.PREFER_ARGB_8888)
+ .signature(ObjectKey("user-icon-notification"))
+ .submit()
+ .get()
+ IconCompat.createWithBitmap(bitmap)
+ */
+ } catch (e: Exception) {
+ Timber.e(e, "decodeFile failed")
+ null
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt
new file mode 100644
index 00000000000..a9c7036418f
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt
@@ -0,0 +1,253 @@
+/*
+ * 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 android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.RemoteInput
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.log.logger.LoggerTag
+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.asRoomId
+import io.element.android.libraries.matrix.api.core.asSessionId
+import io.element.android.libraries.matrix.api.core.asThreadId
+import io.element.android.libraries.push.impl.log.notificationLoggerTag
+import io.element.android.services.analytics.api.AnalyticsTracker
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import timber.log.Timber
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationLoggerTag)
+
+/**
+ * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
+ */
+class NotificationBroadcastReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var notificationDrawerManager: NotificationDrawerManager
+
+ //@Inject lateinit var activeSessionHolder: ActiveSessionHolder
+ @Inject lateinit var analyticsTracker: AnalyticsTracker
+ @Inject lateinit var clock: SystemClock
+ @Inject lateinit var actionIds: NotificationActionIds
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent == null || context == null) return
+ context.bindings().inject(this)
+ Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent")
+ val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.asSessionId() ?: return
+ when (intent.action) {
+ actionIds.smartReply ->
+ handleSmartReply(intent, context)
+ actionIds.dismissRoom ->
+ intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId ->
+ notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
+ }
+ actionIds.dismissSummary ->
+ notificationDrawerManager.clearAllEvents(sessionId)
+ actionIds.markRoomRead ->
+ intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId ->
+ notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
+ handleMarkAsRead(sessionId, roomId)
+ }
+ actionIds.join -> {
+ intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId ->
+ notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId)
+ handleJoinRoom(sessionId, roomId)
+ }
+ }
+ actionIds.reject -> {
+ intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()?.let { roomId ->
+ notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId)
+ handleRejectRoom(sessionId, roomId)
+ }
+ }
+ }
+ }
+
+ private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) {
+ /*
+ activeSessionHolder.getSafeActiveSession()?.let { session ->
+ val room = session.getRoom(roomId)
+ if (room != null) {
+ session.coroutineScope.launch {
+ tryOrNull {
+ session.roomService().joinRoom(room.roomId)
+ analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification))
+ }
+ }
+ }
+ }
+
+ */
+ }
+
+ private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) {
+ /*
+ activeSessionHolder.getSafeActiveSession()?.let { session ->
+ session.coroutineScope.launch {
+ tryOrNull { session.roomService().leaveRoom(roomId) }
+ }
+ }
+
+ */
+ }
+
+ private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) {
+ /*
+ activeSessionHolder.getActiveSession().let { session ->
+ val room = session.getRoom(roomId)
+ if (room != null) {
+ session.coroutineScope.launch {
+ tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) }
+ }
+ }
+ }
+
+ */
+ }
+
+ private fun handleSmartReply(intent: Intent, context: Context) {
+ val message = getReplyMessage(intent)
+ val sessionId = intent.getStringExtra(KEY_SESSION_ID)?.asSessionId()
+ val roomId = intent.getStringExtra(KEY_ROOM_ID)?.asRoomId()
+ val threadId = intent.getStringExtra(KEY_THREAD_ID)?.asThreadId()
+
+ if (message.isNullOrBlank() || roomId == null) {
+ // ignore this event
+ // Can this happen? should we update notification?
+ return
+ }
+ /*
+ activeSessionHolder.getActiveSession().let { session ->
+ session.getRoom(roomId)?.let { room ->
+ sendMatrixEvent(message, threadId, session, room, context)
+ }
+ }
+
+ */
+ }
+
+ /*
+ private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) {
+ if (threadId != null) {
+ room.relationService().replyInThread(
+ rootThreadEventId = threadId,
+ replyInThreadText = message,
+ )
+ } else {
+ room.sendService().sendTextMessage(message)
+ }
+
+ // Create a new event to be displayed in the notification drawer, right now
+
+ val notifiableMessageEvent = NotifiableMessageEvent(
+ // Generate a Fake event id
+ eventId = UUID.randomUUID().toString(),
+ editedEventId = null,
+ noisy = false,
+ timestamp = clock.epochMillis(),
+ senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName
+ ?: context?.getString(R.string.notification_sender_me),
+ senderId = session.myUserId,
+ body = message,
+ imageUriString = null,
+ roomId = room.roomId,
+ threadId = threadId,
+ roomName = room.roomSummary()?.displayName ?: room.roomId,
+ roomIsDirect = room.roomSummary()?.isDirect == true,
+ outGoingMessage = true,
+ canBeReplaced = false
+ )
+
+ notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
+
+ /*
+ // TODO Error cannot be managed the same way than in Riot
+
+ val event = Event(mxMessage, session.credentials.userId, roomId)
+ room.storeOutgoingEvent(event)
+ room.sendEvent(event, object : MatrixCallback {
+ override fun onSuccess(info: Void?) {
+ Timber.v("Send message : onSuccess ")
+ }
+
+ override fun onNetworkError(e: Exception) {
+ Timber.e(e, "Send message : onNetworkError")
+ onSmartReplyFailed(e.localizedMessage)
+ }
+
+ override fun onMatrixError(e: MatrixError) {
+ Timber.v("Send message : onMatrixError " + e.message)
+ if (e is MXCryptoError) {
+ Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show()
+ onSmartReplyFailed(e.detailedErrorDescription)
+ } else {
+ Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
+ onSmartReplyFailed(e.localizedMessage)
+ }
+ }
+
+ override fun onUnexpectedError(e: Exception) {
+ Timber.e(e, "Send message : onUnexpectedError " + e.message)
+ onSmartReplyFailed(e.message)
+ }
+
+
+ fun onSmartReplyFailed(reason: String?) {
+ val notifiableMessageEvent = NotifiableMessageEvent(
+ event.eventId,
+ false,
+ clock.epochMillis(),
+ session.myUser?.displayname
+ ?: context?.getString(R.string.notification_sender_me),
+ session.myUserId,
+ message,
+ roomId,
+ room.getRoomDisplayName(context),
+ room.isDirect)
+ notifiableMessageEvent.outGoingMessage = true
+ notifiableMessageEvent.outGoingMessageFailed = true
+
+ VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
+ VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null)
+ }
+ })
+ */
+ }
+
+ */
+
+ private fun getReplyMessage(intent: Intent?): String? {
+ if (intent != null) {
+ val remoteInput = RemoteInput.getResultsFromIntent(intent)
+ if (remoteInput != null) {
+ return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString()
+ }
+ }
+ return null
+ }
+
+ companion object {
+ const val KEY_SESSION_ID = "sessionID"
+ const val KEY_ROOM_ID = "roomID"
+ const val KEY_THREAD_ID = "threadID"
+ const val KEY_TEXT_REPLY = "key_text_reply"
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt
new file mode 100644
index 00000000000..ae936e693bb
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 com.squareup.anvil.annotations.ContributesTo
+import io.element.android.libraries.di.AppScope
+
+@ContributesTo(AppScope::class)
+interface NotificationBroadcastReceiverBindings {
+ fun inject(receiver: NotificationBroadcastReceiver)
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
new file mode 100644
index 00000000000..c8331ed8cda
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2021 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 android.Manifest
+import android.app.Notification
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationManagerCompat
+import io.element.android.libraries.di.ApplicationContext
+import timber.log.Timber
+import javax.inject.Inject
+
+class NotificationDisplayer @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val notificationManager = NotificationManagerCompat.from(context)
+
+ fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ Timber.w("Not allowed to notify.")
+ return
+ }
+ notificationManager.notify(tag, id, notification)
+ }
+
+ fun cancelNotificationMessage(tag: String?, id: Int) {
+ notificationManager.cancel(tag, id)
+ }
+
+ fun cancelAllNotifications() {
+ // Keep this try catch (reported by GA)
+ try {
+ notificationManager.cancelAll()
+ } catch (e: Exception) {
+ Timber.e(e, "## cancelAllNotifications() failed")
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt
new file mode 100644
index 00000000000..8d3bfda4c36
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt
@@ -0,0 +1,271 @@
+/*
+ * 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 android.content.Context
+import android.os.Handler
+import android.os.HandlerThread
+import androidx.annotation.WorkerThread
+import io.element.android.libraries.androidutils.throttler.FirstThrottler
+import io.element.android.libraries.core.cache.CircularCache
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+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.push.api.store.PushDataStore
+import io.element.android.libraries.push.impl.R
+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.shouldIgnoreMessageEventInRoom
+import io.element.android.services.appnavstate.api.AppNavigationState
+import io.element.android.services.appnavstate.api.AppNavigationStateService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+/**
+ * The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and
+ * organise them in order to display them in the notification drawer.
+ * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
+ */
+@SingleIn(AppScope::class)
+class NotificationDrawerManager @Inject constructor(
+ @ApplicationContext context: Context,
+ private val pushDataStore: PushDataStore,
+ private val notifiableEventProcessor: NotifiableEventProcessor,
+ private val notificationRenderer: NotificationRenderer,
+ private val notificationEventPersistence: NotificationEventPersistence,
+ private val filteredEventDetector: FilteredEventDetector,
+ private val appNavigationStateService: AppNavigationStateService,
+ private val coroutineScope: CoroutineScope,
+ private val buildMeta: BuildMeta,
+) {
+
+ private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
+ private var backgroundHandler: Handler
+
+ /**
+ * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
+ */
+ private val notificationState by lazy { createInitialNotificationState() }
+ private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
+ private var currentAppNavigationState: AppNavigationState? = null
+ private val firstThrottler = FirstThrottler(200)
+
+ private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat()
+
+ init {
+ handlerThread.start()
+ backgroundHandler = Handler(handlerThread.looper)
+ // Observe application state
+ coroutineScope.launch {
+ appNavigationStateService.appNavigationStateFlow
+ .collect { onAppNavigationStateChange(it) }
+ }
+ }
+
+ private fun onAppNavigationStateChange(appNavigationState: AppNavigationState) {
+ currentAppNavigationState = appNavigationState
+ when (appNavigationState) {
+ AppNavigationState.Root -> {}
+ is AppNavigationState.Session -> {}
+ is AppNavigationState.Space -> {}
+ is AppNavigationState.Room -> {
+ // Cleanup notification for current room
+ clearMessagesForRoom(appNavigationState.parentSpace.parentSession.sessionId, appNavigationState.roomId)
+ }
+ is AppNavigationState.Thread -> {
+ onEnteringThread(
+ appNavigationState.parentRoom.parentSpace.parentSession.sessionId,
+ appNavigationState.parentRoom.roomId,
+ appNavigationState.threadId
+ )
+ }
+ }
+ }
+
+ private fun createInitialNotificationState(): NotificationState {
+ val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents ->
+ NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
+ })
+ val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
+ return NotificationState(queuedEvents, renderedEvents)
+ }
+
+ private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
+ if (!pushDataStore.areNotificationEnabledForDevice()) {
+ Timber.i("Notification are disabled for this device")
+ return
+ }
+ // If we support multi session, event list should be per userId
+ // Currently only manage single session
+ if (buildMeta.lowPrivacyLoggingEnabled) {
+ Timber.d("onNotifiableEventReceived(): $notifiableEvent")
+ } else {
+ Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
+ }
+
+ if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
+ Timber.d("onNotifiableEventReceived(): ignore the event")
+ return
+ }
+
+ add(notifiableEvent)
+ }
+
+ /**
+ * Should be called as soon as a new event is ready to be displayed.
+ * The notification corresponding to this event will not be displayed until
+ * #refreshNotificationDrawer() is called.
+ * Events might be grouped and there might not be one notification per event!
+ */
+ fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
+ updateEvents {
+ it.onNotifiableEventReceived(notifiableEvent)
+ }
+ }
+
+ /**
+ * Clear all known events and refresh the notification drawer.
+ */
+ fun clearAllEvents(sessionId: SessionId) {
+ updateEvents {
+ it.clearMessagesForSession(sessionId)
+ }
+ }
+
+ /**
+ * Should be called when the application is currently opened and showing timeline for the given roomId.
+ * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
+ * Can also be called when a notification for this room is dismissed by the user.
+ */
+ fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
+ updateEvents {
+ it.clearMessagesForRoom(sessionId, roomId)
+ }
+ }
+
+ /**
+ * Clear invitation notification for the provided room.
+ */
+ fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
+ updateEvents {
+ it.clearMemberShipNotificationForRoom(sessionId, roomId)
+ }
+ }
+
+ /**
+ * Should be called when the application is currently opened and showing timeline for the given threadId.
+ * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
+ */
+ private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
+ updateEvents {
+ it.clearMessagesForThread(sessionId, roomId, threadId)
+ }
+ }
+
+ // TODO EAx Must be per account
+ fun notificationStyleChanged() {
+ updateEvents {
+ val newSettings = pushDataStore.useCompleteNotificationFormat()
+ if (newSettings != useCompleteNotificationFormat) {
+ // Settings has changed, remove all current notifications
+ notificationRenderer.cancelAllNotifications()
+ useCompleteNotificationFormat = newSettings
+ }
+ }
+ }
+
+ private fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
+ notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
+ action(queuedEvents)
+ }
+ refreshNotificationDrawer()
+ }
+
+ private fun refreshNotificationDrawer() {
+ // Implement last throttler
+ val canHandle = firstThrottler.canHandle()
+ Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
+ backgroundHandler.removeCallbacksAndMessages(null)
+
+ backgroundHandler.postDelayed(
+ {
+ try {
+ refreshNotificationDrawerBg()
+ } catch (throwable: Throwable) {
+ // It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
+ Timber.w(throwable, "refreshNotificationDrawerBg failure")
+ }
+ },
+ canHandle.waitMillis()
+ )
+ }
+
+ @WorkerThread
+ private fun refreshNotificationDrawerBg() {
+ Timber.v("refreshNotificationDrawerBg()")
+ val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
+ notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
+ queuedEvents.clearAndAdd(it.onlyKeptEvents())
+ }
+ }
+
+ if (notificationState.hasAlreadyRendered(eventsToRender)) {
+ Timber.d("Skipping notification update due to event list not changing")
+ } else {
+ notificationState.clearAndAddRenderedEvents(eventsToRender)
+ renderEvents(eventsToRender)
+ persistEvents()
+ }
+ }
+
+ private fun persistEvents() {
+ notificationState.queuedEvents { queuedEvents ->
+ notificationEventPersistence.persistEvents(queuedEvents)
+ }
+ }
+
+ private fun renderEvents(eventsToRender: List>) {
+ // Group by sessionId
+ val eventsForSessions = eventsToRender.groupBy {
+ it.event.sessionId
+ }
+
+ eventsForSessions.forEach { (sessionId, notifiableEvents) ->
+ // TODO EAx val user = session.getUserOrDefault(session.myUserId)
+ // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
+ val myUserDisplayName = "Todo display name" // user.toMatrixItem().getBestName()
+ // TODO EAx avatar URL
+ val myUserAvatarUrl = null // session.contentUrlResolver().resolveThumbnail(
+ // contentUrl = user.avatarUrl,
+ // width = avatarSize,
+ // height = avatarSize,
+ // method = ContentUrlResolver.ThumbnailMethod.SCALE
+ //)
+ notificationRenderer.render(sessionId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, notifiableEvents)
+ }
+ }
+
+ fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
+ return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt
new file mode 100644
index 00000000000..d6135f28b07
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2021 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 android.content.Context
+import io.element.android.libraries.androidutils.file.EncryptedFileFactory
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.push.impl.log.notificationLoggerTag
+import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
+import timber.log.Timber
+import java.io.File
+import java.io.ObjectInputStream
+import java.io.ObjectOutputStream
+import javax.inject.Inject
+
+private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache"
+private const val FILE_NAME = "notifications.bin"
+
+private val loggerTag = LoggerTag("NotificationEventPersistence", notificationLoggerTag)
+
+class NotificationEventPersistence @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val file by lazy {
+ deleteLegacyFileIfAny()
+ context.getDatabasePath(FILE_NAME)
+ }
+
+ private val encryptedFile by lazy {
+ EncryptedFileFactory(context).create(file)
+ }
+
+ fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue {
+ val rawEvents: ArrayList? = file
+ .takeIf { it.exists() }
+ ?.let {
+ try {
+ encryptedFile.openFileInput().use { fis ->
+ ObjectInputStream(fis).use { ois ->
+ @Suppress("UNCHECKED_CAST")
+ ois.readObject() as? ArrayList
+ }
+ }.also {
+ Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)")
+ }
+ } catch (e: Throwable) {
+ Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info")
+ null
+ }
+ }
+ return factory(rawEvents.orEmpty())
+ }
+
+ fun persistEvents(queuedEvents: NotificationEventQueue) {
+ Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)")
+ // Always delete file before writing, or encryptedFile.openFileOutput() will throw
+ file.delete()
+ if (queuedEvents.isEmpty()) return
+ try {
+ encryptedFile.openFileOutput().use { fos ->
+ ObjectOutputStream(fos).use { oos ->
+ oos.writeObject(queuedEvents.rawEvents())
+ }
+ }
+ } catch (e: Throwable) {
+ Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info")
+ }
+ }
+
+ private fun deleteLegacyFileIfAny() {
+ tryOrNull {
+ File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete()
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt
new file mode 100644
index 00000000000..60fb1baa053
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2021 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.core.cache.CircularCache
+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.ThreadId
+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.SimpleNotifiableEvent
+import timber.log.Timber
+
+data class NotificationEventQueue constructor(
+ private val queue: MutableList,
+ /**
+ * An in memory FIFO cache of the seen events.
+ * Acts as a notification debouncer to stop already dismissed push notifications from
+ * displaying again when the /sync response is delayed.
+ */
+ private val seenEventIds: CircularCache
+) {
+
+ fun markRedacted(eventIds: List) {
+ eventIds.forEach { redactedId ->
+ queue.replace(redactedId) {
+ when (it) {
+ is InviteNotifiableEvent -> it.copy(isRedacted = true)
+ is NotifiableMessageEvent -> it.copy(isRedacted = true)
+ is SimpleNotifiableEvent -> it.copy(isRedacted = true)
+ }
+ }
+ }
+ }
+
+ // TODO EAx call this
+ fun syncRoomEvents(roomsLeft: Collection, roomsJoined: Collection) {
+ if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
+ queue.removeAll {
+ when (it) {
+ is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
+ is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
+ else -> false
+ }
+ }
+ }
+ }
+
+ fun isEmpty() = queue.isEmpty()
+
+ fun clearAndAdd(events: List) {
+ queue.clear()
+ queue.addAll(events)
+ }
+
+ fun clear() {
+ queue.clear()
+ }
+
+ fun add(notifiableEvent: NotifiableEvent) {
+ val existing = findExistingById(notifiableEvent)
+ val edited = findEdited(notifiableEvent)
+ when {
+ existing != null -> {
+ if (existing.canBeReplaced) {
+ // Use the event coming from the event stream as it may contains more info than
+ // the fcm one (like type/content/clear text) (e.g when an encrypted message from
+ // FCM should be update with clear text after a sync)
+ // In this case the message has already been notified, and might have done some noise
+ // So we want the notification to be updated even if it has already been displayed
+ // Use setOnlyAlertOnce to ensure update notification does not interfere with sound
+ // from first notify invocation as outlined in:
+ // https://developer.android.com/training/notify-user/build-notification#Updating
+ replace(replace = existing, with = notifiableEvent)
+ } else {
+ // keep the existing one, do not replace
+ }
+ }
+ edited != null -> {
+ // Replace the existing notification with the new content
+ replace(replace = edited, with = notifiableEvent)
+ }
+ seenEventIds.contains(notifiableEvent.eventId) -> {
+ // we've already seen the event, lets skip
+ Timber.d("onNotifiableEventReceived(): skipping event, already seen")
+ }
+ else -> {
+ seenEventIds.put(notifiableEvent.eventId)
+ queue.add(notifiableEvent)
+ }
+ }
+ }
+
+ private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
+ return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId }
+ }
+
+ private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
+ return notifiableEvent.editedEventId?.let { editedId ->
+ queue.firstOrNull {
+ it.eventId == editedId || it.editedEventId == editedId
+ }
+ }
+ }
+
+ private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
+ queue.remove(replace)
+ queue.add(
+ when (with) {
+ is InviteNotifiableEvent -> with.copy(isUpdated = true)
+ is NotifiableMessageEvent -> with.copy(isUpdated = true)
+ is SimpleNotifiableEvent -> with.copy(isUpdated = true)
+ }
+ )
+ }
+
+ fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
+ Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
+ queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
+ }
+
+ fun clearMessagesForSession(sessionId: SessionId) {
+ Timber.d("clearMessagesForSession $sessionId")
+ queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId }
+ }
+
+ fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
+ Timber.d("clearMessageEventOfRoom $sessionId, $roomId")
+ queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId }
+ }
+
+ fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
+ Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId")
+ queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId }
+ }
+
+ fun rawEvents(): List = queue
+}
+
+private fun MutableList.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) {
+ val indexToReplace = indexOfFirst { it.eventId == eventId }
+ if (indexToReplace == -1) {
+ return
+ }
+ set(indexToReplace, block(get(indexToReplace)))
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt
new file mode 100644
index 00000000000..1a2d4a852f6
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2021 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 android.app.Notification
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+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.SimpleNotifiableEvent
+import javax.inject.Inject
+
+private typealias ProcessedMessageEvents = List>
+
+class NotificationFactory @Inject constructor(
+ private val notificationUtils: NotificationUtils,
+ private val roomGroupMessageCreator: RoomGroupMessageCreator,
+ private val summaryGroupMessageCreator: SummaryGroupMessageCreator
+) {
+
+ fun Map.toNotifications(
+ sessionId: SessionId,
+ myUserDisplayName: String,
+ myUserAvatarUrl: String?
+ ): List {
+ return map { (roomId, events) ->
+ when {
+ events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
+ else -> {
+ val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
+ roomGroupMessageCreator.createRoomMessage(
+ sessionId = sessionId,
+ events = messageEvents,
+ roomId = roomId,
+ userDisplayName = myUserDisplayName,
+ userAvatarUrl = myUserAvatarUrl
+ )
+ }
+ }
+ }
+ }
+
+ private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all {
+ it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed()
+ }
+
+ private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
+
+ @JvmName("toNotificationsInviteNotifiableEvent")
+ fun List>.toNotifications(): List {
+ return map { (processed, event) ->
+ when (processed) {
+ ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value)
+ ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
+ notificationUtils.buildRoomInvitationNotification(event),
+ OneShotNotification.Append.Meta(
+ key = event.roomId.value,
+ summaryLine = event.description,
+ isNoisy = event.noisy,
+ timestamp = event.timestamp
+ )
+ )
+ }
+ }
+ }
+
+ @JvmName("toNotificationsSimpleNotifiableEvent")
+ fun List>.toNotifications(): List {
+ return map { (processed, event) ->
+ when (processed) {
+ ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
+ ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
+ notificationUtils.buildSimpleEventNotification(event),
+ OneShotNotification.Append.Meta(
+ key = event.eventId.value,
+ summaryLine = event.description,
+ isNoisy = event.noisy,
+ timestamp = event.timestamp
+ )
+ )
+ }
+ }
+ }
+
+ fun createSummaryNotification(
+ sessionId: SessionId,
+ roomNotifications: List,
+ invitationNotifications: List,
+ simpleNotifications: List,
+ useCompleteNotificationFormat: Boolean
+ ): SummaryNotification {
+ val roomMeta = roomNotifications.filterIsInstance().map { it.meta }
+ val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta }
+ val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta }
+ return when {
+ roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
+ else -> SummaryNotification.Update(
+ summaryGroupMessageCreator.createSummaryNotification(
+ sessionId = sessionId,
+ roomNotifications = roomMeta,
+ invitationNotifications = invitationMeta,
+ simpleNotifications = simpleMeta,
+ useCompleteNotificationFormat = useCompleteNotificationFormat
+ )
+ )
+ }
+ }
+}
+
+sealed interface RoomNotification {
+ data class Removed(val roomId: RoomId) : RoomNotification
+ data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
+ data class Meta(
+ val roomId: RoomId,
+ val summaryLine: CharSequence,
+ val messageCount: Int,
+ val latestTimestamp: Long,
+ val shouldBing: Boolean
+ )
+ }
+}
+
+sealed interface OneShotNotification {
+ data class Removed(val key: String) : OneShotNotification
+ data class Append(val notification: Notification, val meta: Meta) : OneShotNotification {
+ data class Meta(
+ val key: String,
+ val summaryLine: CharSequence,
+ val isNoisy: Boolean,
+ val timestamp: Long,
+ )
+ }
+}
+
+sealed interface SummaryNotification {
+ object Removed : SummaryNotification
+ data class Update(val notification: Notification) : SummaryNotification
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt
new file mode 100644
index 00000000000..3ce941de2f7
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt
@@ -0,0 +1,51 @@
+/*
+ * 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
+ }
+
+ 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 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
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
new file mode 100644
index 00000000000..277dc3b8223
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
@@ -0,0 +1,154 @@
+/*
+ * 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 androidx.annotation.WorkerThread
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+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.SimpleNotifiableEvent
+import timber.log.Timber
+import javax.inject.Inject
+
+class NotificationRenderer @Inject constructor(
+ private val notificationIdProvider: NotificationIdProvider,
+ private val notificationDisplayer: NotificationDisplayer,
+ private val notificationFactory: NotificationFactory,
+) {
+
+ @WorkerThread
+ fun render(
+ sessionId: SessionId,
+ myUserDisplayName: String,
+ myUserAvatarUrl: String?,
+ useCompleteNotificationFormat: Boolean,
+ eventsToProcess: List>
+ ) {
+ val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
+ with(notificationFactory) {
+ val roomNotifications = roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl)
+ val invitationNotifications = invitationEvents.toNotifications()
+ val simpleNotifications = simpleEvents.toNotifications()
+ val summaryNotification = createSummaryNotification(
+ sessionId = sessionId,
+ roomNotifications = roomNotifications,
+ invitationNotifications = invitationNotifications,
+ simpleNotifications = simpleNotifications,
+ useCompleteNotificationFormat = useCompleteNotificationFormat
+ )
+
+ // Remove summary first to avoid briefly displaying it after dismissing the last notification
+ if (summaryNotification == SummaryNotification.Removed) {
+ Timber.d("Removing summary notification")
+ notificationDisplayer.cancelNotificationMessage(null, notificationIdProvider.getSummaryNotificationId(sessionId))
+ }
+
+ roomNotifications.forEach { wrapper ->
+ when (wrapper) {
+ is RoomNotification.Removed -> {
+ Timber.d("Removing room messages notification ${wrapper.roomId}")
+ notificationDisplayer.cancelNotificationMessage(wrapper.roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
+ }
+ is RoomNotification.Message -> if (useCompleteNotificationFormat) {
+ Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
+ notificationDisplayer.showNotificationMessage(
+ wrapper.meta.roomId.value,
+ notificationIdProvider.getRoomMessagesNotificationId(sessionId),
+ wrapper.notification
+ )
+ }
+ }
+ }
+
+ invitationNotifications.forEach { wrapper ->
+ when (wrapper) {
+ is OneShotNotification.Removed -> {
+ Timber.d("Removing invitation notification ${wrapper.key}")
+ notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomInvitationNotificationId(sessionId))
+ }
+ is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
+ Timber.d("Updating invitation notification ${wrapper.meta.key}")
+ notificationDisplayer.showNotificationMessage(
+ wrapper.meta.key,
+ notificationIdProvider.getRoomInvitationNotificationId(sessionId),
+ wrapper.notification
+ )
+ }
+ }
+ }
+
+ simpleNotifications.forEach { wrapper ->
+ when (wrapper) {
+ is OneShotNotification.Removed -> {
+ Timber.d("Removing simple notification ${wrapper.key}")
+ notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomEventNotificationId(sessionId))
+ }
+ is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
+ Timber.d("Updating simple notification ${wrapper.meta.key}")
+ notificationDisplayer.showNotificationMessage(
+ wrapper.meta.key,
+ notificationIdProvider.getRoomEventNotificationId(sessionId),
+ wrapper.notification
+ )
+ }
+ }
+ }
+
+ // Update summary last to avoid briefly displaying it before other notifications
+ if (summaryNotification is SummaryNotification.Update) {
+ Timber.d("Updating summary notification")
+ notificationDisplayer.showNotificationMessage(
+ null,
+ notificationIdProvider.getSummaryNotificationId(sessionId),
+ summaryNotification.notification
+ )
+ }
+ }
+ }
+
+ fun cancelAllNotifications() {
+ notificationDisplayer.cancelAllNotifications()
+ }
+}
+
+private fun List>.groupByType(): GroupedNotificationEvents {
+ val roomIdToEventMap: MutableMap>> = LinkedHashMap()
+ val simpleEvents: MutableList> = ArrayList()
+ val invitationEvents: MutableList> = ArrayList()
+ forEach {
+ when (val event = it.event) {
+ is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
+ is NotifiableMessageEvent -> {
+ val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() }
+ roomEvents.add(it.castedToEventType())
+ }
+ is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
+ }
+ }
+ return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents)
+}
+
+@Suppress("UNCHECKED_CAST")
+private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent
+
+data class GroupedNotificationEvents(
+ val roomEvents: Map>>,
+ val simpleEvents: List>,
+ val invitationEvents: List>
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt
new file mode 100644
index 00000000000..808bf4114b3
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2021 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.push.impl.notifications.model.NotifiableEvent
+
+class NotificationState(
+ /**
+ * The notifiable events queued for rendering or currently rendered.
+ *
+ * This is our source of truth for notifications, any changes to this list will be rendered as notifications.
+ * When events are removed the previously rendered notifications will be cancelled.
+ * When adding or updating, the notifications will be notified.
+ *
+ * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id.
+ */
+ private val queuedEvents: NotificationEventQueue,
+
+ /**
+ * The last known rendered notifiable events.
+ * We keep track of them in order to know which events have been removed from the eventList
+ * allowing us to cancel any notifications previous displayed by now removed events
+ */
+ private val renderedEvents: MutableList>,
+) {
+
+ fun updateQueuedEvents(
+ drawerManager: NotificationDrawerManager,
+ action: NotificationDrawerManager.(NotificationEventQueue, List>) -> T
+ ): T {
+ return synchronized(queuedEvents) {
+ action(drawerManager, queuedEvents, renderedEvents)
+ }
+ }
+
+ fun clearAndAddRenderedEvents(eventsToRender: List>) {
+ renderedEvents.clear()
+ renderedEvents.addAll(eventsToRender)
+ }
+
+ fun hasAlreadyRendered(eventsToRender: List>) = renderedEvents == eventsToRender
+
+ fun queuedEvents(block: (NotificationEventQueue) -> Unit) {
+ synchronized(queuedEvents) {
+ block(queuedEvents)
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt
new file mode 100755
index 00000000000..add8fd74eb5
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt
@@ -0,0 +1,727 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("UNUSED_PARAMETER")
+
+package io.element.android.libraries.push.impl.notifications
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.DrawableRes
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.RemoteInput
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.core.content.res.ResourcesCompat
+import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent
+import io.element.android.libraries.androidutils.uri.createIgnoredUri
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+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.push.impl.R
+import io.element.android.libraries.push.impl.intent.IntentProvider
+import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
+import io.element.android.services.toolbox.api.strings.StringProvider
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import timber.log.Timber
+import javax.inject.Inject
+
+// TODO EAx Split into factories
+@SingleIn(AppScope::class)
+class NotificationUtils @Inject constructor(
+ @ApplicationContext private val context: Context,
+ // private val vectorPreferences: VectorPreferences,
+ private val stringProvider: StringProvider,
+ private val clock: SystemClock,
+ private val actionIds: NotificationActionIds,
+ private val intentProvider: IntentProvider,
+ private val buildMeta: BuildMeta,
+) {
+
+ companion object {
+ /* ==========================================================================================
+ * IDs for notifications
+ * ========================================================================================== */
+
+ /**
+ * Identifier of the foreground notification used to keep the application alive
+ * when it runs in background.
+ * This notification, which is not removable by the end user, displays what
+ * the application is doing while in background.
+ */
+ const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61
+
+ /* ==========================================================================================
+ * IDs for channels
+ * ========================================================================================== */
+
+ // on devices >= android O, we need to define a channel for each notifications
+ private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
+
+ 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"
+
+ @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
+ fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+
+ fun openSystemSettingsForSilentCategory(activity: Activity) {
+ startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID)
+ }
+
+ fun openSystemSettingsForNoisyCategory(activity: Activity) {
+ startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID)
+ }
+
+ fun openSystemSettingsForCallCategory(activity: Activity) {
+ startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID)
+ }
+ }
+
+ private val notificationManager = NotificationManagerCompat.from(context)
+
+ init {
+ createNotificationChannels()
+ }
+
+ /* ==========================================================================================
+ * Channel names
+ * ========================================================================================== */
+
+ /**
+ * Create notification channels.
+ */
+ private fun createNotificationChannels() {
+ if (!supportNotificationChannels()) {
+ return
+ }
+
+ val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
+
+ // Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE
+ // + currentTimeMillis).
+ // Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel
+ // Starting from this version the channel will not be dynamic
+ for (channel in notificationManager.notificationChannels) {
+ val channelId = channel.id
+ val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE"
+ if (channelId.startsWith(legacyBaseName)) {
+ notificationManager.deleteNotificationChannel(channelId)
+ }
+ }
+ // Migration - Remove deprecated channels
+ for (channelId in listOf("DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID", "CALL_NOTIFICATION_CHANNEL_ID")) {
+ notificationManager.getNotificationChannel(channelId)?.let {
+ notificationManager.deleteNotificationChannel(channelId)
+ }
+ }
+
+ /**
+ * Default notification importance: shows everywhere, makes noise, but does not visually
+ * intrude.
+ */
+ notificationManager.createNotificationChannel(NotificationChannel(
+ NOISY_NOTIFICATION_CHANNEL_ID,
+ stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" },
+ NotificationManager.IMPORTANCE_DEFAULT
+ )
+ .apply {
+ description = stringProvider.getString(R.string.notification_channel_noisy)
+ enableVibration(true)
+ enableLights(true)
+ lightColor = accentColor
+ })
+
+ /**
+ * Low notification importance: shows everywhere, but is not intrusive.
+ */
+ notificationManager.createNotificationChannel(NotificationChannel(
+ SILENT_NOTIFICATION_CHANNEL_ID,
+ stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" },
+ NotificationManager.IMPORTANCE_LOW
+ )
+ .apply {
+ description = stringProvider.getString(R.string.notification_channel_silent)
+ setSound(null, null)
+ enableLights(true)
+ lightColor = accentColor
+ })
+
+ notificationManager.createNotificationChannel(NotificationChannel(
+ LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID,
+ stringProvider.getString(R.string.notification_channel_listening_for_events).ifEmpty { "Listening for events" },
+ NotificationManager.IMPORTANCE_MIN
+ )
+ .apply {
+ description = stringProvider.getString(R.string.notification_channel_listening_for_events)
+ setSound(null, null)
+ setShowBadge(false)
+ })
+
+ notificationManager.createNotificationChannel(NotificationChannel(
+ CALL_NOTIFICATION_CHANNEL_ID,
+ stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" },
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ .apply {
+ description = stringProvider.getString(R.string.notification_channel_call)
+ setSound(null, null)
+ enableLights(true)
+ lightColor = accentColor
+ })
+ }
+
+ fun getChannel(channelId: String): NotificationChannel? {
+ return notificationManager.getNotificationChannel(channelId)
+ }
+
+ fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
+ val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
+ return getChannel(notificationChannel)
+ }
+
+ /**
+ * Build a notification for a Room.
+ */
+ fun buildMessagesListNotification(
+ messageStyle: NotificationCompat.MessagingStyle,
+ roomInfo: RoomEventGroupInfo,
+ threadId: ThreadId?,
+ largeIcon: Bitmap?,
+ lastMessageTimestamp: Long,
+ senderDisplayNameForReplyCompat: String?,
+ tickerText: String
+ ): Notification {
+ val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
+ // Build the pending intent for when the notification is clicked
+ val openIntent = when {
+ threadId != null &&
+ true
+ /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */
+ -> buildOpenThreadIntent(roomInfo, threadId)
+ else -> buildOpenRoomIntent(roomInfo.sessionId, roomInfo.roomId)
+ }
+
+ val smallIcon = R.drawable.ic_notification
+
+ val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
+ return NotificationCompat.Builder(context, channelID)
+ .setOnlyAlertOnce(roomInfo.isUpdated)
+ .setWhen(lastMessageTimestamp)
+ // MESSAGING_STYLE sets title and content for API 16 and above devices.
+ .setStyle(messageStyle)
+ // 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)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ // ID of the corresponding shortcut, for conversation features under API 30+
+ .setShortcutId(roomInfo.roomId.value)
+ // Title for API < 16 devices.
+ .setContentTitle(roomInfo.roomDisplayName)
+ // Content for API < 16 devices.
+ .setContentText(stringProvider.getString(R.string.notification_new_messages))
+ // Number of new notifications for API <24 (M and below) devices.
+ .setSubText(
+ stringProvider.getQuantityString(
+ R.plurals.notification_new_messages_for_room,
+ messageStyle.messages.size,
+ messageStyle.messages.size
+ )
+ )
+ // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
+ // devices and all Wear devices. But we want a custom grouping, so we specify the groupID
+ .setGroup(roomInfo.sessionId.value)
+ // In order to avoid notification making sound twice (due to the summary notification)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
+ .setSmallIcon(smallIcon)
+ // Set primary color (important for Wear 2.0 Notifications).
+ .setColor(accentColor)
+ // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for
+ // 'importance' which is set in the NotificationChannel. The integers representing
+ // 'priority' are different from 'importance', so make sure you don't mix them.
+ .apply {
+ if (roomInfo.shouldBing) {
+ // Compat
+ priority = NotificationCompat.PRIORITY_DEFAULT
+ /*
+ vectorPreferences.getNotificationRingTone()?.let {
+ setSound(it)
+ }
+ */
+ setLights(accentColor, 500, 500)
+ } else {
+ priority = NotificationCompat.PRIORITY_LOW
+ }
+
+ // Add actions and notification intents
+ // Mark room as read
+ val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java)
+ markRoomReadIntent.action = actionIds.markRoomRead
+ markRoomReadIntent.data = createIgnoredUri("markRead?${roomInfo.sessionId}&$${roomInfo.roomId}")
+ markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId)
+ markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
+ val markRoomReadPendingIntent = PendingIntent.getBroadcast(
+ context,
+ clock.epochMillis().toInt(),
+ markRoomReadIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ NotificationCompat.Action.Builder(
+ R.drawable.ic_material_done_all_white,
+ stringProvider.getString(R.string.notification_room_action_mark_as_read), markRoomReadPendingIntent
+ )
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
+ .setShowsUserInterface(false)
+ .build()
+ .let { addAction(it) }
+
+ // Quick reply
+ if (!roomInfo.hasSmartReplyError) {
+ buildQuickReplyIntent(roomInfo.sessionId, roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent ->
+ val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
+ .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
+ .build()
+ NotificationCompat.Action.Builder(
+ R.drawable.vector_notification_quick_reply,
+ stringProvider.getString(R.string.notification_room_action_quick_reply), replyPendingIntent
+ )
+ .addRemoteInput(remoteInput)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
+ .build()
+ .let { addAction(it) }
+ }
+ }
+
+ if (openIntent != null) {
+ setContentIntent(openIntent)
+ }
+
+ if (largeIcon != null) {
+ setLargeIcon(largeIcon)
+ }
+
+ val intent = Intent(context, NotificationBroadcastReceiver::class.java)
+ intent.action = actionIds.dismissRoom
+ intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId)
+ intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
+ val pendingIntent = PendingIntent.getBroadcast(
+ context.applicationContext,
+ clock.epochMillis().toInt(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ setDeleteIntent(pendingIntent)
+ }
+ .setTicker(tickerText)
+ .build()
+ }
+
+ fun buildRoomInvitationNotification(
+ inviteNotifiableEvent: InviteNotifiableEvent
+ ): Notification {
+ val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
+ // Build the pending intent for when the notification is clicked
+ val smallIcon = R.drawable.ic_notification
+ val channelID = if (inviteNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
+
+ return NotificationCompat.Builder(context, channelID)
+ .setOnlyAlertOnce(true)
+ .setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
+ .setContentText(inviteNotifiableEvent.description)
+ .setGroup(inviteNotifiableEvent.sessionId.value)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
+ .setSmallIcon(smallIcon)
+ .setColor(accentColor)
+ .apply {
+ val roomId = inviteNotifiableEvent.roomId
+ // offer to type a quick reject button
+ val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java)
+ rejectIntent.action = actionIds.reject
+ rejectIntent.data = createIgnoredUri("rejectInvite?${inviteNotifiableEvent.sessionId}&$roomId")
+ rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId)
+ rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
+ val rejectIntentPendingIntent = PendingIntent.getBroadcast(
+ context,
+ clock.epochMillis().toInt(),
+ rejectIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ addAction(
+ R.drawable.vector_notification_reject_invitation,
+ stringProvider.getString(R.string.notification_invitation_action_reject),
+ rejectIntentPendingIntent
+ )
+
+ // offer to type a quick accept button
+ val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
+ joinIntent.action = actionIds.join
+ joinIntent.data = createIgnoredUri("acceptInvite?${inviteNotifiableEvent.sessionId}&$roomId")
+ joinIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId)
+ joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
+ val joinIntentPendingIntent = PendingIntent.getBroadcast(
+ context,
+ clock.epochMillis().toInt(),
+ joinIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ addAction(
+ R.drawable.vector_notification_accept_invitation,
+ stringProvider.getString(R.string.notification_invitation_action_join),
+ joinIntentPendingIntent
+ )
+
+ /*
+ val contentIntent = HomeActivity.newIntent(
+ context,
+ firstStartMainActivity = true,
+ inviteNotificationRoomId = inviteNotifiableEvent.roomId
+ )
+ contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
+ contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
+ setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
+
+ */
+
+ if (inviteNotifiableEvent.noisy) {
+ // Compat
+ priority = NotificationCompat.PRIORITY_DEFAULT
+ /*
+ vectorPreferences.getNotificationRingTone()?.let {
+ setSound(it)
+ }
+
+ */
+ setLights(accentColor, 500, 500)
+ } else {
+ priority = NotificationCompat.PRIORITY_LOW
+ }
+ setAutoCancel(true)
+ }
+ .build()
+ }
+
+ fun buildSimpleEventNotification(
+ simpleNotifiableEvent: SimpleNotifiableEvent,
+ ): Notification {
+ val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
+ // Build the pending intent for when the notification is clicked
+ val smallIcon = R.drawable.ic_notification
+
+ val channelID = if (simpleNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
+
+ return NotificationCompat.Builder(context, channelID)
+ .setOnlyAlertOnce(true)
+ .setContentTitle(buildMeta.applicationName)
+ .setContentText(simpleNotifiableEvent.description)
+ .setGroup(simpleNotifiableEvent.sessionId.value)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
+ .setSmallIcon(smallIcon)
+ .setColor(accentColor)
+ .setAutoCancel(true)
+ .apply {
+ /* TODO EAx
+ val contentIntent = HomeActivity.newIntent(context, firstStartMainActivity = true)
+ contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
+ contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId)
+ setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
+ */
+ if (simpleNotifiableEvent.noisy) {
+ // Compat
+ priority = NotificationCompat.PRIORITY_DEFAULT
+ /*
+ vectorPreferences.getNotificationRingTone()?.let {
+ setSound(it)
+ }
+
+ */
+ setLights(accentColor, 500, 500)
+ } else {
+ priority = NotificationCompat.PRIORITY_LOW
+ }
+ setAutoCancel(true)
+ }
+ .build()
+ }
+
+ private fun buildOpenRoomIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
+ val roomIntent = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = null)
+ roomIntent.action = actionIds.tapToView
+ // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
+ roomIntent.data = createIgnoredUri("openRoom?$sessionId&$roomId")
+
+ return PendingIntent.getActivity(
+ context,
+ clock.epochMillis().toInt(),
+ roomIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
+ val sessionId = roomInfo.sessionId
+ val roomId = roomInfo.roomId
+ val threadIntentTap = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
+ threadIntentTap.action = actionIds.tapToView
+ // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
+ threadIntentTap.data = createIgnoredUri("openThread?$sessionId&$roomId&$threadId")
+
+ return PendingIntent.getActivity(
+ context,
+ clock.epochMillis().toInt(),
+ threadIntentTap,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent {
+ val intent = intentProvider.getIntent(sessionId = sessionId, roomId = null, threadId = null)
+ intent.data = createIgnoredUri("tapSummary?$sessionId")
+ return PendingIntent.getActivity(
+ context,
+ clock.epochMillis().toInt(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ /*
+ Direct reply is new in Android N, and Android already handles the UI, so the right pending intent
+ here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver,
+ which runs on the UI thread. It also works without unlocking, making the process really fluid for the user.
+ However, for Android devices running Marshmallow and below (API level 23 and below),
+ it will be more appropriate to use an activity. Since you have to provide your own UI.
+ */
+ private fun buildQuickReplyIntent(
+ sessionId: SessionId,
+ roomId: RoomId,
+ threadId: ThreadId?,
+ senderName: String?
+ ): PendingIntent? {
+ val intent: Intent
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ intent = Intent(context, NotificationBroadcastReceiver::class.java)
+ intent.action = actionIds.smartReply
+ intent.data = createIgnoredUri("quickReply?$sessionId&$roomId")
+ intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
+ intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
+ threadId?.let {
+ intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it)
+ }
+
+ return PendingIntent.getBroadcast(
+ context,
+ clock.epochMillis().toInt(),
+ intent,
+ // PendingIntents attached to actions with remote inputs must be mutable
+ PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.FLAG_MUTABLE
+ } else {
+ 0
+ }
+ )
+ } else {
+ /*
+ TODO
+ if (!LockScreenActivity.isDisplayingALockScreenActivity()) {
+ // start your activity for Android M and below
+ val quickReplyIntent = Intent(context, LockScreenActivity::class.java)
+ quickReplyIntent.putExtra(LockScreenActivity.EXTRA_ROOM_ID, roomId)
+ quickReplyIntent.putExtra(LockScreenActivity.EXTRA_SENDER_NAME, senderName ?: "")
+
+ // the action must be unique else the parameters are ignored
+ quickReplyIntent.action = QUICK_LAUNCH_ACTION
+ quickReplyIntent.data = createIgnoredUri($roomId")
+ return PendingIntent.getActivity(context, 0, quickReplyIntent, PendingIntentCompat.FLAG_IMMUTABLE)
+ }
+ */
+ }
+ return null
+ }
+
+ // // Number of new notifications for API <24 (M and below) devices.
+ /**
+ * Build the summary notification.
+ */
+ fun buildSummaryListNotification(
+ sessionId: SessionId,
+ style: NotificationCompat.InboxStyle?,
+ compatSummary: String,
+ noisy: Boolean,
+ lastMessageTimestamp: Long
+ ): Notification {
+ val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
+ val smallIcon = R.drawable.ic_notification
+
+ return NotificationCompat.Builder(context, if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID)
+ .setOnlyAlertOnce(true)
+ // used in compat < N, after summary is built based on child notifications
+ .setWhen(lastMessageTimestamp)
+ .setStyle(style)
+ .setContentTitle(sessionId.value)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setSmallIcon(smallIcon)
+ // set content text to support devices running API level < 24
+ .setContentText(compatSummary)
+ .setGroup(sessionId.value)
+ // set this notification as the summary for the group
+ .setGroupSummary(true)
+ .setColor(accentColor)
+ .apply {
+ if (noisy) {
+ // Compat
+ priority = NotificationCompat.PRIORITY_DEFAULT
+ /*
+ vectorPreferences.getNotificationRingTone()?.let {
+ setSound(it)
+ }
+ */
+ setLights(accentColor, 500, 500)
+ } else {
+ // compat
+ priority = NotificationCompat.PRIORITY_LOW
+ }
+ }
+ .setContentIntent(buildOpenHomePendingIntentForSummary(sessionId))
+ .setDeleteIntent(getDismissSummaryPendingIntent(sessionId))
+ .build()
+ }
+
+ private fun getDismissSummaryPendingIntent(sessionId: SessionId): PendingIntent {
+ val intent = Intent(context, NotificationBroadcastReceiver::class.java)
+ intent.action = actionIds.dismissSummary
+ intent.data = createIgnoredUri("deleteSummary?$sessionId")
+ intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
+ return PendingIntent.getBroadcast(
+ context.applicationContext,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ /**
+ * Cancel the foreground notification service.
+ */
+ fun cancelNotificationForegroundService() {
+ notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
+ }
+
+ /**
+ * Cancel all the notification.
+ */
+ fun cancelAllNotifications() {
+ // Keep this try catch (reported by GA)
+ try {
+ notificationManager.cancelAll()
+ } catch (e: Exception) {
+ Timber.e(e, "## cancelAllNotifications() failed")
+ }
+ }
+
+ @SuppressLint("LaunchActivityFromNotification")
+ fun displayDiagnosticNotification() {
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ Timber.w("Not allowed to notify.")
+ return
+ }
+ val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
+ testActionIntent.action = actionIds.diagnostic
+ val testPendingIntent = PendingIntent.getBroadcast(
+ context,
+ 0,
+ testActionIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ notificationManager.notify(
+ "DIAGNOSTIC",
+ 888,
+ NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(buildMeta.applicationName)
+ .setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
+ .setSmallIcon(R.drawable.ic_notification)
+ .setLargeIcon(getBitmap(context, R.drawable.element_logo_green))
+ .setColor(ContextCompat.getColor(context, R.color.notification_accent_color))
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+ .setAutoCancel(true)
+ .setContentIntent(testPendingIntent)
+ .build()
+ )
+ }
+
+ private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? {
+ val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
+ val canvas = Canvas()
+ val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
+ canvas.setBitmap(bitmap)
+ drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
+ drawable.draw(canvas)
+ return bitmap
+ }
+
+ /**
+ * Return true it the user has enabled the do not disturb mode.
+ */
+ fun isDoNotDisturbModeOn(): Boolean {
+ // We cannot use NotificationManagerCompat here.
+ val setting = context.getSystemService()!!.currentInterruptionFilter
+
+ return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
+ setting == NotificationManager.INTERRUPTION_FILTER_ALARMS
+ }
+
+ /*
+ private 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 {
+ if (title.isNullOrBlank()) {
+ return buildMeta.applicationName
+ }
+
+ return title
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt
new file mode 100644
index 00000000000..5b15dc78d2f
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.push.impl.notifications.model.NotifiableEvent
+import javax.inject.Inject
+
+class OutdatedEventDetector @Inject constructor(
+ /// private val activeSessionDataSource: ActiveSessionDataSource
+) {
+
+ /**
+ * Returns true if the given event is outdated.
+ * Used to clean up notifications if a displayed message has been read on an
+ * other device.
+ */
+ fun isMessageOutdated(notifiableEvent: NotifiableEvent): Boolean {
+ /* TODO EAx
+ val session = activeSessionDataSource.currentValue?.orNull() ?: return false
+
+ if (notifiableEvent is NotifiableMessageEvent) {
+ val eventID = notifiableEvent.eventId
+ val roomID = notifiableEvent.roomId
+ val room = session.getRoom(roomID) ?: return false
+ return room.readService().isEventRead(eventID)
+ }
+
+ */
+ return false
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt
new file mode 100644
index 00000000000..2e91ca34671
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2021 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
+
+data class ProcessedEvent(
+ val type: Type,
+ val event: T
+) {
+ enum class Type {
+ KEEP,
+ REMOVE
+ }
+}
+
+fun List>.onlyKeptEvents() = mapNotNull { processedEvent ->
+ processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt
new file mode 100644
index 00000000000..734c34b051d
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+
+/**
+ * Data class to hold information about a group of notifications for a room.
+ */
+data class RoomEventGroupInfo(
+ val sessionId: SessionId,
+ val roomId: RoomId,
+ val roomDisplayName: String,
+ val isDirect: Boolean = false
+) {
+ // An event in the list has not yet been display
+ var hasNewEvent: Boolean = false
+
+ // true if at least one on the not yet displayed event is noisy
+ var shouldBing: Boolean = false
+ var customSound: String? = null
+ var hasSmartReplyError: Boolean = false
+ var isUpdated: Boolean = false
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt
new file mode 100644
index 00000000000..ab38ad9fb42
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2021 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 android.graphics.Bitmap
+import androidx.core.app.NotificationCompat
+import androidx.core.app.Person
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.push.impl.R
+import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
+import io.element.android.services.toolbox.api.strings.StringProvider
+import me.gujun.android.span.Span
+import me.gujun.android.span.span
+import timber.log.Timber
+import javax.inject.Inject
+
+class RoomGroupMessageCreator @Inject constructor(
+ private val bitmapLoader: NotificationBitmapLoader,
+ private val stringProvider: StringProvider,
+ private val notificationUtils: NotificationUtils
+) {
+
+ fun createRoomMessage(
+ sessionId: SessionId,
+ events: List,
+ roomId: RoomId,
+ userDisplayName: String,
+ userAvatarUrl: String?
+ ): RoomNotification.Message {
+ val lastKnownRoomEvent = events.last()
+ val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: ""
+ val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
+ val style = NotificationCompat.MessagingStyle(
+ Person.Builder()
+ .setName(userDisplayName)
+ .setIcon(bitmapLoader.getUserIcon(userAvatarUrl))
+ .setKey(lastKnownRoomEvent.sessionId.value)
+ .build()
+ ).also {
+ it.conversationTitle = roomName.takeIf { roomIsGroup }
+ it.isGroupConversation = roomIsGroup
+ it.addMessagesFromEvents(events)
+ }
+
+ val tickerText = if (roomIsGroup) {
+ stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description)
+ } else {
+ stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description)
+ }
+
+ val largeBitmap = getRoomBitmap(events)
+
+ val lastMessageTimestamp = events.last().timestamp
+ val smartReplyErrors = events.filter { it.isSmartReplyError() }
+ val messageCount = (events.size - smartReplyErrors.size)
+ val meta = RoomNotification.Message.Meta(
+ summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup),
+ messageCount = messageCount,
+ latestTimestamp = lastMessageTimestamp,
+ roomId = roomId,
+ shouldBing = events.any { it.noisy }
+ )
+ return RoomNotification.Message(
+ notificationUtils.buildMessagesListNotification(
+ style,
+ RoomEventGroupInfo(
+ sessionId = sessionId,
+ roomId = roomId,
+ roomDisplayName = roomName,
+ isDirect = !roomIsGroup,
+ ).also {
+ it.hasSmartReplyError = smartReplyErrors.isNotEmpty()
+ it.shouldBing = meta.shouldBing
+ it.customSound = events.last().soundName
+ it.isUpdated = events.last().isUpdated
+ },
+ threadId = lastKnownRoomEvent.threadId,
+ largeIcon = largeBitmap,
+ lastMessageTimestamp,
+ userDisplayName,
+ tickerText
+ ),
+ meta
+ )
+ }
+
+ private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List) {
+ events.forEach { event ->
+ val senderPerson = if (event.outGoingMessage) {
+ null
+ } else {
+ Person.Builder()
+ .setName(event.senderName)
+ .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
+ .setKey(event.senderId)
+ .build()
+ }
+ when {
+ event.isSmartReplyError() -> addMessage(
+ stringProvider.getString(R.string.notification_inline_reply_failed),
+ event.timestamp,
+ senderPerson
+ )
+ else -> {
+ val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
+ event.imageUri?.let {
+ message.setData("image/", it)
+ }
+ }
+ addMessage(message)
+ }
+ }
+ }
+ }
+
+ private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence {
+ return try {
+ when (events.size) {
+ 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect)
+ else -> {
+ stringProvider.getQuantityString(
+ R.plurals.notification_compat_summary_line_for_room,
+ events.size,
+ roomName,
+ events.size
+ )
+ }
+ }
+ } catch (e: Throwable) {
+ // String not found or bad format
+ Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string")
+ roomName
+ }
+ }
+
+ private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span {
+ return if (roomIsDirect) {
+ span {
+ span {
+ textStyle = "bold"
+ +String.format("%s: ", event.senderName)
+ }
+ +(event.description)
+ }
+ } else {
+ span {
+ span {
+ textStyle = "bold"
+ +String.format("%s: %s ", roomName, event.senderName)
+ }
+ +(event.description)
+ }
+ }
+ }
+
+ private fun getRoomBitmap(events: List): Bitmap? {
+ // Use the last event (most recent?)
+ return events.lastOrNull()
+ ?.roomAvatarPath
+ ?.let { bitmapLoader.getRoomBitmap(it) }
+ }
+}
+
+private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt
new file mode 100644
index 00000000000..ed0053e58cd
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2021 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 android.app.Notification
+import androidx.core.app.NotificationCompat
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.push.impl.R
+import io.element.android.services.toolbox.api.strings.StringProvider
+import javax.inject.Inject
+
+/**
+ * ======== Build summary notification =========
+ * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for
+ * your group using snippets of text from each notification. The user can expand this
+ * notification to see each separate notification.
+ * To support older versions, which cannot show a nested group of notifications,
+ * you must create an extra notification that acts as the summary.
+ * This appears as the only notification and the system hides all the others.
+ * So this summary should include a snippet from all the other notifications,
+ * which the user can tap to open your app.
+ * The behavior of the group summary may vary on some device types such as wearables.
+ * To ensure the best experience on all devices and versions, always include a group summary when you create a group
+ * https://developer.android.com/training/notify-user/group
+ */
+class SummaryGroupMessageCreator @Inject constructor(
+ private val stringProvider: StringProvider,
+ private val notificationUtils: NotificationUtils
+) {
+
+ fun createSummaryNotification(
+ sessionId: SessionId,
+ roomNotifications: List,
+ invitationNotifications: List,
+ simpleNotifications: List,
+ useCompleteNotificationFormat: Boolean
+ ): Notification {
+ val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
+ roomNotifications.forEach { style.addLine(it.summaryLine) }
+ invitationNotifications.forEach { style.addLine(it.summaryLine) }
+ simpleNotifications.forEach { style.addLine(it.summaryLine) }
+ }
+
+ val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
+ invitationNotifications.any { it.isNoisy } ||
+ simpleNotifications.any { it.isNoisy }
+
+ val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount }
+
+ val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp
+ ?: invitationNotifications.lastOrNull()?.timestamp
+ ?: simpleNotifications.last().timestamp
+
+ // FIXME roomIdToEventMap.size is not correct, this is the number of rooms
+ val nbEvents = roomNotifications.size + simpleNotifications.size
+ val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
+ summaryInboxStyle.setBigContentTitle(sumTitle)
+ // TODO get latest event?
+ .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
+ return if (useCompleteNotificationFormat) {
+ notificationUtils.buildSummaryListNotification(
+ sessionId,
+ summaryInboxStyle,
+ sumTitle,
+ noisy = summaryIsNoisy,
+ lastMessageTimestamp = lastMessageTimestamp
+ )
+ } else {
+ processSimpleGroupSummary(
+ sessionId,
+ summaryIsNoisy,
+ messageCount,
+ simpleNotifications.size,
+ invitationNotifications.size,
+ roomNotifications.size,
+ lastMessageTimestamp
+ )
+ }
+ }
+
+ private fun processSimpleGroupSummary(
+ sessionId: SessionId,
+ summaryIsNoisy: Boolean,
+ messageEventsCount: Int,
+ simpleEventsCount: Int,
+ invitationEventsCount: Int,
+ roomCount: Int,
+ lastMessageTimestamp: Long
+ ): Notification {
+ // Add the simple events as message (?)
+ val messageNotificationCount = messageEventsCount + simpleEventsCount
+
+ val privacyTitle = if (invitationEventsCount > 0) {
+ val invitationsStr = stringProvider.getQuantityString(
+ R.plurals.notification_invitations,
+ invitationEventsCount,
+ invitationEventsCount
+ )
+ if (messageNotificationCount > 0) {
+ // Invitation and message
+ val messageStr = stringProvider.getQuantityString(
+ R.plurals.notification_new_messages_for_room,
+ messageNotificationCount, messageNotificationCount
+ )
+ if (roomCount > 1) {
+ // In several rooms
+ val roomStr = stringProvider.getQuantityString(
+ R.plurals.notification_unread_notified_messages_in_room_rooms,
+ roomCount,
+ roomCount
+ )
+ stringProvider.getString(
+ R.string.notification_unread_notified_messages_in_room_and_invitation,
+ messageStr,
+ roomStr,
+ invitationsStr
+ )
+ } else {
+ // In one room
+ stringProvider.getString(
+ R.string.notification_unread_notified_messages_and_invitation,
+ messageStr,
+ invitationsStr
+ )
+ }
+ } else {
+ // Only invitation
+ invitationsStr
+ }
+ } else {
+ // No invitation, only messages
+ val messageStr = stringProvider.getQuantityString(
+ R.plurals.notification_new_messages_for_room,
+ messageNotificationCount,
+ messageNotificationCount
+ )
+ if (roomCount > 1) {
+ // In several rooms
+ val roomStr = stringProvider.getQuantityString(
+ R.plurals.notification_unread_notified_messages_in_room_rooms,
+ roomCount,
+ roomCount
+ )
+ stringProvider.getString(
+ R.string.notification_unread_notified_messages_in_room,
+ messageStr,
+ roomStr
+ )
+ } else {
+ // In one room
+ messageStr
+ }
+ }
+ return notificationUtils.buildSummaryListNotification(
+ sessionId = sessionId,
+ style = null,
+ compatSummary = privacyTitle,
+ noisy = summaryIsNoisy,
+ lastMessageTimestamp = lastMessageTimestamp
+ )
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt
new file mode 100644
index 00000000000..42c0fe61afc
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2020 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 android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+
+class TestNotificationReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ // Internal broadcast to any one interested
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt
new file mode 100644
index 00000000000..4524d27ac26
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.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
+
+data class InviteNotifiableEvent(
+ override val sessionId: SessionId,
+ override val roomId: RoomId,
+ override val eventId: EventId,
+ override val editedEventId: EventId?,
+ override val canBeReplaced: Boolean,
+ val roomName: String?,
+ val noisy: Boolean,
+ val title: String,
+ val description: String,
+ val type: String?,
+ val timestamp: Long,
+ val soundName: String?,
+ override val isRedacted: Boolean = false,
+ override val isUpdated: Boolean = false
+) : NotifiableEvent
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
new file mode 100644
index 00000000000..b1bb7cd032c
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.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 java.io.Serializable
+
+/**
+ * Parent interface for all events which can be displayed as a Notification.
+ */
+sealed interface NotifiableEvent : Serializable {
+ val sessionId: SessionId
+ val roomId: RoomId
+ val eventId: EventId
+ val editedEventId: EventId?
+
+ // Used to know if event should be replaced with the one coming from eventstream
+ val canBeReplaced: Boolean
+ val isRedacted: Boolean
+ val isUpdated: Boolean
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
new file mode 100644
index 00000000000..add172e44f1
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.model
+
+import android.net.Uri
+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.ThreadId
+import io.element.android.services.appnavstate.api.AppNavigationState
+import io.element.android.services.appnavstate.api.currentRoomId
+import io.element.android.services.appnavstate.api.currentSessionId
+import io.element.android.services.appnavstate.api.currentThreadId
+
+data class NotifiableMessageEvent(
+ override val sessionId: SessionId,
+ override val roomId: RoomId,
+ override val eventId: EventId,
+ override val editedEventId: EventId?,
+ override val canBeReplaced: Boolean,
+ val noisy: Boolean,
+ val timestamp: Long,
+ val senderName: String?,
+ val senderId: String?,
+ val body: String?,
+ // We cannot use Uri? type here, as that could trigger a
+ // NotSerializableException when persisting this to storage
+ val imageUriString: String?,
+ val threadId: ThreadId?,
+ val roomName: String?,
+ val roomIsDirect: Boolean = false,
+ val roomAvatarPath: String? = null,
+ val senderAvatarPath: 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,
+ override val isRedacted: Boolean = false,
+ override val isUpdated: Boolean = false
+) : NotifiableEvent {
+
+ val type: String = /* EventType.MESSAGE */ "m.room.message"
+ val description: String = body ?: ""
+ val title: String = senderName ?: ""
+
+ val imageUri: Uri?
+ get() = imageUriString?.let { Uri.parse(it) }
+}
+
+fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(
+ appNavigationState: AppNavigationState?
+): Boolean {
+ val currentSessionId = appNavigationState?.currentSessionId() ?: return false
+ return when (val currentRoomId = appNavigationState.currentRoomId()) {
+ null -> false
+ else -> sessionId == currentSessionId && roomId == currentRoomId && threadId == appNavigationState.currentThreadId()
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt
new file mode 100644
index 00000000000..5cfd04474a8
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.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
+
+data class SimpleNotifiableEvent(
+ override val sessionId: SessionId,
+ override val roomId: RoomId,
+ override val eventId: EventId,
+ override val editedEventId: EventId?,
+ val noisy: Boolean,
+ val title: String,
+ val description: String,
+ val type: String?,
+ val timestamp: Long,
+ val soundName: String?,
+ override var canBeReplaced: Boolean,
+ override val isRedacted: Boolean = false,
+ override val isUpdated: Boolean = false
+) : NotifiableEvent
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt
new file mode 100644
index 00000000000..e1fd17332e1
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2022 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.permission
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
+import javax.inject.Inject
+
+// TODO EAx move
+class NotificationPermissionManager @Inject constructor(
+ private val sdkIntProvider: BuildVersionSdkIntProvider,
+ @ApplicationContext private val context: Context,
+) {
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ fun isPermissionGranted(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ /*
+ fun eventuallyRequestPermission(
+ activity: Activity,
+ requestPermissionLauncher: ActivityResultLauncher>,
+ showRationale: Boolean = true,
+ ignorePreference: Boolean = false,
+ ) {
+ if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
+ // if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return
+ checkPermissions(
+ listOf(Manifest.permission.POST_NOTIFICATIONS),
+ activity,
+ activityResultLauncher = requestPermissionLauncher,
+ if (showRationale) R.string.permissions_rationale_msg_notification else 0
+ )
+ }
+ */
+
+ fun eventuallyRevokePermission(
+ activity: Activity,
+ ) {
+ if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
+ activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt
new file mode 100644
index 00000000000..864155e5222
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.push
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+
+/**
+ * Represent parsed data that the app has received from a Push content.
+ *
+ * @property eventId The Event ID. If not null, it will not be empty, and will have a valid format.
+ * @property roomId The Room ID. If not null, it will not be empty, and will have a valid format.
+ * @property unread Number of unread message.
+ * @property clientSecret A client secret, used to determine which user should receive the notification.
+ */
+data class PushData(
+ val eventId: EventId?,
+ val roomId: RoomId?,
+ val unread: Int?,
+ val clientSecret: String?,
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt
new file mode 100644
index 00000000000..b4c9716b625
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.push
+
+import android.content.Context
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import io.element.android.libraries.androidutils.network.WifiDetector
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.push.api.store.PushDataStore
+import io.element.android.libraries.push.impl.PushersManager
+import io.element.android.libraries.push.impl.clientsecret.PushClientSecret
+import io.element.android.libraries.push.impl.log.pushLoggerTag
+import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
+import io.element.android.libraries.push.impl.notifications.NotificationActionIds
+import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
+import io.element.android.libraries.push.impl.store.DefaultPushDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
+
+class PushHandler @Inject constructor(
+ private val notificationDrawerManager: NotificationDrawerManager,
+ private val notifiableEventResolver: NotifiableEventResolver,
+ private val pushDataStore: PushDataStore,
+ private val defaultPushDataStore: DefaultPushDataStore,
+ private val pushClientSecret: PushClientSecret,
+ private val actionIds: NotificationActionIds,
+ @ApplicationContext private val context: Context,
+ private val buildMeta: BuildMeta,
+ private val matrixAuthenticationService: MatrixAuthenticationService,
+) {
+
+ private val coroutineScope = CoroutineScope(SupervisorJob())
+ private val wifiDetector: WifiDetector = WifiDetector(context)
+
+ // UI handler
+ private val mUIHandler by lazy {
+ Handler(Looper.getMainLooper())
+ }
+
+ /**
+ * Called when message is received.
+ *
+ * @param pushData the data received in the push.
+ */
+ suspend fun handle(pushData: PushData) {
+ Timber.tag(loggerTag.value).d("## handling pushData")
+
+ if (buildMeta.lowPrivacyLoggingEnabled) {
+ Timber.tag(loggerTag.value).d("## pushData: $pushData")
+ }
+
+ defaultPushDataStore.incrementPushCounter()
+
+ // Diagnostic Push
+ if (pushData.eventId == PushersManager.TEST_EVENT_ID) {
+ val intent = Intent(actionIds.push)
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
+ return
+ }
+
+ // TODO EAx Should be per user
+ if (!pushDataStore.areNotificationEnabledForDevice()) {
+ Timber.tag(loggerTag.value).i("Notification are disabled for this device")
+ return
+ }
+
+ mUIHandler.post {
+ coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) }
+ }
+ }
+
+ /**
+ * Internal receive method.
+ *
+ * @param pushData Object containing message data.
+ */
+ private suspend fun handleInternal(pushData: PushData) {
+ try {
+ if (buildMeta.lowPrivacyLoggingEnabled) {
+ Timber.tag(loggerTag.value).d("## handleInternal() : $pushData")
+ } else {
+ Timber.tag(loggerTag.value).d("## handleInternal()")
+ }
+
+ pushData.roomId ?: return
+ pushData.eventId ?: return
+
+ val clientSecret = pushData.clientSecret
+ val userId = if (clientSecret == null) {
+ // Should not happen. In this case, restore default session
+ null
+ } else {
+ // Get userId from client secret
+ pushClientSecret.getUserIdFromSecret(clientSecret)
+ } ?: run {
+ matrixAuthenticationService.getLatestSessionId()
+ }
+
+ if (userId == null) {
+ Timber.w("Unable to get a session")
+ return
+ }
+
+ val notificationData = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
+
+ if (notificationData == null) {
+ Timber.w("Unable to get a notification data")
+ return
+ }
+
+ notificationDrawerManager.onNotifiableEventReceived(notificationData)
+ } catch (e: Exception) {
+ Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt
new file mode 100644
index 00000000000..02bd7850e9e
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.pushgateway
+
+
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+internal interface PushGatewayAPI {
+ /**
+ * Ask the Push Gateway to send a push to the current device.
+ *
+ * Ref: https://matrix.org/docs/spec/push_gateway/r0.1.1#post-matrix-push-v1-notify
+ */
+ @POST(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH + "notify")
+ suspend fun notify(@Body body: PushGatewayNotifyBody): PushGatewayNotifyResponse
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt
new file mode 100644
index 00000000000..5cd46f873d4
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.pushgateway
+
+object PushGatewayConfig {
+ // Push Gateway
+ const val URI_PUSH_GATEWAY_PREFIX_PATH = "_matrix/push/v1/"
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt
new file mode 100644
index 00000000000..7adedfcfd26
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.pushgateway
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class PushGatewayDevice(
+ /**
+ * Required. The app_id given when the pusher was created.
+ */
+ @SerialName("app_id")
+ val appId: String,
+ /**
+ * Required. The pushkey given when the pusher was created.
+ */
+ @SerialName("pushkey")
+ val pushKey: String
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt
new file mode 100644
index 00000000000..b7649f68008
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.pushgateway
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class PushGatewayNotification(
+ @SerialName("event_id")
+ val eventId: String,
+
+ /**
+ * Required. This is an array of devices that the notification should be sent to.
+ */
+ @SerialName("devices")
+ val devices: List
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt
new file mode 100644
index 00000000000..ce41d2d83e9
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.pushgateway
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class PushGatewayNotifyBody(
+ /**
+ * Required. Information about the push notification
+ */
+ @SerialName("notification")
+ val notification: PushGatewayNotification
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt
new file mode 100644
index 00000000000..7130e38d6e3
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.pushgateway
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.network.RetrofitFactory
+import io.element.android.libraries.push.api.gateway.PushGatewayFailure
+import javax.inject.Inject
+
+class PushGatewayNotifyRequest @Inject constructor(
+ private val retrofitFactory: RetrofitFactory,
+) {
+ data class Params(
+ val url: String,
+ val appId: String,
+ val pushKey: String,
+ val eventId: EventId
+ )
+
+ suspend fun execute(params: Params) {
+ val sygnalApi = retrofitFactory.create(
+ params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH)
+ )
+ .create(PushGatewayAPI::class.java)
+
+ val response = sygnalApi.notify(
+ PushGatewayNotifyBody(
+ PushGatewayNotification(
+ eventId = params.eventId.value,
+ devices = listOf(
+ PushGatewayDevice(
+ params.appId,
+ params.pushKey
+ )
+ )
+ )
+ )
+ )
+
+ if (response.rejectedPushKeys.contains(params.pushKey)) {
+ throw PushGatewayFailure.PusherRejected
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt
new file mode 100644
index 00000000000..13d9cbad1de
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.pushgateway
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class PushGatewayNotifyResponse(
+ @SerialName("rejected")
+ val rejectedPushKeys: List
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt
new file mode 100644
index 00000000000..ffbd575aa4f
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt
@@ -0,0 +1,149 @@
+/*
+ * 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.store
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.DefaultPreferences
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.push.api.model.BackgroundSyncMode
+import io.element.android.libraries.push.api.store.PushDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "push_store")
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class DefaultPushDataStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @DefaultPreferences private val defaultPrefs: SharedPreferences,
+) : PushDataStore {
+ private val pushCounter = intPreferencesKey("push_counter")
+
+ override val pushCounterFlow: Flow = context.dataStore.data.map { preferences ->
+ preferences[pushCounter] ?: 0
+ }
+
+ suspend fun incrementPushCounter() {
+ context.dataStore.edit { settings ->
+ val currentCounterValue = settings[pushCounter] ?: 0
+ settings[pushCounter] = currentCounterValue + 1
+ }
+ }
+
+ override fun areNotificationEnabledForDevice(): Boolean {
+ return defaultPrefs.getBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, true)
+ }
+
+ override fun setNotificationEnabledForDevice(enabled: Boolean) {
+ defaultPrefs.edit {
+ putBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, enabled)
+ }
+ }
+
+ override fun backgroundSyncTimeOut(): Int {
+ return tryOrNull {
+ // The xml pref is saved as a string so use getString and parse
+ defaultPrefs.getString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, null)?.toInt()
+ } ?: BackgroundSyncMode.DEFAULT_SYNC_TIMEOUT_SECONDS
+ }
+
+ override fun setBackgroundSyncTimeout(timeInSecond: Int) {
+ defaultPrefs
+ .edit()
+ .putString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, timeInSecond.toString())
+ .apply()
+ }
+
+ override fun backgroundSyncDelay(): Int {
+ return tryOrNull {
+ // The xml pref is saved as a string so use getString and parse
+ defaultPrefs.getString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, null)?.toInt()
+ } ?: BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS
+ }
+
+ override fun setBackgroundSyncDelay(timeInSecond: Int) {
+ defaultPrefs
+ .edit()
+ .putString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, timeInSecond.toString())
+ .apply()
+ }
+
+ override fun isBackgroundSyncEnabled(): Boolean {
+ return getFdroidSyncBackgroundMode() != BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
+ }
+
+ override fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) {
+ defaultPrefs
+ .edit()
+ .putString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, mode.name)
+ .apply()
+ }
+
+ override fun getFdroidSyncBackgroundMode(): BackgroundSyncMode {
+ return try {
+ val strPref = defaultPrefs
+ .getString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY.name)
+ BackgroundSyncMode.values().firstOrNull { it.name == strPref } ?: BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY
+ } catch (e: Throwable) {
+ BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY
+ }
+ }
+
+ /**
+ * Return true if Pin code is disabled, or if user set the settings to see full notification content.
+ */
+ override fun useCompleteNotificationFormat(): Boolean {
+ return true
+ /*
+ return !useFlagPinCode() ||
+ defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG, true)
+ */
+ }
+
+ companion object {
+ // notifications
+ const val SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY = "SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY"
+ const val SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY = "SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY"
+
+ // background sync
+ const val SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY"
+ const val SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY"
+ const val SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY = "SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY"
+ const val SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY = "SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY"
+
+ const val SETTINGS_FDROID_BACKGROUND_SYNC_MODE = "SETTINGS_FDROID_BACKGROUND_SYNC_MODE"
+ const val SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY"
+
+ const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG"
+
+ // notification method
+ const val SETTINGS_NOTIFICATION_METHOD_KEY = "SETTINGS_NOTIFICATION_METHOD_KEY"
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt
new file mode 100644
index 00000000000..08bd4a83263
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.unifiedpush
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+interface GuardServiceStarter {
+ fun start() {}
+ fun stop() {}
+}
+
+@ContributesBinding(AppScope::class)
+class NoopGuardServiceStarter @Inject constructor() : GuardServiceStarter
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt
new file mode 100644
index 00000000000..de66ed3914b
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2022 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.unifiedpush
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/**
+ * UnifiedPush lib tracks an action to check installed and uninstalled distributors.
+ * We declare it to keep the background sync as an internal unifiedpush distributor.
+ * This class is used to declare this action.
+ */
+class KeepInternalDistributor : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {}
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt
new file mode 100644
index 00000000000..56513ab970d
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.unifiedpush
+
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.asEventId
+import io.element.android.libraries.matrix.api.core.asRoomId
+import io.element.android.libraries.push.impl.push.PushData
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * In this case, the format is:
+ *
+ * {
+ * "notification":{
+ * "event_id":"$anEventId",
+ * "room_id":"!aRoomId",
+ * "counts":{
+ * "unread":1
+ * },
+ * "prio":"high"
+ * }
+ * }
+ *
+ * .
+ */
+@Serializable
+data class PushDataUnifiedPush(
+ val notification: PushDataUnifiedPushNotification?
+)
+
+@Serializable
+data class PushDataUnifiedPushNotification(
+ @SerialName("event_id") val eventId: String?,
+ @SerialName("room_id") val roomId: String?,
+ @SerialName("counts") var counts: PushDataUnifiedPushCounts?,
+)
+
+@Serializable
+data class PushDataUnifiedPushCounts(
+ @SerialName("unread") val unread: Int?
+)
+
+fun PushDataUnifiedPush.toPushData() = PushData(
+ eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(),
+ roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(),
+ unread = notification?.counts?.unread,
+ clientSecret = null // TODO EAx check how client secret will be sent through UnifiedPush
+)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt
new file mode 100644
index 00000000000..50ca94f30db
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.unifiedpush
+
+import android.content.Context
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.push.impl.config.PushConfig
+import org.unifiedpush.android.connector.UnifiedPush
+import javax.inject.Inject
+
+class RegisterUnifiedPushUseCase @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ sealed interface RegisterUnifiedPushResult {
+ object Success : RegisterUnifiedPushResult
+ object NeedToAskUserForDistributor : RegisterUnifiedPushResult
+ }
+
+ fun execute(distributor: String = ""): RegisterUnifiedPushResult {
+ if (distributor.isNotEmpty()) {
+ saveAndRegisterApp(distributor)
+ return RegisterUnifiedPushResult.Success
+ }
+
+ if (!PushConfig.allowExternalUnifiedPushDistributors) {
+ saveAndRegisterApp(context.packageName)
+ return RegisterUnifiedPushResult.Success
+ }
+
+ if (UnifiedPush.getDistributor(context).isNotEmpty()) {
+ registerApp()
+ return RegisterUnifiedPushResult.Success
+ }
+
+ val distributors = UnifiedPush.getDistributors(context)
+
+ return if (distributors.size == 1) {
+ saveAndRegisterApp(distributors.first())
+ RegisterUnifiedPushResult.Success
+ } else {
+ RegisterUnifiedPushResult.NeedToAskUserForDistributor
+ }
+ }
+
+ private fun saveAndRegisterApp(distributor: String) {
+ UnifiedPush.saveDistributor(context, distributor)
+ registerApp()
+ }
+
+ private fun registerApp() {
+ UnifiedPush.registerApp(context)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt
new file mode 100644
index 00000000000..9788ecf1a1d
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022 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.unifiedpush
+
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.push.impl.push.PushData
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import javax.inject.Inject
+
+class UnifiedPushParser @Inject constructor() {
+ fun parse(message: ByteArray): PushData? {
+ return tryOrNull { Json.decodeFromString(String(message)) }?.toPushData()
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt
new file mode 100644
index 00000000000..6cd1af1de36
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.unifiedpush
+
+import android.content.Context
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.push.api.model.BackgroundSyncMode
+import io.element.android.libraries.push.api.store.PushDataStore
+import io.element.android.libraries.push.impl.PushersManager
+import io.element.android.libraries.push.impl.UnifiedPushHelper
+import io.element.android.libraries.push.impl.UnifiedPushStore
+import org.unifiedpush.android.connector.UnifiedPush
+import timber.log.Timber
+import javax.inject.Inject
+
+class UnregisterUnifiedPushUseCase @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pushDataStore: PushDataStore,
+ private val unifiedPushStore: UnifiedPushStore,
+ private val unifiedPushHelper: UnifiedPushHelper,
+) {
+
+ suspend fun execute(pushersManager: PushersManager?) {
+ val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
+ pushDataStore.setFdroidSyncBackgroundMode(mode)
+ try {
+ unifiedPushHelper.getEndpointOrToken()?.let {
+ Timber.d("Removing $it")
+ pushersManager?.unregisterPusher(it)
+ }
+ } catch (e: Exception) {
+ Timber.d(e, "Probably unregistering a non existing pusher")
+ }
+ unifiedPushStore.storeUpEndpoint(null)
+ unifiedPushStore.storePushGateway(null)
+ UnifiedPush.unregisterApp(context)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt
new file mode 100644
index 00000000000..81dd389e78a
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt
@@ -0,0 +1,123 @@
+/*
+ * 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.unifiedpush
+
+import android.content.Context
+import android.content.Intent
+import android.widget.Toast
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.push.api.model.BackgroundSyncMode
+import io.element.android.libraries.push.api.store.PushDataStore
+import io.element.android.libraries.push.impl.PushersManager
+import io.element.android.libraries.push.impl.UnifiedPushHelper
+import io.element.android.libraries.push.impl.UnifiedPushStore
+import io.element.android.libraries.push.impl.log.pushLoggerTag
+import io.element.android.libraries.push.impl.push.PushHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.unifiedpush.android.connector.MessagingReceiver
+import timber.log.Timber
+import javax.inject.Inject
+
+private val loggerTag = LoggerTag("Unified", pushLoggerTag)
+
+class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
+ @Inject lateinit var pushersManager: PushersManager
+ @Inject lateinit var pushParser: UnifiedPushParser
+
+ //@Inject lateinit var activeSessionHolder: ActiveSessionHolder
+ @Inject lateinit var pushDataStore: PushDataStore
+ @Inject lateinit var pushHandler: PushHandler
+ @Inject lateinit var guardServiceStarter: GuardServiceStarter
+ @Inject lateinit var unifiedPushStore: UnifiedPushStore
+ @Inject lateinit var unifiedPushHelper: UnifiedPushHelper
+
+ private val coroutineScope = CoroutineScope(SupervisorJob())
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ // Inject
+ context.applicationContext.bindings().inject(this)
+ }
+
+ /**
+ * Called when message is received.
+ *
+ * @param context the Android context
+ * @param message the message
+ * @param instance connection, for multi-account
+ */
+ override fun onMessage(context: Context, message: ByteArray, instance: String) {
+ Timber.tag(loggerTag.value).d("New message")
+ coroutineScope.launch {
+ pushParser.parse(message)?.let {
+ pushHandler.handle(it)
+ } ?: run {
+ Timber.tag(loggerTag.value).w("Invalid received data Json format")
+ }
+ }
+ }
+
+ override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
+ Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint")
+ if (pushDataStore.areNotificationEnabledForDevice() /* TODO EAx && activeSessionHolder.hasActiveSession() */) {
+ // If the endpoint has changed
+ // or the gateway has changed
+ if (unifiedPushHelper.getEndpointOrToken() != endpoint) {
+ unifiedPushStore.storeUpEndpoint(endpoint)
+ coroutineScope.launch {
+ unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) {
+ unifiedPushHelper.getPushGateway()?.let {
+ coroutineScope.launch {
+ pushersManager.onNewUnifiedPushEndpoint(endpoint, it)
+ }
+ }
+ }
+ }
+ } else {
+ Timber.tag(loggerTag.value).i("onNewEndpoint: skipped")
+ }
+ }
+ val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED
+ pushDataStore.setFdroidSyncBackgroundMode(mode)
+ guardServiceStarter.stop()
+ }
+
+ override fun onRegistrationFailed(context: Context, instance: String) {
+ Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show()
+ val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
+ pushDataStore.setFdroidSyncBackgroundMode(mode)
+ guardServiceStarter.start()
+ }
+
+ override fun onUnregistered(context: Context, instance: String) {
+ Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered")
+ val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
+ pushDataStore.setFdroidSyncBackgroundMode(mode)
+ guardServiceStarter.start()
+ runBlocking {
+ try {
+ pushersManager.unregisterPusher(unifiedPushHelper.getEndpointOrToken().orEmpty())
+ } catch (e: Exception) {
+ Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher")
+ }
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt
new file mode 100644
index 00000000000..90857d990d1
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.unifiedpush
+
+import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.libraries.di.AppScope
+
+@ContributesTo(AppScope::class)
+interface VectorUnifiedPushMessagingReceiverBindings {
+ fun inject(receiver: VectorUnifiedPushMessagingReceiver)
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt
new file mode 100644
index 00000000000..82c4beaf20f
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.userpushstore
+
+const val NOTIFICATION_METHOD_FIREBASE = "NOTIFICATION_METHOD_FIREBASE"
+const val NOTIFICATION_METHOD_UNIFIEDPUSH = "NOTIFICATION_METHOD_UNIFIEDPUSH"
+
+/**
+ * Store data related to push about a user.
+ */
+interface UserPushStore {
+ /**
+ * [NOTIFICATION_METHOD_FIREBASE] or [NOTIFICATION_METHOD_UNIFIEDPUSH].
+ */
+ suspend fun getNotificationMethod(): String
+
+ suspend fun setNotificationMethod(value: String)
+
+ suspend fun getCurrentRegisteredPushKey(): String?
+
+ suspend fun setCurrentRegisteredPushKey(value: String)
+
+ suspend fun reset()
+}
+
+suspend fun UserPushStore.isFirebase(): Boolean = getNotificationMethod() == NOTIFICATION_METHOD_FIREBASE
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt
new file mode 100644
index 00000000000..6f25599e545
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.userpushstore
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+/**
+ * Store data related to push about a user.
+ */
+class UserPushStoreDataStore(
+ private val context: Context,
+ userId: String,
+) : UserPushStore {
+ private val Context.dataStore: DataStore by preferencesDataStore(name = "push_store_$userId")
+ private val notificationMethod = stringPreferencesKey("notificationMethod")
+ private val currentPushKey = stringPreferencesKey("currentPushKey")
+
+ override suspend fun getNotificationMethod(): String {
+ return context.dataStore.data.first()[notificationMethod] ?: NOTIFICATION_METHOD_FIREBASE
+ }
+
+ override suspend fun setNotificationMethod(value: String) {
+ context.dataStore.edit {
+ it[notificationMethod] = value
+ }
+ }
+
+ override suspend fun getCurrentRegisteredPushKey(): String? {
+ return context.dataStore.data.first()[currentPushKey]
+ }
+
+ override suspend fun setCurrentRegisteredPushKey(value: String) {
+ context.dataStore.edit {
+ it[currentPushKey] = value
+ }
+ }
+
+ override suspend fun reset() {
+ context.dataStore.edit {
+ it.clear()
+ }
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt
new file mode 100644
index 00000000000..0323713de72
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.userpushstore
+
+import android.content.Context
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+class UserPushStoreFactory @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val sessionObserver: SessionObserver,
+) : SessionListener {
+ init {
+ observeSessions()
+ }
+
+ // We can have only one class accessing a single data store, so keep a cache of them.
+ private val cache = mutableMapOf()
+ fun create(userId: String): UserPushStore {
+ return cache.getOrPut(userId) {
+ UserPushStoreDataStore(
+ context = context,
+ userId = userId
+ )
+ }
+ }
+
+ private fun observeSessions() {
+ sessionObserver.addListener(this)
+ }
+
+ override suspend fun onSessionCreated(userId: String) {
+ // Nothing to do
+ }
+
+ override suspend fun onSessionDeleted(userId: String) {
+ // Delete the store
+ create(userId).reset()
+ }
+}
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml
new file mode 100644
index 00000000000..e9b119c9690
--- /dev/null
+++ b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png
new file mode 100755
index 00000000000..1f3132a3f2f
Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png differ
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png
new file mode 100644
index 00000000000..a86508b71b4
Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png differ
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png
new file mode 100755
index 00000000000..eb2be251878
Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png differ
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png
new file mode 100755
index 00000000000..4af4ae634b4
Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png differ
diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png
new file mode 100755
index 00000000000..51b4401ca05
Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png differ
diff --git a/libraries/push/impl/src/main/res/values/colors.xml b/libraries/push/impl/src/main/res/values/colors.xml
new file mode 100644
index 00000000000..6e04238a1a9
--- /dev/null
+++ b/libraries/push/impl/src/main/res/values/colors.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ #368BD6
+
+
diff --git a/libraries/push/impl/src/main/res/values/dimens.xml b/libraries/push/impl/src/main/res/values/dimens.xml
new file mode 100644
index 00000000000..ce2fee2015c
--- /dev/null
+++ b/libraries/push/impl/src/main/res/values/dimens.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ 50dp
+
+
diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml
new file mode 100644
index 00000000000..3a11adb5d3b
--- /dev/null
+++ b/libraries/push/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,48 @@
+
+
+ "Call"
+ "Listening for events"
+ "Noisy notifications"
+ "Silent notifications"
+ "** Failed to send - please open room"
+ "Join"
+ "Reject"
+ "New Messages"
+ "Mark as read"
+ "Quick reply"
+ "Me"
+ "You are viewing the notification! Click me!"
+ "%1$s: %2$s"
+ "%1$s: %2$s %3$s"
+ "%1$s and %2$s"
+ "%1$s in %2$s"
+ "%1$s in %2$s and %3$s"
+
+ - "%1$s: %2$d message"
+ - "%1$s: %2$d messages"
+
+
+ - "%d notification"
+ - "%d notifications"
+
+
+ - "%d invitation"
+ - "%d invitations"
+
+
+ - "%d new message"
+ - "%d new messages"
+
+
+ - "%d unread notified message"
+ - "%d unread notified messages"
+
+
+ - "%d room"
+ - "%d rooms"
+
+ "Choose how to receive notifications"
+ "Background synchronization"
+ "Google Services"
+ "No valid Google Play Services found. Notifications may not work properly."
+
\ No newline at end of file
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt
new file mode 100644
index 00000000000..25823a57e83
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.clientsecret
+
+private const val A_SECRET_PREFIX = "A_SECRET_"
+
+class FakePushClientSecretFactory : PushClientSecretFactory {
+ private var index = 0
+
+ override fun create() = getSecretForUser(index++)
+
+ fun getSecretForUser(i: Int): String {
+ return A_SECRET_PREFIX + i
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt
new file mode 100644
index 00000000000..a2d2d9c83c0
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.clientsecret
+
+import io.element.android.libraries.matrix.api.core.SessionId
+
+class InMemoryPushClientSecretStore : PushClientSecretStore {
+ private val secrets = mutableMapOf()
+
+ fun getSecrets(): Map = secrets
+
+ override suspend fun storeSecret(userId: SessionId, clientSecret: String) {
+ secrets[userId] = clientSecret
+ }
+
+ override suspend fun getSecret(userId: SessionId): String? {
+ return secrets[userId]
+ }
+
+ override suspend fun resetSecret(userId: SessionId) {
+ secrets.remove(userId)
+ }
+
+ override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? {
+ return secrets.keys.firstOrNull { secrets[it] == clientSecret }
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt
new file mode 100644
index 00000000000..a9d740bf31f
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.libraries.push.impl.clientsecret
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.core.SessionId
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+private val A_USER_ID_0 = SessionId("A_USER_ID_0")
+private val A_USER_ID_1 = SessionId("A_USER_ID_1")
+
+private const val A_UNKNOWN_SECRET = "A_UNKNOWN_SECRET"
+
+internal class PushClientSecretImplTest {
+
+ @Test
+ fun test() = runTest {
+ val factory = FakePushClientSecretFactory()
+ val store = InMemoryPushClientSecretStore()
+ val sut = PushClientSecretImpl(factory, store)
+
+ val secret0 = factory.getSecretForUser(0)
+ val secret1 = factory.getSecretForUser(1)
+ val secret2 = factory.getSecretForUser(2)
+
+ assertThat(store.getSecrets()).isEmpty()
+ assertThat(sut.getUserIdFromSecret(secret0)).isNull()
+ // Create a secret
+ assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret0)
+ assertThat(store.getSecrets()).hasSize(1)
+ // Same secret returned
+ assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret0)
+ assertThat(store.getSecrets()).hasSize(1)
+ // Another secret returned for another user
+ assertThat(sut.getSecretForUser(A_USER_ID_1)).isEqualTo(secret1)
+ assertThat(store.getSecrets()).hasSize(2)
+
+ // Get users from secrets
+ assertThat(sut.getUserIdFromSecret(secret0)).isEqualTo(A_USER_ID_0)
+ assertThat(sut.getUserIdFromSecret(secret1)).isEqualTo(A_USER_ID_1)
+ // Unknown secret
+ assertThat(sut.getUserIdFromSecret(A_UNKNOWN_SECRET)).isNull()
+
+ // User signs out
+ sut.resetSecretForUser(A_USER_ID_0)
+ assertThat(store.getSecrets()).hasSize(1)
+ // Create a new secret after reset
+ assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret2)
+
+ // Check the store content
+ assertThat(store.getSecrets()).isEqualTo(
+ mapOf(
+ A_USER_ID_0 to secret2,
+ A_USER_ID_1 to secret1,
+ )
+ )
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt
new file mode 100644
index 00000000000..80246abb14b
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2021 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.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.A_SPACE_ID
+import io.element.android.libraries.matrix.test.A_THREAD_ID
+import io.element.android.libraries.push.impl.notifications.fake.FakeOutdatedEventDetector
+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.impl.notifications.model.NotifiableEvent
+import io.element.android.services.appnavstate.test.anAppNavigationState
+import org.junit.Test
+
+private val NOT_VIEWING_A_ROOM = anAppNavigationState()
+private val VIEWING_A_ROOM = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID)
+private val VIEWING_A_THREAD = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID)
+
+class NotifiableEventProcessorTest {
+
+ private val outdatedDetector = FakeOutdatedEventDetector()
+ private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance)
+
+ @Test
+ fun `given simple events when processing then keep simple events`() {
+ val events = listOf(
+ aSimpleNotifiableEvent(eventId = AN_EVENT_ID),
+ aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2)
+ )
+
+ val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.KEEP to events[0],
+ ProcessedEvent.Type.KEEP to events[1]
+ )
+ )
+ }
+
+ @Test
+ fun `given redacted simple event when processing then remove redaction event`() {
+ val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = "m.room.redaction"))
+
+ val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.REMOVE to events[0]
+ )
+ )
+ }
+
+ @Test
+ fun `given invites are not auto accepted when processing then keep invitation events`() {
+ val events = listOf(
+ anInviteNotifiableEvent(roomId = A_ROOM_ID),
+ anInviteNotifiableEvent(roomId = A_ROOM_ID_2)
+ )
+
+ val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.KEEP to events[0],
+ ProcessedEvent.Type.KEEP to events[1]
+ )
+ )
+ }
+
+ @Test
+ fun `given out of date message event when processing then removes message event`() {
+ val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
+ outdatedDetector.givenEventIsOutOfDate(events[0])
+
+ val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.REMOVE to events[0],
+ )
+ )
+ }
+
+ @Test
+ fun `given in date message event when processing then keep message event`() {
+ val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
+ outdatedDetector.givenEventIsInDate(events[0])
+
+ val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.KEEP to events[0],
+ )
+ )
+ }
+
+ @Test
+ fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
+ val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
+
+ val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.REMOVE to events[0],
+ )
+ )
+ }
+
+ @Test
+ fun `given viewing the same thread timeline when processing thread message event then removes message`() {
+ val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
+
+ val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.REMOVE to events[0],
+ )
+ )
+ }
+
+ @Test
+ fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() {
+ val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
+ outdatedDetector.givenEventIsInDate(events[0])
+
+ val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.KEEP to events[0],
+ )
+ )
+ }
+
+ @Test
+ fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() {
+ val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
+ outdatedDetector.givenEventIsInDate(events[0])
+
+ val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.KEEP to events[0],
+ )
+ )
+ }
+
+ @Test
+ fun `given events are different to rendered events when processing then removes difference`() {
+ val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))
+ val renderedEvents = listOf>(
+ ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]),
+ ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2))
+ )
+
+ val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents)
+
+ assertThat(result).isEqualTo(
+ listOfProcessedEvents(
+ ProcessedEvent.Type.REMOVE to renderedEvents[1].event,
+ ProcessedEvent.Type.KEEP to renderedEvents[0].event
+ )
+ )
+ }
+
+ private fun listOfProcessedEvents(vararg event: Pair) = event.map {
+ ProcessedEvent(it.first, it.second)
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt
new file mode 100644
index 00000000000..4bec096468d
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2021 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.google.common.truth.Truth.assertThat
+import io.element.android.libraries.core.cache.CircularCache
+import io.element.android.libraries.matrix.api.core.EventId
+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.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.impl.notifications.model.NotifiableEvent
+import org.junit.Test
+
+class NotificationEventQueueTest {
+
+ private val seenIdsCache = CircularCache.create(5)
+
+ @Test
+ fun `given events when redacting some then marks matching event ids as redacted`() {
+ val queue = givenQueue(
+ listOf(
+ aSimpleNotifiableEvent(eventId = EventId("redacted-id-1")),
+ aNotifiableMessageEvent(eventId = EventId("redacted-id-2")),
+ anInviteNotifiableEvent(eventId = EventId("redacted-id-3")),
+ aSimpleNotifiableEvent(eventId = EventId("kept-id")),
+ )
+ )
+
+ queue.markRedacted(listOf(EventId("redacted-id-1"), EventId("redacted-id-2"), EventId("redacted-id-3")))
+
+ assertThat(queue.rawEvents()).isEqualTo(
+ listOf(
+ aSimpleNotifiableEvent(eventId = EventId("redacted-id-1"), isRedacted = true),
+ aNotifiableMessageEvent(eventId = EventId("redacted-id-2"), isRedacted = true),
+ anInviteNotifiableEvent(eventId = EventId("redacted-id-3"), isRedacted = true),
+ aSimpleNotifiableEvent(eventId = EventId("kept-id"), isRedacted = false),
+ )
+ )
+ }
+
+ @Test
+ fun `given invite event when leaving invited room and syncing then removes event`() {
+ val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID)))
+ val roomsLeft = listOf(A_ROOM_ID)
+
+ queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
+
+ assertThat(queue.rawEvents()).isEmpty()
+ }
+
+ @Test
+ fun `given invite event when joining invited room and syncing then removes event`() {
+ val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID)))
+ val joinedRooms = listOf(A_ROOM_ID)
+
+ queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms)
+
+ assertThat(queue.rawEvents()).isEmpty()
+ }
+
+ @Test
+ fun `given message event when leaving message room and syncing then removes event`() {
+ val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
+ val roomsLeft = listOf(A_ROOM_ID)
+
+ queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
+
+ assertThat(queue.rawEvents()).isEmpty()
+ }
+
+ @Test
+ fun `given events when syncing without rooms left or joined ids then does not change the events`() {
+ val queue = givenQueue(
+ listOf(
+ aNotifiableMessageEvent(roomId = A_ROOM_ID),
+ anInviteNotifiableEvent(roomId = A_ROOM_ID)
+ )
+ )
+
+ queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList())
+
+ assertThat(queue.rawEvents()).isEqualTo(
+ listOf(
+ aNotifiableMessageEvent(roomId = A_ROOM_ID),
+ anInviteNotifiableEvent(roomId = A_ROOM_ID)
+ )
+ )
+ }
+
+ @Test
+ fun `given events then is not empty`() {
+ val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
+
+ assertThat(queue.isEmpty()).isFalse()
+ }
+
+ @Test
+ fun `given no events then is empty`() {
+ val queue = givenQueue(emptyList())
+
+ assertThat(queue.isEmpty()).isTrue()
+ }
+
+ @Test
+ fun `given events when clearing and adding then removes previous events and adds only new events`() {
+ val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
+
+ queue.clearAndAdd(listOf(anInviteNotifiableEvent()))
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent()))
+ }
+
+ @Test
+ fun `when clearing then is empty`() {
+ val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
+
+ queue.clear()
+
+ assertThat(queue.rawEvents()).isEmpty()
+ }
+
+ @Test
+ fun `given no events when adding then adds event`() {
+ val queue = givenQueue(listOf())
+
+ queue.add(aSimpleNotifiableEvent())
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(aSimpleNotifiableEvent()))
+ }
+
+ @Test
+ fun `given no events when adding already seen event then ignores event`() {
+ val queue = givenQueue(listOf())
+ val notifiableEvent = aSimpleNotifiableEvent()
+ seenIdsCache.put(notifiableEvent.eventId)
+
+ queue.add(notifiableEvent)
+
+ assertThat(queue.rawEvents()).isEmpty()
+ }
+
+ @Test
+ fun `given replaceable event when adding event with same id then updates existing event`() {
+ val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true)
+ val updatedEvent = replaceableEvent.copy(title = "updated title", isUpdated = true)
+ val queue = givenQueue(listOf(replaceableEvent))
+
+ queue.add(updatedEvent)
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent))
+ }
+
+ @Test
+ fun `given non replaceable event when adding event with same id then ignores event`() {
+ val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false)
+ val updatedEvent = nonReplaceableEvent.copy(title = "updated title")
+ val queue = givenQueue(listOf(nonReplaceableEvent))
+
+ queue.add(updatedEvent)
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(nonReplaceableEvent))
+ }
+
+ @Test
+ fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() {
+ val editedEvent = aSimpleNotifiableEvent(eventId = EventId("id-to-edit"))
+ val updatedEvent = editedEvent.copy(eventId = EventId("1"), editedEventId = EventId("id-to-edit"), title = "updated title", isUpdated = true)
+ val queue = givenQueue(listOf(editedEvent))
+
+ queue.add(updatedEvent)
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent))
+ }
+
+ @Test
+ fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() {
+ val editedEvent = aSimpleNotifiableEvent(eventId = EventId("0"), editedEventId = EventId("id-to-edit"))
+ val updatedEvent = editedEvent.copy(eventId = EventId("1"), editedEventId = EventId("id-to-edit"), title = "updated title", isUpdated = true)
+ val queue = givenQueue(listOf(editedEvent))
+
+ queue.add(updatedEvent)
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent))
+ }
+
+ @Test
+ fun `when clearing membership notification then removes invite events with matching room id`() {
+ val queue = givenQueue(
+ listOf(
+ anInviteNotifiableEvent(roomId = A_ROOM_ID),
+ aNotifiableMessageEvent(roomId = A_ROOM_ID)
+ )
+ )
+
+ queue.clearMemberShipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
+ }
+
+ @Test
+ fun `when clearing messages for room then removes message events with matching room id`() {
+ val queue = givenQueue(
+ listOf(
+ anInviteNotifiableEvent(roomId = A_ROOM_ID),
+ aNotifiableMessageEvent(roomId = A_ROOM_ID)
+ )
+ )
+
+ queue.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID)
+
+ assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID)))
+ }
+
+ private fun givenQueue(events: List) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache)
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt
new file mode 100644
index 00000000000..606923eb1f2
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2021 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.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.core.EventId
+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.FakeNotificationUtils
+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 org.junit.Test
+
+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)
+
+class NotificationFactoryTest {
+
+ private val notificationUtils = FakeNotificationUtils()
+ private val roomGroupMessageCreator = FakeRoomGroupMessageCreator()
+ private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator()
+
+ private val notificationFactory = NotificationFactory(
+ notificationUtils.instance,
+ roomGroupMessageCreator.instance,
+ summaryGroupMessageCreator.instance
+ )
+
+ @Test
+ fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) {
+ val expectedNotification = notificationUtils.givenBuildRoomInvitationNotificationFor(AN_INVITATION_EVENT)
+ val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT))
+
+ val result = roomInvitation.toNotifications()
+
+ assertThat(result).isEqualTo(
+ listOf(
+ OneShotNotification.Append(
+ notification = expectedNotification,
+ meta = OneShotNotification.Append.Meta(
+ key = A_ROOM_ID.value,
+ summaryLine = AN_INVITATION_EVENT.description,
+ isNoisy = AN_INVITATION_EVENT.noisy,
+ timestamp = AN_INVITATION_EVENT.timestamp
+ )
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) {
+ val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, AN_INVITATION_EVENT))
+
+ val result = missingEventRoomInvitation.toNotifications()
+
+ assertThat(result).isEqualTo(
+ listOf(
+ OneShotNotification.Removed(
+ key = A_ROOM_ID.value
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) {
+ val expectedNotification = notificationUtils.givenBuildSimpleInvitationNotificationFor(A_SIMPLE_EVENT)
+ val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT))
+
+ val result = roomInvitation.toNotifications()
+
+ assertThat(result).isEqualTo(
+ listOf(
+ OneShotNotification.Append(
+ notification = expectedNotification,
+ meta = OneShotNotification.Append.Meta(
+ key = AN_EVENT_ID.value,
+ summaryLine = A_SIMPLE_EVENT.description,
+ isNoisy = A_SIMPLE_EVENT.noisy,
+ timestamp = AN_INVITATION_EVENT.timestamp
+ )
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) {
+ val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_SIMPLE_EVENT))
+
+ val result = missingEventRoomInvitation.toNotifications()
+
+ assertThat(result).isEqualTo(
+ listOf(
+ OneShotNotification.Removed(
+ key = AN_EVENT_ID.value
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) {
+ val events = listOf(A_MESSAGE_EVENT)
+ val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(
+ A_SESSION_ID, events, A_ROOM_ID, A_SESSION_ID.value, MY_AVATAR_URL
+ )
+ val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT)))
+
+ val result = roomWithMessage.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
+
+ assertThat(result).isEqualTo(listOf(expectedNotification))
+ }
+
+ @Test
+ fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) {
+ val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT))
+ val emptyRoom = mapOf(A_ROOM_ID to events)
+
+ val result = emptyRoom.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
+
+ assertThat(result).isEqualTo(
+ listOf(
+ RoomNotification.Removed(
+ roomId = A_ROOM_ID
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) {
+ val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true))))
+
+ val result = redactedRoom.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
+
+ assertThat(result).isEqualTo(
+ listOf(
+ RoomNotification.Removed(
+ roomId = A_ROOM_ID
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith(
+ notificationFactory
+ ) {
+ val roomWithRedactedMessage = mapOf(
+ A_ROOM_ID to listOf(
+ ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)),
+ ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(eventId = EventId("not-redacted")))
+ )
+ )
+ val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("not-redacted")))
+ val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(
+ A_SESSION_ID,
+ withRedactedRemoved,
+ A_ROOM_ID,
+ A_SESSION_ID.value,
+ MY_AVATAR_URL
+ )
+
+ val result = roomWithRedactedMessage.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
+
+ assertThat(result).isEqualTo(listOf(expectedNotification))
+ }
+}
+
+fun testWith(receiver: T, block: T.() -> Unit) {
+ receiver.block()
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt
new file mode 100644
index 00000000000..57f28e72db3
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 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 org.junit.Test
+
+class NotificationIdProviderTest {
+ @Test
+ fun `test notification id provider`() {
+ val sut = NotificationIdProvider()
+ val offsetForASessionId = 305410
+ assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 0)
+ assertThat(sut.getRoomMessagesNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 1)
+ assertThat(sut.getRoomEventNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 2)
+ assertThat(sut.getRoomInvitationNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 3)
+ // Check that value will be different for another sessionId
+ assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isNotEqualTo(sut.getSummaryNotificationId(A_SESSION_ID_2))
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt
new file mode 100644
index 00000000000..79c6dfdb025
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt
@@ -0,0 +1,227 @@
+/*
+ * Copyright (c) 2021 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 android.app.Notification
+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.FakeNotificationDisplayer
+import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationFactory
+import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
+import io.mockk.mockk
+import org.junit.Test
+
+private const val MY_USER_DISPLAY_NAME = "display-name"
+private const val MY_USER_AVATAR_URL = "avatar-url"
+private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
+
+private val AN_EVENT_LIST = listOf>()
+private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList())
+private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk())
+private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed
+private val A_NOTIFICATION = mockk()
+private val MESSAGE_META = RoomNotification.Message.Meta(
+ summaryLine = "ignored", messageCount = 1, latestTimestamp = -1, roomId = A_ROOM_ID, shouldBing = false
+)
+private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1)
+
+class NotificationRendererTest {
+
+ private val notificationDisplayer = FakeNotificationDisplayer()
+ private val notificationFactory = FakeNotificationFactory()
+ private val notificationIdProvider = NotificationIdProvider()
+
+ private val notificationRenderer = NotificationRenderer(
+ notificationIdProvider = notificationIdProvider,
+ notificationDisplayer = notificationDisplayer.instance,
+ notificationFactory = notificationFactory.instance,
+ )
+
+ @Test
+ fun `given no notifications when rendering then cancels summary notification`() {
+ givenNoNotifications()
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifySummaryCancelled()
+ notificationDisplayer.verifyNoOtherInteractions()
+ }
+
+ @Test
+ fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() {
+ givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID))
+ cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID))
+ }
+ }
+
+ @Test
+ fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() {
+ givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)))
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID))
+ showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
+ }
+ }
+
+ @Test
+ fun `given a room message group notification is added when rendering then show the message notification and update summary`() {
+ givenNotifications(
+ roomNotifications = listOf(
+ RoomNotification.Message(
+ A_NOTIFICATION,
+ MESSAGE_META
+ )
+ )
+ )
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), A_NOTIFICATION)
+ showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
+ }
+ }
+
+ @Test
+ fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() {
+ givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID))
+ cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID))
+ }
+ }
+
+ @Test
+ fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() {
+ givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)))
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID))
+ showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
+ }
+ }
+
+ @Test
+ fun `given a simple notification is added when rendering then show the simple notification and update summary`() {
+ givenNotifications(
+ simpleNotifications = listOf(
+ OneShotNotification.Append(
+ A_NOTIFICATION,
+ ONE_SHOT_META.copy(key = AN_EVENT_ID.value)
+ )
+ )
+ )
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ showNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION)
+ showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
+ }
+ }
+
+ @Test
+ fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() {
+ givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID))
+ cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID))
+ }
+ }
+
+ @Test
+ fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() {
+ givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)))
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID))
+ showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
+ }
+ }
+
+ @Test
+ fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() {
+ givenNotifications(
+ simpleNotifications = listOf(
+ OneShotNotification.Append(
+ A_NOTIFICATION,
+ ONE_SHOT_META.copy(key = A_ROOM_ID.value)
+ )
+ )
+ )
+
+ renderEventsAsNotifications()
+
+ notificationDisplayer.verifyInOrder {
+ showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION)
+ showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification)
+ }
+ }
+
+ private fun renderEventsAsNotifications() {
+ notificationRenderer.render(
+ sessionId = A_SESSION_ID,
+ myUserDisplayName = MY_USER_DISPLAY_NAME,
+ myUserAvatarUrl = MY_USER_AVATAR_URL,
+ useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT,
+ eventsToProcess = AN_EVENT_LIST
+ )
+ }
+
+ private fun givenNoNotifications() {
+ givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION)
+ }
+
+ private fun givenNotifications(
+ roomNotifications: List = emptyList(),
+ invitationNotifications: List = emptyList(),
+ simpleNotifications: List = emptyList(),
+ useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
+ summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION
+ ) {
+ notificationFactory.givenNotificationsFor(
+ groupedEvents = A_PROCESSED_EVENTS,
+ sessionId = A_SESSION_ID,
+ myUserDisplayName = MY_USER_DISPLAY_NAME,
+ myUserAvatarUrl = MY_USER_AVATAR_URL,
+ useCompleteNotificationFormat = useCompleteNotificationFormat,
+ roomNotifications = roomNotifications,
+ invitationNotifications = invitationNotifications,
+ simpleNotifications = simpleNotifications,
+ summaryNotification = summaryNotification
+ )
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt
new file mode 100644
index 00000000000..9af681490a2
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2021 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 io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
+import io.element.android.libraries.push.impl.notifications.NotificationIdProvider
+import io.mockk.confirmVerified
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifyOrder
+
+class FakeNotificationDisplayer {
+ val instance = mockk(relaxed = true)
+
+ fun verifySummaryCancelled() {
+ verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) }
+ }
+
+ fun verifyNoOtherInteractions() {
+ confirmVerified(instance)
+ }
+
+ fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) {
+ verifyOrder { verifyBlock(instance) }
+ verifyNoOtherInteractions()
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt
new file mode 100644
index 00000000000..7d7812e6cb6
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2021 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 io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.push.impl.notifications.GroupedNotificationEvents
+import io.element.android.libraries.push.impl.notifications.NotificationFactory
+import io.element.android.libraries.push.impl.notifications.OneShotNotification
+import io.element.android.libraries.push.impl.notifications.RoomNotification
+import io.element.android.libraries.push.impl.notifications.SummaryNotification
+import io.mockk.every
+import io.mockk.mockk
+
+class FakeNotificationFactory {
+ val instance = mockk()
+
+ fun givenNotificationsFor(
+ groupedEvents: GroupedNotificationEvents,
+ sessionId: SessionId,
+ myUserDisplayName: String,
+ myUserAvatarUrl: String?,
+ useCompleteNotificationFormat: Boolean,
+ roomNotifications: List,
+ invitationNotifications: List,
+ simpleNotifications: List,
+ summaryNotification: SummaryNotification
+ ) {
+ with(instance) {
+ every { groupedEvents.roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl) } returns roomNotifications
+ every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
+ every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
+
+ every {
+ createSummaryNotification(
+ sessionId,
+ roomNotifications,
+ invitationNotifications,
+ simpleNotifications,
+ useCompleteNotificationFormat
+ )
+ } returns summaryNotification
+ }
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt
new file mode 100644
index 00000000000..046b2edd872
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021 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.app.Notification
+import io.element.android.libraries.push.impl.notifications.NotificationUtils
+import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
+import io.mockk.every
+import io.mockk.mockk
+
+class FakeNotificationUtils {
+ val instance = mockk()
+
+ fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification {
+ val mockNotification = mockk()
+ every { instance.buildRoomInvitationNotification(event) } returns mockNotification
+ return mockNotification
+ }
+
+ fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification {
+ val mockNotification = mockk()
+ every { instance.buildSimpleEventNotification(event) } returns mockNotification
+ return mockNotification
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt
new file mode 100644
index 00000000000..03bf7e8491b
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2021 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 io.element.android.libraries.push.impl.notifications.OutdatedEventDetector
+import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
+import io.mockk.every
+import io.mockk.mockk
+
+class FakeOutdatedEventDetector {
+ val instance = mockk()
+
+ fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) {
+ every { instance.isMessageOutdated(notifiableEvent) } returns true
+ }
+
+ fun givenEventIsInDate(notifiableEvent: NotifiableEvent) {
+ every { instance.isMessageOutdated(notifiableEvent) } returns false
+ }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt
new file mode 100644
index 00000000000..df0b5ad42b1
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2021 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 io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
+import io.element.android.libraries.push.impl.notifications.RoomNotification
+import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
+import io.mockk.every
+import io.mockk.mockk
+
+class FakeRoomGroupMessageCreator {
+
+ val instance = mockk()
+
+ fun givenCreatesRoomMessageFor(
+ sessionId: SessionId,
+ events: List,
+ roomId: RoomId,
+ userDisplayName: String,
+ userAvatarUrl: String?
+ ): RoomNotification.Message {
+ val mockMessage = mockk()
+ every { instance.createRoomMessage(sessionId, events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage
+ return mockMessage
+ }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt
similarity index 63%
rename from libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt
rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt
index dcdb800a199..fc7b0553eb7 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/intent/PendingIntentCompat.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt
@@ -14,17 +14,12 @@
* limitations under the License.
*/
-package io.element.android.libraries.androidutils.intent
+package io.element.android.libraries.push.impl.notifications.fake
-import android.app.PendingIntent
-import android.os.Build
+import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator
+import io.mockk.mockk
-object PendingIntentCompat {
- const val FLAG_IMMUTABLE = PendingIntent.FLAG_IMMUTABLE
+class FakeSummaryGroupMessageCreator {
- val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.FLAG_MUTABLE
- } else {
- 0
- }
+ val instance = mockk()
}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt
new file mode 100644
index 00000000000..9a998abf43c
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2021 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.fixtures
+
+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.ThreadId
+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.model.InviteNotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
+import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
+
+fun aSimpleNotifiableEvent(
+ sessionId: SessionId = A_SESSION_ID,
+ roomId: RoomId = A_ROOM_ID,
+ eventId: EventId = AN_EVENT_ID,
+ type: String? = null,
+ isRedacted: Boolean = false,
+ canBeReplaced: Boolean = false,
+ editedEventId: EventId? = null
+) = SimpleNotifiableEvent(
+ sessionId = sessionId,
+ roomId = roomId,
+ eventId = eventId,
+ editedEventId = editedEventId,
+ noisy = false,
+ title = "title",
+ description = "description",
+ type = type,
+ timestamp = 0,
+ soundName = null,
+ canBeReplaced = canBeReplaced,
+ isRedacted = isRedacted
+)
+
+fun anInviteNotifiableEvent(
+ sessionId: SessionId = A_SESSION_ID,
+ roomId: RoomId = A_ROOM_ID,
+ eventId: EventId = AN_EVENT_ID,
+ isRedacted: Boolean = false
+) = InviteNotifiableEvent(
+ sessionId = sessionId,
+ eventId = eventId,
+ roomId = roomId,
+ roomName = "a room name",
+ editedEventId = null,
+ noisy = false,
+ title = "title",
+ description = "description",
+ type = null,
+ timestamp = 0,
+ soundName = null,
+ canBeReplaced = false,
+ isRedacted = isRedacted
+)
+
+fun aNotifiableMessageEvent(
+ sessionId: SessionId = A_SESSION_ID,
+ roomId: RoomId = A_ROOM_ID,
+ eventId: EventId = AN_EVENT_ID,
+ threadId: ThreadId? = null,
+ isRedacted: Boolean = false
+) = NotifiableMessageEvent(
+ sessionId = sessionId,
+ eventId = eventId,
+ editedEventId = null,
+ noisy = false,
+ timestamp = 0,
+ senderName = "sender-name",
+ senderId = "sending-id",
+ body = "message-body",
+ roomId = roomId,
+ threadId = threadId,
+ roomName = "room-name",
+ roomIsDirect = false,
+ canBeReplaced = false,
+ isRedacted = isRedacted,
+ imageUriString = null
+)
diff --git a/libraries/rustsdk/build.gradle b/libraries/rustsdk/build.gradle
deleted file mode 100644
index bfafe67f282..00000000000
--- a/libraries/rustsdk/build.gradle
+++ /dev/null
@@ -1,2 +0,0 @@
-configurations.maybeCreate("default")
-artifacts.add("default", file('matrix-rust-sdk.aar'))
\ No newline at end of file
diff --git a/libraries/rustsdk/build.gradle.kts b/libraries/rustsdk/build.gradle.kts
new file mode 100644
index 00000000000..56fd0948970
--- /dev/null
+++ b/libraries/rustsdk/build.gradle.kts
@@ -0,0 +1,2 @@
+configurations.maybeCreate("default")
+artifacts.add("default", file("matrix-rust-sdk.aar"))
diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt
index de0ec2f7271..d79d700030c 100644
--- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt
+++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt
@@ -17,11 +17,22 @@
package io.element.android.libraries.sessionstorage.api
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
interface SessionStore {
fun isLoggedIn(): Flow
+ fun sessionsFlow(): Flow>
suspend fun storeData(sessionData: SessionData)
suspend fun getSession(sessionId: String): SessionData?
+ suspend fun getAllSessions(): List
suspend fun getLatestSession(): SessionData?
suspend fun removeSession(sessionId: String)
}
+
+fun List.toUserList(): List {
+ return map { it.userId }
+}
+
+fun Flow>.toUserListFlow(): Flow> {
+ return map { it.toUserList() }
+}
diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt
new file mode 100644
index 00000000000..7bcb4db7926
--- /dev/null
+++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.sessionstorage.api.observer
+
+interface SessionListener {
+ suspend fun onSessionCreated(userId: String)
+ suspend fun onSessionDeleted(userId: String)
+}
diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt
new file mode 100644
index 00000000000..e61b4e2bba5
--- /dev/null
+++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.sessionstorage.api.observer
+
+interface SessionObserver {
+ fun addListener(listener: SessionListener)
+ fun removeListener(listener: SessionListener)
+}
diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt
index b73ffdeb9ae..e23e34983c3 100644
--- a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt
+++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt
@@ -30,6 +30,10 @@ class InMemorySessionStore : SessionStore {
return sessionDataFlow.map { it != null }
}
+ override fun sessionsFlow(): Flow> {
+ return sessionDataFlow.map { listOfNotNull(it) }
+ }
+
override suspend fun storeData(sessionData: SessionData) {
sessionDataFlow.value = sessionData
}
@@ -38,6 +42,10 @@ class InMemorySessionStore : SessionStore {
return sessionDataFlow.value.takeIf { it?.userId == sessionId }
}
+ override suspend fun getAllSessions(): List {
+ return listOfNotNull(sessionDataFlow.value)
+ }
+
override suspend fun getLatestSession(): SessionData? {
return sessionDataFlow.value
}
diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts
index 08dc5810186..b554bb5d8f0 100644
--- a/libraries/session-storage/impl/build.gradle.kts
+++ b/libraries/session-storage/impl/build.gradle.kts
@@ -30,6 +30,7 @@ anvil {
dependencies {
implementation(libs.dagger)
+ implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.encryptedDb)
api(projects.libraries.sessionStorage.api)
diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt
index 6c32bcd1f33..9394b66e66d 100644
--- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt
+++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt
@@ -18,6 +18,7 @@ package io.element.android.libraries.sessionstorage.impl
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.sqldelight.runtime.coroutines.asFlow
+import com.squareup.sqldelight.runtime.coroutines.mapToList
import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
@@ -25,6 +26,7 @@ import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import timber.log.Timber
import javax.inject.Inject
@SingleIn(AppScope::class)
@@ -34,7 +36,10 @@ class DatabaseSessionStore @Inject constructor(
) : SessionStore {
override fun isLoggedIn(): Flow {
- return database.sessionDataQueries.selectFirst().asFlow().mapToOneOrNull().map { it != null }
+ return database.sessionDataQueries.selectFirst()
+ .asFlow()
+ .mapToOneOrNull()
+ .map { it != null }
}
override suspend fun storeData(sessionData: SessionData) {
@@ -53,6 +58,20 @@ class DatabaseSessionStore @Inject constructor(
?.toApiModel()
}
+ override suspend fun getAllSessions(): List {
+ return database.sessionDataQueries.selectAll()
+ .executeAsList()
+ .map { it.toApiModel() }
+ }
+
+ override fun sessionsFlow(): Flow> {
+ Timber.w("Observing session list!")
+ return database.sessionDataQueries.selectAll()
+ .asFlow()
+ .mapToList()
+ .map { it.map { sessionData -> sessionData.toApiModel() } }
+ }
+
override suspend fun removeSession(sessionId: String) {
database.sessionDataQueries.removeSession(sessionId)
}
diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt
index b101fc39b49..052943388eb 100644
--- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt
+++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt
@@ -35,7 +35,7 @@ object SessionStorageModule {
fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase {
val name = "session_database"
val secretFile = context.getDatabasePath("$name.key")
- val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile, name)
+ val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider)
.create(SessionDatabase.Schema, "$name.db", context)
return SessionDatabase(driver)
diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt
new file mode 100644
index 00000000000..8fa5d9dd165
--- /dev/null
+++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.sessionstorage.impl.observer
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import io.element.android.libraries.sessionstorage.api.toUserListFlow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.concurrent.CopyOnWriteArraySet
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class DefaultSessionObserver @Inject constructor(
+ private val sessionStore: SessionStore,
+ private val coroutineScope: CoroutineScope,
+ private val dispatchers: CoroutineDispatchers,
+) : SessionObserver {
+ // Keep only the userId
+ private var currentUsers: Set? = null
+
+ init {
+ observeDatabase()
+ }
+
+ private val listeners = CopyOnWriteArraySet()
+ override fun addListener(listener: SessionListener) {
+ listeners.add(listener)
+ }
+
+ override fun removeListener(listener: SessionListener) {
+ listeners.remove(listener)
+ }
+
+ private fun observeDatabase() {
+ coroutineScope.launch {
+ withContext(dispatchers.io) {
+ sessionStore.sessionsFlow()
+ .toUserListFlow()
+ .map { it.toSet() }
+ .onEach { newUserSet ->
+ val currentUserSet = currentUsers
+ if (currentUserSet != null) {
+ // Compute diff
+ // Removed user
+ val removedUsers = currentUserSet - newUserSet
+ removedUsers.forEach { removedUser ->
+ listeners.onEach { listener ->
+ listener.onSessionDeleted(removedUser)
+ }
+ }
+ // Added user
+ val addedUsers = newUserSet - currentUserSet
+ addedUsers.forEach { addedUser ->
+ listeners.onEach { listener ->
+ listener.onSessionCreated(addedUser)
+ }
+ }
+ }
+
+ currentUsers = newUserSet
+ }
+ .collect()
+ }
+ }
+ }
+}
diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq
index d8fb15338cb..ea8471a36ac 100644
--- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq
+++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq
@@ -10,6 +10,9 @@ CREATE TABLE SessionData (
selectFirst:
SELECT * FROM SessionData LIMIT 1;
+selectAll:
+SELECT * FROM SessionData;
+
selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;
diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt
index 885c04af784..0260604f6eb 100644
--- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt
+++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt
@@ -57,6 +57,7 @@ class DatabaseSessionStoreTests {
databaseSessionStore.storeData(aSessionData.toApiModel())
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData)
+ assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
}
@Test
@@ -88,6 +89,7 @@ class DatabaseSessionStoreTests {
val foundSession = databaseSessionStore.getSession(aSessionData.userId)?.toDbModel()
assertThat(foundSession).isEqualTo(aSessionData)
+ assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2)
}
@Test
@@ -107,5 +109,4 @@ class DatabaseSessionStoreTests {
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
}
-
}
diff --git a/libraries/textcomposer/src/main/res/values-es/translations.xml b/libraries/textcomposer/src/main/res/values-es/translations.xml
new file mode 100644
index 00000000000..e302765a584
--- /dev/null
+++ b/libraries/textcomposer/src/main/res/values-es/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Lista de puntos"
+ "Bloque de código"
+ "Mensaje…"
+ "Aplicar formato negrita"
+ "Aplicar formato cursiva"
+ "Aplicar formato tachado"
+ "Aplicar formato de subrayado"
+ "Pantalla completa"
+ "Añadir sangría"
+ "Código"
+ "Enlazar"
+ "Lista numérica"
+ "Cita"
+ "Quitar sangría"
+
\ No newline at end of file
diff --git a/libraries/textcomposer/src/main/res/values-it/translations.xml b/libraries/textcomposer/src/main/res/values-it/translations.xml
new file mode 100644
index 00000000000..54ca270f288
--- /dev/null
+++ b/libraries/textcomposer/src/main/res/values-it/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Attiva/disattiva l\'elenco puntato"
+ "Attiva/disattiva il blocco di codice"
+ "Messaggio…"
+ "Applica il formato in grassetto"
+ "Applicare il formato corsivo"
+ "Applica il formato barrato"
+ "Applicare il formato di sottolineatura"
+ "Attiva/disattiva la modalità a schermo intero"
+ "Rientro a destra"
+ "Applicare il formato del codice in linea"
+ "Imposta collegamento"
+ "Attiva/disattiva elenco numerato"
+ "Attiva/disattiva citazione"
+ "Rientro a sinistra"
+
\ No newline at end of file
diff --git a/libraries/textcomposer/src/main/res/values-ro/translations.xml b/libraries/textcomposer/src/main/res/values-ro/translations.xml
new file mode 100644
index 00000000000..b053e0ecaa1
--- /dev/null
+++ b/libraries/textcomposer/src/main/res/values-ro/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Comutați lista cu puncte"
+ "Comutați blocul de cod"
+ "Mesaj…"
+ "Aplicați formatul aldin"
+ "Aplicați formatul italic"
+ "Aplicați formatul barat"
+ "Aplică formatul de subliniere"
+ "Comutați modul ecran complet"
+ "Indentare"
+ "Aplicați formatul de cod inline"
+ "Setați linkul"
+ "Comutați lista numerotată"
+ "Aplicați citatul"
+ "Dez-identare"
+
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml
index 64684b79052..22c60db4813 100644
--- a/libraries/ui-strings/src/main/res/values-de/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-de/translations.xml
@@ -1,4 +1,5 @@
"Bestätigen"
+ "de"
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml
new file mode 100644
index 00000000000..4b14f3a4a7d
--- /dev/null
+++ b/libraries/ui-strings/src/main/res/values-es/translations.xml
@@ -0,0 +1,148 @@
+
+
+ "Ocultar contraseña"
+ "Enviar archivos"
+ "Mostrar contraseña"
+ "Menú de usuario"
+ "Atrás"
+ "Cancelar"
+ "Borrar"
+ "Cerrar"
+ "Completar verificación"
+ "Confirmar"
+ "Continuar"
+ "Copiar"
+ "Copiar enlace"
+ "Crear una sala"
+ "Desactivar"
+ "Hecho"
+ "Editar"
+ "Activar"
+ "Invitar"
+ "Invitar amigos a %1$s"
+ "Más información"
+ "Salir"
+ "Salir de la sala"
+ "Siguiente"
+ "No"
+ "Ahora no"
+ "OK"
+ "Respuesta rápida"
+ "Citar"
+ "Eliminar"
+ "Responder"
+ "Informar de un error"
+ "Reportar Contenido"
+ "Reintentar"
+ "Reintentar descifrado"
+ "Guardar"
+ "Buscar"
+ "Enviar"
+ "Compartir"
+ "Compartir enlace"
+ "Saltar"
+ "Comenzar"
+ "Iniciar chat"
+ "Iniciar la verificación"
+ "Ver Fuente"
+ "Sí"
+ "Acerca de"
+ "Sonido"
+ "Burbujas"
+ "Creando sala…"
+ "Saliste de la sala"
+ "Error de descifrado"
+ "Opciones de desarrollador"
+ "(editado)"
+ "Edición"
+ "Cifrado activado"
+ "Error"
+ "Archivo"
+ "GIF"
+ "Imagen"
+ "Enlace copiado al portapapeles"
+ "Cargando…"
+ "Mensaje"
+ "Diseño del mensaje"
+ "Mensaje eliminado"
+ "Moderno"
+ "No hay resultados"
+ "Sin conexión"
+ "Contraseña"
+ "Personas"
+ "Enlace permanente"
+ "Reacciones"
+ "Respondiendo a %1$s"
+ "Informar de un error"
+ "Informe enviado"
+ "Buscar a alguien"
+ "Seguridad"
+ "Selecciona tu servidor"
+ "Enviando…"
+ "Servidor no compatible"
+ "Dirección del servidor"
+ "Ajustes"
+ "Sticker"
+ "Terminado"
+ "Sugerencias"
+ "Tema"
+ "No se puede descifrar"
+ "Evento no compatible"
+ "Usuario"
+ "Verificación cancelada"
+ "Verificación completada"
+ "Vídeo"
+ "Esperando…"
+ "Confirmar"
+ "Error"
+ "Terminado"
+ "Atención"
+ "Actividades"
+ "Banderas"
+ "Comida y bebida"
+ "Animales y naturaleza"
+ "Objetos"
+ "Emojis y personas"
+ "Viajes y lugares"
+ "Símbolos"
+ "No se pudo crear el enlace permanente"
+ "Error al cargar mensajes"
+ "No se encontró ninguna aplicación compatible con esta acción."
+ "Algunos mensajes no se han enviado"
+ "Lo siento, se ha producido un error"
+ "Hola, puedes hablar conmigo en %1$s: %2$s"
+ "¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú."
+ "¿Estás seguro de que quieres abandonar esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación."
+ "¿Seguro que quieres salir de la habitación?"
+ "%1$s Android"
+
+ - "%1$d miembro"
+ - "%1$d miembros"
+
+
+ - "%1$d cambio en la sala"
+ - "%1$d cambios en la sala"
+
+ "Agitar con fuerza para informar de un error"
+ "Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?"
+ "Este mensaje se notificará al administrador de su homeserver. No podrán leer ningún mensaje cifrado."
+ "Motivo para denunciar este contenido"
+ "Este es el principio de %1$s."
+ "Este es el principio de esta conversación."
+ "Nuevos"
+ "Bloquear usuario"
+ "Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario"
+ "Bloquear"
+ "Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."
+ "Bloquear usuario"
+ "Desbloquear"
+ "Al desbloquear al usuario, podrás volver a ver todos sus mensajes."
+ "Desbloquear usuario"
+ "Se ha producido un error al intentar iniciar un chat"
+ "No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación."
+ "Agitar con fuerza"
+ "Umbral de detección"
+ "General"
+ "Versión: %1$s (%2$s)"
+ "es"
+
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml
index b75da2740b6..de1a80a9f54 100644
--- a/libraries/ui-strings/src/main/res/values-fr/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml
@@ -1,4 +1,16 @@
+ "Masquer le mot de passe"
+ "Envoyer des fichiers"
+ "Afficher le mot de passe"
+ "Menu utilisateur"
+ "Retour"
+ "Annuler"
+ "Effacer"
+ "Fermer"
"Confirmer"
+ "Continuer"
+ "Copier"
+ "Copier le lien"
+ "fr"
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml
new file mode 100644
index 00000000000..96d0648d3b3
--- /dev/null
+++ b/libraries/ui-strings/src/main/res/values-it/translations.xml
@@ -0,0 +1,148 @@
+
+
+ "Nascondi password"
+ "Invia file"
+ "Mostra password"
+ "Menu utente"
+ "Indietro"
+ "Annulla"
+ "Cancella"
+ "Chiudi"
+ "Completa verifica"
+ "Conferma"
+ "Continua"
+ "Copia"
+ "Copia collegamento"
+ "Crea una stanza"
+ "Disabilita"
+ "Fine"
+ "Modifica"
+ "Attiva"
+ "Invita"
+ "Invita amici a %1$s"
+ "Ulteriori informazioni"
+ "Esci"
+ "Esci dalla stanza"
+ "Avanti"
+ "No"
+ "Non ora"
+ "OK"
+ "Risposta rapida"
+ "Citazione"
+ "Rimuovi"
+ "Rispondi"
+ "Segnala un problema"
+ "Segnala Contenuto"
+ "Riprova"
+ "Riprova la decrittazione"
+ "Salva"
+ "Ricerca"
+ "Invia"
+ "Condividi"
+ "Condividi collegamento"
+ "Salta"
+ "Inizia"
+ "Avvia conversazione"
+ "Avvia la verifica"
+ "Vedi Sorgente"
+ "Sì"
+ "Informazioni"
+ "Audio"
+ "Fumetti"
+ "Creazione stanza…"
+ "Hai lasciato la stanza"
+ "Errore di decrittazione"
+ "Opzioni sviluppatore"
+ "(modificato)"
+ "Modifica in corso"
+ "Crittografia abilitata"
+ "Errore"
+ "File"
+ "GIF"
+ "Immagine"
+ "Collegamento copiato negli appunti"
+ "Caricamento…"
+ "Messaggio"
+ "Layout del messaggio"
+ "Messaggio rimosso"
+ "Moderno"
+ "Nessun risultato"
+ "Non in linea"
+ "Password"
+ "Persone"
+ "Collegamento permanente"
+ "Reazioni"
+ "Risposta a %1$s"
+ "Segnala un problema"
+ "Segnalazione inviata"
+ "Cerca qualcuno"
+ "Sicurezza"
+ "Seleziona il tuo server"
+ "Invio in corso…"
+ "Server non supportato"
+ "URL del server"
+ "Impostazioni"
+ "Adesivo"
+ "Operazione riuscita"
+ "Suggerimenti"
+ "Oggetto"
+ "Impossibile decrittografare"
+ "Evento non supportato"
+ "Nome utente"
+ "Verifica annullata"
+ "Verifica completata"
+ "Video"
+ "In attesa…"
+ "Conferma"
+ "Errore"
+ "Operazione riuscita"
+ "Attenzione"
+ "Attività"
+ "Bandiere"
+ "Cibi & Bevande"
+ "Animali & Natura"
+ "Oggetti"
+ "Faccine & Persone"
+ "Viaggi & Luoghi"
+ "Simboli"
+ "Impossibile creare il collegamento permanente"
+ "Caricamento dei messaggi non riuscito"
+ "Non è stata trovata alcuna app compatibile per gestire questa azione."
+ "Alcuni messaggi non sono stati inviati"
+ "Siamo spiacenti, si è verificato un errore"
+ "Ehi, parlami su %1$s: %2$s"
+ "Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso."
+ "Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito."
+ "Sei sicuro di voler lasciare la stanza?"
+ "%1$s Android"
+
+ - "%1$d membro"
+ - "%1$d membri"
+
+
+ - "%1$d modifica alla stanza"
+ - "%1$d modifiche alla stanza"
+
+ "Scuoti per segnalare un problema"
+ "Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?"
+ "Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi criptati."
+ "Motivo della segnalazione di questo contenuto"
+ "Questo è l\'inizio di %1$s."
+ "Questo è l\'inizio della conversazione."
+ "Nuovo"
+ "Blocca utente"
+ "Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"
+ "Blocca"
+ "Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."
+ "Blocca utente"
+ "Sblocca"
+ "Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."
+ "Sblocca utente"
+ "Si è verificato un errore durante il tentativo di avviare una chat"
+ "Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto."
+ "Rageshake"
+ "Soglia di rilevamento"
+ "Generali"
+ "Versione: %1$s (%2$s)"
+ "it"
+
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml
index 6878d7aab4e..1872bb057f7 100644
--- a/libraries/ui-strings/src/main/res/values-ro/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml
@@ -1,10 +1,150 @@
- "Confirmare"
+ "Ascundeți parola"
+ "Trimiteți fișiere"
+ "Afișați parola"
+ "Meniu utilizator"
+ "Înapoi"
+ "Anulați"
+ "Ștergeți"
+ "Închideți"
+ "Verificare completă"
+ "Confirmați"
+ "Continuați"
+ "Copiați"
+ "Copiați linkul"
"Creați o cameră"
- "Gata"
+ "Dezactivați"
+ "Efectuat"
+ "Editați"
+ "Activați"
+ "Invitați"
+ "Invitați prieteni în %1$s"
+ "Aflați mai multe"
+ "Părăsiți"
+ "Părăsiți camera"
+ "Următorul"
+ "Nu"
+ "Nu acum"
"OK"
- "Raportează conținutul"
- "Începe discuția"
- "Vezi sursa"
+ "Raspuns rapid"
+ "Citat"
+ "Ștergeți"
+ "Răspundeți"
+ "Raportați o eroare"
+ "Raportați conținutul"
+ "Reîncercați"
+ "Reîncercați decriptarea"
+ "Salvați"
+ "Căutați"
+ "Trimiteți"
+ "Partajați"
+ "Partajați linkul"
+ "Omiteți"
+ "Începeți"
+ "Începeți discuția"
+ "Începeți verificarea"
+ "Vedeți sursă"
+ "Da"
+ "Despre"
+ "Audio"
+ "Baloane"
+ "Se creează camera…"
+ "Ați parăsit camera"
+ "Eroare de decriptare"
+ "Opțiuni programator"
+ "(editat)"
+ "Editare"
+ "Criptare activată"
+ "Eroare"
+ "Fişier"
+ "GIF"
+ "Imagine"
+ "Linkul a fost copiat în clipboard"
+ "Se încarcă…"
+ "Mesaj"
+ "Aranjamentul mesajelor"
+ "Mesaj sters"
+ "Modern"
+ "Niciun rezultat"
+ "Deconectat"
+ "Parola"
+ "Persoane"
+ "Permalink"
+ "Reacții"
+ "Răspuns pentru %1$s"
+ "Raportați o eroare"
+ "Raport trimis"
+ "Căutați pe cineva"
+ "Securitate"
+ "Selectați serverul"
+ "Se trimite…"
+ "Serverul nu este compatibil"
+ "Adresa URL a serverului"
+ "Setări"
+ "Autocolant"
+ "Succes"
+ "Sugestii"
+ "Subiect"
+ "Nu s-a putut decripta"
+ "Eveniment neacceptat"
+ "Utilizator"
+ "Verificare anulată"
+ "Verificare completă"
+ "Video"
+ "Se aşteaptă…"
+ "Confirmare"
+ "Eroare"
+ "Succes"
+ "Avertisment"
+ "Activități"
+ "Steaguri"
+ "Mâncare & Băutură"
+ "Animale și Natură"
+ "Obiecte"
+ "Fețe zâmbitoare & Oameni"
+ "Călătorii & Locuri"
+ "Simboluri"
+ "Crearea permalink-ului a eșuat"
+ "Încărcarea mesajelor a eșuat"
+ "Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune."
+ "Unele mesaje nu au fost trimise"
+ "Ne pare rău, a apărut o eroare"
+ "Hei, vorbește cu mine pe %1$s: %2$s"
+ "Sunteți sigur că vreți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părasiți, nimeni nu se va mai putea alătura în viitor, inclusiv dumneavoastra."
+ "Sunteți sigur că vrei să părăsiți această cameră? Această cameră nu este publică și nu va veti putea alătura din nou fără o invitație."
+ "Sunteți sigur că vreți să părăsiți camera?"
+ "%1$s Android"
+
+ - "%1$d membru"
+
+ - "%1$d membri"
+
+
+ - "%1$d schimbare a camerii"
+
+ - "%1$d schimbări ale camerei"
+
+ "Rageshake pentru a raporta erori"
+ "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?"
+ "Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."
+ "Motivul raportării acestui conținut"
+ "Acesta este începutul conversației %1$s."
+ "Acesta este începutul acestei conversații."
+ "Nou"
+ "Blocați utilizatorul"
+ "Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"
+ "Blocați"
+ "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."
+ "Blocați utilizatorul"
+ "Deblocați"
+ "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."
+ "Deblocați utilizatorul"
+ "A apărut o eroare la încercarea începerii conversației"
+ "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită."
+ "Rageshake"
+ "Prag de detecție"
+ "General"
+ "Versiunea: %1$s (%2$s)"
+ "ro"
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 7ae90b9b53e..37665e72074 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -4,8 +4,10 @@
"Send files"
"Show password"
"User menu"
+ "Accept"
"Back"
"Cancel"
+ "Choose photo"
"Clear"
"Close"
"Complete verification"
@@ -13,13 +15,16 @@
"Continue"
"Copy"
"Copy link"
+ "Create"
"Create a room"
+ "Decline"
"Disable"
"Done"
"Edit"
"Enable"
"Invite"
"Invite friends to %1$s"
+ "Invites"
"Learn more"
"Leave"
"Leave room"
@@ -38,17 +43,21 @@
"Save"
"Search"
"Send"
+ "Send message"
+ "Share"
"Share link"
"Skip"
"Start"
"Start chat"
"Start verification"
+ "Take photo"
"View Source"
"Yes"
"About"
"Audio"
"Bubbles"
"Creating room…"
+ "Left room"
"Decryption error"
"Developer options"
"(edited)"
@@ -128,12 +137,7 @@
"This is the beginning of %1$s."
"This is the beginning of this conversation."
"New"
- "Block"
- "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."
- "Block user"
- "Unblock"
- "On unblocking the user, you will be able to see all messages by them again."
- "Unblock user"
+ "%1$s invited you"
"Block user"
"Check if you want to hide all current and future messages from this user"
"Block"
@@ -143,8 +147,11 @@
"On unblocking the user, you will be able to see all messages by them again."
"Unblock user"
"An error occurred when trying to start a chat"
+ "We can’t validate this user’s Matrix ID. The invite might not be received."
"Rageshake"
"Detection threshold"
"General"
"Version: %1$s (%2$s)"
+ "en"
+ "en"
\ No newline at end of file
diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts
index 6c77f11ac2d..d4324432f3d 100644
--- a/plugins/build.gradle.kts
+++ b/plugins/build.gradle.kts
@@ -27,6 +27,8 @@ repositories {
dependencies {
implementation(libs.android.gradle.plugin)
implementation(libs.kotlin.gradle.plugin)
- implementation(libs.firebase.gradle.plugin)
+ implementation(platform(libs.google.firebase.bom))
+ // FIXME: using the bom ^, it should not be necessary to provide the version v...
+ implementation("com.google.firebase:firebase-appdistribution-gradle:4.0.0")
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
}
diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
index 314421ebc85..9f0cf920994 100644
--- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
+++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
@@ -73,6 +73,8 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:matrixui"))
implementation(project(":libraries:network"))
implementation(project(":libraries:core"))
+ implementation(project(":libraries:permissions:impl"))
+ implementation(project(":libraries:push:impl"))
implementation(project(":libraries:architecture"))
implementation(project(":libraries:dateformatter:impl"))
implementation(project(":libraries:di"))
diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts
index 1f9a9cb9915..6ceba913b7f 100644
--- a/samples/minimal/build.gradle.kts
+++ b/samples/minimal/build.gradle.kts
@@ -49,6 +49,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.impl)
+ implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.sessionStorage.implMemory)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.architecture)
diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
index da9356b0a2d..9911e1f9697 100644
--- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
+++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
@@ -27,6 +27,7 @@ import io.element.android.features.roomlist.impl.RoomListView
import io.element.android.libraries.dateformatter.impl.DateFormatters
import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.launch
@@ -50,6 +51,7 @@ class RoomListScreen(
roomLastMessageFormatter = DefaultRoomLastMessageFormatter(context, matrixClient),
sessionVerificationService = sessionVerificationService,
networkMonitor = NetworkMonitorImpl(context),
+ snackbarDispatcher = SnackbarDispatcher(),
)
@Composable
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt
new file mode 100644
index 00000000000..00fe638a478
--- /dev/null
+++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.services.appnavstate.api
+
+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.SpaceId
+import io.element.android.libraries.matrix.api.core.ThreadId
+
+fun AppNavigationState.currentSessionId(): SessionId? {
+ return when (this) {
+ AppNavigationState.Root -> null
+ is AppNavigationState.Session -> sessionId
+ is AppNavigationState.Space -> parentSession.sessionId
+ is AppNavigationState.Room -> parentSpace.parentSession.sessionId
+ is AppNavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId
+ }
+}
+
+fun AppNavigationState.currentSpaceId(): SpaceId? {
+ return when (this) {
+ AppNavigationState.Root -> null
+ is AppNavigationState.Session -> null
+ is AppNavigationState.Space -> spaceId
+ is AppNavigationState.Room -> parentSpace.spaceId
+ is AppNavigationState.Thread -> parentRoom.parentSpace.spaceId
+ }
+}
+
+fun AppNavigationState.currentRoomId(): RoomId? {
+ return when (this) {
+ AppNavigationState.Root -> null
+ is AppNavigationState.Session -> null
+ is AppNavigationState.Space -> null
+ is AppNavigationState.Room -> roomId
+ is AppNavigationState.Thread -> parentRoom.roomId
+ }
+}
+
+fun AppNavigationState.currentThreadId(): ThreadId? {
+ return when (this) {
+ AppNavigationState.Root -> null
+ is AppNavigationState.Session -> null
+ is AppNavigationState.Space -> null
+ is AppNavigationState.Room -> null
+ is AppNavigationState.Thread -> threadId
+ }
+}
diff --git a/services/appnavstate/test/build.gradle.kts b/services/appnavstate/test/build.gradle.kts
new file mode 100644
index 00000000000..cda023b2362
--- /dev/null
+++ b/services/appnavstate/test/build.gradle.kts
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2022 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.
+ */
+
+// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
+@Suppress("DSL_SCOPE_VIOLATION")
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.services.appnavstate.test"
+}
+
+dependencies {
+ api(projects.libraries.matrix.api)
+ api(projects.services.appnavstate.api)
+}
diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt
new file mode 100644
index 00000000000..20e872b8031
--- /dev/null
+++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.services.appnavstate.test
+
+import io.element.android.libraries.matrix.api.core.MAIN_SPACE
+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.SpaceId
+import io.element.android.libraries.matrix.api.core.ThreadId
+import io.element.android.services.appnavstate.api.AppNavigationState
+
+fun anAppNavigationState(
+ sessionId: SessionId? = null,
+ spaceId: SpaceId? = MAIN_SPACE,
+ roomId: RoomId? = null,
+ threadId: ThreadId? = null,
+): AppNavigationState {
+ if (sessionId == null) {
+ return AppNavigationState.Root
+ }
+ val session = AppNavigationState.Session(sessionId)
+ if (spaceId == null) {
+ return session
+ }
+ val space = AppNavigationState.Space(spaceId, session)
+ if (roomId == null) {
+ return space
+ }
+ val room = AppNavigationState.Room(roomId, space)
+ if (threadId == null) {
+ return room
+ }
+ return AppNavigationState.Thread(threadId, room)
+}
diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt
new file mode 100644
index 00000000000..38f2e2227c9
--- /dev/null
+++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.services.toolbox.api.sdk
+
+import androidx.annotation.ChecksSdkIntAtLeast
+
+interface BuildVersionSdkIntProvider {
+ /**
+ * Return the current version of the Android SDK.
+ */
+ fun get(): Int
+
+ /**
+ * Checks the if the current OS version is equal or greater than [version].
+ * @return A `non-null` result if true, `null` otherwise.
+ */
+ @ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
+ fun whenAtLeast(version: Int, result: () -> T): T? {
+ return if (get() >= version) {
+ result()
+ } else null
+ }
+
+ @ChecksSdkIntAtLeast(parameter = 0)
+ fun isAtLeast(version: Int) = get() >= version
+}
diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt
new file mode 100644
index 00000000000..d4ac1ec7394
--- /dev/null
+++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.services.toolbox.impl.sdk
+
+import android.os.Build
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultBuildVersionSdkIntProvider @Inject constructor() :
+ BuildVersionSdkIntProvider {
+ override fun get() = Build.VERSION.SDK_INT
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 944b17ab523..7429f80b43e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,3 +1,5 @@
+import java.net.URI
+
/*
* Copyright (c) 2022 New Vector Ltd
*
@@ -27,6 +29,14 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven {
+ url = URI("https://www.jitpack.io")
+ content {
+ includeModule("com.github.UnifiedPush", "android-connector")
+ }
+ }
+ //noinspection JcenterRepositoryObsolete
+ jcenter()
flatDir {
dirs("libraries/matrix/libs")
}
@@ -41,7 +51,6 @@ include(":appnav")
include(":tests:uitests")
include(":anvilannotations")
include(":anvilcodegen")
-include(":libraries:rustsdk")
include(":samples:minimal")
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..1e92cf978d0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8f29da5d5aeb65659b065b7bd6afe276f83e020545a027780d2391308d1a4076
+size 20750
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..74acb423d66
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7b91a2d05b975d568116615c286568f376ebead49e25ff17f5aae8b75be0e1f
+size 28876
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..23c4de31941
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ae3e8c4e952b97628d026dfe78781aef894d6c2e742ac6ae1f1a2c0170df159e
+size 20382
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..bb7d1036e41
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:44a35f28b3a59cc28937fee16eda26c26ef7b7622f929218e40f7537e096b2e8
+size 28120
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..62dcdf219cc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogDarkPreview_0_null,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:596dee7cf5300dc5c2c6b314bf537619466f377b9946114e675c630e2e330976
+size 12059
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..e1de911d46b
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_DefaultGroup_RetryDialogLightPreview_0_null,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5f791b1c552138b0d3184b936a51982cfc3e23c0b7de2c9ef5c31559d9279245
+size 12113
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png
index 1be155bb969..a4aa94b6e4a 100644
--- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:67a8353194965d1139b558b848f1d1a5411370555eefc765c055ad8a126c4265
-size 6117
+oid sha256:ddbb6611ae83055106f7b67ec828542f8a896cafb49001ed0baef43633cc77c1
+size 8884
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png
index 38b159ccdf4..317643c5984 100644
--- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:694fda9bd548990ef322fe3db691ccb7973c5d5efb8e78d6e50030109dc96359
-size 5947
+oid sha256:5c9a3c9f68a6627654856b03d2534ae1e4e8e600989bc3719407fbf8e17a7ab1
+size 8631
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..4abfbacbbca
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d91a9a9decf08f9bd9301d5282e889fb4e12d4270e8dc7c4b8b24de0b6059126
+size 24662
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..22ad0f00594
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6548a7cc39e0861de6af8e55bc00424d8835e5aa3d99d4e7c68db682e054d677
+size 24542
diff --git a/tools/localazy/README.md b/tools/localazy/README.md
index dcb45c5be7a..c1ad1d0ee0d 100644
--- a/tools/localazy/README.md
+++ b/tools/localazy/README.md
@@ -2,12 +2,49 @@
Localazy is used to host the source strings and their translations.
+
+
+* [Localazy project](#localazy-project)
+ * [Key naming rules](#key-naming-rules)
+ * [Special suffixes](#special-suffixes)
+ * [Placeholders](#placeholders)
+* [CLI Installation](#cli-installation)
+* [Download translations](#download-translations)
+* [Add translations to a specific module](#add-translations-to-a-specific-module)
+
+
+
## Localazy project
-To add new strings, or to translate existing strings, go the the Localazy project: [https://localazy.com/p/element](https://localazy.com/p/element).
+To add new strings, or to translate existing strings, go the the Localazy project: [https://localazy.com/p/element](https://localazy.com/p/element). Please follow the key naming rules (see below).
Never edit manually the files `localazy.xml` or `translations.xml`!.
+### Key naming rules
+
+For code clarity and in order to download strings to the correct module, here are some naming rules to follow as much as possible:
+
+- Keys for common strings, i.e. strings that can be used at multiple places must start by `action_` if this is a verb, or `common_` if not;
+- Keys for common accessibility strings must start by `a11y_`. Example: `a11y_hide_password`;
+- Keys for strings used in a single screen must start with `screen_` followed by the screen name, followed by a free name. Example: `screen_onboarding_welcome_title`;
+- Keys can have `_title` or `_subtitle` suffixes. Example: `screen_onboarding_welcome_title`, `screen_change_server_subtitle`;
+- For dialogs, keys can have `_dialog_title`, `_dialog_content`, and `_dialog_submit` suffixes. Example: `screen_signout_confirmation_dialog_title`, `screen_signout_confirmation_dialog_content`, `screen_signout_confirmation_dialog_submit`;
+- `a11y_` pattern can be used for strings that are only used for accessibility. Example: `a11y_hide_password`, `screen_roomlist_a11y_create_message`;
+- Strings for error message can start by `error_`, or contain `_error_` if used in a specific screen only. Example: `error_some_messages_have_not_been_sent`, `screen_change_server_error_invalid_homeserver`.
+
+*Note*: those rules applies for `strings` and for `plurals`.
+
+#### Special suffixes
+
+- if a key is suffixed by `_ios`, it will not be imported in the Android project;
+- if a key is suffixed by `_android`, it will not be imported in the iOS project.
+
+So feel free to use those suffixes when necessary for instance when the string content is referring to something related to Android only, or iOS only.
+
+#### Placeholders
+
+Placeholders should have the form `%1$s`, `%1$d`, etc.. Please use numbered placeholders. Note that Localazy will take care of converting the placeholder to Android (-> `%1$s`) and iOS specific format (-> `%1$@`). Ideally add a comment on Localazy to explain with what the placeholder(s) will be replaced at runtime.
+
## CLI Installation
To install the Localazy client, follow the instructions from [here](https://localazy.com/docs/cli/installation).
@@ -20,7 +57,13 @@ In the root folder of the project, run:
./tools/localazy/downloadStrings.sh
```
-It will update all the `localazy.xml` and `translations.xml` resource files. In case of merge conflicts, just erase the files and download again using the script.
+It will update all the `localazy.xml` resource files. In case of merge conflicts, just erase the files and download again using the script.
+
+To also include the translations, i.e. the `translations.xml` files, add `--all` argument:
+
+```shell
+./tools/localazy/downloadStrings.sh --all
+```
## Add translations to a specific module
diff --git a/tools/localazy/config.json b/tools/localazy/config.json
index 155a42836a3..59c6bd69116 100644
--- a/tools/localazy/config.json
+++ b/tools/localazy/config.json
@@ -43,6 +43,13 @@
"rich_text_editor_.*"
]
},
+ {
+ "name": ":libraries:push:impl",
+ "includeRegex": [
+ "push_.*",
+ "notification_.*"
+ ]
+ },
{
"name": ":features:login:impl",
"includeRegex": [
diff --git a/tools/localazy/downloadStrings.sh b/tools/localazy/downloadStrings.sh
index 163aab8e315..0f35f9ec596 100755
--- a/tools/localazy/downloadStrings.sh
+++ b/tools/localazy/downloadStrings.sh
@@ -18,12 +18,24 @@
set -e
+if [[ $1 == "--all" ]]; then
+ echo "Note: I will update all the files."
+ allFiles=1
+else
+ echo "Note: I will update only the English files."
+ allFiles=0
+fi
+
echo "Generating the configuration file for localazy..."
-./tools/localazy/generateLocalazyConfig.py
+python3 ./tools/localazy/generateLocalazyConfig.py $allFiles
-echo "Deleting all existing localazy.xml and translations.xml files..."
+echo "Deleting all existing localazy.xml files..."
find . -name 'localazy.xml' -delete
-find . -name 'translations.xml' -delete
+
+if [[ $allFiles == 1 ]]; then
+ echo "Deleting all existing translations.xml files..."
+ find . -name 'translations.xml' -delete
+fi
echo "Importing the strings..."
localazy download --config ./tools/localazy/localazy.json
diff --git a/tools/localazy/generateLocalazyConfig.py b/tools/localazy/generateLocalazyConfig.py
index 829541462e2..e2bc310e1e6 100755
--- a/tools/localazy/generateLocalazyConfig.py
+++ b/tools/localazy/generateLocalazyConfig.py
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
import json
+import sys
# Read the config.json file
with open('./tools/localazy/config.json', 'r') as f:
config = json.load(f)
+allFiles = sys.argv[1] == "1"
# Convert a module name to a path
# Ex: ":features:verifysession:impl" => "features/verifysession/impl"
@@ -18,6 +20,14 @@ def convertModuleToPath(name):
".*_ios"
]
+baseAction = {
+ "type": "android",
+ # Replacement done in all string values
+ "replacements": {
+ "...": "…"
+ }
+}
+
# Store all regex specific to module, to eclude the corresponding keyx from the common string module
allRegexToExcludeFromMainModule = []
# All actions that will be serialized in the localazy config
@@ -26,8 +36,7 @@ def convertModuleToPath(name):
# Iterating on the config
for entry in config["modules"]:
# Create action for the default language
- action = {
- "type": "android",
+ action = baseAction | {
"output": convertModuleToPath(entry["name"]) + "/src/main/res/values/localazy.xml",
"includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
"excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)),
@@ -35,24 +44,23 @@ def convertModuleToPath(name):
"equals: ${languageCode}, en"
]
}
- # Create action for the translations
- actionTranslation = {
- "type": "android",
- "output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml",
- "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
- "excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)),
- "conditions": [
- "!equals: ${languageCode}, en"
- ]
- }
# print(action)
- allRegexToExcludeFromMainModule.extend(entry["includeRegex"])
allActions.append(action)
- allActions.append(actionTranslation)
+ # Create action for the translations
+ if allFiles:
+ actionTranslation = baseAction | {
+ "output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml",
+ "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])),
+ "excludeKeys": list(map(lambda i: "REGEX:" + i, regexToAlwaysExclude)),
+ "conditions": [
+ "!equals: ${languageCode}, en"
+ ]
+ }
+ allActions.append(actionTranslation)
+ allRegexToExcludeFromMainModule.extend(entry["includeRegex"])
# Append configuration for the main string module: default language
-mainAction = {
- "type": "android",
+mainAction = baseAction | {
"output": "libraries/ui-strings/src/main/res/values/localazy.xml",
"excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)),
"conditions": [
@@ -62,16 +70,16 @@ def convertModuleToPath(name):
# print(mainAction)
allActions.append(mainAction)
-# Append configuration for the main string module: translations
-mainActionTranslation = {
- "type": "android",
- "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml",
- "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)),
- "conditions": [
- "!equals: ${languageCode}, en"
- ]
-}
-allActions.append(mainActionTranslation)
+if allFiles:
+ # Append configuration for the main string module: translations
+ mainActionTranslation = baseAction | {
+ "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml",
+ "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)),
+ "conditions": [
+ "!equals: ${languageCode}, en"
+ ]
+ }
+ allActions.append(mainActionTranslation)
# Generate the configuration for localazy
result = {