Image in notification
This commit is contained in:
parent
271fedb7d4
commit
84c897d682
10 changed files with 272 additions and 14 deletions
|
|
@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
|||
data class NotificationData(
|
||||
val eventId: EventId,
|
||||
val roomId: RoomId,
|
||||
// mxc url
|
||||
val senderAvatarUrl: String?,
|
||||
val senderDisplayName: String?,
|
||||
val roomAvatarUrl: String?,
|
||||
|
|
@ -34,8 +35,6 @@ data class NotificationData(
|
|||
val isNoisy: Boolean,
|
||||
val timestamp: Long,
|
||||
val content: NotificationContent,
|
||||
// For images for instance
|
||||
val contentUrl: String?,
|
||||
val hasMention: Boolean,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ class NotificationMapper(
|
|||
isNoisy = item.isNoisy.orFalse(),
|
||||
timestamp = item.timestamp() ?: clock.epochMillis(),
|
||||
content = item.event.use { notificationContentMapper.map(it) },
|
||||
contentUrl = null,
|
||||
hasMention = item.hasMention.orFalse(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application>
|
||||
<receiver
|
||||
|
|
@ -24,5 +25,17 @@
|
|||
android:name=".notifications.NotificationBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name=".notifications.NotificationsFileProvider"
|
||||
android:authorities="${applicationId}.notifications.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/notifications_provider_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@
|
|||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
|
@ -60,6 +65,8 @@ class NotifiableEventResolver @Inject constructor(
|
|||
private val stringProvider: StringProvider,
|
||||
private val clock: SystemClock,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
|
||||
|
|
@ -75,10 +82,13 @@ class NotifiableEventResolver @Inject constructor(
|
|||
}.getOrNull()
|
||||
|
||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||
return notificationData?.asNotifiableEvent(sessionId)
|
||||
return notificationData?.asNotifiableEvent(client, sessionId)
|
||||
}
|
||||
|
||||
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
|
||||
private suspend fun NotificationData.asNotifiableEvent(
|
||||
client: MatrixClient,
|
||||
userId: SessionId,
|
||||
): NotifiableEvent? {
|
||||
return when (val content = this.content) {
|
||||
is NotificationContent.MessageLike.RoomMessage -> {
|
||||
val messageBody = descriptionFromMessageContent(content, senderDisplayName ?: content.senderId.value)
|
||||
|
|
@ -96,7 +106,7 @@ class NotifiableEventResolver @Inject constructor(
|
|||
timestamp = this.timestamp,
|
||||
senderName = senderDisplayName,
|
||||
body = notificationBody,
|
||||
imageUriString = this.contentUrl,
|
||||
imageUriString = fetchImageIfPresent(client)?.toString(),
|
||||
roomName = roomDisplayName,
|
||||
roomIsDirect = isDirect,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
|
|
@ -238,6 +248,39 @@ class NotifiableEventResolver @Inject constructor(
|
|||
stringProvider.getString(R.string.notification_room_invite_body)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun NotificationData.fetchImageIfPresent(client: MatrixClient): Uri? {
|
||||
val fileResult = when (val content = this.content) {
|
||||
is NotificationContent.MessageLike.RoomMessage -> {
|
||||
when (val messageType = content.messageType) {
|
||||
is AudioMessageType -> null
|
||||
is VoiceMessageType -> null
|
||||
is EmoteMessageType -> null
|
||||
is FileMessageType -> null
|
||||
is ImageMessageType -> notificationMediaRepoFactory.create(client)
|
||||
.getMediaFile(
|
||||
mediaSource = messageType.source,
|
||||
mimeType = messageType.info?.mimetype,
|
||||
body = messageType.body,
|
||||
)
|
||||
is NoticeMessageType -> null
|
||||
is TextMessageType -> null
|
||||
is VideoMessageType -> null
|
||||
is LocationMessageType -> null
|
||||
is OtherMessageType -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
return fileResult
|
||||
.onFailure {
|
||||
Timber.tag(loggerTag.value).e(it, "Failed to download image for notification")
|
||||
}.getOrNull()?.let { mediaFile ->
|
||||
val authority = "${context.packageName}.notifications.fileprovider"
|
||||
FileProvider.getUriForFile(context, authority, mediaFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* 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.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fetches the media file for a notification.
|
||||
*
|
||||
* Media is downloaded from the rust sdk and stored in the application's cache directory.
|
||||
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
|
||||
* Whenever a given mxc is found in the cache, it is returned immediately.
|
||||
*/
|
||||
interface NotificationMediaRepo {
|
||||
|
||||
/**
|
||||
* Factory for [NotificationMediaRepo].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [NotificationMediaRepo].
|
||||
*
|
||||
*/
|
||||
fun create(
|
||||
client: MatrixClient
|
||||
): NotificationMediaRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file.
|
||||
*
|
||||
* In case of a cache hit the file is returned immediately.
|
||||
* In case of a cache miss the file is downloaded and then returned.
|
||||
*
|
||||
* @param mediaSource the media source of the media.
|
||||
* @param mimeType the mime type of the media.
|
||||
* @param body the body of the message.
|
||||
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
|
||||
*/
|
||||
suspend fun getMediaFile(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
): Result<File>
|
||||
}
|
||||
|
||||
class DefaultNotificationMediaRepo @AssistedInject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
@Assisted private val client: MatrixClient,
|
||||
) : NotificationMediaRepo {
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : NotificationMediaRepo.Factory {
|
||||
override fun create(
|
||||
client: MatrixClient,
|
||||
): DefaultNotificationMediaRepo
|
||||
}
|
||||
|
||||
private val matrixMediaLoader = client.mediaLoader
|
||||
|
||||
override suspend fun getMediaFile(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
): Result<File> {
|
||||
val cachedFile = mediaSource.cachedFile()
|
||||
return when {
|
||||
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
|
||||
cachedFile.exists() -> Result.success(cachedFile)
|
||||
else -> matrixMediaLoader.downloadMediaFile(
|
||||
source = mediaSource,
|
||||
mimeType = mimeType,
|
||||
body = body,
|
||||
).mapCatching {
|
||||
it.use { mediaFile ->
|
||||
val dest = cachedFile.apply { parentFile?.mkdirs() }
|
||||
if (mediaFile.persist(dest.path)) {
|
||||
dest
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSource.cachedFile(): File? = mxcUri2FilePath(url)?.let {
|
||||
File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where file are stored.
|
||||
*/
|
||||
private const val CACHE_NOTIFICATION_SUBDIR = "temp/notif"
|
||||
|
||||
/**
|
||||
* Regex to match a Matrix Content (mxc://) URI.
|
||||
*
|
||||
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
|
||||
*/
|
||||
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
|
||||
|
||||
/**
|
||||
* Sanitizes an mxcUri to be used as a relative file path.
|
||||
*
|
||||
* @param mxcUri the Matrix Content (mxc://) URI of the file.
|
||||
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
|
||||
*/
|
||||
private fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
|
||||
buildString {
|
||||
append(match.groupValues[1])
|
||||
append("/")
|
||||
append(match.groupValues[2])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
/**
|
||||
* We have to declare our own file provider to avoid collision with other modules
|
||||
* having their own.
|
||||
*/
|
||||
class NotificationsFileProvider : FileProvider()
|
||||
|
|
@ -58,9 +58,8 @@ data class NotifiableMessageEvent(
|
|||
override val description: String = body ?: ""
|
||||
val title: String = senderName ?: ""
|
||||
|
||||
// TODO EAx The image has to be downloaded and expose using the file provider.
|
||||
// Example of value from Element Android:
|
||||
// content://im.vector.app.debug.mx-sdk.fileprovider/downloads/downloads/816abf76d806c768760568952b1862c8/F/72c33edd23dee3b95f4d5a18aa25fa54/image.png
|
||||
// Example of value:
|
||||
// content://io.element.android.x.debug.notifications.fileprovider/downloads/temp/notif/matrix.org/XGItzSDOnSyXjYtOPfiKexDJ
|
||||
val imageUri: Uri?
|
||||
get() = imageUriString?.let { Uri.parse(it) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="downloads"
|
||||
path="/" />
|
||||
</paths>
|
||||
|
|
@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
|
|||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
|
|
@ -257,7 +258,7 @@ class NotifiableEventResolverTest {
|
|||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = LocationMessageType("Location", "geo:1,2", null),
|
||||
messageType = LocationMessageType("Location", "geo:1,2", null),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -274,7 +275,7 @@ class NotifiableEventResolverTest {
|
|||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = NoticeMessageType("Notice", null),
|
||||
messageType = NoticeMessageType("Notice", null),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -291,7 +292,7 @@ class NotifiableEventResolverTest {
|
|||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = EmoteMessageType("is happy", null),
|
||||
messageType = EmoteMessageType("is happy", null),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -487,11 +488,15 @@ class NotifiableEventResolverTest {
|
|||
Result.success(FakeMatrixClient(notificationService = notificationService))
|
||||
}
|
||||
})
|
||||
|
||||
val notificationMediaRepoFactory = NotificationMediaRepo.Factory {
|
||||
FakeNotificationMediaRepo()
|
||||
}
|
||||
return NotifiableEventResolver(
|
||||
stringProvider = AndroidStringProvider(context.resources),
|
||||
clock = FakeSystemClock(),
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
notificationMediaRepoFactory = notificationMediaRepoFactory,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -512,7 +517,6 @@ class NotifiableEventResolverTest {
|
|||
isNoisy = false,
|
||||
timestamp = A_TIMESTAMP,
|
||||
content = content,
|
||||
contentUrl = null,
|
||||
hasMention = hasMention,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications.fake
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationMediaRepo
|
||||
import java.io.File
|
||||
|
||||
class FakeNotificationMediaRepo : NotificationMediaRepo {
|
||||
override suspend fun getMediaFile(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
): Result<File> {
|
||||
return Result.failure(IllegalStateException("Fake class"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue