refactor: better notifications

- caption in notifications
- media preview
- stack media messages
- fix typing notification
This commit is contained in:
rhunk 2024-05-23 00:27:51 +02:00
parent 97aed78894
commit 645b7befa9
6 changed files with 127 additions and 85 deletions

View File

@ -600,7 +600,49 @@
},
"better_notifications": {
"name": "Better Notifications",
"description": "Adds more information in received notifications"
"description": "Adds more information in received notifications",
"properties": {
"group_notifications": {
"name": "Group Notifications",
"description": "Group notifications into a single one"
},
"chat_preview": {
"name": "Chat Preview",
"description": "Shows a preview of received messages in the notification"
},
"media_preview": {
"name": "Media Preview",
"description": "Shows a preview of the selected media types in the notification"
},
"media_caption": {
"name": "Media Caption",
"description": "Shows the attached caption of media in the notification"
},
"stacked_media_messages": {
"name": "Stacked Media Messages",
"description": "Combines multiple media messages into one text notification when they cannot be previewed. Use in combination with Chat Preview"
},
"friend_add_source": {
"name": "Friend Add Source",
"description": "Shows the source of a friend request in the notification"
},
"reply_button": {
"name": "Reply Button",
"description": "Adds a reply button to the notification"
},
"download_button": {
"name": "Download Button",
"description": "Allows you to download media from the notification"
},
"mark_as_read_button": {
"name": "Mark as Read Button",
"description": "Allows you to mark a message as read from the notification"
},
"mark_as_read_and_save_in_chat": {
"name": "Mark as Read and Save in Chat",
"description": "Adds a mark as read and save in chat button to the notification"
}
}
},
"notification_blacklist": {
"name": "Notification Blacklist",
@ -1048,16 +1090,6 @@
"always_light": "Always Light",
"always_dark": "Always Dark"
},
"better_notifications": {
"chat_preview": "Show a preview of chat",
"media_preview": "Show a preview of media",
"reply_button": "Add reply button",
"download_button": "Add download button",
"mark_as_read_button": "Mark as Read button",
"mark_as_read_and_save_in_chat": "Save in Chat when marking as read (depends on Auto Save)",
"friend_add_source": "Show friend add source",
"group": "Group notifications"
},
"theme_picker": {
"amoled_dark_mode": "AMOLED Dark Mode",
"custom": "Custom Colors",

View File

@ -48,6 +48,21 @@ class MessagingTweaks : ConfigContainer() {
}
}
class BetterNotifications: ConfigContainer() {
val groupNotifications = boolean("group_notifications")
val chatPreview = boolean("chat_preview")
val mediaPreview = multiple("media_preview", "SNAP", "NOTE", "EXTERNAL_MEDIA", "STICKER") {
customOptionTranslationPath = "content_type"
}
val mediaCaption = boolean("media_caption")
val stackedMediaMessages = boolean("stacked_media_messages")
val friendAddSource = boolean("friend_add_source")
val replyButton = boolean("reply_button") { addNotices(FeatureNotice.UNSTABLE) }
val downloadButton = boolean("download_button")
val markAsReadButton = boolean("mark_as_read_button") { addNotices(FeatureNotice.UNSTABLE) }
val markAsReadAndSaveInChat = boolean("mark_as_read_and_save_in_chat") { addNotices(FeatureNotice.UNSTABLE) }
}
val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() }
val anonymousStoryViewing = boolean("anonymous_story_viewing")
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
@ -81,16 +96,7 @@ class MessagingTweaks : ConfigContainer() {
"bitmoji_background_changes",
"bitmoji_scene_changes",
) { requireRestart() }
val betterNotifications = multiple("better_notifications",
"chat_preview",
"media_preview",
"reply_button",
"download_button",
"mark_as_read_button",
"mark_as_read_and_save_in_chat",
"friend_add_source",
"group"
) { requireRestart() }
val betterNotifications = container("better_notifications", BetterNotifications()) { requireRestart() }
val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) {
customOptionTranslationPath = "features.options.notifications"
}

View File

