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": { "better_notifications": {
"name": "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": { "notification_blacklist": {
"name": "Notification Blacklist", "name": "Notification Blacklist",
@ -1048,16 +1090,6 @@
"always_light": "Always Light", "always_light": "Always Light",
"always_dark": "Always Dark" "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": { "theme_picker": {
"amoled_dark_mode": "AMOLED Dark Mode", "amoled_dark_mode": "AMOLED Dark Mode",
"custom": "Custom Colors", "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 bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() }
val anonymousStoryViewing = boolean("anonymous_story_viewing") val anonymousStoryViewing = boolean("anonymous_story_viewing")
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() } val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
@ -81,16 +96,7 @@ class MessagingTweaks : ConfigContainer() {
"bitmoji_background_changes", "bitmoji_background_changes",
"bitmoji_scene_changes", "bitmoji_scene_changes",
) { requireRestart() } ) { requireRestart() }
val betterNotifications = multiple("better_notifications", val betterNotifications = container("better_notifications", BetterNotifications()) { requireRestart() }
"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 notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) {
customOptionTranslationPath = "features.options.notifications" customOptionTranslationPath = "features.options.notifications"
} }

View File

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

View File

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

View File

@ -14,13 +14,11 @@ import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.* import kotlinx.coroutines.*
import me.rhunk.snapenhance.common.data.ContentType 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.MessageUpdate
import me.rhunk.snapenhance.common.data.NotificationType import me.rhunk.snapenhance.common.data.NotificationType
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType 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.MediaDownloaderHelper
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.core.features.Feature 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 translations by lazy { context.translation.getCategory("better_notifications") }
private val config by lazy { context.config.messaging.betterNotifications }
private val betterNotificationFilter by lazy {
context.config.messaging.betterNotifications.get()
}
private fun newNotificationBuilder(notification: Notification) = XposedHelpers.newInstance( private fun newNotificationBuilder(notification: Notification) = XposedHelpers.newInstance(
Notification.Builder::class.java, Notification.Builder::class.java,
@ -138,7 +133,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
} }
newAction(translations["button.reply"], ACTION_REPLY, { 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") val chatReplyInput = RemoteInput.Builder("chat_reply_input")
.setLabel(translations["button.reply"]) .setLabel(translations["button.reply"])
@ -147,11 +142,11 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
} }
newAction(translations["button.download"], ACTION_DOWNLOAD, { 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, { 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 { 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 messaging = context.feature(Messaging::class)
val autoSave = context.feature(AutoSave::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() val notificationId = if (forceCreate) System.nanoTime().toInt() else message.messageDescriptor?.conversationId?.toBytes().contentHashCode()
sentNotifications.computeIfAbsent(notificationId) { conversationId } sentNotifications.computeIfAbsent(notificationId) { conversationId }
if (betterNotificationFilter.contains("group")) { if (config.groupNotifications.get()) {
runCatching { runCatching {
if (notificationManager.activeNotifications.firstOrNull { if (notificationManager.activeNotifications.firstOrNull {
it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 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 }[orderKey] = if (includeUsername) "$senderUsername: $text" else text
} }
when ( if (config.mediaPreview.get().contains(contentType.name)) {
contentType.takeIf { MessageDecoder.decode(message.messageContent!!).firstOrNull()?.also { media ->
(it != ContentType.SNAP && it != ContentType.EXTERNAL_MEDIA) || betterNotificationFilter.contains("media_preview") runCatching {
} ?: ContentType.UNKNOWN media.openStream { mediaStream, length ->
) { if (mediaStream == null || length > 25 * 1024 * 1024) {
ContentType.CHAT -> { context.log.error("Failed to open media stream or media is too large")
ProtoReader(message.messageContent!!.content!!).getString(2, 1)?.trim()?.let { sendNotification(message, data, true)
setNotificationText(it) return@openStream
} }
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")
val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>() val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>()
MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream -> MediaDownloaderHelper.getSplitElements(mediaStream) { type, inputStream ->
downloadedMedias[type] = inputStream.readBytes() 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 { downloadedMedias[SplitMediaAssetType.OVERLAY]?.let {
bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size)) 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?) 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) 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) 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 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 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) notificationData.notification.setObjectField("mGroupKey", SNAPCHAT_NOTIFICATION_GROUP)
} }
@ -430,7 +431,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
return@hook 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 val userId = notificationData.notification.shortcutId?.split("|")?.lastOrNull() ?: return@hook
runBlocking { runBlocking {
var addSource: String? = null var addSource: String? = null
@ -446,8 +447,8 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
return@hook return@hook
} }
if (!betterNotificationFilter.contains("chat_preview") && !betterNotificationFilter.contains("media_preview")) return@hook if (!config.chatPreview.get() && config.mediaPreview.isEmpty()) return@hook
if (notificationType == "typing") return@hook if (notificationType.endsWith("typing")) return@hook
val serverMessageId = extras.getString("message_id") ?: return@hook val serverMessageId = extras.getString("message_id") ?: return@hook
val conversationId = extras.getString("conversation_id").also { id -> val conversationId = extras.getString("conversation_id").also { id ->

View File

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