From bae52f661bb2dad8a767bafbfb83fd04846ade3d Mon Sep 17 00:00:00 2001
From: KawaiiZapic <577358285@qq.com>
Date: Wed, 12 Jul 2023 23:08:07 +0800
Subject: [PATCH] feat: MessagingStyle Notification for QQ 8.9.68
---
.../hook/MessageStyleNotification.kt | 482 ------------------
.../zapic/hook/MessagingStyleNotification.kt | 275 ++++++++++
.../java/moe/zapic/util/QQAvatarHelper.kt | 107 ++++
.../moe/zapic/util/QQRecentContactInfo.kt | 91 ++++
4 files changed, 473 insertions(+), 482 deletions(-)
delete mode 100644 app/src/main/java/me/singleneuron/hook/MessageStyleNotification.kt
create mode 100644 app/src/main/java/moe/zapic/hook/MessagingStyleNotification.kt
create mode 100644 app/src/main/java/moe/zapic/util/QQAvatarHelper.kt
create mode 100644 app/src/main/java/moe/zapic/util/QQRecentContactInfo.kt
diff --git a/app/src/main/java/me/singleneuron/hook/MessageStyleNotification.kt b/app/src/main/java/me/singleneuron/hook/MessageStyleNotification.kt
deleted file mode 100644
index 0f3afa4938..0000000000
--- a/app/src/main/java/me/singleneuron/hook/MessageStyleNotification.kt
+++ /dev/null
@@ -1,482 +0,0 @@
-/*
- * QAuxiliary - An Xposed module for QQ/TIM
- * Copyright (C) 2019-2022 qwq233@qwq2333.top
- * https://github.com/cinit/QAuxiliary
- *
- * This software is non-free but opensource software: you can redistribute it
- * and/or modify it under the terms of the GNU Affero General Public License
- * as published by the Free Software Foundation; either
- * version 3 of the License, or any later version and our eula as published
- * by QAuxiliary contributors.
- *
- * This software is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * and eula along with this software. If not, see
- *
- * .
- */
-
-package me.singleneuron.hook
-
-import android.app.Activity
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationChannelGroup
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.graphics.PorterDuff
-import android.graphics.PorterDuffXfermode
-import android.graphics.Rect
-import android.graphics.RectF
-import android.os.Build
-import android.util.Log
-import android.util.LruCache
-import android.view.WindowInsets
-import androidx.annotation.RequiresApi
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationCompat.MessagingStyle
-import androidx.core.app.Person
-import androidx.core.app.RemoteInput
-import androidx.core.content.pm.ShortcutInfoCompat
-import androidx.core.content.pm.ShortcutManagerCompat
-import androidx.core.graphics.drawable.IconCompat
-import cc.chenhe.qqnotifyevo.utils.NotifyChannel
-import cc.chenhe.qqnotifyevo.utils.getChannelId
-import cc.chenhe.qqnotifyevo.utils.getNotificationChannels
-import cc.ioctl.util.Reflex
-import cc.ioctl.util.hookAfterIfEnabled
-import de.robv.android.xposed.XC_MethodHook
-import de.robv.android.xposed.XC_MethodReplacement
-import de.robv.android.xposed.XposedBridge
-import de.robv.android.xposed.XposedHelpers
-import io.github.qauxv.base.annotation.FunctionHookEntry
-import io.github.qauxv.base.annotation.UiItemAgentEntry
-import io.github.qauxv.bridge.AppRuntimeHelper
-import io.github.qauxv.bridge.ChatActivityFacade
-import io.github.qauxv.bridge.FaceImpl
-import io.github.qauxv.bridge.SessionInfoImpl
-import io.github.qauxv.dsl.FunctionEntryRouter
-import io.github.qauxv.hook.CommonSwitchFunctionHook
-import io.github.qauxv.util.Initiator
-import io.github.qauxv.util.LicenseStatus
-import io.github.qauxv.util.SyncUtils
-import io.github.qauxv.util.hostInfo
-import xyz.nextalone.util.clazz
-import xyz.nextalone.util.hookAfter
-import xyz.nextalone.util.method
-import java.io.File
-
-// FIXME: current not working: channel not assigned or overwritten
-
-@FunctionHookEntry
-@UiItemAgentEntry
-object NewQNotifyEvolution : CommonSwitchFunctionHook(SyncUtils.PROC_ANY) {
- override val isAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- override val name = "MessageStyle通知"
- override val description: String = "更加优雅的通知样式w,致敬QQ Helper" + if (isAvailable) "" else " [系统不支持]"
-
- override val uiItemLocation = FunctionEntryRouter.Locations.Auxiliary.NOTIFICATION_CATEGORY
-
- private val numRegex = Regex("""\((\d+)\S{1,3}新消息\)?$""")
- private val senderName = Regex("""^.*?: """)
- private const val activityName = "com.tencent.mobileqq.activity.miniaio.MiniChatActivity"
- private val toMD5Method = "com.tencent.qphone.base.util.MD5".clazz!!.getMethod("toMD5", String::class.java)
- private var avatarCachePath: String? = null
-
- private val historyMessage: HashMap = HashMap()
- private val avatarCache: LruCache = LruCache(50)
- private var windowHeight = -1
-
- @Throws(Exception::class)
- override fun initOnce(): Boolean {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- createNotificationChannels()
- }
-
- val buildNotification = Reflex.findSingleMethod(
- Initiator._MobileQQServiceExtend()!!,
- android.app.Notification::class.java,
- false,
- Intent::class.java,
- Bitmap::class.java,
- String::class.java,
- String::class.java,
- String::class.java
- )
- val context = hostInfo.application
- avatarCachePath = File(
- context.getExternalFilesDir(null)?.parent,
- "Tencent/MobileQQ/head/_hd"
- ).absolutePath
-
- hookAfterIfEnabled(buildNotification) { param ->
- val intent = param.args[0] as Intent
- // TODO: 2022-07-14 uin may be null
- val uin = intent.getStringExtra("uin")
- ?: intent.getStringExtra("param_uin")!!
- val isTroop = intent.getIntExtra(
- "uintype",
- intent.getIntExtra("param_uinType", -1)
- )
- if (isTroop != 0 && isTroop != 1 && isTroop != 3000) return@hookAfterIfEnabled
- val bitmap = param.args[1] as Bitmap?
- var title = numRegex.replace(param.args[3] as String, "")
- var text = param.args[4] as String
- val key = when (isTroop) {
- 1 -> "group_$uin"
- else -> "private_$uin"
- }
- val oldNotification = param.result as Notification
- val notificationId =
- intent.getIntExtra("KEY_NOTIFY_ID_FROM_PROCESSOR", -113)
-
- var channelId: NotifyChannel = NotifyChannel.FRIEND
- if (title.contains("[特别关心]")) {
- channelId = NotifyChannel.FRIEND_SPECIAL
- title = title.removePrefix("[特别关心]")
- }
-
- var messageStyle = historyMessage[notificationId]
- if (messageStyle == null) {
- messageStyle = MessagingStyle(
- Person.Builder()
- .setName(title)
- .setIcon(IconCompat.createWithBitmap(bitmap!!))
- .setImportant(channelId == NotifyChannel.FRIEND_SPECIAL)
- .setKey(uin)
- .build()
- )
- historyMessage[notificationId] = messageStyle
- }
-
- var person: Person? = null
-
- if (isTroop == 1) {
- val sender = senderName.find(text)?.value?.replace(": ", "")
- text = senderName.replace(text, "")
- val senderUin = intent.getStringExtra("param_fromuin")!!
- /*throwOrTrue {
- val senderUin = intent.getStringExtra("param_fromuin")
- bitmap = face.getBitmapFromCache(TYPE_USER,senderUin)
- }*/
- person = Person.Builder()
- .setName(sender)
- .setKey(senderUin)
- .setIcon(getAvatar(senderUin))
- .build()
- messageStyle.conversationTitle = title
- messageStyle.isGroupConversation = true
- channelId = NotifyChannel.GROUP
- }
-
- val message = MessagingStyle.Message(text, oldNotification.`when`, person)
- messageStyle.addMessage(message)
-
- //Log.d(historyMessage.toString())
- val builder = NotificationCompat.Builder(context, oldNotification)
- .setContentTitle(null)
- .setContentText(null)
- .setLargeIcon(null)
- .setStyle(messageStyle)
- if (isTroop == 1) {
- builder.setLargeIcon(bitmap)
- }
-
- val remoteInput: RemoteInput = RemoteInput.Builder("KEY_REPLY").run {
- setLabel("回复")
- build()
- }
- val replyIntent = Intent(context, "com.tencent.mobileqq.servlet.NotificationClickReceiver".clazz).apply {
- putExtra("TO_UIN", uin)
- putExtra("UIN_TYPE", isTroop)
- putExtra("NOTIFY_ID", notificationId)
- }
-
- val replyPendingIntent: PendingIntent =
- PendingIntent.getBroadcast(
- context,
- uin.toLong().toInt(),
- replyIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
- )
- val replyAction = NotificationCompat.Action.Builder(
- android.R.drawable.ic_menu_edit,
- "回复",
- replyPendingIntent
- )
- .addRemoteInput(remoteInput)
- .build()
- builder.addAction(replyAction)
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- val newIntent = intent.clone() as Intent
- newIntent.component = ComponentName(
- context,
- activityName.clazz!!
- )
- newIntent.putExtra("key_mini_from", 2)
- newIntent.putExtra("minaio_height_ration", 1f)
- newIntent.putExtra("minaio_scaled_ration", 1f)
- newIntent.putExtra(
- "public_fragment_class",
- "com.tencent.mobileqq.activity.miniaio.MiniChatFragment"
- )
- val bubbleIntent = PendingIntent.getActivity(
- context,
- uin.toLong().toInt(), // uin may be lager than Int.MAX_VALUE but small than 2^32-1
- newIntent,
- PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
- )
-
- val bubbleData = NotificationCompat.BubbleMetadata.Builder(
- bubbleIntent,
- // FIXME: 2022-06-24 handle NPE if bitmap is null
- IconCompat.createWithBitmap(bitmap!!)
- )
- .setDesiredHeight(600)
- .build()
-
- val shortcut =
- ShortcutInfoCompat.Builder(context, key)
- .setIntent(intent)
- .setLongLived(true)
- .setShortLabel(title)
- .setIcon(bubbleData.icon!!)
- .build()
-
- ShortcutManagerCompat.pushDynamicShortcut(
- context,
- shortcut
- )
-
- builder.apply {
- setShortcutInfo(shortcut)
- bubbleMetadata = bubbleData
- setChannelId(getChannelId(channelId))
- }
- }
- Log.i("QNotifyEvolutionXp", "send as channel " + channelId.name)
- param.result = builder.build()
- }
-
- XposedHelpers.findAndHookMethod(
- "com.tencent.mobileqq.servlet.NotificationClickReceiver".clazz,
- "onReceive",
- Context::class.java,
- Intent::class.java,
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- val ctx = param.args[0] as Context
- val intent = param.args[1] as Intent
- val uinType = intent.getIntExtra("UIN_TYPE", -1)
- if (uinType != -1) {
- param.result = null
- val uin = intent.getStringExtra("TO_UIN") ?: return
- val result = RemoteInput.getResultsFromIntent(intent)?.getString("KEY_REPLY")?: return
- val selfUin = AppRuntimeHelper.getAccount()
- // send message
- ChatActivityFacade.sendMessage(
- AppRuntimeHelper.getQQAppInterface(),
- hostInfo.application,
- SessionInfoImpl.createSessionInfo(uin, uinType),
- result
- )
-
- // update exist notification
- val notifyId = intent.getIntExtra("NOTIFY_ID", -113)
- val notificationManager = ctx.getSystemService(NotificationManager::class.java)
- val origin = notificationManager.activeNotifications.find {
- it.id == notifyId
- } ?: return
- val msg = historyMessage[notifyId]?: return
- val sendMsg: MessagingStyle.Message = MessagingStyle.Message(
- result,
- System.currentTimeMillis(),
- Person.Builder().setName("我").setIcon(getAvatar(selfUin)).setKey(selfUin).build()
- )
- msg.addMessage(sendMsg)
- historyMessage[notifyId] = msg
- val newNotification =
- NotificationCompat.Builder(ctx, origin.notification)
- .setStyle(msg)
- .setSilent(true)
- .build()
- notificationManager.notify(origin.id, newNotification)
- }
- }
- }
- )
-
- XposedHelpers.findAndHookMethod(
- "com.tencent.commonsdk.util.notification.QQNotificationManager".clazz,
- "cancel", String::class.java, Int::class.javaPrimitiveType,
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- if (!isEnabled or LicenseStatus.sDisableCommonHooks) return
- if (param.args[0] as String != "MobileQQServiceWrapper.showMsgNotification") {
- historyMessage.remove(param.args[1] as Int)
- } else {
- // stop QQ cancel the old message to prevent message flashing in notification area
- param.result = null
- }
- }
- }
- )
- XposedHelpers.findAndHookMethod(
- "com.tencent.commonsdk.util.notification.QQNotificationManager".clazz,
- "cancelAll",
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- if (!isEnabled or LicenseStatus.sDisableCommonHooks) return
- historyMessage.clear()
- }
- }
- )
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- // Fix the height of launch from bubble
- XposedHelpers.findAndHookMethod(
- "com.tencent.widget.immersive.ImmersiveUtils".clazz,
- "getStatusBarHeight",
- Context::class.java,
- object : XC_MethodHook() {
- override fun afterHookedMethod(param: MethodHookParam) {
- if (!isEnabled or LicenseStatus.sDisableCommonHooks) return
- if ((param.args[0] as? Activity)?.isLaunchedFromBubble == true)
- param.result = 0
- }
- })
- // Don't clear notification when launching from bubble
- XposedHelpers.findAndHookMethod("com.tencent.mobileqq.app.lifecycle.BaseActivityLifecycleCallbackImpl".clazz,
- "doOnWindowFocusChanged",
- Activity::class.java,
- Boolean::class.javaPrimitiveType,
- object : XC_MethodReplacement() {
- override fun replaceHookedMethod(param: MethodHookParam): Any? {
- val id = Thread.currentThread().id
- val unhook = if (isEnabled && !LicenseStatus.sDisableCommonHooks &&
- param.args[1] as Boolean &&
- (param.args[0] as Activity).isLaunchedFromBubble
- )
- XposedHelpers.findAndHookMethod("com.tencent.mobileqq.app.QQAppInterface".clazz,
- "removeNotification",
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- if (id == Thread.currentThread().id) param.result = null
- }
- }
- ) else null
- val result = XposedBridge.invokeOriginalMethod(
- param.method,
- param.thisObject,
- param.args
- )
- unhook?.unhook()
- return result
- }
- }
- )
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- activityName.clazz!!.method("doOnStart")!!.hookAfter(this) {
- val activity = it.thisObject as Activity
- val rootView = activity.window.decorView
- windowHeight = activity.window.attributes.height
- rootView.setOnApplyWindowInsetsListener { _, insets ->
- val attr = activity.window.attributes
- if (insets.isVisible(WindowInsets.Type.ime()) && attr.height != windowHeight) {
- attr.height = windowHeight
- activity.window.attributes = attr
- }
- insets
- }
- }
- }
- return true
- }
-
- @RequiresApi(Build.VERSION_CODES.O)
- private fun createNotificationChannels() {
- val notificationChannels: List = getNotificationChannels()
- // don't create new channel group since the old channel ids are still used
- val notificationChannelGroup = NotificationChannelGroup("qq_evolution", "QQ通知进化 Plus")
- val notificationManager: NotificationManager = hostInfo.application.getSystemService(NotificationManager::class.java)
- if (notificationChannels.any { notificationChannel ->
- notificationManager.getNotificationChannel(notificationChannel.id) == null
- }) {
- Log.i("QNotifyEvolutionXp", "Creating channels...")
- notificationManager.createNotificationChannelGroup(notificationChannelGroup)
- notificationManager.createNotificationChannels(notificationChannels)
- }
- }
-
- private fun toMD5(uin: String): String {
- return toMD5Method.invoke(null, uin) as String
- }
-
- private fun getCroppedBitmap(bm: Bitmap): Bitmap {
- var w: Int = bm.width
- var h: Int = bm.height
-
- val radius = if (w < h) w else h
- w = radius
- h = radius
-
- val bmOut = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
- val canvas = Canvas(bmOut)
-
- val paint = Paint()
- paint.isAntiAlias = true
- paint.color = -0xbdbdbe
-
- val rect = Rect(0, 0, w, h)
- val rectF = RectF(rect)
-
- canvas.drawARGB(0, 0, 0, 0)
- canvas.drawCircle(
- rectF.left + rectF.width() / 2, rectF.top + rectF.height() / 2,
- (radius / 2).toFloat(), paint
- )
-
- paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
- canvas.drawBitmap(bm, rect, rect, paint)
-
- return bmOut
- }
-
- private fun getAvatarFromFile(uin: String): Bitmap? {
- val md5 = toMD5(toMD5(toMD5(uin) + uin) + uin)
- val file = File(avatarCachePath, "$md5.jpg_")
- if (file.isFile) {
- return getCroppedBitmap(BitmapFactory.decodeFile(file.absolutePath))
- }
- return null
- }
- private fun getAvatar(uin: String): IconCompat? {
- if (avatarCache[uin] == null) {
- var cached = getAvatarFromFile(uin)
- if (cached == null) {
- val face = FaceImpl.getInstance()
- cached = face.getBitmapFromCache(1, uin)
- if (cached == null) {
- face.requestDecodeFace(1, uin)
- return null
- }
- }
- avatarCache.put(uin, IconCompat.createWithBitmap(cached))
- }
- return avatarCache[uin]
- }
-}
diff --git a/app/src/main/java/moe/zapic/hook/MessagingStyleNotification.kt b/app/src/main/java/moe/zapic/hook/MessagingStyleNotification.kt
new file mode 100644
index 0000000000..7b4dc31052
--- /dev/null
+++ b/app/src/main/java/moe/zapic/hook/MessagingStyleNotification.kt
@@ -0,0 +1,275 @@
+/*
+ * QAuxiliary - An Xposed module for QQ/TIM
+ * Copyright (C) 2019-2023 QAuxiliary developers
+ * https://github.com/cinit/QAuxiliary
+ *
+ * This software is non-free but opensource software: you can redistribute it
+ * and/or modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version and our eula as published
+ * by QAuxiliary contributors.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * and eula along with this software. If not, see
+ *
+ * .
+ */
+
+package moe.zapic.hook
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationCompat.MessagingStyle
+import androidx.core.app.Person
+import androidx.core.content.LocusIdCompat
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
+import androidx.core.graphics.drawable.toBitmap
+import cc.chenhe.qqnotifyevo.utils.NotifyChannel
+import cc.chenhe.qqnotifyevo.utils.getChannelId
+import cc.chenhe.qqnotifyevo.utils.getNotificationChannels
+import cc.ioctl.util.Reflex
+import cc.ioctl.util.hookAfterIfEnabled
+import cc.ioctl.util.hookBeforeIfEnabled
+import de.robv.android.xposed.XC_MethodHook
+import de.robv.android.xposed.XposedHelpers
+import io.github.qauxv.base.annotation.FunctionHookEntry
+import io.github.qauxv.base.annotation.UiItemAgentEntry
+import io.github.qauxv.dsl.FunctionEntryRouter
+import io.github.qauxv.hook.CommonSwitchFunctionHook
+import io.github.qauxv.ui.ResUtils
+import io.github.qauxv.util.LicenseStatus
+import io.github.qauxv.util.SyncUtils
+import io.github.qauxv.util.hostInfo
+import moe.zapic.util.QQAvatarHelper
+import moe.zapic.util.QQRecentContactInfo
+import xyz.nextalone.util.clazz
+import xyz.nextalone.util.get
+import java.lang.reflect.Method
+import java.util.WeakHashMap
+
+@FunctionHookEntry
+@UiItemAgentEntry
+object MessagingStyleNotification : CommonSwitchFunctionHook(SyncUtils.PROC_ANY) {
+ override val isAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ override val name = "MessagingStyle通知"
+ override val description: String = "更加优雅的通知样式,致敬QQ Helper" + if (isAvailable) "" else " [系统不支持]"
+
+ override val uiItemLocation = FunctionEntryRouter.Locations.Auxiliary.NOTIFICATION_CATEGORY
+
+ private val notificationInfoMap = WeakHashMap>()
+
+ private val historyMessage: HashMap = HashMap()
+ private val avatarHelper = QQAvatarHelper()
+
+ @Throws(Exception::class)
+ override fun initOnce(): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createNotificationChannels()
+ }
+ val cNotificationFacede = "com.tencent.qqnt.notification.NotificationFacade".clazz!!
+ val cAppRuntime = "mqq.app.AppRuntime".clazz!!
+ val cCommonInfo = "com.tencent.qqnt.kernel.nativeinterface.NotificationCommonInfo".clazz!!
+ val cRecentInfo = "com.tencent.qqnt.kernel.nativeinterface.RecentContactInfo".clazz!!
+ val postNotification = Reflex.findSingleMethod(
+ cNotificationFacede,
+ null,
+ false,
+ Notification::class.java,
+ Int::class.javaPrimitiveType
+ )
+ lateinit var buildNotification: Method
+ lateinit var recentInfoBuilder: Method
+ cNotificationFacede.declaredMethods.forEach {
+ if (it.parameterTypes.size != 3) return@forEach
+ if (it.parameterTypes[0] != cAppRuntime) return@forEach
+ if (it.parameterTypes[2] == cCommonInfo) {
+ buildNotification = it
+ } else if (it.parameterTypes[1] == cRecentInfo){
+ recentInfoBuilder = it
+ }
+ }
+
+ val context = hostInfo.application
+
+ // 获取消息详细信息 使用WeakMap存储(K/V:简单信息->详细信息)
+ hookAfterIfEnabled(recentInfoBuilder) { param ->
+ val si = param.args[1]
+ val it = param.result.get(Intent::class.java) ?: return@hookAfterIfEnabled
+ notificationInfoMap[param.result] = Pair(si, it)
+ }
+
+ // 构建通知前获取(K/V:contentIntent->详细信息)
+ hookBeforeIfEnabled(buildNotification) { param ->
+ val el = param.args[1] as Any
+ val pt = el.get(PendingIntent::class.java) ?: return@hookBeforeIfEnabled
+ notificationInfoMap[pt] = notificationInfoMap[el]
+ notificationInfoMap.remove(el)
+ }
+ hookBeforeIfEnabled(postNotification) { param ->
+ val oldNotification = param.args[0] as Notification
+ val pair = notificationInfoMap[oldNotification.contentIntent] ?: return@hookBeforeIfEnabled
+ val info = QQRecentContactInfo(pair.first)
+ notificationInfoMap.remove(oldNotification.contentIntent)
+ if (info.chatType == 1 || info.chatType == 2){
+ val content = info.abstractContent?.joinToString { it.get("content", String::class.java) ?: "[未解析消息]" } ?: return@hookBeforeIfEnabled
+ val senderName = info.getUserName() ?: return@hookBeforeIfEnabled
+ val senderUin = info.senderUin ?: return@hookBeforeIfEnabled
+ val senderIcon: IconCompat
+ val shortcut: ShortcutInfoCompat
+ var groupName: String? = null
+ var groupUin: Long? = null
+ var groupIcon: IconCompat? = null
+
+ // 好友消息
+ if (info.chatType == 1) {
+ senderIcon = IconCompat.createFromIcon(hostInfo.application, oldNotification.getLargeIcon()) ?: IconCompat.createWithBitmap(ResUtils.loadDrawableFromAsset("face.png", context).toBitmap())
+ shortcut = getShortcut("private_$senderUin", senderName, senderIcon, pair.second)
+ } else if (info.chatType == 2) {
+ groupName = info.peerName ?: return@hookBeforeIfEnabled
+ groupUin = info.peerUin ?: return@hookBeforeIfEnabled
+
+ senderIcon = avatarHelper.getAvatar(senderUin.toString()) ?: IconCompat.createWithBitmap(ResUtils.loadDrawableFromAsset("face.png", context).toBitmap())
+ groupIcon = IconCompat.createFromIcon(hostInfo.application, oldNotification.getLargeIcon()) ?: IconCompat.createWithBitmap(ResUtils.loadDrawableFromAsset("face.png", context).toBitmap())
+ shortcut = getShortcut("group_$groupUin", groupName, groupIcon, pair.second)
+ } else {
+ // Impossible
+ throw Error("what the hell?")
+ }
+ val notification = createNotification(
+ content,
+ senderName,
+ senderUin,
+ senderIcon,
+ groupName,
+ groupUin,
+ groupIcon,
+ shortcut,
+ oldNotification
+ )
+ param.args[0] = notification
+ }
+ }
+
+ XposedHelpers.findAndHookMethod(
+ "com.tencent.commonsdk.util.notification.QQNotificationManager".clazz,
+ "cancelAll",
+ object : XC_MethodHook() {
+ override fun beforeHookedMethod(param: MethodHookParam) {
+ if (!isEnabled || LicenseStatus.sDisableCommonHooks) return
+ historyMessage.clear()
+ }
+ }
+ )
+ return true
+ }
+
+ private fun getShortcut(id: String, name: String, icon: IconCompat, intent: Intent): ShortcutInfoCompat {
+ val context = hostInfo.application
+
+ val shortcut =
+ ShortcutInfoCompat.Builder(context, id)
+ .setLongLived(true)
+ .setIntent(intent)
+ .setShortLabel(name)
+ .setIcon(icon)
+ .setLocusId(LocusIdCompat(id))
+ .build()
+ ShortcutManagerCompat.pushDynamicShortcut(
+ context,
+ shortcut
+ )
+ return shortcut
+ }
+
+ private fun createNotification(
+ content: String,
+ senderName: String,
+ senderUin: Long,
+ senderIcon: IconCompat,
+ groupName: String?,
+ groupUin: Long?,
+ groupIcon: IconCompat?,
+ shortcut: ShortcutInfoCompat,
+ oldNotification: Notification
+ ): Notification {
+ val mainUin: Long
+ val mainName: String?
+ val mainIcon: IconCompat?
+ val channelId: NotifyChannel
+
+ if (groupUin != null) {
+ mainUin = groupUin
+ mainName = groupName ?: ""
+ mainIcon = groupIcon
+ channelId = NotifyChannel.GROUP
+ } else {
+ mainUin = senderUin
+ mainName = senderName
+ mainIcon = senderIcon
+ channelId = NotifyChannel.FRIEND
+ }
+
+ var messageStyle = historyMessage["$mainUin"]
+
+ if (messageStyle == null) {
+ messageStyle = MessagingStyle(Person.Builder()
+ .setName(mainName)
+ .setIcon(mainIcon)
+ .setKey(mainUin.toString())
+ .build())
+ messageStyle.conversationTitle = senderName
+ historyMessage["$mainUin"] = messageStyle
+ }
+ var senderPerson: Person? = null
+ if (groupUin != null) {
+ senderPerson = Person.Builder()
+ .setName(senderName)
+ .setIcon(senderIcon)
+ .setKey(senderUin.toString())
+ .build()
+ }
+ val message = MessagingStyle.Message(content, oldNotification.`when`, senderPerson)
+ messageStyle.addMessage(message)
+ val builder = NotificationCompat.Builder(hostInfo.application, oldNotification)
+ .setContentTitle(mainName)
+ .setContentText(content)
+ .setLargeIcon(null)
+ .setStyle(messageStyle)
+ .setChannelId(getChannelId(channelId))
+
+ builder.setShortcutInfo(shortcut)
+ return builder.build()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createNotificationChannels() {
+ val notificationChannels: List = getNotificationChannels()
+ // don't create new channel group since the old channel ids are still used
+ val notificationChannelGroup = NotificationChannelGroup("qq_evolution", "QQ通知进化 Plus")
+ val notificationManager: NotificationManager = hostInfo.application.getSystemService(NotificationManager::class.java)
+ if (notificationChannels.any { notificationChannel ->
+ notificationManager.getNotificationChannel(notificationChannel.id) == null
+ }) {
+ Log.i("QNotifyEvolutionXp", "Creating channels...")
+ notificationManager.createNotificationChannelGroup(notificationChannelGroup)
+ notificationManager.createNotificationChannels(notificationChannels)
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/moe/zapic/util/QQAvatarHelper.kt b/app/src/main/java/moe/zapic/util/QQAvatarHelper.kt
new file mode 100644
index 0000000000..93f393b97f
--- /dev/null
+++ b/app/src/main/java/moe/zapic/util/QQAvatarHelper.kt
@@ -0,0 +1,107 @@
+/*
+ * QAuxiliary - An Xposed module for QQ/TIM
+ * Copyright (C) 2019-2023 QAuxiliary developers
+ * https://github.com/cinit/QAuxiliary
+ *
+ * This software is non-free but opensource software: you can redistribute it
+ * and/or modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version and our eula as published
+ * by QAuxiliary contributors.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * and eula along with this software. If not, see
+ *
+ * .
+ */
+
+package moe.zapic.util
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.graphics.Rect
+import android.graphics.RectF
+import android.util.LruCache
+import androidx.core.graphics.drawable.IconCompat
+import io.github.qauxv.bridge.FaceImpl
+import io.github.qauxv.util.hostInfo
+import xyz.nextalone.util.clazz
+import java.io.File
+
+class QQAvatarHelper {
+
+ private val toMD5Method = "com.tencent.qphone.base.util.MD5".clazz!!.getMethod("toMD5", String::class.java)
+ private val avatarCache: LruCache = LruCache(50)
+
+ private val avatarCachePath = File(
+ hostInfo.application.getExternalFilesDir(null)?.parent,
+ "Tencent/MobileQQ/head/_hd"
+ ).absolutePath
+
+ private fun toMD5(uin: String): String {
+ return toMD5Method.invoke(null, uin) as String
+ }
+
+ private fun getCroppedBitmap(bm: Bitmap): Bitmap {
+ var w: Int = bm.width
+ var h: Int = bm.height
+
+ val radius = if (w < h) w else h
+ w = radius
+ h = radius
+
+ val bmOut = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmOut)
+
+ val paint = Paint()
+ paint.isAntiAlias = true
+ paint.color = -0xbdbdbe
+
+ val rect = Rect(0, 0, w, h)
+ val rectF = RectF(rect)
+
+ canvas.drawARGB(0, 0, 0, 0)
+ canvas.drawCircle(
+ rectF.left + rectF.width() / 2, rectF.top + rectF.height() / 2,
+ (radius / 2).toFloat(), paint
+ )
+
+ paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
+ canvas.drawBitmap(bm, rect, rect, paint)
+
+ return bmOut
+ }
+
+ private fun getAvatarFromFile(uin: String): Bitmap? {
+ val md5 = toMD5(toMD5(toMD5(uin) + uin) + uin)
+ val file = File(avatarCachePath, "$md5.jpg_")
+ if (file.isFile) {
+ return getCroppedBitmap(BitmapFactory.decodeFile(file.absolutePath))
+ }
+ return null
+ }
+ fun getAvatar(uin: String): IconCompat? {
+ if (avatarCache[uin] == null) {
+ var cached = getAvatarFromFile(uin)
+ if (cached == null) {
+ val face = FaceImpl.getInstance()
+ cached = face.getBitmapFromCache(1, uin)
+ if (cached == null) {
+ face.requestDecodeFace(1, uin)
+ return null
+ }
+ }
+ avatarCache.put(uin, IconCompat.createWithBitmap(cached))
+ }
+ return avatarCache[uin]
+ }
+}
diff --git a/app/src/main/java/moe/zapic/util/QQRecentContactInfo.kt b/app/src/main/java/moe/zapic/util/QQRecentContactInfo.kt
new file mode 100644
index 0000000000..03cafec1de
--- /dev/null
+++ b/app/src/main/java/moe/zapic/util/QQRecentContactInfo.kt
@@ -0,0 +1,91 @@
+/*
+ * QAuxiliary - An Xposed module for QQ/TIM
+ * Copyright (C) 2019-2023 QAuxiliary developers
+ * https://github.com/cinit/QAuxiliary
+ *
+ * This software is non-free but opensource software: you can redistribute it
+ * and/or modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version and our eula as published
+ * by QAuxiliary contributors.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * and eula along with this software. If not, see
+ *
+ * .
+ */
+
+package moe.zapic.util
+
+import xyz.nextalone.util.get
+
+class QQRecentContactInfo(obj: Any) {
+ var abstractContent: ArrayList? = null
+ var anonymousFlag: Int? = null
+ var atType: Int? = null
+ var avatarPath: String? = null
+ var avatarUrl: String? = null
+ var chatType: Int? = null
+ var contactId: Long? = null
+ var draftFlag: Byte? = null
+ var draftTime: Long? = null
+ var hiddenFlag: Int? = null
+ var isBeat: Boolean? = null
+ var isBlock: Boolean? = null
+ var isMsgDisturb: Boolean? = null
+ var isOnlineMsg: Boolean? = null
+ var keepHiddenFlag: Int? = null
+ var msgId: Long? = null
+ var msgSeq: Long? = null
+ var msgTime: Long? = null
+ var nestedSortedContactList: ArrayList? = null
+ var notifiedType: Int? = null
+ var peerName: String? = null
+ var peerUid: String? = null
+ var peerUin: Long? = null
+ var remark: String? = null
+ var sendMemberName: String? = null
+ var sendNickName: String? = null
+ var sendRemarkName: String? = null
+ var sendStatus: Int? = null
+ var senderUid: String? = null
+ var senderUin: Long? = null
+ var sessionType: Int? = null
+ var shieldFlag: Long? = null
+ var sortField: Long? = null
+ var specialCareFlag: Byte? = null
+ var topFlag: Byte? = null
+ var topFlagTime: Long? = null
+ var unreadChatCnt: Int? = null
+ var unreadCnt: Long? = null
+ var unreadFlag: Long? = null
+
+
+ init {
+ this::class.java.declaredFields.forEach {
+ try {
+ it.set(this, obj.get(it.name))
+ } catch (e: Throwable) {
+ return@forEach
+ }
+ }
+ }
+
+ fun getUserName(): String? {
+ return if (!sendMemberName.isNullOrEmpty()) {
+ sendMemberName
+ } else if (!sendRemarkName.isNullOrEmpty()) {
+ sendRemarkName
+ } else if (!sendNickName.isNullOrEmpty()) {
+ sendNickName
+ } else {
+ null
+ }
+ }
+
+}