@ -574,7 +574,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
var previewBitmap: Bitmap? = null
val previewCoroutine = context.coroutineScope.launch {
runCatching {
attachment.openStream { attachmentStream ->
attachment.openStream { attachmentStream, _ ->
val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
MediaDownloaderHelper.getSplitElements(attachmentStream!!) {

View File

@ -31,18 +31,21 @@ data class DecodedAttachment(
}
@OptIn(ExperimentalEncodingApi::class)
inline fun openStream(crossinline callback: (InputStream?) -> Unit) {
inline fun openStream(crossinline callback: (mediaStream: InputStream?, length: Long) -> Unit) {
boltKey?.let { mediaUrlKey ->
RemoteMediaResolver.downloadBoltMedia(Base64.decode(mediaUrlKey), decryptionCallback = {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(mediaUrlKey), decryptionCallback = {
attachmentInfo?.encryption?.decryptInputStream(it) ?: it
}, resultCallback = { inputStream, _ ->
callback(inputStream)
}, resultCallback = { inputStream, length ->
callback(inputStream, length)
})
} ?: directUrl?.let { rawMediaUrl ->
URL(rawMediaUrl).openStream().let { inputStream ->
attachmentInfo?.encryption?.decryptInputStream(inputStream) ?: inputStream
}.use(callback)
} ?: callback(null)
val connection = URL(rawMediaUrl).openConnection()
connection.getInputStream().let {
attachmentInfo?.encryption?.decryptInputStream(it) ?: it
}.use {
callback(it, connection.contentLengthLong)
}
} ?: callback(null, 0)
}
fun createInputMedia(

View File

@ -14,13 +14,11 @@ import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.*
import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.MediaReferenceType
import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.MessageUpdate
import me.rhunk.snapenhance.common.data.NotificationType
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.core.features.Feature
@ -81,10 +79,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}
private val translations by lazy { context.translation.getCategory("better_notifications") }
private val betterNotificationFilter by lazy {
context.config.messaging.betterNotifications.get()
}
private val config by lazy { context.config.messaging.betterNotifications }
private fun newNotificationBuilder(notification: Notification) = XposedHelpers.newInstance(
Notification.Builder::class.java,
@ -138,7 +133,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}
newAction(translations["button.reply"], ACTION_REPLY, {
betterNotificationFilter.contains("reply_button") && contentType == ContentType.CHAT
config.replyButton.get() && contentType == ContentType.CHAT
}) {
val chatReplyInput = RemoteInput.Builder("chat_reply_input")
.setLabel(translations["button.reply"])
@ -147,11 +142,11 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}
newAction(translations["button.download"], ACTION_DOWNLOAD, {
betterNotificationFilter.contains("download_button") && betterNotificationFilter.contains("media_preview") && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP)
config.downloadButton.get() && config.mediaPreview.get().contains(contentType.name)
}) {}
newAction(translations["button.mark_as_read"], ACTION_MARK_AS_READ, {
betterNotificationFilter.contains("mark_as_read_button")
config.markAsReadButton.get()
}) {}
val notificationBuilder = newNotificationBuilder(notificationData.notification).apply {
@ -232,7 +227,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}
)
if (betterNotificationFilter.contains("mark_as_read_and_save_in_chat")) {
if (config.markAsReadAndSaveInChat.get()) {
val messaging = context.feature(Messaging::class)
val autoSave = context.feature(AutoSave::class)
@ -285,7 +280,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
val notificationId = if (forceCreate) System.nanoTime().toInt() else message.messageDescriptor?.conversationId?.toBytes().contentHashCode()
sentNotifications.computeIfAbsent(notificationId) { conversationId }
if (betterNotificationFilter.contains("group")) {
if (config.groupNotifications.get()) {
runCatching {
if (notificationManager.activeNotifications.firstOrNull {
it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0
@ -336,41 +331,23 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}[orderKey] = if (includeUsername) "$senderUsername: $text" else text
}
when (
contentType.takeIf {
(it != ContentType.SNAP && it != ContentType.EXTERNAL_MEDIA) || betterNotificationFilter.contains("media_preview")
} ?: ContentType.UNKNOWN
) {
ContentType.CHAT -> {
ProtoReader(message.messageContent!!.content!!).getString(2, 1)?.trim()?.let {
setNotificationText(it)
}
computeMessages()
}
ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> {
val mediaReferences = MessageDecoder.getMediaReferences(
messageContent = context.gson.toJsonTree(message.messageContent!!.instanceNonNull())
)
val mediaReferenceKeys = mediaReferences.map { reference ->
reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray()
}
MessageDecoder.decode(message.messageContent!!).firstOrNull()?.also { media ->
val mediaType = MediaReferenceType.valueOf(mediaReferences.first().asJsonObject["mMediaType"].asString)
runCatching {
val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(mediaReferenceKeys.first(), decryptionCallback = {
media.attachmentInfo?.encryption?.decryptInputStream(it) ?: it
}) ?: throw Throwable("Unable to download media")
if (config.mediaPreview.get().contains(contentType.name)) {
MessageDecoder.decode(message.messageContent!!).firstOrNull()?.also { media ->
runCatching {
media.openStream { mediaStream, length ->
if (mediaStream == null || length > 25 * 1024 * 1024) {
context.log.error("Failed to open media stream or media is too large")
sendNotification(message, data, true)
return@openStream
}
val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>()
MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream ->
MediaDownloaderHelper.getSplitElements(mediaStream) { type, inputStream ->
downloadedMedias[type] = inputStream.readBytes()
}
var bitmapPreview = PreviewUtils.createPreview(downloadedMedias[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
val originalMedia = downloadedMedias[SplitMediaAssetType.ORIGINAL]!!
var bitmapPreview = PreviewUtils.createPreview(originalMedia, FileType.fromByteArray(originalMedia).isVideo)!!
downloadedMedias[SplitMediaAssetType.OVERLAY]?.let {
bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size))
@ -381,19 +358,43 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?)
}
if (config.mediaCaption.get()) {
message.serialize()?.let {
notificationBuilder.setContentText(it)
}
}
sendNotification(message, data.copy(notification = notificationBuilder.build()), true)
return
}.onFailure {
context.log.error("Failed to send preview notification", it)
}
return
}.onFailure {
context.log.error("Failed to send preview notification", it)
sendNotification(message, data, true)
return
}
}
else -> {
setNotificationText("[" + context.translation.getCategory("content_type")[contentType.name] + "]")
computeMessages()
}
}
if (!betterNotificationFilter.contains("chat_preview")) return
if (config.chatPreview.get()) {
if (contentType == ContentType.CHAT) {
setNotificationText(message.serialize() ?: "[Failed to parse message]")
} else {
if (config.stackedMediaMessages.get()) {
setNotificationText(buildString {
append("[")
append(context.translation.getCategory("content_type")[contentType.name])
append("]")
if (config.mediaCaption.get()) {
message.serialize()?.let { append(" $it") }
}
})
} else {
sendNotification(message, data, true)
return
}
}
computeMessages()
}
sendNotification(message, data, false)
}
@ -419,7 +420,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3))
val extras = notificationData.notification.extras.getBundle("system_notification_extras")?: return@hook
if (betterNotificationFilter.contains("group")) {
if (config.groupNotifications.get()) {
notificationData.notification.setObjectField("mGroupKey", SNAPCHAT_NOTIFICATION_GROUP)
}
@ -430,7 +431,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
return@hook
}
if (notificationType == "addfriend" && betterNotificationFilter.contains("friend_add_source")) {
if (notificationType == "addfriend" && config.friendAddSource.get()) {
val userId = notificationData.notification.shortcutId?.split("|")?.lastOrNull() ?: return@hook
runBlocking {
var addSource: String? = null
@ -446,8 +447,8 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
return@hook
}
if (!betterNotificationFilter.contains("chat_preview") && !betterNotificationFilter.contains("media_preview")) return@hook
if (notificationType == "typing") return@hook
if (!config.chatPreview.get() && config.mediaPreview.isEmpty()) return@hook
if (notificationType.endsWith("typing")) return@hook
val serverMessageId = extras.getString("message_id") ?: return@hook
val conversationId = extras.getString("conversation_id").also { id ->

View File

@ -114,7 +114,7 @@ class ConversationExporter(
for (i in 0..5) {
printLog("downloading ${attachment.boltKey ?: attachment.directUrl}... (attempt ${i + 1}/5)")
runCatching {
attachment.openStream { downloadedInputStream ->
attachment.openStream { downloadedInputStream, _ ->
MediaDownloaderHelper.getSplitElements(downloadedInputStream!!) { type, splitInputStream ->
val mediaKey = "${type}_${attachment.mediaUniqueId}"
val bufferedInputStream = BufferedInputStream(splitInputStream)