diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dca843a..872dfaa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,7 +73,8 @@ dependencies { implementation("com.jakewharton.timber:timber:5.0.1") testImplementation("junit:junit:4.13.2") - testImplementation("org.amshove.kluent:kluent-android:1.68") + testImplementation("io.kotest:kotest-assertions-core:5.7.2") + testImplementation("org.json:json:20231013") // JSONObject } fun String.runCommand(currentWorkingDir: File = file("./")): String { diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationProcessor.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationProcessor.kt index 48b573a..54c6420 100644 --- a/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationProcessor.kt +++ b/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationProcessor.kt @@ -7,7 +7,6 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.service.notification.StatusBarNotification -import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.content.ContextCompat @@ -25,7 +24,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import timber.log.Timber import java.util.* -import java.util.regex.Pattern abstract class NotificationProcessor(context: Context, scope: CoroutineScope) { @@ -52,128 +50,6 @@ abstract class NotificationProcessor(context: Context, scope: CoroutineScope) { else -> Tag.UNKNOWN } } - - // 群聊消息 - // ------------- 单个消息 - // title: 群名 - // ticker: 群名: [特别关心]昵称: 消息内容 - // text: [特别关心]昵称: 消息内容 - // ------------- 多个消息 - // title: 群名(x条新消息) - // ticker: 群名(x条新消息): [特别关心]昵称: 消息内容 - // text: [特别关心]昵称: 消息内容 - // QQHD v5.8.8.3445 中群里特别关心前缀为 特别关注。 - - /** - * 匹配群聊消息 Ticker. - * - * 限制:昵称不能包含英文括号 `()`. - */ - @VisibleForTesting - val groupMsgPattern = - """^(?.+?)(?:\((?\d+)条新消息\))?: (?\[特别关心])?(?.+?): (?[\s\S]+)$""".toRegex() - - /** - * 匹配群聊消息 Content. - * - * QQHD v5.8.8.3445 中群里特别关心前缀为 特别关注。 - */ - @VisibleForTesting - val groupMsgContentPattern = - """^(?\[特别关心])?(?.+?): (?[\s\S]+)""".toRegex() - - // 私聊消息 - // title: [特别关心]昵称 | [特别关心]昵称(x条新消息) - // ticker: [特别关心]昵称: 消息内容 | [特别关心]昵称(x条新消息): 消息内容 - // text: 消息内容 - - /** - * 匹配私聊消息 Ticker. - * - * Group: nickname-昵称, num-消息个数, msg-消息内容 - */ - @VisibleForTesting - val msgPattern = - """^(?\[特别关心])?(?.+?)(\((?\d+)条新消息\))?: (?[\s\S]+)$""".toRegex() - - /** - * 匹配私聊消息 Title. - * - * Group: 1\[特别关心\], 2新消息数目 - */ - @VisibleForTesting - val msgTitlePattern: Pattern = - Pattern.compile("^(\\[特别关心])?.+?(?:\\((\\d+)条新消息\\))?$") - - // 关联QQ消息 - // title: - // - 只有一条消息: 关联QQ号 - // - 一人发来多条消息: 关联QQ号 ({x}条新消息) - // - 多人发来消息: QQ - // ticker: 关联QQ号-{发送者昵称}:{消息内容} - // content: - // - 一人发来消息: {发送者昵称}:{消息内容} - // - 多人发来消息: 有 {x} 个联系人给你发过来{y}条新消息 - - /** - * 匹配关联 QQ 消息 ticker. - * - * Group: 1发送者昵称, 2消息内容 - */ - @VisibleForTesting - val bindingQQMsgTickerPattern: Pattern = Pattern.compile("^关联QQ号-(.+?):([\\s\\S]+)$") - - /** - * 匹配关联 QQ 消息 content. 用于提取未读消息个数。 - * - * Group: 1未读消息个数 - */ - @VisibleForTesting - val bindingQQMsgContextPattern: Pattern = - Pattern.compile("^有 \\d+ 个联系人给你发过来(\\d+)条新消息$") - - /** - * 匹配关联 QQ 消息 title. 用于提取未读消息个数。 - * - * Group: 1未读消息个数 - */ - @VisibleForTesting - val bindingQQMsgTitlePattern: Pattern = Pattern.compile("^关联QQ号 \\((\\d+)条新消息\\)$") - - // Q空间动态 - // --------------- 说说评论/点赞 - // title: QQ空间动态(共1条未读) - // ticker: XXX评论了你 | XXX赞了你的说说 - // content: XXX评论了你 | XXX赞了你的说说 - - // --------------- 特别关心动态通知 - // title: QQ空间动态 - // ticker: 【特别关心】昵称:动态内容 - // content: 【特别关心】昵称:动态内容 - - // 注意:与我相关动态、特别关心动态是两个独立的通知,不会互相覆盖。 - - /** - * 匹配 QQ 空间 Title. - * - * Group: 1新消息数目 - */ - @VisibleForTesting - val qzoneTitlePattern: Pattern = Pattern.compile("^QQ空间动态(?:\\(共(\\d+)条未读\\))?$") - - // 隐藏消息详情 - // title: QQ - // ticker: QQ: 你收到了x条新消息 - // text: 你收到了x条新消息 - - /** - * 匹配隐藏通知详情时的 Ticker. - * - * Group: 1新消息数目 - */ - @VisibleForTesting - val hideMsgPattern: Pattern = Pattern.compile("^QQ: 你收到了(\\d+)条新消息$") - } protected val ctx: Context = context.applicationContext @@ -293,248 +169,146 @@ abstract class NotificationProcessor(context: Context, scope: CoroutineScope) { val original = sbn.notification ?: return null val tag = getTagFromPackageName(packageName) if (tag == Tag.UNKNOWN) { - Timber.tag(TAG).d("Unknown tag, skip. pkgName=$packageName") + Timber.tag(TAG).d("Unknown tag, skip. pkgName=%s", packageName) return null } - val title = original.extras.getString(Notification.EXTRA_TITLE) - val content = original.extras.getString(Notification.EXTRA_TEXT) - val ticker = original.tickerText?.toString() - - val isMulti = isMulti(ticker) - val isQzone = isQzone(title) - - Timber.tag(TAG) - .v("Title: $title; Ticker: $ticker; QZone: $isQzone; Multi: $isMulti; Content: $content") - - if (isMulti) { - onMultiMessageDetected(ticker?.contains("关联QQ号-") ?: false) - } - - // 隐藏消息详情 - if (isHidden(ticker)) { - Timber.tag(TAG).v("Hidden message content, skip.") - return null - } - - // QQ空间 - tryResolveQzone( - context = context, - tag = tag, - original = original, - isQzone = isQzone, - title = title, - ticker = ticker, - content = content - )?.also { conversation -> - return renewQzoneNotification(context, tag, conversation, sbn, original) - } - - if (ticker == null) { - Timber.tag(TAG).i("Ticker is null, skip.") - return null - } - - // 群消息 - tryResolveGroupMsg( - context = context, - tag = tag, - original = original, - ticker = ticker, - content = content - )?.also { (channel, conversation) -> - return renewConversionNotification(context, tag, channel, conversation, sbn, original) - } - - // 私聊消息 - tryResolvePrivateMsg( - context = context, - tag = tag, - original = original, - ticker = ticker, - content = content, - )?.also { (channel, conversation) -> - return renewConversionNotification(context, tag, channel, conversation, sbn, original) - } - - // 关联账号消息 - tryResolveBindingMsg( - context, - tag, - original, - title, - ticker, - content - )?.also { (channel, conversation) -> - return renewConversionNotification(context, tag, channel, conversation, sbn, original) - } - - Timber.tag(TAG).w("[None] Not match any pattern.") - return null - } - - private fun isMulti(ticker: String?): Boolean { - if (ticker == null) return false - val g = msgPattern.matchEntire(ticker)?.groups ?: return false - return g["num"]?.value.isNullOrEmpty().not() - } - - private fun isQzone(title: String?): Boolean { - return title?.let { qzoneTitlePattern.matcher(it).matches() } ?: false - } - - private fun isHidden(ticker: String?): Boolean { - return ticker != null && hideMsgPattern.matcher(ticker).matches() - } - - private fun tryResolveQzone( - context: Context, tag: Tag, original: Notification, isQzone: Boolean, title: String?, - ticker: String?, content: String? - ): Conversation? { - if (!isQzone || title.isNullOrEmpty() || ticker.isNullOrEmpty() || content.isNullOrEmpty()) { - return null - } + val resolver: NotificationResolver = QQNotificationResolver() + + return when (val r = resolver.resolveNotification(packageName, tag, sbn)) { + is QQNotification.BindingAccountMessage -> { + val conversation = addMessage( + tag = r.tag, + name = context.getString(R.string.notify_binding_msg_title, r.sender), + content = r.message, + group = null, + icon = getNotifyLargeIcon(context, original), + contentIntent = original.contentIntent, + deleteIntent = original.deleteIntent, + special = false, + ) + deleteOldMessage(conversation, r.num) + Timber.tag(TAG).d("[Binding] Sender: ${r.sender}; Text: ${r.message}") + val channel = NotifyChannel.FRIEND + renewConversionNotification(context, r.tag, channel, conversation, sbn, original) + } - if (ticker.startsWith("【特别关心】")) { - // 特别关心动态推送 - getNotifyLargeIcon(context, original)?.also { - avatarManager.saveAvatar(CONVERSATION_NAME_QZONE_SPECIAL.hashCode(), it) + is QQNotification.GroupMessage -> { + if (r.num == 1) { + // 单个消息 + getNotifyLargeIcon(context, original)?.also { + avatarManager.saveAvatar(r.groupName.hashCode(), it) + } + } + val conversation = addMessage( + tag = r.tag, + name = r.nickname, + content = r.message, + group = r.groupName, + icon = avatarManager.getAvatar(r.nickname.hashCode()), + contentIntent = original.contentIntent, + deleteIntent = original.deleteIntent, + special = r.special, + ) + if (r.num > 1) { + deleteOldMessage(conversation, r.num) + } + Timber.tag(TAG) + .d("[${if (r.special) "GroupS" else "Group"}] Name: ${r.nickname}; Group: ${r.groupName}; Text: ${r.message}") + val channel = if (r.special) { + when (specialGroupChannel) { + Group -> NotifyChannel.GROUP + Special -> NotifyChannel.FRIEND_SPECIAL + } + } else { + NotifyChannel.GROUP + } + renewConversionNotification(context, r.tag, channel, conversation, sbn, original) } - val conversation = addMessage( - tag = tag, - name = qzoneSpecialTitle, - content = content, - group = null, - icon = avatarManager.getAvatar(CONVERSATION_NAME_QZONE_SPECIAL.hashCode()), - contentIntent = original.contentIntent, - deleteIntent = original.deleteIntent, - special = false - ) - // 由于特别关心动态推送的通知没有显示未读消息个数,所以这里无法提取并删除多余的历史消息。 - // Workaround: 在通知删除回调下来匹配并清空特别关心动态历史记录。 - Timber.tag(TAG).d("[QZoneSpecial] Ticker: $ticker") - return conversation - } - val num = matchQzoneNum(title) - if (num != null) { - // 普通空间通知 - getNotifyLargeIcon(context, original)?.also { - avatarManager.saveAvatar(CONVERSATION_NAME_QZONE.hashCode(), it) + + is QQNotification.HiddenMessage -> { + Timber.tag(TAG).v("Hidden message content, skip") + null } - val conversation = addMessage( - tag = tag, - name = context.getString(R.string.notify_qzone_title), - content = content, - group = null, - icon = avatarManager.getAvatar(CONVERSATION_NAME_QZONE.hashCode()), - contentIntent = original.contentIntent, - deleteIntent = original.deleteIntent, - special = false - ) - deleteOldMessage(conversation, num) - Timber.tag(TAG).d("[QZone] Ticker: $ticker") - return conversation - } - return null - } - private fun tryResolveGroupMsg( - context: Context, tag: Tag, original: Notification, ticker: String, content: String? - ): Pair? { - if (content.isNullOrEmpty() || ticker.isEmpty()) { - return null - } - val tickerGroups = groupMsgPattern.matchEntire(ticker)?.groups ?: return null - val contentGroups = groupMsgContentPattern.matchEntire(content)?.groups ?: return null - val name = tickerGroups["nickname"]?.value ?: return null - val groupName = tickerGroups["name"]?.value ?: return null - val num = tickerGroups["num"]?.value?.toIntOrNull() - val text = contentGroups["msg"]?.value ?: return null - val special = contentGroups["sp"]?.value != null - - if (num == null || num == 1) { - // 单个消息 - getNotifyLargeIcon(context, original)?.also { - avatarManager.saveAvatar(groupName.hashCode(), it) + is QQNotification.PrivateMessage -> { + if (r.num == 1) { + // 单个消息 + getNotifyLargeIcon(context, original)?.also { + avatarManager.saveAvatar(r.nickname.hashCode(), it) + } + } + val conversation = addMessage( + tag = r.tag, + name = r.nickname, + content = r.message, + group = null, + icon = avatarManager.getAvatar(r.nickname.hashCode()), + contentIntent = original.contentIntent, + deleteIntent = original.deleteIntent, + special = r.special, + ) + if (r.num > 1) { + deleteOldMessage(conversation, r.num) + } + val prefix = if (r.special) "[FriendS]" else "[Friend]" + Timber.tag(TAG).d("$prefix Name: ${r.nickname}; Text: ${r.message}") + val channel = if (r.special) { + NotifyChannel.FRIEND_SPECIAL + } else { + NotifyChannel.FRIEND + } + renewConversionNotification(context, r.tag, channel, conversation, sbn, original) } - } - val conversation = addMessage( - tag, name, text, groupName, avatarManager.getAvatar(name.hashCode()), - original.contentIntent, original.deleteIntent, special - ) - if (num != null && num > 1) { - deleteOldMessage(conversation, num) - } - Timber.tag(TAG) - .d("[${if (special) "GroupS" else "Group"}] Name: $name; Group: $groupName; Text: $text") - val channel = if (special) { - when (specialGroupChannel) { - Group -> NotifyChannel.GROUP - Special -> NotifyChannel.FRIEND_SPECIAL + is QQNotification.QZoneMessage -> { + getNotifyLargeIcon(context, original)?.also { + avatarManager.saveAvatar(CONVERSATION_NAME_QZONE.hashCode(), it) + } + val conversation = addMessage( + tag = r.tag, + name = context.getString(R.string.notify_qzone_title), + content = r.content, + group = null, + icon = avatarManager.getAvatar(CONVERSATION_NAME_QZONE.hashCode()), + contentIntent = original.contentIntent, + deleteIntent = original.deleteIntent, + special = false + ) + deleteOldMessage(conversation, r.num) + Timber.tag(TAG).d("[QZone] content: ${r.content}") + renewQzoneNotification(context, r.tag, conversation, sbn, original) } - } else { - NotifyChannel.GROUP - } - return Pair(channel, conversation) - } - private fun tryResolvePrivateMsg( - context: Context, tag: Tag, original: Notification, ticker: String?, - content: String? - ): Pair? { - if (ticker.isNullOrEmpty() || content.isNullOrEmpty()) { - return null - } - val tickerGroups = msgPattern.matchEntire(ticker)?.groups ?: return null - val special = tickerGroups["sp"] != null - val name = tickerGroups["nickname"]?.value ?: return null - val num = tickerGroups["num"]?.value?.toIntOrNull() - - if (num == null || num == 1) { - // 单个消息 - getNotifyLargeIcon(context, original)?.also { - avatarManager.saveAvatar(name.hashCode(), it) + is QQNotification.QZoneSpecialPost -> { + getNotifyLargeIcon(context, original)?.also { + avatarManager.saveAvatar(CONVERSATION_NAME_QZONE_SPECIAL.hashCode(), it) + } + val conversation = addMessage( + tag = r.tag, + name = qzoneSpecialTitle, + content = r.content, + group = null, + icon = avatarManager.getAvatar(CONVERSATION_NAME_QZONE_SPECIAL.hashCode()), + contentIntent = original.contentIntent, + deleteIntent = original.deleteIntent, + special = false + ) + // 由于特别关心动态推送的通知没有显示未读消息个数,所以这里无法提取并删除多余的历史消息。 + // Workaround: 在通知删除回调下来匹配并清空特别关心动态历史记录。 + Timber.tag(TAG).d("[QZoneSpecial] content: ${r.content}") + renewQzoneNotification(context, r.tag, conversation, sbn, original) } - } - val conversation = addMessage( - tag, name, content, null, avatarManager.getAvatar(name.hashCode()), - original.contentIntent, original.deleteIntent, special - ) - if (num != null && num > 1) { - deleteOldMessage(conversation, num) - } - return if (special) { - Timber.tag(TAG).d("[FriendS] Name: $name; Text: $content") - Pair(NotifyChannel.FRIEND_SPECIAL, conversation) - } else { - Timber.tag(TAG).d("[Friend] Name: $name; Text: $content") - Pair(NotifyChannel.FRIEND, conversation) - } - } - private fun tryResolveBindingMsg( - context: Context, tag: Tag, original: Notification, title: String?, - ticker: String, content: String? - ): Pair? { - val matcher = bindingQQMsgTickerPattern.matcher(ticker) - if (!matcher.matches()) { - return null + null -> { + Timber.tag(TAG).w("[None] Not match any pattern") + null + } } - val sender = matcher.group(1) ?: return null - val text = matcher.group(2) ?: return null - val conversation = addMessage( - tag, context.getString(R.string.notify_binding_msg_title, sender), - text, null, getNotifyLargeIcon(context, original), original.contentIntent, - original.deleteIntent, false - ) - deleteOldMessage(conversation, matchBindingMsgNum(title, content)) - Timber.tag(TAG).d("[Binding] Sender: $sender; Text: $text") - return Pair(NotifyChannel.FRIEND, conversation) - } + fun onNotificationRemoved(sbn: StatusBarNotification, reason: Int) { val tag = Tag.valueOf(sbn.notification.extras.getString(NOTIFICATION_EXTRA_TAG, Tag.UNKNOWN.name)) @@ -555,42 +329,6 @@ abstract class NotificationProcessor(context: Context, scope: CoroutineScope) { } } - /** - * 提取空间未读消息个数。 - * - * @return 动态未读消息个数。提取失败返回 `null`。 - */ - private fun matchQzoneNum(title: String): Int? { - val matcher = qzoneTitlePattern.matcher(title) - if (matcher.matches()) { - return matcher.group(1)?.toIntOrNull() - } - return null - } - - - /** - * 提取关联账号的未读消息个数。 - */ - private fun matchBindingMsgNum(title: String?, content: String?): Int { - if (title == null || content == null) return 1 - if (title == "QQ") { - bindingQQMsgContextPattern.matcher(content).also { matcher -> - if (matcher.matches()) { - return matcher.group(1)?.toInt() ?: 1 - } - } - } else { - bindingQQMsgTitlePattern.matcher(title).also { matcher -> - if (matcher.matches()) { - return matcher.group(1)?.toInt() ?: 1 - } - } - } - - return 1 - } - /** * 获取通知的大图标。 * diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationResolver.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationResolver.kt new file mode 100644 index 0000000..1063921 --- /dev/null +++ b/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationResolver.kt @@ -0,0 +1,110 @@ +package cc.chenhe.qqnotifyevo.core + +import android.app.Notification +import android.service.notification.StatusBarNotification +import cc.chenhe.qqnotifyevo.BuildConfig +import cc.chenhe.qqnotifyevo.utils.Tag +import org.json.JSONObject +import timber.log.Timber + +/** + * 已知的 QQ 通知种类 + */ +sealed class QQNotification { + abstract val tag: Tag + + /** 隐藏了消息内容的通知 */ + data class HiddenMessage(override val tag: Tag) : QQNotification() + + /** QQ 空间特别关心动态推送 */ + data class QZoneSpecialPost(override val tag: Tag, val content: String) : QQNotification() + + /** QQ 空间动态:点赞评论等 */ + data class QZoneMessage(override val tag: Tag, val content: String, val num: Int) : + QQNotification() + + /** + * 群聊消息 + * @param nickname 消息发送者昵称,通常是在群聊中的昵称 + * @param special 特别关心 + */ + data class GroupMessage( + override val tag: Tag, + val groupName: String, + val nickname: String, + val message: String, + val special: Boolean, + val num: Int, + ) : QQNotification() + + /** + * 私聊消息 + * @param special 特别关心 + */ + data class PrivateMessage( + override val tag: Tag, + val nickname: String, + val message: String, + val special: Boolean, + val num: Int, + ) : QQNotification() + + /** + * 来自关联账号的消息 + * @param sender 消息发送者昵称,不是被关联账号的昵称 + */ + data class BindingAccountMessage( + override val tag: Tag, + val sender: String, + val message: String, + val num: Int, + ) : QQNotification() +} + +private const val TAG = "NotificationResolver" + +/** + * A resolver that can parse arbitrary notification from QQ (or TIM e.g.) into a known pattern. + * Not responsible for managing history. In general, different implementations work for different + * source APPs and versions. + */ +interface NotificationResolver { + + /** + * Resolve the given notification into a known pattern. + * @return resolved pattern, `null` if not matched. + */ + fun resolveNotification( + packageName: String, + tag: Tag, + sbn: StatusBarNotification + ): QQNotification? { + val original = sbn.notification ?: return null + val title = original.extras.getString(Notification.EXTRA_TITLE) + val content = original.extras.getString(Notification.EXTRA_TEXT) + val ticker = original.tickerText?.toString() + + if (BuildConfig.DEBUG) { + val jsonStr = JSONObject().apply { + put("title", title) + put("ticker", ticker) + put("content", content) + }.toString() + Timber.tag(TAG).v(jsonStr) + } + + Timber.tag(TAG).v("Title: $title; Ticker: $ticker; Content: $content") + return resolveNotification(tag, title, content, ticker) + } + + /** + * Resolve the given notification components into a known pattern. + * @return resolved pattern, `null` if not matched. + */ + fun resolveNotification( + tag: Tag, + title: String?, + content: String?, + ticker: String?, + ): QQNotification? +} \ No newline at end of file diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolver.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolver.kt new file mode 100644 index 0000000..9e0df6d --- /dev/null +++ b/app/src/main/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolver.kt @@ -0,0 +1,269 @@ +package cc.chenhe.qqnotifyevo.core + +import cc.chenhe.qqnotifyevo.utils.Tag +import timber.log.Timber +import java.util.regex.Pattern + +/** + * For com.tencent.mobileqq ver 8.9.85.12820 build 4766 + */ +class QQNotificationResolver : NotificationResolver { + companion object { + private const val TAG = "QQNotificationResolver" + // Q空间动态 + // --------------- 说说评论/点赞 + // title: QQ空间动态(共1条未读) + // ticker: XXX评论了你 | XXX赞了你的说说 + // content: XXX评论了你 | XXX赞了你的说说 + + // --------------- 特别关心动态通知 + // title: QQ空间动态 + // ticker: 【特别关心】昵称:动态内容 + // content: 【特别关心】昵称:动态内容 + + // 注意:与我相关动态、特别关心动态是两个独立的通知,不会互相覆盖。 + + /** + * 匹配 QQ 空间 Title. + * + * Group: 1新消息数目 + */ + private val qzoneTitlePattern: Pattern = + Pattern.compile("^QQ空间动态(?:\\(共(\\d+)条未读\\))?$") + + + // 隐藏消息详情 + // title: QQ + // ticker: QQ: 你收到了x条新消息 + // text: 你收到了x条新消息 + + /** + * 匹配隐藏通知详情时的 Ticker. + * + * Group: 1新消息数目 + */ + private val hideMsgPattern: Pattern = Pattern.compile("^QQ: 你收到了(\\d+)条新消息$") + + // 群聊消息 + // ------------- 单个消息 + // title: 群名 + // ticker: 群名: [特别关心]昵称: 消息内容 + // text: [特别关心]昵称: 消息内容 + // ------------- 多个消息 + // title: 群名(x条新消息) + // ticker: 群名(x条新消息): [特别关心]昵称: 消息内容 + // text: [特别关心]昵称: 消息内容 + // QQHD v5.8.8.3445 中群里特别关心前缀为 特别关注。 + + /** + * 匹配群聊消息 Ticker. + * + * 限制:昵称不能包含英文括号 `()`. + */ + private val groupMsgPattern = + """^(?.+?)(?:\((?\d+)条新消息\))?: (?\[特别关心])?(?.+?): (?[\s\S]+)$""".toRegex() + + /** + * 匹配群聊消息 Content. + * + * QQHD v5.8.8.3445 中群里特别关心前缀为 特别关注。 + */ + private val groupMsgContentPattern = + """^(?\[特别关心])?(?.+?): (?[\s\S]+)""".toRegex() + + // 私聊消息 + // title: [特别关心]昵称 | [特别关心]昵称(x条新消息) + // ticker: [特别关心]昵称: 消息内容 | [特别关心]昵称(x条新消息): 消息内容 + // text: 消息内容 + + /** + * 匹配私聊消息 Ticker. + * + * Group: nickname-昵称, num-消息个数, msg-消息内容 + */ + private val msgPattern = + """^(?\[特别关心])?(?.+?)(\((?\d+)条新消息\))?: (?[\s\S]+)$""".toRegex() + + // 关联QQ消息 + // title: + // - 只有一条消息: 关联QQ号 + // - 一人发来多条消息: 关联QQ号 ({x}条新消息) + // - 多人发来消息: QQ + // ticker: 关联QQ号-{发送者昵称}:{消息内容} + // content: + // - 一人发来消息: {发送者昵称}:{消息内容} + // - 多人发来消息: 有 {x} 个联系人给你发过来{y}条新消息 + + /** + * 匹配关联 QQ 消息 ticker. + * + * Group: 1发送者昵称, 2消息内容 + */ + private val bindingQQMsgTickerPattern: Pattern = + Pattern.compile("^关联QQ号-(.+?):([\\s\\S]+)$") + + /** + * 匹配关联 QQ 消息 content. 用于提取未读消息个数。 + * + * Group: 1未读消息个数 + */ + private val bindingQQMsgContextPattern: Pattern = + Pattern.compile("^有 \\d+ 个联系人给你发过来(\\d+)条新消息$") + + /** + * 匹配关联 QQ 消息 title. 用于提取未读消息个数。 + * + * Group: 1未读消息个数 + */ + private val bindingQQMsgTitlePattern: Pattern = + Pattern.compile("^关联QQ号 \\((\\d+)条新消息\\)$") + } + + override fun resolveNotification( + tag: Tag, + title: String?, + content: String?, + ticker: String? + ): QQNotification? { + if (title.isNullOrEmpty() || content.isNullOrEmpty()) { + return null + } + if (isHidden(ticker)) { + return QQNotification.HiddenMessage(tag) + } + tryResolveQZone(tag, title, content, ticker)?.also { return it } + + if (ticker == null) { + Timber.tag(TAG).i("Ticker is null, skip") + return null + } + + tryResolveGroupMsg(tag, content, ticker)?.also { return it } + tryResolvePrivateMsg(tag, content, ticker)?.also { return it } + tryResolveBindingMsg(tag, title, content, ticker)?.also { return it } + + return null + } + + private fun isHidden(ticker: String?): Boolean { + return ticker != null && hideMsgPattern.matcher(ticker).matches() + } + + private fun tryResolveQZone( + tag: Tag, + title: String, + content: String, + ticker: String? + ): QQNotification? { + if (ticker == null || !isQZone(title)) { + return null + } + if (ticker.startsWith("【特别关心】")) { + // 特别关心动态推送 + return QQNotification.QZoneSpecialPost(tag, content) + } + val num = matchQZoneNum(title) + if (num != null) { + // 普通空间通知 + return QQNotification.QZoneMessage(tag, content, num) + } + return null + } + + private fun isQZone(title: String?): Boolean { + return title?.let { qzoneTitlePattern.matcher(it).matches() } ?: false + } + + /** + * 提取空间未读消息个数。 + * + * @return 动态未读消息个数。提取失败返回 `null`。 + */ + private fun matchQZoneNum(title: String): Int? { + val matcher = qzoneTitlePattern.matcher(title) + if (matcher.matches()) { + return matcher.group(1)?.toIntOrNull() + } + return null + } + + private fun tryResolveGroupMsg(tag: Tag, content: String, ticker: String): QQNotification? { + if (content.isEmpty() || ticker.isEmpty()) { + return null + } + val tickerGroups = + groupMsgPattern.matchEntire(ticker)?.groups ?: return null + val contentGroups = + groupMsgContentPattern.matchEntire(content)?.groups ?: return null + val name = tickerGroups["nickname"]?.value ?: return null + val groupName = tickerGroups["name"]?.value ?: return null + val num = tickerGroups["num"]?.value?.toIntOrNull() + val text = contentGroups["msg"]?.value ?: return null + val special = contentGroups["sp"]?.value != null + + return QQNotification.GroupMessage( + tag = tag, + groupName = groupName, + nickname = name, + message = text, + special = special, + num = num ?: 1, + ) + } + + private fun tryResolvePrivateMsg(tag: Tag, content: String, ticker: String): QQNotification? { + if (ticker.isEmpty() || content.isEmpty()) { + return null + } + val tickerGroups = msgPattern.matchEntire(ticker)?.groups ?: return null + val special = tickerGroups["sp"] != null + val name = tickerGroups["nickname"]?.value ?: return null + val num = tickerGroups["num"]?.value?.toIntOrNull() + + return QQNotification.PrivateMessage( + tag = tag, + nickname = name, + message = content, + special = special, + num = num ?: 1, + ) + } + + private fun tryResolveBindingMsg( + tag: Tag, + title: String, + content: String, + ticker: String + ): QQNotification? { + val matcher = bindingQQMsgTickerPattern.matcher(ticker) + if (!matcher.matches()) { + return null + } + + val sender = matcher.group(1) ?: return null + val text = matcher.group(2) ?: return null + val num = matchBindingMsgNum(title, content) + return QQNotification.BindingAccountMessage(tag, sender, text, num) + } + + /** + * 提取关联账号的未读消息个数。 + */ + private fun matchBindingMsgNum(title: String?, content: String?): Int { + if (title == null || content == null) return 1 + if (title == "QQ") { + bindingQQMsgContextPattern.matcher(content).also { matcher -> + if (matcher.matches()) { + return matcher.group(1)?.toInt() ?: 1 + } + } + } else { + bindingQQMsgTitlePattern.matcher(title).also { matcher -> + if (matcher.matches()) { + return matcher.group(1)?.toInt() ?: 1 + } + } + } + return 1 + } +} \ No newline at end of file diff --git a/app/src/test/java/cc/chenhe/qqnotifyevo/core/BaseResolverTest.kt b/app/src/test/java/cc/chenhe/qqnotifyevo/core/BaseResolverTest.kt new file mode 100644 index 0000000..a85dc99 --- /dev/null +++ b/app/src/test/java/cc/chenhe/qqnotifyevo/core/BaseResolverTest.kt @@ -0,0 +1,20 @@ +package cc.chenhe.qqnotifyevo.core + +import org.json.JSONObject + +abstract class BaseResolverTest { + protected data class NotificationData( + val title: String?, + val ticker: String?, + val content: String?, + ) + + protected fun parse(json: String): NotificationData { + val o = JSONObject(json) + return NotificationData( + title = o.getString("title"), + content = o.getString("content"), + ticker = o.getString("ticker") + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/cc/chenhe/qqnotifyevo/core/NotificationProcessorTest.kt b/app/src/test/java/cc/chenhe/qqnotifyevo/core/NotificationProcessorTest.kt deleted file mode 100644 index c62ad08..0000000 --- a/app/src/test/java/cc/chenhe/qqnotifyevo/core/NotificationProcessorTest.kt +++ /dev/null @@ -1,234 +0,0 @@ -package cc.chenhe.qqnotifyevo.core - -import org.amshove.kluent.* -import org.junit.Test - -class NotificationProcessorTest { - - private fun generateGroupTicker( - nickName: String, - groupName: String, - message: String, - special: Boolean = false, - messageNum: Int = 1, - ): String { - val sb = StringBuilder(groupName) - if (messageNum > 1) - sb.append("(${messageNum}条新消息)") - sb.append(": ") - if (special) - sb.append("[特别关心]") - sb.append(nickName).append(": ").append(message) - return sb.toString() - } - - private fun generateGroupContent(nickName: String, message: String, special: Boolean): String { - return "${"[特别关心]".takeIf { special }.orEmpty()}$nickName: $message" - } - - private fun generateFriendTicker(nickName: String, message: String): String { - return "$nickName: $message" - } - - private fun generateFriendTitle( - nickName: String, - messageNum: Int, - special: Boolean = false - ): String { - return (if (special) "[特别关心]" else "") + nickName + if (messageNum > 1) "(${messageNum}条新消息)" else "" - } - - private fun generateQzoneTitle(messageNum: Int = 1): String { - return "QQ空间动态(共${messageNum}条未读)" - } - - private fun generateHiddenTicker(messageNum: Int = 1): String { - return "QQ: 你收到了${messageNum}条新消息" - } - - @Test - fun group_ticker_match() { - val ticker = generateGroupTicker("Bob", "Family(1)", "Hello~") - val groups = NotificationProcessor.groupMsgPattern.matchEntire(ticker)?.groups - groups.shouldNotBeNull() - - groups["sp"].shouldBeNull() - groups["num"].shouldBeNull() - groups["nickname"]?.value shouldBeEqualTo "Bob" - groups["name"]?.value shouldBeEqualTo "Family(1)" - groups["msg"]?.value shouldBeEqualTo "Hello~" - } - - @Test - fun group_ticker_multiMsg_match() { - val ticker = generateGroupTicker("Bob", "Family(1)", "Hello~", messageNum = 3) - val groups = NotificationProcessor.groupMsgPattern.matchEntire(ticker)?.groups - groups.shouldNotBeNull() - - groups["sp"].shouldBeNull() - groups["num"]?.value?.toIntOrNull() shouldBeEqualTo 3 - groups["nickname"]?.value shouldBeEqualTo "Bob" - groups["name"]?.value shouldBeEqualTo "Family(1)" - groups["msg"]?.value shouldBeEqualTo "Hello~" - } - - @Test - fun group_ticker_match_multiLines() { - val ticker = generateGroupTicker("Bob", "Family(1)", "Hello\nhere\nyep") - val groups = NotificationProcessor.groupMsgPattern.matchEntire(ticker)?.groups - groups.shouldNotBeNull() - - groups["sp"].shouldBeNull() - groups["num"].shouldBeNull() - groups["nickname"]?.value shouldBeEqualTo "Bob" - groups["name"]?.value shouldBeEqualTo "Family(1)" - groups["msg"]?.value shouldBeEqualTo "Hello\nhere\nyep" - } - - @Test - fun group_ticker_special_match() { - val ticker = generateGroupTicker("Bob", "Family (1)", "Hello", special = true) - val groups = NotificationProcessor.groupMsgPattern.matchEntire(ticker)?.groups - groups.shouldNotBeNull() - - groups["sp"].shouldNotBeNull() - groups["num"].shouldBeNull() - groups["name"]?.value shouldBeEqualTo "Family (1)" - groups["nickname"]?.value shouldBeEqualTo "Bob" - groups["msg"]?.value shouldBeEqualTo "Hello" - } - - @Test - fun group_ticker_mismatch_friend() { - val ticker = generateFriendTicker("Bob", "Hello~") - val groups = NotificationProcessor.groupMsgPattern.matchEntire(ticker)?.groups - groups.shouldBeNull() - } - - @Test - fun group_content_special_match() { - val content = generateGroupContent("(id1)", "Yea", true) - val groups = NotificationProcessor.groupMsgContentPattern.matchEntire(content)?.groups - groups.shouldNotBeNull() - - groups["sp"]?.value.shouldNotBeNullOrEmpty() - groups["name"]?.value shouldBeEqualTo "(id1)" - groups["msg"]?.value shouldBeEqualTo "Yea" - } - - @Test - fun group_content_nonSpecial_match() { - val content = generateGroupContent("(id2)", "Yes", false) - val groups = NotificationProcessor.groupMsgContentPattern.matchEntire(content)?.groups - groups.shouldNotBeNull() - - groups["sp"].shouldBeNull() - groups["name"]?.value shouldBeEqualTo "(id2)" - groups["msg"]?.value shouldBeEqualTo "Yes" - } - - @Test - fun friend_ticker_match() { - val ticker = generateFriendTicker("Alice", "hi") - val groups = NotificationProcessor.msgPattern.matchEntire(ticker)?.groups - groups.shouldNotBeNull() - - groups["nickname"]?.value shouldBeEqualTo "Alice" - groups["msg"]?.value shouldBeEqualTo "hi" - } - - @Test - fun friend_ticker_match_multiLines() { - val ticker = generateFriendTicker("Alice", "hi\nok\nthanks") - val groups = NotificationProcessor.msgPattern.matchEntire(ticker)?.groups - groups.shouldNotBeNull() - - groups["nickname"]?.value shouldBeEqualTo "Alice" - groups["msg"]?.value shouldBeEqualTo "hi\nok\nthanks" - } - - @Test - fun friend_title_match_single() { - val title = generateFriendTitle("Bob", 1, false) - val matcher = NotificationProcessor.msgTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - - matcher.group(1).shouldBeNull() - matcher.group(2).shouldBeNull() - } - - @Test - fun friend_special_title_match_single() { - val title = generateFriendTitle("Bob", 1, true) - val matcher = NotificationProcessor.msgTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - - matcher.group(1).shouldNotBeNull() - matcher.group(2).shouldBeNull() - } - - @Test - fun friend_title_match_multi() { - val title = generateFriendTitle("Bob", 11, false) - val matcher = NotificationProcessor.msgTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - - matcher.group(1).shouldBeNull() - matcher.group(2)!!.toInt() shouldBeEqualTo 11 - } - - @Test - fun friend_special_title_match_multi() { - val title = generateFriendTitle("Bob", 11, true) - val matcher = NotificationProcessor.msgTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - - matcher.group(1).shouldNotBeNull() - matcher.group(2)?.toIntOrNull() shouldBeEqualTo 11 - } - - @Test - fun qzone_title_match() { - val title = generateQzoneTitle(2) - val matcher = NotificationProcessor.qzoneTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - matcher.group(1)!!.toInt() shouldBeEqualTo 2 - } - - @Test - fun hidden_message_match() { - val ticker = generateHiddenTicker() - val matcher = NotificationProcessor.hideMsgPattern.matcher(ticker) - matcher.matches().shouldBeTrue() - } - - @Test - fun hidden_message_mismatch_friend() { - val ticker = generateFriendTicker("Bob", "Hello~") - val matcher = NotificationProcessor.hideMsgPattern.matcher(ticker) - matcher.matches().shouldBeFalse() - } - - @Test - fun hidden_message_mismatch_group() { - val ticker = generateGroupTicker("Alice", "group", "hi") - val matcher = NotificationProcessor.hideMsgPattern.matcher(ticker) - matcher.matches().shouldBeFalse() - } - - @Test - fun chat_message_num_match() { - val title = "Bob (2条新消息)" - val matcher = NotificationProcessor.msgTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - matcher.group(2)!!.toInt() shouldBeEqualTo 2 - } - - @Test - fun chat_message_num_mismatch() { - val title = generateFriendTitle("Bob", 1) - val matcher = NotificationProcessor.msgTitlePattern.matcher(title) - matcher.matches().shouldBeTrue() - matcher.group(2).shouldBeNull() - } -} \ No newline at end of file diff --git a/app/src/test/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolverTest.kt b/app/src/test/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolverTest.kt new file mode 100644 index 0000000..bcb20d8 --- /dev/null +++ b/app/src/test/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolverTest.kt @@ -0,0 +1,147 @@ +package cc.chenhe.qqnotifyevo.core + +import cc.chenhe.qqnotifyevo.utils.Tag +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.types.shouldBeTypeOf +import org.junit.Before +import org.junit.Test + +class QQNotificationResolverTest : BaseResolverTest() { + private lateinit var resolver: QQNotificationResolver + + @Before + fun setup() { + resolver = QQNotificationResolver() + } + + private fun resolve(data: NotificationData): QQNotification? { + return resolver.resolveNotification( + tag = Tag.QQ, + title = data.title, + content = data.content, + ticker = data.ticker, + ) + } + + // 私聊消息 -––––--––––---––––---––––---––––---––––---–––– + + @Test + fun private_normal() { + val n = parse("""{"title":"咕咕咕","ticker":"咕咕咕: qqq","content":"123qqq"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual(n.content!!) + r.num.shouldBeEqual(1) + r.special.shouldBeFalse() + } + + @Test + fun private_special_MultiMessage() { + val n = + parse("""{"title":"[特别关心]咕咕咕(2条新消息)","ticker":"[特别关心]咕咕咕(2条新消息): 222","content":"222"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual(n.content!!) + r.num.shouldBeEqual(2) + r.special.shouldBeTrue() + } + + @Test + fun private_special() { + val n = + parse("""{"title":"[特别关心]咕咕咕","ticker":"[特别关心]咕咕咕: ok111","content":"ok111"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual(n.content!!) + r.num.shouldBeEqual(1) + r.special.shouldBeTrue() + } + + // 群聊消息 -––––--––––---––––---––––---––––---––––---–––– + + @Test + fun group_normal() { + val n = + parse("""{"title":"测试群","ticker":"测试群: 咕咕咕: from group","content":"咕咕咕: from group"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.groupName.shouldBeEqual("测试群") + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual("from group") + r.num.shouldBeEqual(1) + r.special.shouldBeFalse() + } + + @Test + fun group_multiMessage() { + val n = + parse("""{"title":"测试群(2条新消息)","ticker":"测试群(2条新消息): 咕咕咕: 2222","content":"咕咕咕: 2222"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.groupName.shouldBeEqual("测试群") + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual("2222") + r.num.shouldBeEqual(2) + r.special.shouldBeFalse() + } + + @Test + fun group_special_multiMessage() { + val n = + parse("""{"title":"测试群(3条新消息)","ticker":"测试群(3条新消息): [特别关心]咕咕咕: 333","content":"[特别关心]咕咕咕: 333"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.groupName.shouldBeEqual("测试群") + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual("333") + r.num.shouldBeEqual(3) + r.special.shouldBeTrue() + } + + @Test + fun group_special() { + val n = + parse("""{"title":"测试群","ticker":"测试群: [特别关心]咕咕咕: from group","content":"[特别关心]咕咕咕: from group"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.groupName.shouldBeEqual("测试群") + r.nickname.shouldBeEqual("咕咕咕") + r.message.shouldBeEqual("from group") + r.num.shouldBeEqual(1) + r.special.shouldBeTrue() + } + + // QQ 空间 -––––--––––---––––---––––---––––---––––---–––– + + @Test + fun qzone_specialPost() { + val n = + parse("""{"title":"QQ空间动态","ticker":"【特别关心】咕咕咕:QZone post","content":"【特别关心】咕咕咕:QZone post"}""") + resolve(n).shouldNotBeNull().shouldBeTypeOf() + } + + @Test + fun qzone_message() { + val n = + parse("""{"title":"QQ空间动态(共1条未读)","ticker":"咕咕咕赞了你的说说","content":"咕咕咕赞了你的说说"}""") + resolve(n).shouldNotBeNull().shouldBeTypeOf() + } + + // 其他 -––––--––––---––––---––––---––––---––––---–––– + + @Test + fun hidden() { + val n = + parse(""" {"title":"QQ","ticker":"QQ: 你收到了1条新消息","content":"你收到了1条新消息"}""") + resolve(n).shouldNotBeNull().shouldBeTypeOf() + } + + @Test + fun binding_multiMessage_multiLine() { + val n = + parse("""{"title":"关联QQ号 (3条新消息)","ticker":"关联QQ号-\/dev\/urandom:d\nd","content":"\/dev\/urandom:d\nd"}""") + val r = resolve(n).shouldNotBeNull().shouldBeTypeOf() + r.sender.shouldBeEqual("/dev/urandom") + r.message.shouldBeEqual("d\nd") + r.num.shouldBeEqual(3) + } +} \ No newline at end of file diff --git a/app/src/test/java/cc/chenhe/qqnotifyevo/log/LogWriterTest.kt b/app/src/test/java/cc/chenhe/qqnotifyevo/log/LogWriterTest.kt index b5d9330..3fe56d0 100644 --- a/app/src/test/java/cc/chenhe/qqnotifyevo/log/LogWriterTest.kt +++ b/app/src/test/java/cc/chenhe/qqnotifyevo/log/LogWriterTest.kt @@ -1,13 +1,13 @@ package cc.chenhe.qqnotifyevo.log -import org.amshove.kluent.shouldBeEqualTo -import org.amshove.kluent.shouldNotBeEqualTo +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.equals.shouldNotBeEqual import org.junit.After import org.junit.Before import org.junit.BeforeClass import org.junit.Test import java.io.File -import java.util.* +import java.util.Calendar class LogWriterTest { @@ -53,7 +53,7 @@ class LogWriterTest { fun writeLog() { writer.write("Test", TIME) writer.write("Hello", TIME) - writer.logFile.readText() shouldBeEqualTo "Test\nHello\n" + writer.logFile.readText().shouldBeEqual("Test\nHello\n") } @Test @@ -63,7 +63,7 @@ class LogWriterTest { } createLogWriter().use { w -> w.write("line2", TIME) - w.logFile.readText() shouldBeEqualTo "line1\nline2\n" + w.logFile.readText().shouldBeEqual("line1\nline2\n") } } @@ -77,8 +77,8 @@ class LogWriterTest { writer.write("Test2", calendar.timeInMillis) val f2 = writer.logFile - f1.name shouldNotBeEqualTo f2.name - f1.readText() shouldBeEqualTo "Test\n" - f2.readText() shouldBeEqualTo "Test2\n" + f1.name.shouldNotBeEqual(f2.name) + f1.readText().shouldBeEqual("Test\n") + f2.readText().shouldBeEqual("Test2\n") } } \ No newline at end of file