├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── ic_launcher-web.png
│ │ ├── res
│ │ │ ├── mipmap
│ │ │ │ └── ic_launcher.png
│ │ │ ├── values
│ │ │ │ ├── styles.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── strings.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── accessibility.xml
│ │ │ └── drawable
│ │ │ │ ├── ic_notify_action_learn_more.xml
│ │ │ │ ├── ic_notify_upgrade.xml
│ │ │ │ ├── ic_notify_warning.xml
│ │ │ │ ├── ic_notify_action_dnot_show.xml
│ │ │ │ ├── ic_notify_tim.xml
│ │ │ │ ├── ic_notify_qzone.xml
│ │ │ │ └── ic_notify_qq.xml
│ │ ├── java
│ │ │ └── cc
│ │ │ │ └── chenhe
│ │ │ │ └── qqnotifyevo
│ │ │ │ ├── utils
│ │ │ │ ├── Tag.kt
│ │ │ │ ├── NotifyChannel.kt
│ │ │ │ ├── ContextUtils.kt
│ │ │ │ ├── UpgradeUtils.kt
│ │ │ │ ├── PreferencesUtils.kt
│ │ │ │ └── Utils.kt
│ │ │ │ ├── log
│ │ │ │ ├── CrashHandler.kt
│ │ │ │ ├── ReleaseTree.kt
│ │ │ │ └── LogWriter.kt
│ │ │ │ ├── core
│ │ │ │ ├── DelegateNotificationResolver.kt
│ │ │ │ ├── AvatarManager.kt
│ │ │ │ ├── NotificationResolver.kt
│ │ │ │ ├── NevoNotificationProcessor.kt
│ │ │ │ ├── InnerNotificationProcessor.kt
│ │ │ │ ├── TimNotificationResolver.kt
│ │ │ │ └── QQNotificationResolver.kt
│ │ │ │ ├── ui
│ │ │ │ ├── common
│ │ │ │ │ ├── MviAndroidViewModel.kt
│ │ │ │ │ ├── ErrorCard.kt
│ │ │ │ │ ├── permission
│ │ │ │ │ │ ├── PermissionState.kt
│ │ │ │ │ │ └── MutablePermissionState.kt
│ │ │ │ │ └── PreferenceComponent.kt
│ │ │ │ ├── MainViewModel.kt
│ │ │ │ ├── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── main
│ │ │ │ │ └── MainPreferenceViewModel.kt
│ │ │ │ ├── permission
│ │ │ │ │ └── PermissionViewModel.kt
│ │ │ │ ├── advanced
│ │ │ │ │ └── AdvancedOptionsViewModel.kt
│ │ │ │ └── MainActivity.kt
│ │ │ │ ├── StaticReceiver.kt
│ │ │ │ ├── service
│ │ │ │ ├── AccessibilityMonitorService.kt
│ │ │ │ ├── NotificationMonitorService.kt
│ │ │ │ ├── NevoDecorator.kt
│ │ │ │ └── UpgradeService.kt
│ │ │ │ └── MyApplication.kt
│ │ └── AndroidManifest.xml
│ └── test
│ │ └── java
│ │ └── cc
│ │ └── chenhe
│ │ └── qqnotifyevo
│ │ ├── core
│ │ ├── BaseResolverTest.kt
│ │ ├── TimNotificationResolverTest.kt
│ │ └── QQNotificationResolverTest.kt
│ │ └── log
│ │ └── LogWriterTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── .gitattributes
├── fastlane
└── metadata
│ └── android
│ ├── zh-CN
│ ├── title.txt
│ ├── short_description.txt
│ └── full_description.txt
│ └── en-US
│ ├── title.txt
│ ├── short_description.txt
│ ├── images
│ └── icon.png
│ └── full_description.txt
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── settings.gradle.kts
├── .github
└── workflows
│ ├── android.yml
│ └── release.yml
├── gradle.properties
├── .gitignore
├── README.md
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.bat text eol=crlf
2 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/title.txt:
--------------------------------------------------------------------------------
1 | 企鹅通知进化
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/title.txt:
--------------------------------------------------------------------------------
1 | QQ Notification Evolution
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/short_description.txt:
--------------------------------------------------------------------------------
1 | 免 ROOT 优化 QQ 通知
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | Optimize QQ notification without ROOT
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ichenhe/QQ-Notify-Evolution/HEAD/app/src/main/ic_launcher-web.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ichenhe/QQ-Notify-Evolution/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ichenhe/QQ-Notify-Evolution/HEAD/app/src/main/res/mipmap/ic_launcher.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ichenhe/QQ-Notify-Evolution/HEAD/fastlane/metadata/android/en-US/images/icon.png
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | include(":app")
2 |
3 | pluginManagement {
4 | repositories {
5 | google()
6 | mavenCentral()
7 | gradlePluginPortal()
8 | }
9 | }
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | #fece00
5 | #1ED0FC
6 |
7 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/utils/Tag.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.utils
2 |
3 | /**
4 | * 用于标记通知的来源。
5 | */
6 | enum class Tag(val pkg: String) {
7 | UNKNOWN(""),
8 | QQ("com.tencent.mobileqq"),
9 | TIM("com.tencent.tim");
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/utils/NotifyChannel.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.utils
2 |
3 | enum class NotifyChannel {
4 | /** 私聊消息 */
5 | FRIEND,
6 |
7 | /** 特别关心私聊消息 */
8 | FRIEND_SPECIAL,
9 |
10 | /** 群聊消息 */
11 | GROUP,
12 |
13 | /** Q空间 */
14 | QZONE
15 | }
16 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/full_description.txt:
--------------------------------------------------------------------------------
1 | 支持原版 QQ / Tim / 轻聊版。
2 |
3 | 强制腐朽的 QQ 通知进化为现代化的样式,包括以下特性:
4 |
5 | - 适配原生分渠道通知,可对私聊、群聊、特别关心、空间消息设置不同的提示方式。
6 | - 使用原生通知音量与振动,高度兼容智能手环与手表。
7 | - 遵循 Android 消息应用通知样式最佳实践,支持显示多组会话与历史消息。
8 | - 支持作为 Nevo 插件或者独立运行。
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_action_learn_more.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_upgrade.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/log/CrashHandler.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.log
2 |
3 | import timber.log.Timber
4 |
5 | object CrashHandler : Thread.UncaughtExceptionHandler {
6 |
7 | private val default = Thread.getDefaultUncaughtExceptionHandler()
8 |
9 | override fun uncaughtException(thread: Thread, t: Throwable) {
10 | try {
11 | Timber.e(t)
12 | } catch (e: Exception) {
13 | e.printStackTrace()
14 | }
15 | default?.uncaughtException(thread, t)
16 | }
17 |
18 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_warning.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/accessibility.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_action_dnot_show.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/test/java/cc/chenhe/qqnotifyevo/core/BaseResolverTest.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import org.json.JSONObject
4 |
5 | abstract class BaseResolverTest {
6 | protected data class NotificationData(
7 | val title: String?,
8 | val ticker: String?,
9 | val content: String?,
10 | )
11 |
12 | protected fun parse(json: String): NotificationData {
13 | val o = JSONObject(json)
14 | return NotificationData(
15 | title = o.getString("title"),
16 | content = o.getString("content"),
17 | ticker = o.getString("ticker")
18 | )
19 | }
20 | }
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Support the original QQ / Tim / QQ Lite.
2 |
3 | Force the decay QQ notification evolve into a modern style, including features as follows:
4 |
5 | - Adapted to native sub-channel notifications, and set different prompts for private chats, group chats, special care, and space messages.
6 | - Using native notification volume and vibration, it is highly compatible with smart binds and watches.
7 | - Following Android messaging app notification style best practices, it supports displaying multiple groups of conversations and historical messages.
8 | - Supported as plugin or standalone.
9 |
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - '**.md'
7 | - LICENSE
8 | - 'fastlane/**'
9 | tags-ignore:
10 | - '**'
11 | pull_request:
12 | paths-ignore:
13 | - '**.md'
14 | - LICENSE
15 | - 'fastlane/**'
16 |
17 | jobs:
18 | build:
19 | runs-on: ubuntu-latest
20 |
21 | steps:
22 | - uses: actions/checkout@v3
23 | - name: set up JDK 17
24 | uses: actions/setup-java@v3
25 | with:
26 | java-version: '17'
27 | distribution: 'temurin'
28 | cache: 'gradle'
29 |
30 | - name: Assemble
31 | run: ./gradlew assemble
32 |
33 | - name: Build And Test
34 | run: ./gradlew build
35 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/DelegateNotificationResolver.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import cc.chenhe.qqnotifyevo.utils.Tag
4 |
5 | /**
6 | * A [NotificationResolver] that call different implementations based on [Tag].
7 | */
8 | class DelegateNotificationResolver : NotificationResolver {
9 | private val qqResolver by lazy { QQNotificationResolver() }
10 | private val timResolver by lazy { TimNotificationResolver() }
11 |
12 | override fun resolveNotification(
13 | tag: Tag,
14 | title: String?,
15 | content: String?,
16 | ticker: String?
17 | ): QQNotification? {
18 | return when (tag) {
19 | Tag.UNKNOWN -> null
20 | Tag.QQ -> qqResolver
21 | Tag.TIM -> timResolver
22 | }?.run { resolveNotification(tag, title, content, ticker) }
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.utils
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.ContextWrapper
6 | import android.content.pm.PackageManager
7 | import androidx.core.content.ContextCompat
8 |
9 | /**
10 | * @throws IllegalStateException can not find [Activity]
11 | */
12 | fun Context.getActivity(): Activity {
13 | var current = this
14 | while (current is ContextWrapper) {
15 | if (current is Activity) {
16 | return current
17 | }
18 | current = current.baseContext
19 | }
20 | throw IllegalStateException("can not find Activity from current context")
21 | }
22 |
23 | fun Context.hasPermission(permission: String): Boolean =
24 | ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | android.defaults.buildfeatures.buildconfig=true
13 | android.enableJetifier=true
14 | android.nonFinalResIds=false
15 | android.nonTransitiveRClass=false
16 | android.useAndroidX=true
17 | org.gradle.jvmargs=-Xmx1536m
18 |
19 | # When configured, Gradle will run in incubating parallel mode.
20 | # This option should only be used with decoupled projects. More details, visit
21 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
22 | # org.gradle.parallel=true
23 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # By default, the flags in this file are appended to flags specified
3 | # in C:\Users\acaoa\AppData\Local\Android\Sdk/tools/proguard/proguard-android.txt
4 | # You can edit the include path and order by changing the proguardFiles
5 | # directive in build.gradle.
6 | #
7 | # For more details, see
8 | # http://developer.android.com/guide/developing/tools/proguard.html
9 |
10 | # Add any project specific keep options here:
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
19 | # Uncomment this to preserve the line number information for
20 | # debugging stack traces.
21 | #-keepattributes SourceFile,LineNumberTable
22 |
23 | # If you keep the line number information, uncomment this to
24 | # hide the original source file name.
25 | #-renamesourcefileattribute SourceFile
26 |
27 | -dontobfuscate # 关闭混淆
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vs
2 | .gradle
3 | .DS_Store
4 | release/
5 | /build
6 | /version.properties
7 |
8 | # IntelliJ
9 | *.iml
10 | .idea
11 |
12 | # External native build folder generated in Android Studio 2.2 and later
13 | .externalNativeBuild
14 |
15 | # Google Services (e.g. APIs or Firebase)
16 | google-services.json
17 |
18 | # Built application files
19 | *.apk
20 | *.ap_
21 |
22 | # Files for the ART/Dalvik VM
23 | *.dex
24 |
25 | # Java class files
26 | *.class
27 |
28 | # Generated files
29 | bin/
30 | gen/
31 | out/
32 |
33 | # Gradle files
34 | .gradle/
35 | build/
36 |
37 | # Local configuration file (sdk path, etc)
38 | local.properties
39 |
40 | # Proguard folder generated by Eclipse
41 | proguard/
42 |
43 | # Log Files
44 | *.log
45 |
46 | # Android Studio Navigation editor temp files
47 | .navigation/
48 |
49 | # Android Studio captures folder
50 | captures/
51 |
52 | # Freeline
53 | freeline.py
54 | freeline/
55 | freeline_project_description.json
56 |
57 | # fastlane
58 | fastlane/report.xml
59 | fastlane/Preview.html
60 | fastlane/screenshots
61 | fastlane/test_output
62 | fastlane/readme.md
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/log/ReleaseTree.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.log
2 |
3 | import android.util.Log
4 | import timber.log.Timber
5 | import java.io.File
6 | import java.text.SimpleDateFormat
7 | import java.util.*
8 |
9 | class ReleaseTree(logDir: File) : Timber.Tree(), AutoCloseable {
10 |
11 | private val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA)
12 | private val date = Date()
13 | private val logWriter = LogWriter(logDir)
14 |
15 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
16 | val p = when (priority) {
17 | Log.VERBOSE -> "V"
18 | Log.DEBUG -> "D"
19 | Log.INFO -> "I"
20 | Log.WARN -> "W"
21 | Log.ERROR -> "E"
22 | Log.ASSERT -> "A"
23 | else -> "U"
24 | }
25 | date.time = System.currentTimeMillis()
26 | val time = format.format(date)
27 |
28 | var s = "$time [$p] [${tag}]: $message"
29 | t?.let {
30 | s += "\n${it.message}"
31 | }
32 | logWriter.write(s, date.time)
33 | }
34 |
35 | override fun close() {
36 | logWriter.close()
37 | }
38 |
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/MviAndroidViewModel.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.common
2 |
3 | import android.app.Application
4 | import androidx.lifecycle.AndroidViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import kotlinx.coroutines.flow.MutableSharedFlow
7 | import kotlinx.coroutines.flow.MutableStateFlow
8 | import kotlinx.coroutines.flow.asStateFlow
9 | import kotlinx.coroutines.launch
10 |
11 | abstract class MviAndroidViewModel(
12 | application: Application,
13 | initialUiState: UiState,
14 | ) :
15 | AndroidViewModel(application) {
16 |
17 | @Suppress("PropertyName")
18 | protected val _uiState = MutableStateFlow(initialUiState)
19 | val uiState = _uiState.asStateFlow()
20 |
21 | private val viewIntents = MutableSharedFlow()
22 |
23 | init {
24 | viewModelScope.launch {
25 | subscribeViewIntents()
26 | }
27 | }
28 |
29 | private suspend fun subscribeViewIntents() {
30 | viewIntents.collect { viewIntent ->
31 | handleViewIntent(viewIntent)
32 | }
33 | }
34 |
35 | abstract suspend fun handleViewIntent(intent: ViewIntent)
36 |
37 | fun sendIntent(viewIntent: ViewIntent) {
38 | viewModelScope.launch { viewIntents.emit(viewIntent) }
39 | }
40 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 企鹅通知进化 - 原「QQ 通知进化」
2 |
3 | 免 ROOT 优化 QQ 通知:多会话/多消息/多渠道。支持 [Nevo](https://www.coolapk.com/apk/com.oasisfeng.nevo) 插件与独立运行双模式。
4 |
5 | **[📖使用手册](https://github.com/ichenhe/QQ-Notify-Evolution/wiki)**
6 |
7 |
8 |
9 | ## 主要功能
10 |
11 | 支持 QQ 标准版 与 TIM。
12 |
13 | 
14 |
15 | **强制腐朽的 QQ 通知进化为现代化的样式,包括以下特性:**
16 |
17 | - 适配原生分渠道通知,可对私聊、群聊、特别关心、空间消息设置不同的提示方式。
18 | - 使用原生通知音量与振动,高度兼容智能手环与手表。
19 | - 遵循 Android 消息应用通知样式最佳实践,支持显示多组会话与历史消息。
20 | - 支持作为 [Nevo](https://www.coolapk.com/apk/com.oasisfeng.nevo) 插件或者独立运行。
21 |
22 | ## 感谢
23 |
24 | - [QQ-Notfiy-Improve](https://github.com/Jinhaihan/QQ-Notfiy-Improve)(已获授权)
25 | - [QQNotfAndShare](https://github.com/ekibun/QQNotfAndShare)(原始项目)
26 | - [QQNotifyPlus](https://github.com/ekibun/QQNotifyPlus)(QQ 与 QZone 图标)
27 |
28 | ## Sponsorship
29 |
30 | CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne
31 |
32 |
33 |
34 | Best Asian CDN, Edge, and Secure Solutions - Tencent EdgeOne
35 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/StaticReceiver.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import androidx.core.app.NotificationManagerCompat
7 | import cc.chenhe.qqnotifyevo.service.NotificationMonitorService
8 | import cc.chenhe.qqnotifyevo.service.UpgradeService
9 | import cc.chenhe.qqnotifyevo.utils.*
10 | import kotlinx.coroutines.flow.first
11 | import kotlinx.coroutines.runBlocking
12 | import timber.log.Timber
13 |
14 | class StaticReceiver : BroadcastReceiver() {
15 | companion object {
16 | private const val TAG = "StaticReceiver"
17 | }
18 |
19 | override fun onReceive(context: Context, intent: Intent) {
20 | when (intent.action) {
21 | Intent.ACTION_BOOT_COMPLETED -> {
22 | runBlocking {
23 | val mode = context.applicationContext.dataStore.data.first()[PREFERENCE_MODE]
24 | if (Mode.fromValue(mode) == Mode.Legacy) {
25 | val start = Intent(context, NotificationMonitorService::class.java)
26 | context.startService(start)
27 | }
28 | }
29 | }
30 |
31 | Intent.ACTION_MY_PACKAGE_REPLACED -> {
32 | Timber.tag(TAG).i("receive broadcast: %s", Intent.ACTION_MY_PACKAGE_REPLACED)
33 | UpgradeService.startIfNecessary(context)
34 | }
35 |
36 | ACTION_MULTI_MSG_DONT_SHOW -> {
37 | NotificationManagerCompat.from(context).cancel(NOTIFY_ID_MULTI_MSG)
38 | nevoMultiMsgTip(context, false)
39 | }
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/utils/UpgradeUtils.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.utils
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.content.pm.PackageManager
6 | import android.os.Build
7 | import androidx.core.content.edit
8 |
9 | object UpgradeUtils {
10 |
11 | private const val NAME = "upgrade"
12 | private const val ITEM_OLD_VERSION = "oldVersion"
13 |
14 | private fun Context.deviceProtected(): Context {
15 | return if (isDeviceProtectedStorage)
16 | this
17 | else
18 | createDeviceProtectedStorageContext()
19 | }
20 |
21 | private fun deviceProtectedSp(context: Context, name: String = NAME): SharedPreferences {
22 | return context.deviceProtected().getSharedPreferences(name, Context.MODE_PRIVATE)
23 | }
24 |
25 | fun getOldVersion(context: Context): Long {
26 | return deviceProtectedSp(context).getLong(ITEM_OLD_VERSION, 0L)
27 | }
28 |
29 | fun setOldVersion(context: Context, value: Long) {
30 | deviceProtectedSp(context).edit {
31 | putLong(ITEM_OLD_VERSION, value)
32 | }
33 | }
34 |
35 | fun getCurrentVersion(context: Context): Long {
36 | try {
37 | val pi = context.deviceProtected().packageManager.getPackageInfo(context.packageName, 0)
38 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
39 | pi.longVersionCode
40 | } else {
41 | @Suppress("DEPRECATION")
42 | pi.versionCode.toLong()
43 | }
44 | } catch (e: PackageManager.NameNotFoundException) {
45 | e.printStackTrace()
46 | }
47 | return 0L
48 | }
49 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/log/LogWriter.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.log
2 |
3 | import java.io.File
4 | import java.io.FileOutputStream
5 | import java.text.SimpleDateFormat
6 | import java.util.*
7 | import kotlin.math.abs
8 |
9 | class LogWriter(
10 | private val logDir: File,
11 | time: Long = System.currentTimeMillis()
12 | ) : AutoCloseable {
13 |
14 | companion object {
15 | private const val MILLIS_PER_DAY = 24 * 3600 * 1000
16 | }
17 |
18 | private var logFileTime: Long = time
19 | var logFile: File = logFile(logFileTime)
20 | private set(value) {
21 | field = value
22 | out.flush()
23 | out.close()
24 | out = FileOutputStream(value, true)
25 | }
26 | private var out: FileOutputStream = FileOutputStream(logFile, true)
27 |
28 | private fun logFile(time: Long): File {
29 | if (!logDir.isDirectory) {
30 | logDir.mkdirs()
31 | }
32 | val format = SimpleDateFormat("yyyyMMdd-HHmmssSSS", Locale.CHINA)
33 | return File(logDir, format.format(Date(time)) + ".log")
34 | }
35 |
36 | fun write(message: String, time: Long = System.currentTimeMillis()) {
37 | if (!isSameDay(logFileTime, time)) {
38 | logFileTime = time
39 | logFile = logFile(time)
40 | }
41 | out.write((message + "\n").toByteArray())
42 | }
43 |
44 | private fun isSameDay(t1: Long, t2: Long): Boolean {
45 | if (abs(t1 - t2) > MILLIS_PER_DAY) {
46 | return false
47 | }
48 | val offset = TimeZone.getDefault().rawOffset
49 | return (t1 + offset) / MILLIS_PER_DAY == (t2 + offset) / MILLIS_PER_DAY
50 | }
51 |
52 | override fun close() {
53 | out.close()
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_tim.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_qzone.xml:
--------------------------------------------------------------------------------
1 |
16 |
21 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/service/AccessibilityMonitorService.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.service
2 |
3 | import android.accessibilityservice.AccessibilityService
4 | import android.content.Intent
5 | import android.view.accessibility.AccessibilityEvent
6 | import cc.chenhe.qqnotifyevo.core.NotificationProcessor
7 | import timber.log.Timber
8 |
9 | class AccessibilityMonitorService : AccessibilityService() {
10 | companion object {
11 | const val TAG = "Accessibility"
12 | }
13 |
14 | override fun onCreate() {
15 | super.onCreate()
16 | Timber.tag(TAG).v("Service - onCreate")
17 | }
18 |
19 | override fun onDestroy() {
20 | super.onDestroy()
21 | Timber.tag(TAG).v("Service - onDestroy")
22 | }
23 |
24 | override fun onAccessibilityEvent(event: AccessibilityEvent) {
25 | if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
26 | return
27 | if (event.packageName == null || event.className == null)
28 | return
29 | val tag = NotificationProcessor.getTagFromPackageName(event.packageName.toString())
30 | val className = event.className.toString()
31 | if ("com.tencent.mobileqq.activity.SplashActivity" == className ||
32 | "com.dataline.activities.LiteActivity" == className
33 | ) {
34 | Intent(this, NotificationMonitorService::class.java)
35 | .putExtra("tag", tag.name)
36 | .also { startService(it) }
37 | } else if (className.startsWith("cooperation.qzone.")) {
38 | Intent(this, NotificationMonitorService::class.java)
39 | .putExtra("tag", tag.name)
40 | .also { startService(it) }
41 | }
42 | }
43 |
44 | override fun onInterrupt() {
45 | Timber.tag(TAG).w("Service - onInterrupt")
46 | }
47 |
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui
2 |
3 | import android.app.Application
4 | import androidx.datastore.preferences.core.edit
5 | import androidx.lifecycle.viewModelScope
6 | import cc.chenhe.qqnotifyevo.ui.common.MviAndroidViewModel
7 | import cc.chenhe.qqnotifyevo.utils.Mode
8 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_MODE
9 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_IN_RECENT_APPS
10 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT
11 | import cc.chenhe.qqnotifyevo.utils.dataStore
12 | import kotlinx.coroutines.flow.collectLatest
13 | import kotlinx.coroutines.flow.getAndUpdate
14 | import kotlinx.coroutines.flow.map
15 | import kotlinx.coroutines.launch
16 |
17 | data class MainUiState(
18 | val showMultiMessageWarning: Boolean = false,
19 | )
20 |
21 | sealed interface MainViewIntent {
22 | data class ShowMultiMessageWarning(val show: Boolean) : MainViewIntent
23 | data object ChangeToLegacyMode : MainViewIntent
24 | }
25 |
26 | class MainViewModel(application: Application) :
27 | MviAndroidViewModel(application, MainUiState()) {
28 |
29 | var showInRecent: Boolean = PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT
30 | private set
31 |
32 | init {
33 | viewModelScope.launch {
34 | application.dataStore.data.map { it[PREFERENCE_SHOW_IN_RECENT_APPS] }.collectLatest {
35 | showInRecent = it ?: PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT
36 | }
37 | }
38 | }
39 |
40 | override suspend fun handleViewIntent(intent: MainViewIntent) {
41 | when (intent) {
42 | is MainViewIntent.ShowMultiMessageWarning -> {
43 | _uiState.getAndUpdate { it.copy(showMultiMessageWarning = intent.show) }
44 | }
45 |
46 | MainViewIntent.ChangeToLegacyMode ->
47 | getApplication().dataStore.edit {
48 | it[PREFERENCE_MODE] = Mode.Legacy.v
49 | }
50 | }
51 | }
52 |
53 | }
--------------------------------------------------------------------------------
/app/src/test/java/cc/chenhe/qqnotifyevo/log/LogWriterTest.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.log
2 |
3 | import io.kotest.matchers.equals.shouldBeEqual
4 | import io.kotest.matchers.equals.shouldNotBeEqual
5 | import org.junit.After
6 | import org.junit.Before
7 | import org.junit.BeforeClass
8 | import org.junit.Test
9 | import java.io.File
10 | import java.util.Calendar
11 |
12 |
13 | class LogWriterTest {
14 |
15 | companion object {
16 | private const val TIME = 1595582295000
17 |
18 | private lateinit var cacheDir: File
19 |
20 | @BeforeClass
21 | @JvmStatic
22 | fun classSetup() {
23 | cacheDir = if (System.getenv("GITHUB_ACTIONS") == "true") {
24 | File(System.getenv("HOME"), "qqevo_log_test")
25 | } else {
26 | File(System.getProperty("java.io.tmpdir"), "qqevo_log_test")
27 | }
28 | println("[LogWriterTest] Cache dir: ${cacheDir.absolutePath}")
29 | }
30 | }
31 |
32 | private lateinit var writer: LogWriter
33 |
34 | private fun createLogWriter(): LogWriter {
35 | return LogWriter(cacheDir, TIME)
36 | }
37 |
38 | @Before
39 | fun setup() {
40 | if (cacheDir.isDirectory) {
41 | cacheDir.deleteRecursively()
42 | }
43 | writer = createLogWriter()
44 | }
45 |
46 | @After
47 | fun after() {
48 | writer.close()
49 | cacheDir.deleteRecursively()
50 | }
51 |
52 | @Test
53 | fun writeLog() {
54 | writer.write("Test", TIME)
55 | writer.write("Hello", TIME)
56 | writer.logFile.readText().shouldBeEqual("Test\nHello\n")
57 | }
58 |
59 | @Test
60 | fun appendLog() {
61 | createLogWriter().use { w ->
62 | w.write("line1", TIME)
63 | }
64 | createLogWriter().use { w ->
65 | w.write("line2", TIME)
66 | w.logFile.readText().shouldBeEqual("line1\nline2\n")
67 | }
68 | }
69 |
70 | @Test
71 | fun writeLog_differentDay() {
72 | val calendar = Calendar.getInstance().apply { timeInMillis = TIME }
73 | writer.write("Test", calendar.timeInMillis)
74 | val f1 = writer.logFile
75 |
76 | calendar.add(Calendar.DAY_OF_MONTH, 1)
77 | writer.write("Test2", calendar.timeInMillis)
78 | val f2 = writer.logFile
79 |
80 | f1.name.shouldNotBeEqual(f2.name)
81 | f1.readText().shouldBeEqual("Test\n")
82 | f2.readText().shouldBeEqual("Test2\n")
83 | }
84 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("unused")
2 |
3 | package cc.chenhe.qqnotifyevo.ui.theme
4 |
5 | import androidx.compose.ui.graphics.Color
6 |
7 | val md_theme_light_primary = Color(0xFF0061A5)
8 | val md_theme_light_onPrimary = Color(0xFFFFFFFF)
9 | val md_theme_light_primaryContainer = Color(0xFFD2E4FF)
10 | val md_theme_light_onPrimaryContainer = Color(0xFF001D36)
11 | val md_theme_light_secondary = Color(0xFF535F70)
12 | val md_theme_light_onSecondary = Color(0xFFFFFFFF)
13 | val md_theme_light_secondaryContainer = Color(0xFFD7E3F8)
14 | val md_theme_light_onSecondaryContainer = Color(0xFF101C2B)
15 | val md_theme_light_tertiary = Color(0xFF0061A5)
16 | val md_theme_light_onTertiary = Color(0xFFFFFFFF)
17 | val md_theme_light_tertiaryContainer = Color(0xFFD2E4FF)
18 | val md_theme_light_onTertiaryContainer = Color(0xFF001D36)
19 | val md_theme_light_error = Color(0xFFBA1A1A)
20 | val md_theme_light_errorContainer = Color(0xFFFFDAD6)
21 | val md_theme_light_onError = Color(0xFFFFFFFF)
22 | val md_theme_light_onErrorContainer = Color(0xFF410002)
23 | val md_theme_light_background = Color(0xFFFDFCFF)
24 | val md_theme_light_onBackground = Color(0xFF1A1C1E)
25 | val md_theme_light_surface = Color(0xFFFDFCFF)
26 | val md_theme_light_onSurface = Color(0xFF1A1C1E)
27 | val md_theme_light_surfaceVariant = Color(0xFFDFE2EB)
28 | val md_theme_light_onSurfaceVariant = Color(0xFF43474E)
29 | val md_theme_light_outline = Color(0xFF73777F)
30 | val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4)
31 | val md_theme_light_inverseSurface = Color(0xFF2F3033)
32 | val md_theme_light_inversePrimary = Color(0xFF9FCAFF)
33 | val md_theme_light_shadow = Color(0xFF000000)
34 | val md_theme_light_surfaceTint = Color(0xFF0061A5)
35 | val md_theme_light_outlineVariant = Color(0xFFC3C6CF)
36 | val md_theme_light_scrim = Color(0xFF000000)
37 |
38 | val md_theme_dark_primary = Color(0xFF9FCAFF)
39 | val md_theme_dark_onPrimary = Color(0xFF003259)
40 | val md_theme_dark_primaryContainer = Color(0xFF00497E)
41 | val md_theme_dark_onPrimaryContainer = Color(0xFFD2E4FF)
42 | val md_theme_dark_secondary = Color(0xFFBBC7DB)
43 | val md_theme_dark_onSecondary = Color(0xFF253140)
44 | val md_theme_dark_secondaryContainer = Color(0xFF3B4858)
45 | val md_theme_dark_onSecondaryContainer = Color(0xFFD7E3F8)
46 | val md_theme_dark_tertiary = Color(0xFF9FCAFF)
47 | val md_theme_dark_onTertiary = Color(0xFF003259)
48 | val md_theme_dark_tertiaryContainer = Color(0xFF00497E)
49 | val md_theme_dark_onTertiaryContainer = Color(0xFFD2E4FF)
50 | val md_theme_dark_error = Color(0xFFFFB4AB)
51 | val md_theme_dark_errorContainer = Color(0xFF93000A)
52 | val md_theme_dark_onError = Color(0xFF690005)
53 | val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
54 | val md_theme_dark_background = Color(0xFF1A1C1E)
55 | val md_theme_dark_onBackground = Color(0xFFE2E2E6)
56 | val md_theme_dark_surface = Color(0xFF1A1C1E)
57 | val md_theme_dark_onSurface = Color(0xFFE2E2E6)
58 | val md_theme_dark_surfaceVariant = Color(0xFF43474E)
59 | val md_theme_dark_onSurfaceVariant = Color(0xFFC3C6CF)
60 | val md_theme_dark_outline = Color(0xFF8D9199)
61 | val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E)
62 | val md_theme_dark_inverseSurface = Color(0xFFE2E2E6)
63 | val md_theme_dark_inversePrimary = Color(0xFF0061A5)
64 | val md_theme_dark_shadow = Color(0xFF000000)
65 | val md_theme_dark_surfaceTint = Color(0xFF9FCAFF)
66 | val md_theme_dark_outlineVariant = Color(0xFF43474E)
67 | val md_theme_dark_scrim = Color(0xFF000000)
68 |
69 |
70 | val seed = Color(0xFF0099FF)
71 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notify_qq.xml:
--------------------------------------------------------------------------------
1 |
16 |
21 |
24 |
27 |
30 |
33 |
36 |
39 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/ErrorCard.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.common
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.rounded.ErrorOutline
11 | import androidx.compose.material3.ButtonDefaults
12 | import androidx.compose.material3.Card
13 | import androidx.compose.material3.CardDefaults
14 | import androidx.compose.material3.Icon
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Text
17 | import androidx.compose.material3.TextButton
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.ui.Alignment
20 | import androidx.compose.ui.Modifier
21 | import androidx.compose.ui.graphics.Color
22 | import androidx.compose.ui.graphics.vector.ImageVector
23 | import androidx.compose.ui.tooling.preview.Preview
24 | import androidx.compose.ui.unit.dp
25 | import cc.chenhe.qqnotifyevo.ui.theme.AppTheme
26 |
27 |
28 | @Composable
29 | @Preview
30 | private fun ErrorCardPreview() {
31 | AppTheme {
32 | ErrorCard(title = "Error", description = "message", button = {
33 | TextButton(
34 | onClick = { },
35 | colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onErrorContainer)
36 | ) {
37 | Text(text = "Confirm")
38 | }
39 | })
40 | }
41 | }
42 |
43 | @Composable
44 | internal fun ErrorCard(
45 | title: String,
46 | modifier: Modifier = Modifier,
47 | icon: ImageVector = Icons.Rounded.ErrorOutline,
48 | description: String? = null,
49 | button: (@Composable () -> Unit)? = null,
50 | containerColor: Color = MaterialTheme.colorScheme.errorContainer,
51 | ) {
52 | Card(
53 | colors = CardDefaults.cardColors(containerColor = containerColor),
54 | modifier = modifier
55 | ) {
56 | Column(
57 | horizontalAlignment = Alignment.End,
58 | modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
59 | ) {
60 | Row(
61 | verticalAlignment = Alignment.CenterVertically
62 | ) {
63 | Icon(
64 | icon,
65 | contentDescription = null,
66 | modifier = Modifier.size(24.dp)
67 | )
68 | Column(
69 | modifier = Modifier
70 | .weight(1f)
71 | .padding(start = 12.dp)
72 | ) {
73 | Text(
74 | text = title,
75 | style = MaterialTheme.typography.bodyLarge
76 | )
77 | if (!description.isNullOrEmpty()) {
78 | Text(
79 | text = description,
80 | color = MaterialTheme.colorScheme.onSurfaceVariant,
81 | style = MaterialTheme.typography.bodyMedium
82 | )
83 | }
84 | }
85 | }
86 | if (button != null) {
87 | button()
88 | } else {
89 | Spacer(modifier = Modifier.height(16.dp))
90 | }
91 | }
92 | }
93 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/AvatarManager.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import android.graphics.Bitmap
4 | import android.graphics.BitmapFactory
5 | import androidx.collection.LruCache
6 | import timber.log.Timber
7 | import java.io.File
8 | import java.io.FileOutputStream
9 | import java.io.IOException
10 | import kotlin.math.min
11 |
12 | /**
13 | * 管理会话头像磁盘+内存二级缓存。
14 | *
15 | * 在某些情况,例如群聊消息或旧版 QQ 有多个联系人发来消息时,通知不会显示联系人头像。故缓存以作备用。
16 | *
17 | * 过期后依然可以成功读取缓存,下次请求保存头像时会覆盖。
18 | *
19 | * @param cacheDir 缓存文件夹。
20 | * @param period 缓存有效期(毫秒)。
21 | */
22 | class AvatarManager private constructor(
23 | private val cacheDir: File,
24 | var period: Long
25 | ) {
26 |
27 | companion object {
28 | private const val TAG = "AvatarManager"
29 | private const val MAX_MEMORY_CACHE_SIZE = 5 * 1024 * 1024L
30 |
31 | private var instance: AvatarManager? = null
32 |
33 | fun get(cacheDir: File, period: Long): AvatarManager {
34 | if (instance == null) {
35 | synchronized(AvatarManager::class) {
36 | if (instance == null)
37 | instance = AvatarManager(cacheDir, period)
38 | }
39 | }
40 | return requireNotNull(instance)
41 | }
42 | }
43 |
44 | private class AvatarLruCache : LruCache(
45 | min((Runtime.getRuntime().freeMemory() / 4), MAX_MEMORY_CACHE_SIZE).toInt()
46 | ) {
47 | override fun sizeOf(key: Int, value: Bitmap): Int {
48 | return value.allocationByteCount
49 | }
50 | }
51 |
52 | private val lru: AvatarLruCache = AvatarLruCache()
53 |
54 | init {
55 | if (!cacheDir.isDirectory) {
56 | cacheDir.mkdirs()
57 | }
58 | }
59 |
60 | fun saveAvatar(conversionId: Int, bmp: Bitmap): File {
61 | val file = File(cacheDir, conversionId.toString())
62 | if (!file.isFile || System.currentTimeMillis() - file.lastModified() > period) {
63 | lru.remove(conversionId)
64 | try {
65 | val outStream = FileOutputStream(file)
66 | bmp.compress(Bitmap.CompressFormat.PNG, 100, outStream)
67 | outStream.flush()
68 | outStream.close()
69 | } catch (e: IOException) {
70 | e.printStackTrace()
71 | }
72 | }
73 | return file
74 | }
75 |
76 | fun getAvatar(conversionId: Int): Bitmap? {
77 | lru[conversionId].also { if (it != null) return it }
78 | val file = File(cacheDir, conversionId.toString())
79 | if (file.isFile) {
80 | return try {
81 | BitmapFactory.decodeFile(file.absolutePath).also {
82 | lru.put(conversionId, it)
83 | }
84 | } catch (e: Exception) {
85 | Timber.tag(TAG)
86 | .e("Decode avatar file error, delete the cache. conversionId=$conversionId")
87 | e.printStackTrace()
88 | file.delete()
89 | lru.remove(conversionId)
90 | null
91 | }
92 | }
93 | return null
94 | }
95 |
96 | /**
97 | * 清空磁盘与内存缓存。
98 | */
99 | fun clearCache() {
100 | Timber.tag(TAG).d("Clear avatar cache in disk and memory.")
101 | cacheDir.listFiles()?.forEach { f ->
102 | f?.deleteRecursively()
103 | }
104 | lru.evictAll()
105 | }
106 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/NotificationResolver.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import android.app.Notification
4 | import android.service.notification.StatusBarNotification
5 | import cc.chenhe.qqnotifyevo.BuildConfig
6 | import cc.chenhe.qqnotifyevo.utils.Tag
7 | import org.json.JSONObject
8 | import timber.log.Timber
9 |
10 | /**
11 | * 已知的 QQ 通知种类
12 | */
13 | sealed class QQNotification {
14 | abstract val tag: Tag
15 |
16 | /** 隐藏了消息内容的通知 */
17 | data class HiddenMessage(override val tag: Tag) : QQNotification()
18 |
19 | /** QQ 空间特别关心动态推送 */
20 | data class QZoneSpecialPost(override val tag: Tag, val content: String) : QQNotification()
21 |
22 | /** QQ 空间动态:点赞评论等 */
23 | data class QZoneMessage(override val tag: Tag, val content: String, val num: Int) :
24 | QQNotification()
25 |
26 | /**
27 | * 群聊消息
28 | * @param nickname 消息发送者昵称,通常是在群聊中的昵称
29 | * @param special 特别关心
30 | */
31 | data class GroupMessage(
32 | override val tag: Tag,
33 | val groupName: String,
34 | val nickname: String,
35 | val message: String,
36 | val special: Boolean,
37 | val num: Int,
38 | ) : QQNotification()
39 |
40 | /**
41 | * 私聊消息
42 | * @param special 特别关心
43 | */
44 | data class PrivateMessage(
45 | override val tag: Tag,
46 | val nickname: String,
47 | val message: String,
48 | val special: Boolean,
49 | val num: Int,
50 | ) : QQNotification()
51 |
52 | /**
53 | * 来自关联账号的消息
54 | * @param sender 消息发送者昵称,不是被关联账号的昵称
55 | */
56 | data class BindingAccountMessage(
57 | override val tag: Tag,
58 | val sender: String,
59 | val message: String,
60 | val num: Int,
61 | ) : QQNotification()
62 | }
63 |
64 | private const val TAG = "NotificationResolver"
65 |
66 | /**
67 | * A resolver that can parse arbitrary notification from QQ (or TIM e.g.) into a known pattern.
68 | * Not responsible for managing history. In general, different implementations work for different
69 | * source APPs and versions.
70 | */
71 | interface NotificationResolver {
72 |
73 | /**
74 | * Resolve the given notification into a known pattern.
75 | * @return resolved pattern, `null` if not matched.
76 | */
77 | fun resolveNotification(
78 | packageName: String,
79 | tag: Tag,
80 | sbn: StatusBarNotification
81 | ): QQNotification? {
82 | val original = sbn.notification ?: return null
83 | val title = original.extras.getString(Notification.EXTRA_TITLE)
84 | val content = original.extras.getString(Notification.EXTRA_TEXT)
85 | val ticker = original.tickerText?.toString()
86 |
87 | if (BuildConfig.DEBUG) {
88 | val jsonStr = JSONObject().apply {
89 | put("title", title)
90 | put("ticker", ticker)
91 | put("content", content)
92 | }.toString()
93 | Timber.tag(TAG).v(jsonStr)
94 | }
95 |
96 | Timber.tag(TAG).v("Title: $title; Ticker: $ticker; Content: $content")
97 | return resolveNotification(tag, title, content, ticker)
98 | }
99 |
100 | /**
101 | * Resolve the given notification components into a known pattern.
102 | * @return resolved pattern, `null` if not matched.
103 | */
104 | fun resolveNotification(
105 | tag: Tag,
106 | title: String?,
107 | content: String?,
108 | ticker: String?,
109 | ): QQNotification?
110 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.material3.lightColorScheme
6 | import androidx.compose.material3.darkColorScheme
7 | import androidx.compose.runtime.Composable
8 |
9 |
10 | @Suppress("PrivatePropertyName")
11 | private val LightColors = lightColorScheme(
12 | primary = md_theme_light_primary,
13 | onPrimary = md_theme_light_onPrimary,
14 | primaryContainer = md_theme_light_primaryContainer,
15 | onPrimaryContainer = md_theme_light_onPrimaryContainer,
16 | secondary = md_theme_light_secondary,
17 | onSecondary = md_theme_light_onSecondary,
18 | secondaryContainer = md_theme_light_secondaryContainer,
19 | onSecondaryContainer = md_theme_light_onSecondaryContainer,
20 | tertiary = md_theme_light_tertiary,
21 | onTertiary = md_theme_light_onTertiary,
22 | tertiaryContainer = md_theme_light_tertiaryContainer,
23 | onTertiaryContainer = md_theme_light_onTertiaryContainer,
24 | error = md_theme_light_error,
25 | errorContainer = md_theme_light_errorContainer,
26 | onError = md_theme_light_onError,
27 | onErrorContainer = md_theme_light_onErrorContainer,
28 | background = md_theme_light_background,
29 | onBackground = md_theme_light_onBackground,
30 | surface = md_theme_light_surface,
31 | onSurface = md_theme_light_onSurface,
32 | surfaceVariant = md_theme_light_surfaceVariant,
33 | onSurfaceVariant = md_theme_light_onSurfaceVariant,
34 | outline = md_theme_light_outline,
35 | inverseOnSurface = md_theme_light_inverseOnSurface,
36 | inverseSurface = md_theme_light_inverseSurface,
37 | inversePrimary = md_theme_light_inversePrimary,
38 | surfaceTint = md_theme_light_surfaceTint,
39 | outlineVariant = md_theme_light_outlineVariant,
40 | scrim = md_theme_light_scrim,
41 | )
42 |
43 |
44 | @Suppress("PrivatePropertyName")
45 | private val DarkColors = darkColorScheme(
46 | primary = md_theme_dark_primary,
47 | onPrimary = md_theme_dark_onPrimary,
48 | primaryContainer = md_theme_dark_primaryContainer,
49 | onPrimaryContainer = md_theme_dark_onPrimaryContainer,
50 | secondary = md_theme_dark_secondary,
51 | onSecondary = md_theme_dark_onSecondary,
52 | secondaryContainer = md_theme_dark_secondaryContainer,
53 | onSecondaryContainer = md_theme_dark_onSecondaryContainer,
54 | tertiary = md_theme_dark_tertiary,
55 | onTertiary = md_theme_dark_onTertiary,
56 | tertiaryContainer = md_theme_dark_tertiaryContainer,
57 | onTertiaryContainer = md_theme_dark_onTertiaryContainer,
58 | error = md_theme_dark_error,
59 | errorContainer = md_theme_dark_errorContainer,
60 | onError = md_theme_dark_onError,
61 | onErrorContainer = md_theme_dark_onErrorContainer,
62 | background = md_theme_dark_background,
63 | onBackground = md_theme_dark_onBackground,
64 | surface = md_theme_dark_surface,
65 | onSurface = md_theme_dark_onSurface,
66 | surfaceVariant = md_theme_dark_surfaceVariant,
67 | onSurfaceVariant = md_theme_dark_onSurfaceVariant,
68 | outline = md_theme_dark_outline,
69 | inverseOnSurface = md_theme_dark_inverseOnSurface,
70 | inverseSurface = md_theme_dark_inverseSurface,
71 | inversePrimary = md_theme_dark_inversePrimary,
72 | surfaceTint = md_theme_dark_surfaceTint,
73 | outlineVariant = md_theme_dark_outlineVariant,
74 | scrim = md_theme_dark_scrim,
75 | )
76 |
77 | @Composable
78 | fun AppTheme(
79 | useDarkTheme: Boolean = isSystemInDarkTheme(),
80 | content: @Composable () -> Unit
81 | ) {
82 | val colors = if (!useDarkTheme) {
83 | LightColors
84 | } else {
85 | DarkColors
86 | }
87 |
88 | MaterialTheme(
89 | colorScheme = colors,
90 | content = content
91 | )
92 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/NevoNotificationProcessor.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import android.Manifest
4 | import android.annotation.SuppressLint
5 | import android.app.Notification
6 | import android.app.PendingIntent
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.service.notification.StatusBarNotification
10 | import androidx.core.app.NotificationCompat
11 | import androidx.core.app.NotificationManagerCompat
12 | import cc.chenhe.qqnotifyevo.R
13 | import cc.chenhe.qqnotifyevo.StaticReceiver
14 | import cc.chenhe.qqnotifyevo.ui.MainActivity
15 | import cc.chenhe.qqnotifyevo.utils.*
16 | import kotlinx.coroutines.CoroutineScope
17 |
18 | /**
19 | * 配合 [cc.chenhe.qqnotifyevo.service.NevoDecorator] 使用的通知处理器,直接创建并返回优化后的通知。
20 | */
21 | class NevoNotificationProcessor(context: Context, scope: CoroutineScope) :
22 | NotificationProcessor(context, scope) {
23 |
24 | companion object {
25 | private const val REQ_MULTI_MSG_LEARN_MORE = 1
26 | private const val REQ_MULTI_MSG_DONT_SHOW = 2
27 | }
28 |
29 | override fun renewQzoneNotification(
30 | context: Context,
31 | tag: Tag,
32 | conversation: Conversation,
33 | sbn: StatusBarNotification,
34 | original: Notification
35 | ): Notification {
36 | return createQZoneNotification(context, tag, conversation, original)
37 | }
38 |
39 | override fun renewConversionNotification(
40 | context: Context,
41 | tag: Tag,
42 | channel: NotifyChannel,
43 | conversation: Conversation,
44 | sbn: StatusBarNotification,
45 | original: Notification
46 | ): Notification {
47 | return createConversationNotification(context, tag, channel, conversation, original)
48 | }
49 |
50 | override fun onMultiMessageDetected(isBindingMsg: Boolean) {
51 | super.onMultiMessageDetected(isBindingMsg)
52 | if (isBindingMsg) {
53 | // 目前关联账号的消息都会合并
54 | return
55 | }
56 | @SuppressLint("MissingPermission")
57 | if (nevoMultiMsgTip(ctx) && ctx.hasPermission(Manifest.permission.POST_NOTIFICATIONS)) {
58 | val dontShow = PendingIntent.getBroadcast(
59 | ctx, REQ_MULTI_MSG_DONT_SHOW,
60 | Intent(ctx, StaticReceiver::class.java).also {
61 | it.action = ACTION_MULTI_MSG_DONT_SHOW
62 | }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
63 | )
64 |
65 | val learnMore = PendingIntent.getActivity(
66 | ctx, REQ_MULTI_MSG_LEARN_MORE,
67 | Intent(ctx, MainActivity::class.java).also {
68 | it.putExtra(MainActivity.EXTRA_NEVO_MULTI_MSG, true)
69 | it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
70 | }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
71 | )
72 |
73 | val style = NotificationCompat.BigTextStyle()
74 | .setBigContentTitle(ctx.getString(R.string.notify_multi_msg_title))
75 | .bigText(ctx.getString(R.string.notify_multi_msg_content))
76 | val n = NotificationCompat.Builder(ctx, NOTIFY_SELF_TIPS_CHANNEL_ID)
77 | .setStyle(style)
78 | .setAutoCancel(true)
79 | .setContentTitle(ctx.getString(R.string.notify_multi_msg_title))
80 | .setContentText(ctx.getString(R.string.notify_multi_msg_content))
81 | .setSmallIcon(R.drawable.ic_notify_warning)
82 | .setContentIntent(learnMore)
83 | .addAction(
84 | R.drawable.ic_notify_action_dnot_show,
85 | ctx.getString(R.string.dont_show),
86 | dontShow
87 | )
88 | .addAction(
89 | R.drawable.ic_notify_action_learn_more,
90 | ctx.getString(R.string.learn_more),
91 | learnMore
92 | )
93 | .build()
94 |
95 | NotificationManagerCompat.from(ctx).notify(NOTIFY_ID_MULTI_MSG, n)
96 | }
97 | }
98 |
99 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
27 |
28 |
33 |
34 |
35 |
36 |
37 |
38 |
43 |
44 |
45 |
46 |
47 |
50 |
51 |
52 |
53 |
59 |
60 |
61 |
62 |
63 |
66 |
69 |
70 |
71 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Android Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - v[0-9]+.[0-9]+.[0-9]+**
7 | workflow_dispatch:
8 | inputs:
9 | sshDebug:
10 | description: "Setup an ssh connection for debugging."
11 | required: true
12 | type: boolean
13 | default: false
14 |
15 | env:
16 | VERSION_BRANCH: version
17 | VERSION_FILE: version.properties
18 | ARTIFACT_APK: release.apk
19 | ARTIFACT_MAPPINGS: mappings
20 |
21 | jobs:
22 | build:
23 | runs-on: ubuntu-latest
24 | env:
25 | QNEVO_SIGNING_STORE_PATH: ${{ github.workspace }}/release.jks
26 |
27 | steps:
28 | - uses: actions/checkout@v4
29 | with:
30 | fetch-depth: 0
31 | - name: set up JDK 17
32 | uses: actions/setup-java@v3
33 | with:
34 | java-version: '17'
35 | distribution: 'temurin'
36 | cache: 'gradle'
37 | - name: Grant execute permission for gradlew
38 | run: chmod +x gradlew
39 |
40 | - name: Write KeyStore File
41 | uses: RollyPeres/base64-to-path@v1
42 | with:
43 | filePath: ${{ env.QNEVO_SIGNING_STORE_PATH }}
44 | encodedString: ${{ secrets.SIGNING_KEYSTORE }}
45 |
46 | - name: Setup Debug Session
47 | if: ${{ inputs.sshDebug }}
48 | uses: csexton/debugger-action@master
49 |
50 | - name: Generate version properties
51 | run: ./gradlew appVersion
52 |
53 | - name: Assemble Release APK
54 | run: ./gradlew assembleRelease
55 | env:
56 | QNEVO_SIGNING_STORE_PWD: ${{ secrets.SIGNING_STORE_PASSWORD }}
57 | QNEVO_SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
58 | QNEVO_SIGNING_KEY_PWD: ${{ secrets.SIGNING_KEY_PASSWORD }}
59 |
60 | - name: Upload APK
61 | uses: actions/upload-artifact@v3
62 | with:
63 | name: ${{ env.ARTIFACT_APK }}
64 | path: "app/build/outputs/apk/release/app-release.apk"
65 | if-no-files-found: error
66 |
67 | - name: Upload Mapping Files
68 | uses: actions/upload-artifact@v3
69 | with:
70 | name: ${{ env.ARTIFACT_MAPPINGS }}
71 | path: "app/build/outputs/mapping/release"
72 | if-no-files-found: error
73 |
74 | - name: Generate Version Info
75 | run: ./gradlew appVersion
76 |
77 | - name: Upload version.properties
78 | uses: actions/upload-artifact@v3
79 | with:
80 | name: ${{ env.VERSION_FILE }}
81 | path: ${{ env.VERSION_FILE }}
82 | if-no-files-found: error
83 | retention-days: 1
84 |
85 | draft-release:
86 | runs-on: ubuntu-latest
87 | needs: build
88 | permissions:
89 | contents: write
90 |
91 | steps:
92 | - name: Download APK
93 | uses: actions/download-artifact@v3
94 | with:
95 | name: ${{ env.ARTIFACT_APK }}
96 |
97 | - name: Download Mappings
98 | uses: actions/download-artifact@v3
99 | with:
100 | name: ${{ env.ARTIFACT_MAPPINGS }}
101 | path: mappings
102 |
103 | - name: Tar Mapping Files
104 | run: |
105 | cd mappings
106 | tar -zcf mappings.tar.gz ./*
107 | mv mappings.tar.gz ../
108 | cd ..
109 |
110 | - name: Release
111 | uses: ncipollo/release-action@v1
112 | with:
113 | draft: true
114 | artifactErrorsFailBuild: true
115 | artifacts: "app-release.apk,mappings.tar.gz"
116 |
117 | # commit the version file to 'version' branch so that F-Droid can utilize it for new version detecting
118 | update-version:
119 | runs-on: ubuntu-latest
120 | needs: build
121 | permissions:
122 | contents: write
123 |
124 | steps:
125 | - name: Checkout version branch
126 | uses: actions/checkout@v4
127 | with:
128 | ref: ${{ env.VERSION_BRANCH }}
129 | fetch-depth: 0
130 |
131 | - name: Delete version.properties
132 | run: rm -f $VERSION_FILE
133 |
134 | - name: Download version.properties
135 | uses: actions/download-artifact@v3
136 | with:
137 | name: ${{ env.VERSION_FILE }}
138 |
139 | - name: Show version info
140 | run: cat $VERSION_FILE
141 |
142 | - name: Commit and push
143 | uses: stefanzweifel/git-auto-commit-action@v5
144 | with:
145 | commit_message: Update version.properties
146 | push_options: '--force'
147 | skip_fetch: true
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.config.JvmTarget
2 | import java.util.Properties
3 |
4 | plugins {
5 | id("com.android.application")
6 | id("org.jetbrains.kotlin.android")
7 | }
8 |
9 | val versionProperties = Properties().apply {
10 | val f = rootProject.file("version.properties")
11 | if (f.isFile) {
12 | load(f.reader())
13 | }
14 | }
15 |
16 | android {
17 | namespace = "cc.chenhe.qqnotifyevo"
18 | compileSdk = 34
19 | defaultConfig {
20 | applicationId = "cc.chenhe.qqnotifyevo"
21 | minSdk = 26
22 | targetSdk = 33
23 | versionCode = versionProperties.getProperty("code", "1").toIntOrNull() ?: 1
24 | versionName = versionProperties.getProperty("name", "UNKNOWN")
25 | }
26 | buildFeatures {
27 | viewBinding = true
28 | compose = true
29 | }
30 | composeOptions {
31 | // must correspond to kotlin version: https://developer.android.com/jetpack/androidx/releases/compose-kotlin#pre-release_kotlin_compatibility
32 | kotlinCompilerExtensionVersion = "1.5.3"
33 | }
34 | signingConfigs {
35 | readSigningConfig()?.also { config ->
36 | logger.lifecycle("Use key alias '{}' to sign release", config.keyAlias)
37 | create("release") {
38 | storeFile = config.storeFile
39 | storePassword = config.storePwd
40 | keyAlias = config.keyAlias
41 | keyPassword = config.keyPwd
42 |
43 | enableV2Signing = true
44 | }
45 | }
46 | }
47 | buildTypes {
48 | release {
49 | isMinifyEnabled = true
50 | isShrinkResources = true
51 | proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
52 | signingConfigs.findByName("release")?.also { signingConfig = it }
53 | }
54 | }
55 | compileOptions {
56 | sourceCompatibility = JavaVersion.VERSION_1_8
57 | targetCompatibility = JavaVersion.VERSION_1_8
58 | }
59 | kotlinOptions {
60 | jvmTarget = JvmTarget.JVM_1_8.description
61 | }
62 | }
63 |
64 | dependencies {
65 | val lifecycleVersion = "2.6.2"
66 |
67 | // compose
68 | val composeBom = platform("androidx.compose:compose-bom:2023.10.01")
69 | implementation(composeBom)
70 | androidTestImplementation(composeBom)
71 | implementation("androidx.compose.material3:material3")
72 | implementation("androidx.compose.material:material-icons-extended")
73 | implementation("androidx.compose.ui:ui-tooling-preview")
74 | debugImplementation("androidx.compose.ui:ui-tooling")
75 | implementation("androidx.activity:activity-compose:1.8.0")
76 |
77 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
78 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
79 | implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
80 | implementation("androidx.lifecycle:lifecycle-service:$lifecycleVersion")
81 |
82 | implementation("androidx.navigation:navigation-compose:2.7.5")
83 | implementation("androidx.datastore:datastore-preferences:1.0.0")
84 | implementation("androidx.constraintlayout:constraintlayout:2.1.4")
85 | implementation("androidx.preference:preference-ktx:1.2.1")
86 | implementation("androidx.core:core-ktx:1.12.0")
87 | implementation("androidx.concurrent:concurrent-futures-ktx:1.1.0") // support ListenableFuture
88 | implementation("com.oasisfeng.nevo:sdk:2.0.0-rc01")
89 | implementation("com.jakewharton.timber:timber:5.0.1")
90 |
91 | testImplementation("junit:junit:4.13.2")
92 | testImplementation("io.kotest:kotest-assertions-core:5.7.2")
93 | testImplementation("org.json:json:20231013") // JSONObject
94 | }
95 |
96 | data class SigningConfig(
97 | val storeFile: File,
98 | val storePwd: String,
99 | val keyAlias: String,
100 | val keyPwd: String,
101 | )
102 |
103 | fun readSigningConfig(): SigningConfig? {
104 | val path = System.getenv("QNEVO_SIGNING_STORE_PATH") ?: return null
105 | val f = File(path)
106 | if (!f.isFile) {
107 | logger.warn("Key store file not exist: {}", path)
108 | return null
109 | }
110 | return SigningConfig(
111 | storeFile = f,
112 | storePwd = System.getenv("QNEVO_SIGNING_STORE_PWD") ?: return null,
113 | keyAlias = System.getenv("QNEVO_SIGNING_KEY_ALIAS") ?: return null,
114 | keyPwd = System.getenv("QNEVO_SIGNING_KEY_PWD") ?: return null
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/service/NotificationMonitorService.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.service
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Service
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.os.IBinder
8 | import android.service.notification.NotificationListenerService
9 | import android.service.notification.StatusBarNotification
10 | import androidx.lifecycle.Lifecycle
11 | import androidx.lifecycle.LifecycleOwner
12 | import androidx.lifecycle.LifecycleRegistry
13 | import androidx.lifecycle.lifecycleScope
14 | import cc.chenhe.qqnotifyevo.core.InnerNotificationProcessor
15 | import cc.chenhe.qqnotifyevo.utils.Mode
16 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_MODE
17 | import cc.chenhe.qqnotifyevo.utils.Tag
18 | import cc.chenhe.qqnotifyevo.utils.dataStore
19 | import kotlinx.coroutines.flow.first
20 | import kotlinx.coroutines.launch
21 | import kotlinx.coroutines.runBlocking
22 | import timber.log.Timber
23 |
24 | class NotificationMonitorService : NotificationListenerService(),
25 | InnerNotificationProcessor.Commander,
26 | LifecycleOwner {
27 |
28 | companion object {
29 | private const val TAG = "NotifyMonitor"
30 |
31 | @SuppressLint("StaticFieldLeak")
32 | private var instance: NotificationMonitorService? = null
33 |
34 | fun isRunning(): Boolean {
35 | return try {
36 | // 如果服务被强制结束,标记没有释放,那么此处会抛出异常。
37 | instance?.ping() ?: false
38 | } catch (e: Exception) {
39 | false
40 | }
41 | }
42 | }
43 |
44 | private lateinit var lifecycleRegistry: LifecycleRegistry
45 | private lateinit var ctx: Context
46 | private lateinit var mode: Mode
47 |
48 | private lateinit var processor: InnerNotificationProcessor
49 |
50 | override val lifecycle: Lifecycle
51 | get() = lifecycleRegistry
52 |
53 | override fun onCreate() {
54 | super.onCreate()
55 | instance = this
56 | ctx = this
57 | Timber.tag(TAG).v("Service - onCreate")
58 | lifecycleRegistry = LifecycleRegistry(this).apply { currentState = Lifecycle.State.CREATED }
59 | mode = runBlocking {
60 | Mode.fromValue(ctx.dataStore.data.first()[PREFERENCE_MODE])
61 | }
62 | lifecycleScope.launch {
63 | ctx.dataStore.data.collect { pref ->
64 | mode = Mode.fromValue(pref[PREFERENCE_MODE])
65 | }
66 | }
67 |
68 | processor = InnerNotificationProcessor(this, this, lifecycleScope)
69 | }
70 |
71 | override fun onBind(intent: Intent?): IBinder? {
72 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
73 | return super.onBind(intent)
74 | }
75 |
76 | @Deprecated("Deprecated in Android")
77 | override fun onStart(intent: Intent?, startId: Int) {
78 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
79 | @Suppress("DEPRECATION")
80 | super.onStart(intent, startId)
81 | }
82 |
83 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
84 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
85 | if (mode != Mode.Legacy) {
86 | return Service.START_STICKY
87 | }
88 | if (intent?.hasExtra("tag") == true) {
89 | (intent.getStringExtra("tag") ?: Tag.UNKNOWN.name)
90 | .let { Tag.valueOf(it) }
91 | .also { processor.clearHistory(ctx, it) }
92 | }
93 | return Service.START_STICKY
94 | }
95 |
96 | override fun onDestroy() {
97 | Timber.tag(TAG).v("Service - onDestroy")
98 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
99 | super.onDestroy()
100 | instance = null
101 | }
102 |
103 | private fun ping() = true
104 |
105 | override fun onNotificationPosted(sbn: StatusBarNotification) {
106 | Timber.tag(TAG).v("Detect notification from ${sbn.packageName}.")
107 |
108 | if (mode != Mode.Legacy) {
109 | Timber.tag(TAG).d("Not in legacy mode, skip.")
110 | return
111 | }
112 | processor.resolveNotification(ctx, sbn.packageName, sbn)
113 | }
114 |
115 | override fun onNotificationRemoved(
116 | sbn: StatusBarNotification?,
117 | rankingMap: RankingMap?,
118 | reason: Int
119 | ) {
120 | if (sbn == null || mode != Mode.Legacy) {
121 | return
122 | }
123 | processor.onNotificationRemoved(sbn, reason)
124 | super.onNotificationRemoved(sbn, rankingMap, reason)
125 | }
126 |
127 | }
128 |
129 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/InnerNotificationProcessor.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import android.Manifest
4 | import android.annotation.SuppressLint
5 | import android.app.Notification
6 | import android.content.Context
7 | import android.service.notification.StatusBarNotification
8 | import androidx.core.app.NotificationCompat
9 | import androidx.core.app.NotificationManagerCompat
10 | import androidx.core.content.pm.ShortcutInfoCompat
11 | import androidx.core.content.pm.ShortcutManagerCompat
12 | import cc.chenhe.qqnotifyevo.utils.NotifyChannel
13 | import cc.chenhe.qqnotifyevo.utils.Tag
14 | import cc.chenhe.qqnotifyevo.utils.hasPermission
15 | import kotlinx.coroutines.CoroutineScope
16 | import timber.log.Timber
17 | import java.util.*
18 |
19 | /**
20 | * 配合 [cc.chenhe.qqnotifyevo.service.NotificationMonitorService] 使用的通知处理器,将直接发送新的通知并将原生通知移除。
21 | *
22 | * 部分版本的QQ当有多个联系人发来消息时,会合并为一个通知 「`有 x 个联系人给你发过来y条新消息`」,这种情况下 Nevo 模式无法正常工作,因为 Nevo
23 | * 只能对原始通知进行修改,无法把1个通知拆分成多个。[NotificationProcessor] 也仅会返回最新通知对应的会话,因此这个类进行了必要的修改,
24 | * 将会遍历所有历史会话并分别发送通知。
25 | */
26 | class InnerNotificationProcessor(
27 | private val commander: Commander,
28 | context: Context,
29 | scope: CoroutineScope,
30 | ) : NotificationProcessor(context, scope) {
31 |
32 | companion object {
33 | private const val TAG = "InnerNotifyProcessor"
34 | }
35 |
36 | interface Commander {
37 | fun cancelNotification(key: String)
38 | }
39 |
40 | // 储存所有通知的 id 以便清除
41 | private val qqNotifyIds: MutableSet = HashSet()
42 | private val timNotifyIds: MutableSet = HashSet()
43 |
44 | /**
45 | * 清空对应来源的通知与历史记录,内部调用了单参 [clearHistory].
46 | */
47 | fun clearHistory(context: Context, tag: Tag) {
48 | clearHistory(tag)
49 | val ids = when (tag) {
50 | Tag.QQ -> qqNotifyIds
51 | Tag.TIM -> timNotifyIds
52 | Tag.UNKNOWN -> null
53 | }
54 | Timber.tag(TAG).v("Clear all evolutionary notifications.")
55 | NotificationManagerCompat.from(context).apply {
56 | ids?.forEach { id -> cancel(id) }
57 | ids?.clear()
58 | }
59 | }
60 |
61 | private fun sendNotification(context: Context, tag: Tag, id: Int, notification: Notification) {
62 | @SuppressLint("MissingPermission")
63 | if (context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)) {
64 | NotificationManagerCompat.from(context).notify(id, notification)
65 | addNotifyId(tag, id)
66 | }
67 | }
68 |
69 | override fun renewQzoneNotification(
70 | context: Context, tag: Tag, conversation: Conversation,
71 | sbn: StatusBarNotification, original: Notification
72 | ): Notification {
73 |
74 | val notification = createQZoneNotification(context, tag, conversation, original).apply {
75 | contentIntent = original.contentIntent
76 | deleteIntent = original.deleteIntent
77 | }
78 | sendNotification(context, tag, "QZone".hashCode(), notification)
79 | commander.cancelNotification(sbn.key)
80 |
81 | return notification
82 | }
83 |
84 | override fun renewConversionNotification(
85 | context: Context,
86 | tag: Tag,
87 | channel: NotifyChannel,
88 | conversation: Conversation,
89 | sbn: StatusBarNotification,
90 | original: Notification
91 | ): Notification {
92 | val history = getHistoryMessage(tag)
93 | var notification: Notification? = null
94 | for (c in history) {
95 | if (c.name != conversation.name || c.isGroup && channel != NotifyChannel.GROUP ||
96 | !c.isGroup && channel == NotifyChannel.GROUP
97 | ) {
98 | // 确保只刷新新增的通知
99 | continue
100 | }
101 | notification =
102 | createConversationNotification(context, tag, channel, c, original).apply {
103 | contentIntent = original.contentIntent
104 | deleteIntent = original.deleteIntent
105 | }
106 | sendNotification(context, tag, c.name.hashCode(), notification)
107 | commander.cancelNotification(sbn.key)
108 | }
109 | return notification ?: Notification() // 此处返回值没有实际意义
110 | }
111 |
112 | override fun buildNotification(
113 | builder: NotificationCompat.Builder,
114 | shortcutInfo: ShortcutInfoCompat?
115 | ): Notification {
116 | if (shortcutInfo != null) {
117 | ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcutInfo)
118 | builder.setShortcutId(shortcutInfo.id)
119 | }
120 | return builder.build()
121 | }
122 |
123 | private fun addNotifyId(tag: Tag, ids: Int) {
124 | when (tag) {
125 | Tag.QQ -> qqNotifyIds.add(ids)
126 | Tag.TIM -> timNotifyIds.add(ids)
127 | Tag.UNKNOWN -> {
128 | }
129 | }
130 | }
131 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/MyApplication.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo
2 |
3 | import android.app.Application
4 | import android.app.NotificationChannel
5 | import android.app.NotificationChannelGroup
6 | import android.app.NotificationManager
7 | import android.media.AudioAttributes
8 | import android.media.RingtoneManager
9 | import androidx.core.app.NotificationManagerCompat
10 | import cc.chenhe.qqnotifyevo.log.CrashHandler
11 | import cc.chenhe.qqnotifyevo.log.ReleaseTree
12 | import cc.chenhe.qqnotifyevo.utils.NOTIFY_QQ_GROUP_ID
13 | import cc.chenhe.qqnotifyevo.utils.NOTIFY_SELF_TIPS_CHANNEL_ID
14 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ENABLE_LOG
15 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ENABLE_LOG_DEFAULT
16 | import cc.chenhe.qqnotifyevo.utils.dataStore
17 | import cc.chenhe.qqnotifyevo.utils.getLogDir
18 | import cc.chenhe.qqnotifyevo.utils.getNotificationChannels
19 | import cc.chenhe.qqnotifyevo.utils.getVersion
20 | import kotlinx.coroutines.MainScope
21 | import kotlinx.coroutines.flow.collectLatest
22 | import kotlinx.coroutines.flow.first
23 | import kotlinx.coroutines.flow.map
24 | import kotlinx.coroutines.launch
25 | import kotlinx.coroutines.runBlocking
26 | import kotlinx.coroutines.sync.Mutex
27 | import kotlinx.coroutines.sync.withLock
28 | import timber.log.Timber
29 |
30 |
31 | class MyApplication : Application() {
32 | companion object {
33 | private const val TAG = "Application"
34 | }
35 |
36 | private val logMutex = Mutex()
37 | private var enableLog: Boolean = PREFERENCE_ENABLE_LOG_DEFAULT
38 |
39 | private var debugTree: Timber.DebugTree? = null
40 | private var releaseTree: ReleaseTree? = null
41 |
42 | override fun onCreate() {
43 | super.onCreate()
44 |
45 | runBlocking {
46 | enableLog =
47 | dataStore.data.first()[PREFERENCE_ENABLE_LOG] ?: PREFERENCE_ENABLE_LOG_DEFAULT
48 | setupTimber(enableLog, false)
49 | }
50 | MainScope().launch {
51 | dataStore.data.map { it[PREFERENCE_ENABLE_LOG] ?: PREFERENCE_ENABLE_LOG_DEFAULT }
52 | .collectLatest {
53 | if (enableLog != it) {
54 | enableLog = it
55 | setupTimber(it, false)
56 | }
57 | }
58 | }
59 |
60 | Thread.setDefaultUncaughtExceptionHandler(CrashHandler)
61 | Timber.tag(TAG).i("\n\n")
62 | Timber.tag(TAG).i("==================================================")
63 | Timber.tag(TAG).i("= App Create ver: ${getVersion(this)}")
64 | Timber.tag(TAG).i("==================================================\n")
65 | registerNotificationChannel()
66 | }
67 |
68 | private suspend fun setupTimber(enableLog: Boolean, deleteLog: Boolean) {
69 | logMutex.withLock {
70 | if (deleteLog) {
71 | releaseTree?.close()
72 | releaseTree = null
73 | getLogDir(this).deleteRecursively()
74 | }
75 |
76 | if (BuildConfig.DEBUG) {
77 | if (debugTree == null)
78 | debugTree = Timber.DebugTree()
79 | plantIfNotExist(debugTree!!)
80 | }
81 | if (enableLog) {
82 | if (releaseTree == null)
83 | releaseTree = ReleaseTree(getLogDir(this))
84 | plantIfNotExist(releaseTree!!)
85 | } else {
86 | releaseTree?.also { r ->
87 | Timber.uproot(r)
88 | r.close()
89 | releaseTree = null
90 | }
91 | }
92 | }
93 |
94 | }
95 |
96 | private fun plantIfNotExist(tree: Timber.Tree) {
97 | if (!Timber.forest().contains(tree))
98 | Timber.plant(tree)
99 | }
100 |
101 | suspend fun deleteLog() {
102 | setupTimber(enableLog, true)
103 | }
104 |
105 | private fun registerNotificationChannel() {
106 | Timber.tag(TAG).d("Register system notification channels")
107 | val group =
108 | NotificationChannelGroup(NOTIFY_QQ_GROUP_ID, getString(R.string.notify_group_base))
109 |
110 |
111 | val att = AudioAttributes.Builder()
112 | .setUsage(AudioAttributes.USAGE_NOTIFICATION)
113 | .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
114 | .build()
115 | val tipChannel = NotificationChannel(
116 | NOTIFY_SELF_TIPS_CHANNEL_ID,
117 | getString(R.string.notify_self_tips_channel_name),
118 | NotificationManager.IMPORTANCE_DEFAULT
119 | ).apply {
120 | setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), att)
121 | }
122 |
123 | NotificationManagerCompat.from(this).apply {
124 | createNotificationChannelGroup(group)
125 | for (channel in getNotificationChannels(this@MyApplication, false)) {
126 | channel.group = group.id
127 | createNotificationChannel(channel)
128 | }
129 | createNotificationChannel(tipChannel)
130 | }
131 | }
132 |
133 | }
134 |
--------------------------------------------------------------------------------
/app/src/test/java/cc/chenhe/qqnotifyevo/core/TimNotificationResolverTest.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import cc.chenhe.qqnotifyevo.utils.Tag
4 | import io.kotest.matchers.booleans.shouldBeFalse
5 | import io.kotest.matchers.booleans.shouldBeTrue
6 | import io.kotest.matchers.equals.shouldBeEqual
7 | import io.kotest.matchers.nulls.shouldNotBeNull
8 | import io.kotest.matchers.types.shouldBeTypeOf
9 | import org.junit.Before
10 | import org.junit.Test
11 |
12 | class TimNotificationResolverTest : BaseResolverTest() {
13 | private lateinit var resolver: TimNotificationResolver
14 |
15 | @Before
16 | fun setup() {
17 | resolver = TimNotificationResolver()
18 | }
19 |
20 | private fun resolve(data: NotificationData): QQNotification? {
21 | return resolver.resolveNotification(
22 | tag = Tag.TIM,
23 | title = data.title,
24 | content = data.content,
25 | ticker = data.ticker,
26 | )
27 | }
28 |
29 | // 私聊消息 -––––--––––---––––---––––---––––---––––---––––
30 |
31 | @Test
32 | fun private_normal() {
33 | val n = parse("""{"title":"咕咕咕","ticker":"咕咕咕: Hi","content":"Hi"}""")
34 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
35 | r.nickname.shouldBeEqual("咕咕咕")
36 | r.message.shouldBeEqual(n.content!!)
37 | r.num.shouldBeEqual(1)
38 | r.special.shouldBeFalse()
39 | }
40 |
41 | @Test
42 | fun private_special() {
43 | val n =
44 | parse("""{"title":"[特别关心]咕咕咕","ticker":"咕咕咕: In memory of the days with another developer cs\nAnd I’m sorry ","content":"In memory of the days with another developer cs\nAnd I’m sorry "}""")
45 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
46 | r.nickname.shouldBeEqual("咕咕咕")
47 | r.message.shouldBeEqual(n.content!!)
48 | r.num.shouldBeEqual(1)
49 | r.special.shouldBeTrue()
50 | }
51 |
52 | @Test
53 | fun private_special_MultiMessage() {
54 | val n =
55 | parse("""{"title":"[特别关心]咕咕咕 (2条新消息)","ticker":"咕咕咕: &¥","content":"&¥"}""")
56 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
57 | r.nickname.shouldBeEqual("咕咕咕")
58 | r.message.shouldBeEqual(n.content!!)
59 | r.num.shouldBeEqual(2)
60 | r.special.shouldBeTrue()
61 | }
62 |
63 | // 群聊消息 -––––--––––---––––---––––---––––---––––---––––
64 |
65 | @Test
66 | fun group_normal() {
67 | val n =
68 | parse("""{"title":"测试群","ticker":"咕咕咕(测试群):Xxx","content":"咕咕咕: Xxx"}""")
69 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
70 | r.groupName.shouldBeEqual("测试群")
71 | r.nickname.shouldBeEqual("咕咕咕")
72 | r.message.shouldBeEqual("Xxx")
73 | r.num.shouldBeEqual(1)
74 | r.special.shouldBeFalse()
75 | }
76 |
77 | @Test
78 | fun group_multiMessage() {
79 | val n =
80 | parse("""{"title":"测试群 (2条新消息)","ticker":"咕咕咕(测试群):Yyy","content":"咕咕咕: Yyy"}""")
81 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
82 | r.groupName.shouldBeEqual("测试群")
83 | r.nickname.shouldBeEqual("咕咕咕")
84 | r.message.shouldBeEqual("Yyy")
85 | r.num.shouldBeEqual(2)
86 | r.special.shouldBeFalse()
87 | }
88 |
89 | @Test
90 | fun group_special() {
91 | val n =
92 | parse("""{"title":"测试群","ticker":"咕咕咕(测试群):111","content":"[有关注的内容]咕咕咕: 111"}""")
93 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
94 | r.groupName.shouldBeEqual("测试群")
95 | r.nickname.shouldBeEqual("咕咕咕")
96 | r.message.shouldBeEqual("111")
97 | r.num.shouldBeEqual(1)
98 | r.special.shouldBeTrue()
99 | }
100 |
101 | @Test
102 | fun group_special_multiMessage() {
103 | val n =
104 | parse("""{"title":"测试群 (2条新消息)","ticker":"咕咕咕(测试群):222","content":"[有关注的内容]咕咕咕: 222"}""")
105 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
106 | r.groupName.shouldBeEqual("测试群")
107 | r.nickname.shouldBeEqual("咕咕咕")
108 | r.message.shouldBeEqual("222")
109 | r.num.shouldBeEqual(2)
110 | r.special.shouldBeTrue()
111 | }
112 |
113 | // 其他 -––––--––––---––––---––––---––––---––––---––––
114 |
115 | @Test
116 | fun hidden() {
117 | val n =
118 | parse("""{"title":"TIM","ticker":"你收到了1条新消息","content":"你收到了1条新消息"}""")
119 | resolve(n).shouldNotBeNull().shouldBeTypeOf()
120 | }
121 |
122 |
123 | @Test
124 | fun binding_multiMessage_multiLine() {
125 | val n =
126 | parse("""{"title":"关联QQ号 (2条新消息)","ticker":"关联QQ号-\/dev\/urandom:a\nb","content":"\/dev\/urandom:a\nb"}""")
127 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
128 | r.sender.shouldBeEqual("/dev/urandom")
129 | r.message.shouldBeEqual("a\nb")
130 | r.num.shouldBeEqual(2)
131 | }
132 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/main/MainPreferenceViewModel.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.main
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Application
5 | import android.content.Context
6 | import androidx.datastore.preferences.core.edit
7 | import androidx.lifecycle.viewModelScope
8 | import cc.chenhe.qqnotifyevo.service.NevoDecorator
9 | import cc.chenhe.qqnotifyevo.service.NotificationMonitorService
10 | import cc.chenhe.qqnotifyevo.ui.common.MviAndroidViewModel
11 | import cc.chenhe.qqnotifyevo.utils.IconStyle
12 | import cc.chenhe.qqnotifyevo.utils.Mode
13 | import cc.chenhe.qqnotifyevo.utils.Mode.Legacy
14 | import cc.chenhe.qqnotifyevo.utils.Mode.Nevo
15 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ICON
16 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_MODE
17 | import cc.chenhe.qqnotifyevo.utils.USAGE_SHOW_UNSUPPORTED_APP_WARNING
18 | import cc.chenhe.qqnotifyevo.utils.USAGE_SHOW_UNSUPPORTED_APP_WARNING_DEFAULT
19 | import cc.chenhe.qqnotifyevo.utils.dataStore
20 | import kotlinx.coroutines.delay
21 | import kotlinx.coroutines.flow.collectLatest
22 | import kotlinx.coroutines.flow.first
23 | import kotlinx.coroutines.flow.getAndUpdate
24 | import kotlinx.coroutines.flow.map
25 | import kotlinx.coroutines.launch
26 |
27 | data class MainPreferenceUiState(
28 | val mode: Mode = Legacy,
29 | val iconStyle: IconStyle = IconStyle.Auto,
30 | val isServiceRunning: Boolean = true,
31 | val showNevoNotInstalledDialog: Boolean = false,
32 | val showUnsupportedAppWarning: Boolean = false,
33 | )
34 |
35 | sealed interface MainPreferenceIntent {
36 | data class SetMode(val newMode: Mode) : MainPreferenceIntent
37 | data class SetIconStyle(val newIcon: IconStyle) : MainPreferenceIntent
38 | data class ShowNevoNotInstalledDialog(val show: Boolean) : MainPreferenceIntent
39 | data object DismissUnsupportedAppWarning : MainPreferenceIntent
40 | }
41 |
42 | class MainPreferenceViewModel(application: Application) :
43 | MviAndroidViewModel(
44 | application,
45 | MainPreferenceUiState()
46 | ) {
47 | companion object {
48 | private const val CHECK_SERVICE_INTERVAL = 1000L
49 | }
50 |
51 | init {
52 | viewModelScope.launch {
53 | checkUnsupportedApp()
54 | }
55 |
56 | viewModelScope.launch {
57 | application.dataStore.data.collectLatest { pref ->
58 | val newMode = Mode.fromValue(pref[PREFERENCE_MODE])
59 | _uiState.getAndUpdate {
60 | it.copy(mode = newMode, iconStyle = IconStyle.fromValue(pref[PREFERENCE_ICON]))
61 | }
62 | when (newMode) {
63 | Nevo -> while (true) {
64 | updateServiceState(NevoDecorator.isRunning())
65 | delay(CHECK_SERVICE_INTERVAL)
66 | }
67 |
68 | Legacy -> while (true) {
69 | updateServiceState(NotificationMonitorService.isRunning())
70 | delay(CHECK_SERVICE_INTERVAL)
71 | }
72 | }
73 | }
74 | }
75 | }
76 |
77 | @SuppressLint("QueryPermissionsNeeded")
78 | internal suspend fun checkUnsupportedApp() {
79 | val ctx: Context = getApplication()
80 | val shouldShowWarning =
81 | ctx.dataStore.data.map {
82 | it[USAGE_SHOW_UNSUPPORTED_APP_WARNING]
83 | }.first() ?: USAGE_SHOW_UNSUPPORTED_APP_WARNING_DEFAULT
84 | && getApplication().packageManager.getInstalledApplications(0)
85 | .find {
86 | it.packageName == "com.tencent.qqlite" || it.packageName == "com.tencent.minihd.qq"
87 | } != null
88 | _uiState.getAndUpdate { it.copy(showUnsupportedAppWarning = shouldShowWarning) }
89 | }
90 |
91 | private fun updateServiceState(running: Boolean) {
92 | _uiState.getAndUpdate { old ->
93 | if (old.isServiceRunning == running) old else old.copy(isServiceRunning = running)
94 | }
95 | }
96 |
97 | override suspend fun handleViewIntent(intent: MainPreferenceIntent) {
98 | when (intent) {
99 | is MainPreferenceIntent.SetMode -> {
100 | getApplication().dataStore.edit { preferences ->
101 | preferences[PREFERENCE_MODE] = intent.newMode.v
102 | }
103 | }
104 |
105 | is MainPreferenceIntent.SetIconStyle -> {
106 | getApplication().dataStore.edit { preferences ->
107 | preferences[PREFERENCE_ICON] = intent.newIcon.v
108 | }
109 | }
110 |
111 | is MainPreferenceIntent.ShowNevoNotInstalledDialog -> {
112 | _uiState.getAndUpdate { it.copy(showNevoNotInstalledDialog = intent.show) }
113 | }
114 |
115 | MainPreferenceIntent.DismissUnsupportedAppWarning -> {
116 | getApplication().dataStore.edit {
117 | it[USAGE_SHOW_UNSUPPORTED_APP_WARNING] = false
118 | }
119 | _uiState.getAndUpdate { it.copy(showUnsupportedAppWarning = false) }
120 | }
121 | }
122 | }
123 |
124 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionViewModel.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.permission
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import android.os.PowerManager
6 | import android.provider.Settings
7 | import android.text.TextUtils
8 | import androidx.concurrent.futures.await
9 | import androidx.core.content.PackageManagerCompat
10 | import androidx.core.content.UnusedAppRestrictionsConstants
11 | import androidx.lifecycle.viewModelScope
12 | import cc.chenhe.qqnotifyevo.service.AccessibilityMonitorService
13 | import cc.chenhe.qqnotifyevo.ui.common.MviAndroidViewModel
14 | import cc.chenhe.qqnotifyevo.utils.Mode
15 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_MODE
16 | import cc.chenhe.qqnotifyevo.utils.dataStore
17 | import kotlinx.coroutines.flow.collectLatest
18 | import kotlinx.coroutines.flow.getAndUpdate
19 | import kotlinx.coroutines.flow.map
20 | import kotlinx.coroutines.launch
21 |
22 | data class PermissionUiState(
23 | val mode: Mode = Mode.Legacy,
24 | val ignoreBatteryOptimize: Boolean? = null,
25 | val unusedAppRestrictionsEnabled: Boolean? = null,
26 | val notificationAccess: Boolean? = null,
27 | val accessibility: Boolean? = null,
28 | )
29 |
30 | class PermissionViewModel(application: Application) :
31 | MviAndroidViewModel(application, PermissionUiState()) {
32 |
33 | init {
34 | viewModelScope.launch {
35 | application.dataStore.data.map { it[PREFERENCE_MODE] }.collectLatest { newMode ->
36 | _uiState.getAndUpdate { it.copy(mode = Mode.fromValue(newMode)) }
37 | }
38 | }
39 | }
40 |
41 | override suspend fun handleViewIntent(intent: Unit) {
42 | }
43 |
44 | suspend fun refreshPermissionState() {
45 | refreshNotificationAccessState()
46 | refreshAccessibilityState()
47 | refreshIgnoreBatteryOptimizationState()
48 | refreshUnusedAppRestrictionStatus()
49 | }
50 |
51 | private fun refreshNotificationAccessState() {
52 | val ctx: Context = getApplication()
53 | val s = Settings.Secure.getString(ctx.contentResolver, "enabled_notification_listeners")
54 | val notificationAccessOn = s != null && s.contains(ctx.packageName)
55 | _uiState.getAndUpdate { state ->
56 | state.takeIf { it.notificationAccess == notificationAccessOn }
57 | ?: state.copy(notificationAccess = notificationAccessOn)
58 | }
59 | }
60 |
61 | private fun refreshAccessibilityState() {
62 | val ctx: Context = getApplication()
63 | val accessibilityOn = isAccessibilitySettingsOn(ctx)
64 | _uiState.getAndUpdate { state ->
65 | state.takeIf { it.accessibility == accessibilityOn }
66 | ?: state.copy(accessibility = accessibilityOn)
67 | }
68 | }
69 |
70 | private fun isAccessibilitySettingsOn(context: Context): Boolean {
71 | val service =
72 | context.packageName + "/" + AccessibilityMonitorService::class.java.canonicalName
73 | val accessibilityEnabled = try {
74 | Settings.Secure.getInt(
75 | context.applicationContext.contentResolver,
76 | Settings.Secure.ACCESSIBILITY_ENABLED
77 | )
78 | } catch (e: Settings.SettingNotFoundException) {
79 | e.printStackTrace()
80 | 0
81 | }
82 | val mStringColonSplitter = TextUtils.SimpleStringSplitter(':')
83 | if (accessibilityEnabled == 1) {
84 | val settingValue = Settings.Secure.getString(
85 | context.applicationContext.contentResolver,
86 | Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
87 | )
88 | if (settingValue != null) {
89 | mStringColonSplitter.setString(settingValue)
90 | while (mStringColonSplitter.hasNext()) {
91 | val accessibilityService = mStringColonSplitter.next()
92 | if (accessibilityService.equals(service, ignoreCase = true)) {
93 | return true
94 | }
95 | }
96 | }
97 | }
98 | return false
99 | }
100 |
101 | private fun refreshIgnoreBatteryOptimizationState() {
102 | val ctx: Context = getApplication()
103 | val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
104 | val ignore = powerManager.isIgnoringBatteryOptimizations(ctx.packageName)
105 | _uiState.getAndUpdate { state ->
106 | state.takeIf { it.ignoreBatteryOptimize == ignore }
107 | ?: state.copy(ignoreBatteryOptimize = ignore)
108 | }
109 | }
110 |
111 | private suspend fun refreshUnusedAppRestrictionStatus() {
112 | val unusedAppRestrictionsStatus: Boolean? =
113 | when (PackageManagerCompat.getUnusedAppRestrictionsStatus(getApplication()).await()) {
114 | UnusedAppRestrictionsConstants.ERROR,
115 | UnusedAppRestrictionsConstants.FEATURE_NOT_AVAILABLE -> null
116 |
117 | UnusedAppRestrictionsConstants.DISABLED -> false
118 | UnusedAppRestrictionsConstants.API_30_BACKPORT,
119 | UnusedAppRestrictionsConstants.API_30,
120 | UnusedAppRestrictionsConstants.API_31 -> true
121 |
122 | else -> null
123 | }
124 | _uiState.getAndUpdate { it.copy(unusedAppRestrictionsEnabled = unusedAppRestrictionsStatus) }
125 | }
126 |
127 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/utils/PreferencesUtils.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.utils
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.content.pm.ApplicationInfo
6 | import android.content.pm.PackageManager
7 | import android.os.Build
8 | import androidx.annotation.StringRes
9 | import androidx.core.content.edit
10 | import androidx.datastore.core.DataStore
11 | import androidx.datastore.preferences.core.Preferences
12 | import androidx.datastore.preferences.core.booleanPreferencesKey
13 | import androidx.datastore.preferences.core.intPreferencesKey
14 | import androidx.datastore.preferences.core.longPreferencesKey
15 | import androidx.datastore.preferences.core.stringPreferencesKey
16 | import androidx.datastore.preferences.preferencesDataStore
17 | import androidx.preference.PreferenceManager
18 | import cc.chenhe.qqnotifyevo.R
19 |
20 | enum class Mode(val v: Int, @StringRes val strId: Int) {
21 | Nevo(1, R.string.pref_mode_nevo),
22 | Legacy(2, R.string.pref_mode_legacy);
23 |
24 | companion object {
25 | fun fromValue(v: Int?): Mode {
26 | return Mode.values().firstOrNull { it.v == v } ?: Legacy
27 | }
28 | }
29 | }
30 |
31 | enum class IconStyle(val v: Int, @StringRes val strId: Int) {
32 | Auto(0, R.string.pref_icon_mode_auto),
33 | QQ(1, R.string.pref_icon_mode_qq),
34 | TIM(2, R.string.pref_icon_mode_tim);
35 |
36 | companion object {
37 | fun fromValue(v: Int?): IconStyle {
38 | return values().firstOrNull { it.v == v } ?: Auto
39 | }
40 | }
41 | }
42 |
43 | enum class SpecialGroupChannel(val v: String, @StringRes val strId: Int) {
44 | Group("group", R.string.pref_advanced_special_group_channel_group),
45 | Special("special", R.string.pref_advanced_special_group_channel_special);
46 |
47 | companion object {
48 | fun fromValue(v: String?): SpecialGroupChannel {
49 | return values().firstOrNull { it.v == v } ?: PREFERENCE_SPECIAL_GROUP_CHANNEL_DEFAULT
50 | }
51 | }
52 | }
53 |
54 | enum class AvatarCacheAge(val v: Long, @StringRes val strId: Int) {
55 | TenMinute(600000, R.string.pref_acatar_cache_period_10min),
56 | OneDay(86400000, R.string.pref_acatar_cache_period_1day),
57 | SevenDay(604800000, R.string.pref_acatar_cache_period_7day);
58 |
59 | companion object {
60 | fun fromValue(v: Long?): AvatarCacheAge {
61 | return values().firstOrNull { it.v == v } ?: PREFERENCE_AVATAR_CACHE_AGE_DEFAULT
62 | }
63 | }
64 | }
65 |
66 |
67 | private fun sp(context: Context): SharedPreferences = PreferenceManager
68 | .getDefaultSharedPreferences(context.createDeviceProtectedStorageContext())
69 |
70 | // ---------------------------------------------------------
71 | // Tips
72 | // ---------------------------------------------------------
73 | private const val PREF_NEVO_MULTI_MSG_TIP = "tip_nevo_multi_msg"
74 |
75 |
76 | fun nevoMultiMsgTip(context: Context, shouldShow: Boolean) {
77 | sp(context).edit {
78 | putBoolean(PREF_NEVO_MULTI_MSG_TIP, shouldShow)
79 | }
80 | }
81 |
82 | fun nevoMultiMsgTip(context: Context): Boolean =
83 | sp(context).getBoolean(PREF_NEVO_MULTI_MSG_TIP, true)
84 |
85 |
86 | val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
87 |
88 | val PREFERENCE_MODE = intPreferencesKey("mode")
89 | val PREFERENCE_ICON = intPreferencesKey("icon")
90 | val PREFERENCE_SHOW_SPECIAL_PREFIX = booleanPreferencesKey("show_special_prefix")
91 | const val PREFERENCE_SHOW_SPECIAL_PREFIX_DEFAULT = false
92 | val PREFERENCE_SPECIAL_GROUP_CHANNEL = stringPreferencesKey("special_in_group_channel")
93 | val PREFERENCE_SPECIAL_GROUP_CHANNEL_DEFAULT = SpecialGroupChannel.Group
94 | val PREFERENCE_FORMAT_NICKNAME = booleanPreferencesKey("format_nickname")
95 | const val PREFERENCE_FORMAT_NICKNAME_DEFAULT = false
96 | val PREFERENCE_NICKNAME_FORMAT = stringPreferencesKey("format_nickname_format")
97 | const val PREFERENCE_NICKNAME_FORMAT_DEFAULT = "[\$n]"
98 | val PREFERENCE_AVATAR_CACHE_AGE = longPreferencesKey("avatar_cache_age")
99 | val PREFERENCE_AVATAR_CACHE_AGE_DEFAULT = AvatarCacheAge.OneDay
100 | val PREFERENCE_SHOW_IN_RECENT_APPS = booleanPreferencesKey("show_in_recent_apps")
101 | const val PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT = true
102 | val PREFERENCE_ENABLE_LOG = booleanPreferencesKey("enable_log")
103 | const val PREFERENCE_ENABLE_LOG_DEFAULT = false
104 |
105 | val USAGE_TIP_NEVO_MULTI_MESSAGE = booleanPreferencesKey("show_nevo_multi_message_tip")
106 | const val USAGE_TIP_NEVO_MULTI_MESSAGE_DEFAULT = true
107 | val USAGE_SHOW_UNSUPPORTED_APP_WARNING = booleanPreferencesKey("show_unsupported_app_warning")
108 | const val USAGE_SHOW_UNSUPPORTED_APP_WARNING_DEFAULT = true
109 |
110 |
111 | fun getAvatarCachePeriod(context: Context): Long {
112 | val s = sp(context).getString("avatar_cache_period", "0") ?: "0"
113 | return s.toLong()
114 | }
115 |
116 | fun getVersion(context: Context): String {
117 | var versionName = ""
118 | var versionCode = 0L
119 | var isApkInDebug = false
120 | try {
121 | val pi = context.packageManager.getPackageInfo(context.packageName, 0)
122 | versionName = pi?.versionName ?: "UNKNOWN"
123 | versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
124 | pi?.longVersionCode ?: 0
125 | } else {
126 | @Suppress("DEPRECATION")
127 | pi?.versionCode?.toLong() ?: 0
128 | }
129 | val info = context.applicationInfo
130 | isApkInDebug = info.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
131 | } catch (e: PackageManager.NameNotFoundException) {
132 | e.printStackTrace()
133 | }
134 | return "$versionName-${if (isApkInDebug) "debug" else "release"}($versionCode)"
135 | }
136 |
--------------------------------------------------------------------------------
/app/src/test/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolverTest.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import cc.chenhe.qqnotifyevo.utils.Tag
4 | import io.kotest.matchers.booleans.shouldBeFalse
5 | import io.kotest.matchers.booleans.shouldBeTrue
6 | import io.kotest.matchers.equals.shouldBeEqual
7 | import io.kotest.matchers.nulls.shouldNotBeNull
8 | import io.kotest.matchers.types.shouldBeTypeOf
9 | import org.junit.Before
10 | import org.junit.Test
11 |
12 | class QQNotificationResolverTest : BaseResolverTest() {
13 | private lateinit var resolver: QQNotificationResolver
14 |
15 | @Before
16 | fun setup() {
17 | resolver = QQNotificationResolver()
18 | }
19 |
20 | private fun resolve(data: NotificationData): QQNotification? {
21 | return resolver.resolveNotification(
22 | tag = Tag.QQ,
23 | title = data.title,
24 | content = data.content,
25 | ticker = data.ticker,
26 | )
27 | }
28 |
29 | // 私聊消息 -––––--––––---––––---––––---––––---––––---––––
30 |
31 | @Test
32 | fun private_normal() {
33 | val n = parse("""{"title":"咕咕咕","ticker":"咕咕咕: qqq","content":"123qqq"}""")
34 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
35 | r.nickname.shouldBeEqual("咕咕咕")
36 | r.message.shouldBeEqual(n.content!!)
37 | r.num.shouldBeEqual(1)
38 | r.special.shouldBeFalse()
39 | }
40 |
41 | @Test
42 | fun private_special_MultiMessage() {
43 | val n =
44 | parse("""{"title":"[特别关心]咕咕咕(2条新消息)","ticker":"[特别关心]咕咕咕(2条新消息): 222","content":"222"}""")
45 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
46 | r.nickname.shouldBeEqual("咕咕咕")
47 | r.message.shouldBeEqual(n.content!!)
48 | r.num.shouldBeEqual(2)
49 | r.special.shouldBeTrue()
50 | }
51 |
52 | @Test
53 | fun private_special() {
54 | val n =
55 | parse("""{"title":"[特别关心]咕咕咕","ticker":"[特别关心]咕咕咕: ok111","content":"ok111"}""")
56 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
57 | r.nickname.shouldBeEqual("咕咕咕")
58 | r.message.shouldBeEqual(n.content!!)
59 | r.num.shouldBeEqual(1)
60 | r.special.shouldBeTrue()
61 | }
62 |
63 | // 群聊消息 -––––--––––---––––---––––---––––---––––---––––
64 |
65 | @Test
66 | fun group_normal() {
67 | val n =
68 | parse("""{"title":"测试群","ticker":"测试群: 咕咕咕: from group","content":"咕咕咕: from group"}""")
69 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
70 | r.groupName.shouldBeEqual("测试群")
71 | r.nickname.shouldBeEqual("咕咕咕")
72 | r.message.shouldBeEqual("from group")
73 | r.num.shouldBeEqual(1)
74 | r.special.shouldBeFalse()
75 | }
76 |
77 | @Test
78 | fun group_multiMessage() {
79 | val n =
80 | parse("""{"title":"测试群(2条新消息)","ticker":"测试群(2条新消息): 咕咕咕: 2222","content":"咕咕咕: 2222"}""")
81 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
82 | r.groupName.shouldBeEqual("测试群")
83 | r.nickname.shouldBeEqual("咕咕咕")
84 | r.message.shouldBeEqual("2222")
85 | r.num.shouldBeEqual(2)
86 | r.special.shouldBeFalse()
87 | }
88 |
89 | @Test
90 | fun group_special_multiMessage() {
91 | val n =
92 | parse("""{"title":"测试群(3条新消息)","ticker":"测试群(3条新消息): [特别关心]咕咕咕: 333","content":"[特别关心]咕咕咕: 333"}""")
93 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
94 | r.groupName.shouldBeEqual("测试群")
95 | r.nickname.shouldBeEqual("咕咕咕")
96 | r.message.shouldBeEqual("333")
97 | r.num.shouldBeEqual(3)
98 | r.special.shouldBeTrue()
99 | }
100 |
101 | @Test
102 | fun group_special() {
103 | val n =
104 | parse("""{"title":"测试群","ticker":"测试群: [特别关心]咕咕咕: from group","content":"[特别关心]咕咕咕: from group"}""")
105 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
106 | r.groupName.shouldBeEqual("测试群")
107 | r.nickname.shouldBeEqual("咕咕咕")
108 | r.message.shouldBeEqual("from group")
109 | r.num.shouldBeEqual(1)
110 | r.special.shouldBeTrue()
111 | }
112 |
113 | // QQ 空间 -––––--––––---––––---––––---––––---––––---––––
114 |
115 | @Test
116 | fun qzone_specialPost() {
117 | val n =
118 | parse("""{"title":"QQ空间动态","ticker":"【特别关心】咕咕咕:QZone post","content":"【特别关心】咕咕咕:QZone post"}""")
119 | resolve(n).shouldNotBeNull().shouldBeTypeOf()
120 | }
121 |
122 | @Test
123 | fun qzone_message() {
124 | val n =
125 | parse("""{"title":"QQ空间动态(共1条未读)","ticker":"咕咕咕赞了你的说说","content":"咕咕咕赞了你的说说"}""")
126 | resolve(n).shouldNotBeNull().shouldBeTypeOf()
127 | }
128 |
129 | // 其他 -––––--––––---––––---––––---––––---––––---––––
130 |
131 | @Test
132 | fun hidden() {
133 | val n =
134 | parse(""" {"title":"QQ","ticker":"QQ: 你收到了1条新消息","content":"你收到了1条新消息"}""")
135 | resolve(n).shouldNotBeNull().shouldBeTypeOf()
136 | }
137 |
138 | @Test
139 | fun binding_multiMessage_multiLine() {
140 | val n =
141 | parse("""{"title":"关联QQ号 (3条新消息)","ticker":"关联QQ号-\/dev\/urandom:d\nd","content":"\/dev\/urandom:d\nd"}""")
142 | val r = resolve(n).shouldNotBeNull().shouldBeTypeOf()
143 | r.sender.shouldBeEqual("/dev/urandom")
144 | r.message.shouldBeEqual("d\nd")
145 | r.num.shouldBeEqual(3)
146 | }
147 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/TimNotificationResolver.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import cc.chenhe.qqnotifyevo.utils.Tag
4 | import timber.log.Timber
5 |
6 | /**
7 | * For com.tencent.tim ver 3.5.5.3198 build 1328.
8 | *
9 | * Doesn't support QZone notifications because TIM failed to post anything about QZone.
10 | */
11 | class TimNotificationResolver : NotificationResolver {
12 | companion object {
13 | private const val TAG = "TimNotificationResolver"
14 |
15 | // 隐藏消息详情
16 | // title: TIM
17 | // ticker: 你收到了x条新消息
18 | // text: 你收到了x条新消息
19 |
20 | private const val HIDE_MESSAGE_TITLE = "TIM"
21 | private val hideMsgTickerPattern = """^你收到了(?\d+)条新消息$""".toRegex()
22 |
23 | // 群聊消息
24 | // ------------- 单个消息
25 | // title: 群名
26 | // ticker: 昵称(群名):消息内容
27 | // text: [有关注的内容]昵称: 消息内容
28 | // ------------- 多个消息
29 | // title: 群名 (x条新消息)
30 | // ticker: 昵称(群名):消息内容
31 | // text: [有关注的内容]昵称: 消息内容
32 |
33 | /**
34 | * 匹配群聊消息 Ticker.
35 | *
36 | * 限制:昵称不能包含英文括号 `()`.
37 | */
38 | private val groupMsgPattern =
39 | """^(?.+?)\((?.+?)\):(?[\s\S]+)$""".toRegex()
40 |
41 | private val groupTitlePattern =
42 | """^(?.+?)(?: \((?\d+)条新消息\))?$""".toRegex()
43 |
44 | /**
45 | * 匹配群聊消息 Content.
46 | */
47 | private val groupMsgContentPattern =
48 | """^(?\[有关注的内容])?(?.+?): (?[\s\S]+)$""".toRegex()
49 |
50 | // 私聊消息
51 | // title: [特别关心]昵称 | [特别关心]昵称 (x条新消息)
52 | // ticker: 昵称: 消息内容
53 | // text: 消息内容
54 |
55 | private val privateTitlePattern =
56 | """^(?\[特别关心])?(?.+?)(?: \((?\d+)条新消息\))?$""".toRegex()
57 |
58 |
59 | // 关联QQ消息
60 | // title: 关联QQ号 | 关联QQ号 (x条新消息)
61 | // ticker: 关联QQ号-Sender:消息内容
62 | // text: Sender:消息内容
63 |
64 | private val bindingTitlePattern =
65 | """^关联QQ号(?: \((?\d+)条新消息\))?$""".toRegex()
66 |
67 | private val bindingTextPattern =
68 | """^(?.+?):(?[\s\S]+)$""".toRegex()
69 | }
70 |
71 | override fun resolveNotification(
72 | tag: Tag,
73 | title: String?,
74 | content: String?,
75 | ticker: String?
76 | ): QQNotification? {
77 | if (title.isNullOrEmpty() || content.isNullOrEmpty()) {
78 | return null
79 | }
80 | if (isHidden(title = title, ticker = ticker)) {
81 | return QQNotification.HiddenMessage(tag)
82 | }
83 |
84 | if (ticker == null) {
85 | Timber.tag(TAG).i("Ticker is null, skip")
86 | return null
87 | }
88 |
89 | tryResolveBindingMsg(tag, title, content)?.also { return it }
90 | tryResolveGroupMsg(tag, title, content, ticker)?.also { return it }
91 | tryResolvePrivateMsg(tag, title, content)?.also { return it }
92 |
93 | return null
94 | }
95 |
96 | private fun isHidden(title: String?, ticker: String?): Boolean {
97 | return title == HIDE_MESSAGE_TITLE && ticker != null
98 | && hideMsgTickerPattern.matchEntire(ticker) != null
99 | }
100 |
101 | private fun tryResolveGroupMsg(
102 | tag: Tag,
103 | title: String,
104 | content: String,
105 | ticker: String,
106 | ): QQNotification? {
107 | if (content.isEmpty() || ticker.isEmpty()) {
108 | return null
109 | }
110 | val tickerGroups = groupMsgPattern.matchEntire(ticker)?.groups ?: return null
111 | val titleGroups = groupTitlePattern.matchEntire(title)?.groups ?: return null
112 | val contentGroups = groupMsgContentPattern.matchEntire(content)?.groups ?: return null
113 | val name = tickerGroups["nickname"]?.value ?: return null
114 | val groupName = titleGroups["group"]?.value ?: return null
115 | val text = contentGroups["msg"]?.value ?: return null
116 | val special = contentGroups["sp"]?.value != null
117 | val num = titleGroups["num"]?.value?.toIntOrNull()
118 |
119 | return QQNotification.GroupMessage(
120 | tag = tag,
121 | groupName = groupName,
122 | nickname = name,
123 | message = text,
124 | special = special,
125 | num = num ?: 1,
126 | )
127 | }
128 |
129 | private fun tryResolvePrivateMsg(tag: Tag, title: String, content: String): QQNotification? {
130 | if (title.isEmpty() || content.isEmpty()) {
131 | return null
132 | }
133 | val titleGroups = privateTitlePattern.matchEntire(title)?.groups ?: return null
134 | val special = titleGroups["sp"] != null
135 | val name = titleGroups["nickname"]?.value ?: return null
136 | val num = titleGroups["num"]?.value?.toIntOrNull()
137 |
138 | return QQNotification.PrivateMessage(
139 | tag = tag,
140 | nickname = name,
141 | message = content,
142 | special = special,
143 | num = num ?: 1,
144 | )
145 | }
146 |
147 | private fun tryResolveBindingMsg(
148 | tag: Tag,
149 | title: String,
150 | content: String
151 | ): QQNotification? {
152 | val titleGroups = bindingTitlePattern.matchEntire(title)?.groups ?: return null
153 | val textGroups = bindingTextPattern.matchEntire(content)?.groups ?: return null
154 |
155 | val sender = textGroups["nickname"]?.value ?: return null
156 | val text = textGroups["msg"]?.value ?: return null
157 | val num = titleGroups["num"]?.value?.toIntOrNull()
158 | return QQNotification.BindingAccountMessage(tag, sender, text, num ?: 1)
159 | }
160 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/PermissionState.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.common.permission
2 |
3 | import android.Manifest
4 | import android.annotation.SuppressLint
5 | import android.content.pm.PackageManager
6 | import android.os.Build
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.Stable
9 | import androidx.compose.ui.platform.LocalContext
10 | import androidx.compose.ui.platform.LocalInspectionMode
11 | import androidx.core.app.ActivityCompat
12 | import androidx.core.app.NotificationManagerCompat
13 | import cc.chenhe.qqnotifyevo.utils.getActivity
14 |
15 | /**
16 | * Creates a [PermissionState] that is remembered across compositions.
17 | *
18 | * It's recommended that apps exercise the permissions workflow as described in the
19 | * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions).
20 | *
21 | * @param permission the permission to control and observe.
22 | * @param onPermissionResult will be called with whether or not the user granted the permission
23 | * after [PermissionState.launchPermissionRequest] is called.
24 | * @param onAlwaysDenied will be called if the user denied the permission and
25 | * `shouldShowRationale=false` after [PermissionState.launchPermissionRequest] is called.
26 | * It doesn't affect the calling of [onPermissionResult].
27 | * @param permissionChecker can custom the logic of permission checking.
28 | */
29 | @Composable
30 | fun rememberPermissionState(
31 | permission: String,
32 | onPermissionResult: (Boolean) -> Unit = {},
33 | onAlwaysDenied: () -> Unit = {},
34 | permissionChecker: ((permission: String) -> PermissionStatus)? = null
35 | ): PermissionState = rememberMutablePermissionState(
36 | permission = permission,
37 | onPermissionResult = onPermissionResult,
38 | onAlwaysDenied = onAlwaysDenied,
39 | permissionChecker = permissionChecker,
40 | )
41 |
42 | /**
43 | * Similar to [rememberPermissionState], but supports api level that below 33. If the system doesn't
44 | * support [Manifest.permission.POST_NOTIFICATIONS] permission, [NotificationManagerCompat.areNotificationsEnabled]
45 | * is used to check the permission status, and [PermissionStatus.Denied.shouldShowRationale] is always `false`.
46 | *
47 | * **Warning:** do not call [PermissionState.launchPermissionRequest] if api level is lower than 33.
48 | * You must implement your own logic instead, typically it should be like:
49 | * ```
50 | * val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
51 | * putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
52 | * }
53 | * context.startActivity(intent)
54 | * ```
55 | * The returned [PermissionState] will be updated automatically, just like the one returned by
56 | * [rememberPermissionState], regardless of the api level.
57 | */
58 | @SuppressLint("InlinedApi")
59 | @Composable
60 | fun rememberNotificationPermissionState(
61 | onPermissionResult: (Boolean) -> Unit = {},
62 | onAlwaysDenied: () -> Unit = {},
63 | ): PermissionState {
64 | val inspectMode = LocalInspectionMode.current
65 | val ctx = LocalContext.current
66 | return rememberMutablePermissionState(
67 | permission = Manifest.permission.POST_NOTIFICATIONS,
68 | onPermissionResult = onPermissionResult,
69 | onAlwaysDenied = onAlwaysDenied,
70 | alwaysRefreshPermissionStatus = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU,
71 | permissionChecker = { p ->
72 | val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
73 | ctx.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED
74 | } else {
75 | NotificationManagerCompat.from(ctx).areNotificationsEnabled()
76 | }
77 | if (hasPermission) {
78 | PermissionStatus.Granted
79 | } else {
80 | val shouldShowRationale =
81 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
82 | try {
83 | ctx.getActivity()
84 | } catch (e: IllegalStateException) {
85 | if (inspectMode) {
86 | null
87 | } else {
88 | throw e
89 | }
90 | }?.let { aty ->
91 | ActivityCompat.shouldShowRequestPermissionRationale(aty, p)
92 | } ?: false
93 | } else {
94 | false
95 | }
96 | PermissionStatus.Denied(shouldShowRationale)
97 | }
98 | },
99 | )
100 | }
101 |
102 | @Stable
103 | interface PermissionState {
104 | /**
105 | * The permission to control and observe.
106 | */
107 | val permission: String
108 |
109 |
110 | /**
111 | * [permission]'s status
112 | */
113 | var status: PermissionStatus
114 |
115 | val isGranted: Boolean get() = this.status == PermissionStatus.Granted
116 |
117 | /**
118 | * Request the [permission] to the user.
119 | *
120 | * This should always be triggered from non-composable scope, for example, from a side-effect
121 | * or a non-composable callback. Otherwise, this will result in an IllegalStateException.
122 | *
123 | * This triggers a system dialog that asks the user to grant or revoke the permission.
124 | * Note that this dialog might not appear on the screen if the user doesn't want to be asked
125 | * again or has denied the permission multiple times.
126 | * This behavior varies depending on the Android level API.
127 | */
128 | fun launchPermissionRequest()
129 | }
130 |
131 | @Stable
132 | sealed interface PermissionStatus {
133 | data object Granted : PermissionStatus
134 | data class Denied(
135 | val shouldShowRationale: Boolean
136 | ) : PermissionStatus
137 | }
138 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/utils/Utils.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.utils
2 |
3 | import android.app.NotificationChannel
4 | import android.app.NotificationManager
5 | import android.content.Context
6 | import android.media.AudioAttributes
7 | import android.media.RingtoneManager
8 | import android.os.Environment
9 | import cc.chenhe.qqnotifyevo.R
10 | import java.io.File
11 |
12 | //-----------------------------------------------------------
13 | // Intent Action
14 | //-----------------------------------------------------------
15 |
16 | const val ACTION_DELETE_NEVO_CHANNEL = "deleteNevoChannel"
17 |
18 | /** 不再显示关于 Nevo模式下遇到合并消息的使用提示。此 ACTION 用于注册静态接收器。 */
19 | const val ACTION_MULTI_MSG_DONT_SHOW = "dnotShowNevoMultiMsgTips"
20 |
21 | /** 应用更新迁移数据完成的广播。 */
22 | const val ACTION_APPLICATION_UPGRADE_COMPLETE = "applicationUpgradeComplete"
23 |
24 | // Android O+ 通知渠道 id
25 | const val NOTIFY_FRIEND_CHANNEL_ID = "QQ_Friend"
26 | const val NOTIFY_FRIEND_SPECIAL_CHANNEL_ID = "QQ_Friend_Special"
27 | const val NOTIFY_GROUP_CHANNEL_ID = "QQ_Group"
28 | const val NOTIFY_QZONE_CHANNEL_ID = "QQ_Zone"
29 | const val NOTIFY_SELF_TIPS_CHANNEL_ID = "Tips"
30 | const val NOTIFY_SELF_FOREGROUND_SERVICE_CHANNEL_ID = "ForegroundService"
31 |
32 | /** Nevo 模式下检测到合并消息的提示。 */
33 | const val NOTIFY_ID_MULTI_MSG = 100
34 |
35 | /** 升级前台服务的通知 */
36 | const val NOTIFY_ID_UPGRADE = 101
37 |
38 | // 自身转发QQ消息的通知渠道组
39 | const val NOTIFY_QQ_GROUP_ID = "base"
40 |
41 | const val GITHUB_URL = "https://github.com/ichenhe/QQ-Notify-Evolution/releases"
42 | const val MANUAL_URL = "https://github.com/ichenhe/QQ-Notify-Evolution/wiki"
43 |
44 |
45 | /**
46 | * 适配的应用包名列表。
47 | */
48 | val packageNameList: List
49 | get() = listOf(
50 | Tag.QQ.pkg,
51 | Tag.TIM.pkg,
52 | )
53 |
54 | /**
55 | * 通知渠道 id 列表。
56 | */
57 | val notificationChannelIdList: List
58 | get() = listOf(
59 | NOTIFY_FRIEND_CHANNEL_ID,
60 | NOTIFY_FRIEND_SPECIAL_CHANNEL_ID,
61 | NOTIFY_GROUP_CHANNEL_ID,
62 | NOTIFY_QZONE_CHANNEL_ID
63 | )
64 |
65 | fun getChannelId(channel: NotifyChannel): String = when (channel) {
66 | NotifyChannel.FRIEND -> NOTIFY_FRIEND_CHANNEL_ID
67 | NotifyChannel.FRIEND_SPECIAL -> NOTIFY_FRIEND_SPECIAL_CHANNEL_ID
68 | NotifyChannel.GROUP -> NOTIFY_GROUP_CHANNEL_ID
69 | NotifyChannel.QZONE -> NOTIFY_QZONE_CHANNEL_ID
70 | }
71 |
72 | /**
73 | * 创建通知渠道。仅创建渠道实例,未注册到系统。
74 | */
75 | fun getNotificationChannels(context: Context, nevo: Boolean): List {
76 | val prefix = if (nevo) context.getString(R.string.notify_nevo_prefix) else ""
77 |
78 | val att = AudioAttributes.Builder()
79 | .setUsage(AudioAttributes.USAGE_NOTIFICATION)
80 | .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
81 | .build()
82 |
83 | val friendChannel = NotificationChannel(
84 | NOTIFY_FRIEND_CHANNEL_ID,
85 | prefix + context.getString(R.string.notify_friend_channel_name),
86 | NotificationManager.IMPORTANCE_HIGH
87 | ).apply {
88 | description = context.getString(R.string.notify_friend_channel_des)
89 | setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), att)
90 | enableVibration(true)
91 | enableLights(true)
92 | }
93 |
94 | val friendSpecialChannel = NotificationChannel(
95 | NOTIFY_FRIEND_SPECIAL_CHANNEL_ID,
96 | prefix + context.getString(R.string.notify_friend_special_channel_name),
97 | NotificationManager.IMPORTANCE_HIGH
98 | ).apply {
99 | description = context.getString(R.string.notify_friend_special_channel_des)
100 | setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), att)
101 | enableVibration(true)
102 | enableLights(true)
103 | }
104 |
105 | val groupChannel = NotificationChannel(
106 | NOTIFY_GROUP_CHANNEL_ID,
107 | prefix + context.getString(R.string.notify_group_channel_name),
108 | NotificationManager.IMPORTANCE_HIGH
109 | ).apply {
110 | description = context.getString(R.string.notify_group_channel_des)
111 | setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), att)
112 | enableVibration(true)
113 | enableLights(true)
114 | }
115 |
116 | val qzoneChannel = NotificationChannel(
117 | NOTIFY_QZONE_CHANNEL_ID,
118 | prefix + context.getString(R.string.notify_qzone_channel_name),
119 | NotificationManager.IMPORTANCE_DEFAULT
120 | ).apply {
121 | description = context.getString(R.string.notify_qzone_channel_des)
122 | setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), att)
123 | enableLights(true)
124 | }
125 |
126 | return listOf(friendChannel, friendSpecialChannel, groupChannel, qzoneChannel)
127 | }
128 |
129 | private fun getCacheDir(context: Context): File {
130 | return if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
131 | || !Environment.isExternalStorageRemovable()
132 | ) {
133 | context.externalCacheDir ?: context.cacheDir
134 | } else {
135 | context.cacheDir
136 | }
137 | }
138 |
139 | private fun getDataDir(context: Context): File {
140 | return if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
141 | || !Environment.isExternalStorageRemovable()
142 | ) {
143 | context.getExternalFilesDir(null)!!
144 | } else {
145 | context.filesDir
146 | }
147 | }
148 |
149 | fun getAvatarDiskCacheDir(context: Context): File {
150 | return File(getCacheDir(context), "conversion_icon")
151 | }
152 |
153 | fun getLogDir(context: Context): File {
154 | return File(getDataDir(context), "log")
155 | }
156 |
157 | fun describeFileSize(size: Long): String {
158 | return if (size < 1000) {
159 | "${size}B"
160 | } else if (size < 1000 * 1000) {
161 | String.format("%.2fKB", size / 1000f)
162 | } else {
163 | String.format("%.2fMB", size / (1000 * 1000f))
164 | }
165 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/PreferenceComponent.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.common
2 |
3 | import androidx.compose.animation.animateContentSize
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.ColumnScope
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.RowScope
9 | import androidx.compose.foundation.layout.Spacer
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.size
14 | import androidx.compose.foundation.layout.width
15 | import androidx.compose.foundation.lazy.LazyColumn
16 | import androidx.compose.foundation.lazy.itemsIndexed
17 | import androidx.compose.material.icons.Icons
18 | import androidx.compose.material.icons.filled.Star
19 | import androidx.compose.material3.AlertDialog
20 | import androidx.compose.material3.Card
21 | import androidx.compose.material3.Divider
22 | import androidx.compose.material3.Icon
23 | import androidx.compose.material3.MaterialTheme
24 | import androidx.compose.material3.RadioButton
25 | import androidx.compose.material3.Text
26 | import androidx.compose.material3.TextButton
27 | import androidx.compose.material3.minimumInteractiveComponentSize
28 | import androidx.compose.runtime.Composable
29 | import androidx.compose.ui.Alignment
30 | import androidx.compose.ui.Modifier
31 | import androidx.compose.ui.draw.clip
32 | import androidx.compose.ui.graphics.vector.ImageVector
33 | import androidx.compose.ui.res.stringResource
34 | import androidx.compose.ui.tooling.preview.Preview
35 | import androidx.compose.ui.unit.dp
36 | import cc.chenhe.qqnotifyevo.R
37 | import cc.chenhe.qqnotifyevo.ui.theme.AppTheme
38 |
39 | @Composable
40 | @Preview(showBackground = true)
41 | private fun PreferencePreview() {
42 | AppTheme {
43 | PreferenceGroup(groupTitle = "常规") {
44 | PreferenceItem(
45 | title = "工作模式",
46 | icon = Icons.Default.Star,
47 | description = "传统",
48 | modifier = Modifier.fillMaxWidth()
49 | )
50 | PreferenceDivider()
51 | PreferenceItem(
52 | title = "工作模式",
53 | icon = Icons.Default.Star,
54 | description = "传统",
55 | modifier = Modifier.fillMaxWidth()
56 | )
57 | }
58 | }
59 | }
60 |
61 | @Composable
62 | internal fun PreferenceGroup(groupTitle: String?, content: @Composable ColumnScope.() -> Unit) {
63 | Column {
64 | if (groupTitle != null) {
65 | Text(
66 | text = groupTitle,
67 | style = MaterialTheme.typography.titleSmall,
68 | modifier = Modifier.padding(start = 24.dp, bottom = 8.dp),
69 | color = MaterialTheme.colorScheme.secondary
70 | )
71 | }
72 | Card(content = content, modifier = Modifier.animateContentSize())
73 | }
74 | }
75 |
76 | @Composable
77 | internal fun PreferenceGroupInterval() {
78 | Spacer(modifier = Modifier.height(12.dp))
79 | }
80 |
81 | @Composable
82 | internal fun PreferenceDivider() {
83 | Divider(modifier = Modifier.padding(horizontal = 48.dp))
84 | }
85 |
86 | @Composable
87 | internal fun PreferenceItem(
88 | title: String,
89 | modifier: Modifier = Modifier,
90 | icon: ImageVector? = null,
91 | description: String? = null,
92 | descriptionModifier: Modifier = Modifier,
93 | enabled: Boolean = true,
94 | onClick: () -> Unit = {},
95 | button: (@Composable RowScope.() -> Unit)? = null,
96 | ) {
97 | Row(
98 | modifier = modifier
99 | .clickable(enabled = enabled, onClick = onClick)
100 | .padding(16.dp), verticalAlignment = Alignment.CenterVertically
101 | ) {
102 | if (icon != null) {
103 | Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp))
104 | } else {
105 | Spacer(modifier = Modifier.size(24.dp))
106 | }
107 | Column(
108 | modifier = Modifier
109 | .weight(1f)
110 | .padding(start = 12.dp)
111 | ) {
112 | Text(text = title, style = MaterialTheme.typography.bodyLarge)
113 | if (description != null) {
114 | Text(
115 | text = description,
116 | color = MaterialTheme.colorScheme.onSurfaceVariant,
117 | style = MaterialTheme.typography.bodyMedium,
118 | modifier = descriptionModifier
119 | )
120 | }
121 |
122 | }
123 | if (button != null) {
124 | Spacer(modifier = Modifier.width(8.dp))
125 | button()
126 | }
127 | }
128 | }
129 |
130 | @Composable
131 | internal fun SingleSelectionDialog(
132 | icon: @Composable (() -> Unit)? = null,
133 | title: String,
134 | data: List,
135 | currentSelectedIndex: Int,
136 | onSelected: (Int) -> Unit,
137 | onDismiss: () -> Unit,
138 | onConfirm: () -> Unit,
139 | ) {
140 | AlertDialog(
141 | title = { Text(text = title) },
142 | icon = icon,
143 | text = {
144 | LazyColumn {
145 | itemsIndexed(data) { i, opt ->
146 | Row(
147 | verticalAlignment = Alignment.CenterVertically,
148 | modifier = Modifier
149 | .fillMaxWidth()
150 | .clip(MaterialTheme.shapes.small)
151 | .clickable { onSelected(i) }) {
152 | RadioButton(
153 | selected = i == currentSelectedIndex,
154 | onClick = null,
155 | Modifier.minimumInteractiveComponentSize()
156 | )
157 | Text(text = opt)
158 | }
159 | }
160 | }
161 | },
162 | onDismissRequest = onDismiss,
163 | confirmButton = {
164 | TextButton(onClick = { onConfirm() }) {
165 | Text(text = stringResource(id = R.string.confirm))
166 | }
167 | },
168 | dismissButton = {
169 | TextButton(onClick = onDismiss) {
170 | Text(text = stringResource(id = R.string.cancel))
171 | }
172 | }
173 | )
174 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/service/NevoDecorator.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.service
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.IntentFilter
8 | import android.os.IBinder
9 | import android.os.Process
10 | import android.service.notification.StatusBarNotification
11 | import androidx.lifecycle.Lifecycle
12 | import androidx.lifecycle.LifecycleOwner
13 | import androidx.lifecycle.LifecycleRegistry
14 | import androidx.lifecycle.lifecycleScope
15 | import cc.chenhe.qqnotifyevo.core.NevoNotificationProcessor
16 | import cc.chenhe.qqnotifyevo.utils.*
17 | import com.oasisfeng.nevo.sdk.MutableStatusBarNotification
18 | import com.oasisfeng.nevo.sdk.NevoDecoratorService
19 | import kotlinx.coroutines.flow.first
20 | import kotlinx.coroutines.launch
21 | import kotlinx.coroutines.runBlocking
22 | import timber.log.Timber
23 |
24 | class NevoDecorator : NevoDecoratorService(), LifecycleOwner {
25 |
26 | companion object {
27 | private var instance: NevoDecorator? = null
28 |
29 | fun isRunning(): Boolean {
30 | return try {
31 | // 如果服务被强制结束,标记没有释放,那么此处会抛出异常。
32 | instance?.ping() ?: false
33 | } catch (e: Exception) {
34 | false
35 | }
36 | }
37 | }
38 |
39 | private lateinit var lifecycleRegistry: LifecycleRegistry
40 | private lateinit var mode: Mode
41 |
42 | /**
43 | * 保存已创建过通知渠道的包名,尽力避免多次创建。
44 | */
45 | private lateinit var notificationChannelCreated: MutableSet
46 |
47 | private lateinit var processor: NevoNotificationProcessor
48 | private lateinit var receiver: Receiver
49 |
50 | private inner class Receiver : BroadcastReceiver() {
51 | override fun onReceive(context: Context?, i: Intent?) {
52 | if (i?.action == ACTION_DELETE_NEVO_CHANNEL) {
53 | deleteChannels()
54 | }
55 | }
56 | }
57 |
58 | override val lifecycle: Lifecycle
59 | get() = lifecycleRegistry
60 |
61 | @SuppressLint("WrongThread") // wrong report
62 | override fun onCreate() {
63 | super.onCreate()
64 | instance = this
65 | Timber.tag(TAG).v("Service - onCreate")
66 | lifecycleRegistry = LifecycleRegistry(this).apply { currentState = Lifecycle.State.CREATED }
67 | mode = runBlocking {
68 | Mode.fromValue(this@NevoDecorator.dataStore.data.first()[PREFERENCE_MODE])
69 | }
70 | lifecycleScope.launch {
71 | this@NevoDecorator.dataStore.data.collect { pref ->
72 | mode = Mode.fromValue(pref[PREFERENCE_MODE])
73 | }
74 | }
75 |
76 | receiver = Receiver()
77 | registerReceiver(receiver, IntentFilter(ACTION_DELETE_NEVO_CHANNEL))
78 |
79 | processor = NevoNotificationProcessor(this, lifecycleScope)
80 | }
81 |
82 | override fun onBind(intent: Intent?): IBinder? {
83 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
84 | return super.onBind(intent)
85 | }
86 |
87 | @Deprecated("Deprecated in Android")
88 | override fun onStart(intent: Intent?, startId: Int) {
89 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
90 | @Suppress("DEPRECATION")
91 | super.onStart(intent, startId)
92 | }
93 |
94 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
95 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
96 | return super.onStartCommand(intent, flags, startId)
97 | }
98 |
99 | override fun onDestroy() {
100 | Timber.tag(TAG).v("Service - onDestroy")
101 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
102 | super.onDestroy()
103 | instance = null
104 | if (::receiver.isInitialized) {
105 | unregisterReceiver(receiver)
106 | }
107 | }
108 |
109 | private fun ping() = true
110 |
111 | override fun onConnected() {
112 | super.onConnected()
113 | Timber.tag(TAG).d("Nevo connected")
114 | notificationChannelCreated = mutableSetOf()
115 |
116 | if (mode == Mode.Nevo) {
117 | createChannels(null)
118 | }
119 | }
120 |
121 | private fun deleteChannels() {
122 | packageNameList.forEach out@{ pkg ->
123 | notificationChannelIdList.forEach { id ->
124 | try {
125 | deleteNotificationChannel(pkg, Process.myUserHandle(), id)
126 | Timber.tag(TAG).d("Delete nevo notification channel, pkg=$pkg, channelId=$id")
127 | } catch (e: SecurityException) {
128 | return@out
129 | }
130 | }
131 | }
132 | }
133 |
134 | private fun createChannels(packageName: String?) {
135 | if (notificationChannelCreated.contains(packageName)) {
136 | return
137 | }
138 | Timber.tag(TAG).d("Register nevo notification channel for ${packageName ?: "All"}")
139 | if (packageName != null) {
140 | notificationChannelCreated.add(packageName)
141 | createNotificationChannels(
142 | packageName,
143 | Process.myUserHandle(),
144 | getNotificationChannels(this, true)
145 | )
146 | } else {
147 | packageNameList.forEach { pkg ->
148 | try {
149 | createNotificationChannels(
150 | pkg,
151 | Process.myUserHandle(),
152 | getNotificationChannels(this, true)
153 | )
154 | } catch (e: SecurityException) {
155 | Timber.tag(TAG).w(e, "Register nevo notification channel error.")
156 | }
157 | }
158 | }
159 | }
160 |
161 | override fun apply(evolving: MutableStatusBarNotification?): Boolean {
162 | Timber.tag(TAG).v("Detect notification from ${evolving?.packageName}.")
163 | if (mode != Mode.Nevo) {
164 | Timber.tag(TAG).d("Not in nevo mode, skip.")
165 | return false
166 | }
167 | if (evolving == null) {
168 | Timber.tag(TAG).w(" is null, skip.")
169 | return false
170 | }
171 |
172 | createChannels(evolving.packageName)
173 |
174 | val newNotification = processor.resolveNotification(this, evolving.packageName, evolving)
175 | if (newNotification == null) {
176 | Timber.tag(TAG).i("No need to evolve, skip.")
177 | return false
178 | }
179 |
180 | val mutable = evolving.notification
181 | mutable.extras = newNotification.extras
182 | mutable.channelId = newNotification.channelId
183 | mutable.number = newNotification.number
184 | mutable.`when` = newNotification.`when`
185 | mutable.smallIcon = newNotification.smallIcon
186 | mutable.color = newNotification.color
187 | return true
188 | }
189 |
190 | override fun onNotificationRemoved(sbn: StatusBarNotification?, reason: Int): Boolean {
191 | if (sbn == null || mode != Mode.Nevo) {
192 | return false
193 | }
194 | processor.onNotificationRemoved(sbn, reason)
195 | return false
196 | }
197 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/MutablePermissionState.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.common.permission
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.pm.PackageManager
6 | import android.os.SystemClock
7 | import androidx.activity.compose.rememberLauncherForActivityResult
8 | import androidx.activity.result.ActivityResultLauncher
9 | import androidx.activity.result.contract.ActivityResultContracts
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.DisposableEffect
12 | import androidx.compose.runtime.Stable
13 | import androidx.compose.runtime.getValue
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.runtime.setValue
17 | import androidx.compose.ui.platform.LocalContext
18 | import androidx.compose.ui.platform.LocalInspectionMode
19 | import androidx.compose.ui.platform.LocalLifecycleOwner
20 | import androidx.core.app.ActivityCompat
21 | import androidx.lifecycle.Lifecycle
22 | import androidx.lifecycle.LifecycleEventObserver
23 | import cc.chenhe.qqnotifyevo.utils.getActivity
24 |
25 | /**
26 | * If the time (ms) between requesting permission and being rejected is less than this threshold,
27 | * it may be permanently rejected.
28 | */
29 | private const val ALWAYS_DENY_THRESHOLD = 200
30 |
31 |
32 | /**
33 | * Creates a [MutablePermissionState] that is remembered across compositions.
34 | *
35 | * It's recommended that apps exercise the permissions workflow as described in the
36 | * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions).
37 | *
38 | * @param permission the permission to control and observe.
39 | * @param onPermissionResult will be called with whether or not the user granted the permission
40 | * after [PermissionState.launchPermissionRequest] is called.
41 | * @param onAlwaysDenied will be called if the user denied the permission and
42 | * `shouldShowRationale=false` after [PermissionState.launchPermissionRequest] is called.
43 | * It doesn't affect the calling of [onPermissionResult].
44 | * @param permissionChecker can custom the logic of permission checking.
45 | * @param alwaysRefreshPermissionStatus refresh the permission status, even if current status is
46 | * [PermissionStatus.Granted]. Normally it is unnecessary because denying a permission triggers a
47 | * process restart.
48 | */
49 | @Composable
50 | internal fun rememberMutablePermissionState(
51 | permission: String,
52 | onPermissionResult: (Boolean) -> Unit,
53 | onAlwaysDenied: () -> Unit,
54 | permissionChecker: ((permission: String) -> PermissionStatus)?,
55 | alwaysRefreshPermissionStatus: Boolean = false,
56 | ): MutablePermissionState {
57 | val ctx = LocalContext.current
58 | val inspectMode = LocalInspectionMode.current
59 | val permissionState = remember(permission, permissionChecker) {
60 | val activity = try {
61 | ctx.getActivity()
62 | } catch (e: IllegalStateException) {
63 | if (inspectMode) {
64 | null
65 | } else {
66 | throw e
67 | }
68 | }
69 | MutablePermissionState(permission, ctx, activity, permissionChecker)
70 | }
71 |
72 | // Refresh the permission status when the lifecycle is resumed
73 | PermissionLifecycleCheckerEffect(
74 | permissionState = permissionState,
75 | alwaysRefreshPermissionStatus = alwaysRefreshPermissionStatus
76 | )
77 |
78 | val launcher =
79 | rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {
80 | permissionState.refreshPermissionStatus()
81 | if (!it && !(permissionState.status as PermissionStatus.Denied).shouldShowRationale
82 | && SystemClock.elapsedRealtime() - permissionState.launchTime < ALWAYS_DENY_THRESHOLD
83 | ) {
84 | onAlwaysDenied()
85 | }
86 | onPermissionResult(it)
87 | }
88 | DisposableEffect(permissionState, launcher) {
89 | permissionState.launcher = launcher
90 | onDispose {
91 | permissionState.launcher = null
92 | }
93 | }
94 |
95 | return permissionState
96 | }
97 |
98 | /**
99 | * Effect that updates the `hasPermission` state of a revoked [MutablePermissionState] permission
100 | * when the lifecycle gets called with [lifecycleEvent].
101 | *
102 | * @param alwaysRefreshPermissionStatus refresh the permission status, even if current status is
103 | * [PermissionStatus.Granted]. Normally it is unnecessary because denying a permission triggers a
104 | * process restart.
105 | */
106 | @Composable
107 | internal fun PermissionLifecycleCheckerEffect(
108 | permissionState: MutablePermissionState,
109 | lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME,
110 | alwaysRefreshPermissionStatus: Boolean = false,
111 | ) {
112 | val observer = remember(permissionState) {
113 | LifecycleEventObserver { _, event ->
114 | if (event == lifecycleEvent) {
115 | // We don't check if the permission was denied as that triggers a process restart.
116 | if (alwaysRefreshPermissionStatus || permissionState.status != PermissionStatus.Granted) {
117 | permissionState.refreshPermissionStatus()
118 | }
119 | }
120 | }
121 | }
122 | val lifecycle = LocalLifecycleOwner.current.lifecycle
123 | DisposableEffect(key1 = lifecycle, observer) {
124 | lifecycle.addObserver(observer)
125 | onDispose {
126 | lifecycle.removeObserver(observer)
127 | }
128 | }
129 | }
130 |
131 | /**
132 | * A mutable state object that can be used to control and observe permission status changes.
133 | *
134 | * In most cases, this will be created via [rememberMutablePermissionState].
135 | *
136 | * @param permission the permission to control and observe.
137 | * @param context to check the status of the [permission].
138 | * @param activity to check if the user should be presented with a rationale for [permission].
139 | * should never be null unless in compose preview.
140 | * @param permissionChecker can custom the logic of permission checking.
141 | */
142 | @Stable
143 | internal class MutablePermissionState(
144 | override val permission: String,
145 | private val context: Context,
146 | private val activity: Activity?,
147 | private val permissionChecker: ((permission: String) -> PermissionStatus)?,
148 | ) : PermissionState {
149 | override var status: PermissionStatus by mutableStateOf(getPermissionStatus())
150 |
151 | internal var launcher: ActivityResultLauncher? = null
152 |
153 | internal var launchTime: Long = 0
154 | override fun launchPermissionRequest() {
155 | launchTime = SystemClock.elapsedRealtime()
156 | launcher?.launch(permission)
157 | ?: throw IllegalStateException("ActivityResultLauncher cannot be null")
158 | }
159 |
160 | internal fun refreshPermissionStatus() {
161 | status = getPermissionStatus()
162 | }
163 |
164 | private fun getPermissionStatus(): PermissionStatus {
165 | if (permissionChecker != null) {
166 | return permissionChecker.invoke(permission)
167 | }
168 | val hasPermission =
169 | context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
170 | return if (hasPermission) {
171 | PermissionStatus.Granted
172 | } else {
173 | if (activity == null) {
174 | PermissionStatus.Denied(false)
175 | } else {
176 | PermissionStatus.Denied(
177 | ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
178 | )
179 | }
180 | }
181 | }
182 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
84 |
85 | APP_NAME="Gradle"
86 | APP_BASE_NAME=${0##*/}
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | MAX_FD=$( ulimit -H -n ) ||
147 | warn "Could not query maximum file descriptor limit"
148 | esac
149 | case $MAX_FD in #(
150 | '' | soft) :;; #(
151 | *)
152 | ulimit -n "$MAX_FD" ||
153 | warn "Could not set maximum file descriptor limit to $MAX_FD"
154 | esac
155 | fi
156 |
157 | # Collect all arguments for the java command, stacking in reverse order:
158 | # * args from the command line
159 | # * the main class name
160 | # * -classpath
161 | # * -D...appname settings
162 | # * --module-path (only if needed)
163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
164 |
165 | # For Cygwin or MSYS, switch paths to Windows format before running java
166 | if "$cygwin" || "$msys" ; then
167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
169 |
170 | JAVACMD=$( cygpath --unix "$JAVACMD" )
171 |
172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
173 | for arg do
174 | if
175 | case $arg in #(
176 | -*) false ;; # don't mess with options #(
177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
178 | [ -e "$t" ] ;; #(
179 | *) false ;;
180 | esac
181 | then
182 | arg=$( cygpath --path --ignore --mixed "$arg" )
183 | fi
184 | # Roll the args list around exactly as many times as the number of
185 | # args, so each arg winds up back in the position where it started, but
186 | # possibly modified.
187 | #
188 | # NB: a `for` loop captures its iteration list before it begins, so
189 | # changing the positional parameters here affects neither the number of
190 | # iterations, nor the values presented in `arg`.
191 | shift # remove old arg
192 | set -- "$@" "$arg" # push replacement arg
193 | done
194 | fi
195 |
196 | # Collect all arguments for the java command;
197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
198 | # shell script including quotes and variable substitutions, so put them in
199 | # double quotes to make sure that they get re-expanded; and
200 | # * put everything else in single quotes, so that it's not re-expanded.
201 |
202 | set -- \
203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
204 | -classpath "$CLASSPATH" \
205 | org.gradle.wrapper.GradleWrapperMain \
206 | "$@"
207 |
208 | # Use "xargs" to parse quoted args.
209 | #
210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
211 | #
212 | # In Bash we could simply go:
213 | #
214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
215 | # set -- "${ARGS[@]}" "$@"
216 | #
217 | # but POSIX shell has neither arrays nor command substitution, so instead we
218 | # post-process each arg (as a line of input to sed) to backslash-escape any
219 | # character that might be a shell metacharacter, then use eval to reverse
220 | # that process (while maintaining the separation between arguments), and wrap
221 | # the whole thing up as a single "set" statement.
222 | #
223 | # This will of course break if any of these variables contains a newline or
224 | # an unmatched quote.
225 | #
226 |
227 | eval "set -- $(
228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
229 | xargs -n1 |
230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
231 | tr '\n' ' '
232 | )" '"$@"'
233 |
234 | exec "$JAVACMD" "$@"
235 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/core/QQNotificationResolver.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.core
2 |
3 | import cc.chenhe.qqnotifyevo.utils.Tag
4 | import timber.log.Timber
5 | import java.util.regex.Pattern
6 |
7 | /**
8 | * For com.tencent.mobileqq ver 8.9.85.12820 build 4766
9 | */
10 | class QQNotificationResolver : NotificationResolver {
11 | companion object {
12 | private const val TAG = "QQNotificationResolver"
13 | // Q空间动态
14 | // --------------- 说说评论/点赞
15 | // title: QQ空间动态(共1条未读)
16 | // ticker: XXX评论了你 | XXX赞了你的说说
17 | // content: XXX评论了你 | XXX赞了你的说说
18 |
19 | // --------------- 特别关心动态通知
20 | // title: QQ空间动态
21 | // ticker: 【特别关心】昵称:动态内容
22 | // content: 【特别关心】昵称:动态内容
23 |
24 | // 注意:与我相关动态、特别关心动态是两个独立的通知,不会互相覆盖。
25 |
26 | /**
27 | * 匹配 QQ 空间 Title.
28 | *
29 | * Group: 1新消息数目
30 | */
31 | private val qzoneTitlePattern: Pattern =
32 | Pattern.compile("^QQ空间动态(?:\\(共(\\d+)条未读\\))?$")
33 |
34 |
35 | // 隐藏消息详情
36 | // title: QQ
37 | // ticker: QQ: 你收到了x条新消息
38 | // text: 你收到了x条新消息
39 |
40 | /**
41 | * 匹配隐藏通知详情时的 Ticker.
42 | *
43 | * Group: 1新消息数目
44 | */
45 | private val hideMsgPattern: Pattern = Pattern.compile("^QQ: 你收到了(\\d+)条新消息$")
46 |
47 | // 群聊消息
48 | // ------------- 单个消息
49 | // title: 群名
50 | // ticker: 群名: [特别关心]昵称: 消息内容
51 | // text: [特别关心]昵称: 消息内容
52 | // ------------- 多个消息
53 | // title: 群名(x条新消息)
54 | // ticker: 群名(x条新消息): [特别关心]昵称: 消息内容
55 | // text: [特别关心]昵称: 消息内容
56 | // QQHD v5.8.8.3445 中群里特别关心前缀为 特别关注。
57 |
58 | /**
59 | * 匹配群聊消息 Ticker.
60 | *
61 | * 限制:昵称不能包含英文括号 `()`.
62 | */
63 | private val groupMsgPattern =
64 | """^(?.+?)(?:\((?\d+)条新消息\))?: (?\[特别关心])?(?.+?): (?[\s\S]+)$""".toRegex()
65 |
66 | /**
67 | * 匹配群聊消息 Content.
68 | *
69 | * QQHD v5.8.8.3445 中群里特别关心前缀为 特别关注。
70 | */
71 | private val groupMsgContentPattern =
72 | """^(?\[特别关心])?(?.+?): (?[\s\S]+)""".toRegex()
73 |
74 | // 私聊消息
75 | // title: [特别关心]昵称 | [特别关心]昵称(x条新消息)
76 | // ticker: [特别关心]昵称: 消息内容 | [特别关心]昵称(x条新消息): 消息内容
77 | // text: 消息内容
78 |
79 | /**
80 | * 匹配私聊消息 Ticker.
81 | *
82 | * Group: nickname-昵称, num-消息个数, msg-消息内容
83 | */
84 | private val msgPattern =
85 | """^(?\[特别关心])?(?.+?)(\((?\d+)条新消息\))?: (?[\s\S]+)$""".toRegex()
86 |
87 | // 关联QQ消息
88 | // title:
89 | // - 只有一条消息: 关联QQ号
90 | // - 一人发来多条消息: 关联QQ号 ({x}条新消息)
91 | // - 多人发来消息: QQ
92 | // ticker: 关联QQ号-{发送者昵称}:{消息内容}
93 | // content:
94 | // - 一人发来消息: {发送者昵称}:{消息内容}
95 | // - 多人发来消息: 有 {x} 个联系人给你发过来{y}条新消息
96 |
97 | /**
98 | * 匹配关联 QQ 消息 ticker.
99 | *
100 | * Group: 1发送者昵称, 2消息内容
101 | */
102 | private val bindingQQMsgTickerPattern: Pattern =
103 | Pattern.compile("^关联QQ号-(.+?):([\\s\\S]+)$")
104 |
105 | /**
106 | * 匹配关联 QQ 消息 content. 用于提取未读消息个数。
107 | *
108 | * Group: 1未读消息个数
109 | */
110 | private val bindingQQMsgContextPattern: Pattern =
111 | Pattern.compile("^有 \\d+ 个联系人给你发过来(\\d+)条新消息$")
112 |
113 | /**
114 | * 匹配关联 QQ 消息 title. 用于提取未读消息个数。
115 | *
116 | * Group: 1未读消息个数
117 | */
118 | private val bindingQQMsgTitlePattern: Pattern =
119 | Pattern.compile("^关联QQ号 \\((\\d+)条新消息\\)$")
120 | }
121 |
122 | override fun resolveNotification(
123 | tag: Tag,
124 | title: String?,
125 | content: String?,
126 | ticker: String?
127 | ): QQNotification? {
128 | if (title.isNullOrEmpty() || content.isNullOrEmpty()) {
129 | return null
130 | }
131 | if (isHidden(ticker)) {
132 | return QQNotification.HiddenMessage(tag)
133 | }
134 | tryResolveQZone(tag, title, content, ticker)?.also { return it }
135 |
136 | if (ticker == null) {
137 | Timber.tag(TAG).i("Ticker is null, skip")
138 | return null
139 | }
140 |
141 | tryResolveGroupMsg(tag, content, ticker)?.also { return it }
142 | tryResolvePrivateMsg(tag, content, ticker)?.also { return it }
143 | tryResolveBindingMsg(tag, title, content, ticker)?.also { return it }
144 |
145 | return null
146 | }
147 |
148 | private fun isHidden(ticker: String?): Boolean {
149 | return ticker != null && hideMsgPattern.matcher(ticker).matches()
150 | }
151 |
152 | private fun tryResolveQZone(
153 | tag: Tag,
154 | title: String,
155 | content: String,
156 | ticker: String?
157 | ): QQNotification? {
158 | if (ticker == null || !isQZone(title)) {
159 | return null
160 | }
161 | if (ticker.startsWith("【特别关心】")) {
162 | // 特别关心动态推送
163 | return QQNotification.QZoneSpecialPost(tag, content)
164 | }
165 | val num = matchQZoneNum(title)
166 | if (num != null) {
167 | // 普通空间通知
168 | return QQNotification.QZoneMessage(tag, content, num)
169 | }
170 | return null
171 | }
172 |
173 | private fun isQZone(title: String?): Boolean {
174 | return title?.let { qzoneTitlePattern.matcher(it).matches() } ?: false
175 | }
176 |
177 | /**
178 | * 提取空间未读消息个数。
179 | *
180 | * @return 动态未读消息个数。提取失败返回 `null`。
181 | */
182 | private fun matchQZoneNum(title: String): Int? {
183 | val matcher = qzoneTitlePattern.matcher(title)
184 | if (matcher.matches()) {
185 | return matcher.group(1)?.toIntOrNull()
186 | }
187 | return null
188 | }
189 |
190 | private fun tryResolveGroupMsg(tag: Tag, content: String, ticker: String): QQNotification? {
191 | if (content.isEmpty() || ticker.isEmpty()) {
192 | return null
193 | }
194 | val tickerGroups =
195 | groupMsgPattern.matchEntire(ticker)?.groups ?: return null
196 | val contentGroups =
197 | groupMsgContentPattern.matchEntire(content)?.groups ?: return null
198 | val name = tickerGroups["nickname"]?.value ?: return null
199 | val groupName = tickerGroups["name"]?.value ?: return null
200 | val num = tickerGroups["num"]?.value?.toIntOrNull()
201 | val text = contentGroups["msg"]?.value ?: return null
202 | val special = contentGroups["sp"]?.value != null
203 |
204 | return QQNotification.GroupMessage(
205 | tag = tag,
206 | groupName = groupName,
207 | nickname = name,
208 | message = text,
209 | special = special,
210 | num = num ?: 1,
211 | )
212 | }
213 |
214 | private fun tryResolvePrivateMsg(tag: Tag, content: String, ticker: String): QQNotification? {
215 | if (ticker.isEmpty() || content.isEmpty()) {
216 | return null
217 | }
218 | val tickerGroups = msgPattern.matchEntire(ticker)?.groups ?: return null
219 | val special = tickerGroups["sp"] != null
220 | val name = tickerGroups["nickname"]?.value ?: return null
221 | val num = tickerGroups["num"]?.value?.toIntOrNull()
222 |
223 | return QQNotification.PrivateMessage(
224 | tag = tag,
225 | nickname = name,
226 | message = content,
227 | special = special,
228 | num = num ?: 1,
229 | )
230 | }
231 |
232 | private fun tryResolveBindingMsg(
233 | tag: Tag,
234 | title: String,
235 | content: String,
236 | ticker: String
237 | ): QQNotification? {
238 | val matcher = bindingQQMsgTickerPattern.matcher(ticker)
239 | if (!matcher.matches()) {
240 | return null
241 | }
242 |
243 | val sender = matcher.group(1) ?: return null
244 | val text = matcher.group(2) ?: return null
245 | val num = matchBindingMsgNum(title, content)
246 | return QQNotification.BindingAccountMessage(tag, sender, text, num)
247 | }
248 |
249 | /**
250 | * 提取关联账号的未读消息个数。
251 | */
252 | private fun matchBindingMsgNum(title: String?, content: String?): Int {
253 | if (title == null || content == null) return 1
254 | if (title == "QQ") {
255 | bindingQQMsgContextPattern.matcher(content).also { matcher ->
256 | if (matcher.matches()) {
257 | return matcher.group(1)?.toInt() ?: 1
258 | }
259 | }
260 | } else {
261 | bindingQQMsgTitlePattern.matcher(title).also { matcher ->
262 | if (matcher.matches()) {
263 | return matcher.group(1)?.toInt() ?: 1
264 | }
265 | }
266 | }
267 | return 1
268 | }
269 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/advanced/AdvancedOptionsViewModel.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui.advanced
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import android.content.Intent
6 | import androidx.datastore.preferences.core.Preferences
7 | import androidx.datastore.preferences.core.edit
8 | import androidx.lifecycle.viewModelScope
9 | import cc.chenhe.qqnotifyevo.MyApplication
10 | import cc.chenhe.qqnotifyevo.R
11 | import cc.chenhe.qqnotifyevo.core.AvatarManager
12 | import cc.chenhe.qqnotifyevo.ui.common.MviAndroidViewModel
13 | import cc.chenhe.qqnotifyevo.utils.ACTION_DELETE_NEVO_CHANNEL
14 | import cc.chenhe.qqnotifyevo.utils.AvatarCacheAge
15 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_AVATAR_CACHE_AGE
16 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_AVATAR_CACHE_AGE_DEFAULT
17 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ENABLE_LOG
18 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ENABLE_LOG_DEFAULT
19 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_FORMAT_NICKNAME
20 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_FORMAT_NICKNAME_DEFAULT
21 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_NICKNAME_FORMAT
22 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_NICKNAME_FORMAT_DEFAULT
23 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_IN_RECENT_APPS
24 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT
25 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_SPECIAL_PREFIX
26 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_SPECIAL_PREFIX_DEFAULT
27 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SPECIAL_GROUP_CHANNEL
28 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SPECIAL_GROUP_CHANNEL_DEFAULT
29 | import cc.chenhe.qqnotifyevo.utils.SpecialGroupChannel
30 | import cc.chenhe.qqnotifyevo.utils.USAGE_TIP_NEVO_MULTI_MESSAGE
31 | import cc.chenhe.qqnotifyevo.utils.USAGE_TIP_NEVO_MULTI_MESSAGE_DEFAULT
32 | import cc.chenhe.qqnotifyevo.utils.dataStore
33 | import cc.chenhe.qqnotifyevo.utils.describeFileSize
34 | import cc.chenhe.qqnotifyevo.utils.getAvatarCachePeriod
35 | import cc.chenhe.qqnotifyevo.utils.getAvatarDiskCacheDir
36 | import cc.chenhe.qqnotifyevo.utils.getLogDir
37 | import kotlinx.coroutines.delay
38 | import kotlinx.coroutines.flow.collectLatest
39 | import kotlinx.coroutines.flow.getAndUpdate
40 | import kotlinx.coroutines.launch
41 |
42 | data class AdvancedOptionsUiState(
43 | val specialPrefix: Boolean = PREFERENCE_SHOW_SPECIAL_PREFIX_DEFAULT,
44 | val specialInGroupChannel: SpecialGroupChannel = PREFERENCE_SPECIAL_GROUP_CHANNEL_DEFAULT,
45 | val formatNickname: Boolean = PREFERENCE_FORMAT_NICKNAME_DEFAULT,
46 | val nicknameFormat: String = PREFERENCE_NICKNAME_FORMAT_DEFAULT,
47 | val avatarCacheAge: AvatarCacheAge = PREFERENCE_AVATAR_CACHE_AGE_DEFAULT,
48 | val deleteAvatarCacheDone: Boolean = false,
49 | val deleteNevoChannelDone: Boolean = false,
50 | val resetUsageTipDone: Boolean = false,
51 | val showInRecentApps: Boolean = PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT,
52 | val enableLog: Boolean = PREFERENCE_ENABLE_LOG_DEFAULT,
53 | val logSize: String = "",
54 | )
55 |
56 | sealed interface AdvancedOptionsIntent {
57 | data class SetSpecialPrefix(val showPrefix: Boolean) : AdvancedOptionsIntent
58 | data class SetSpecialGroupChannel(val v: SpecialGroupChannel) : AdvancedOptionsIntent
59 | data class SetFormatNickname(val v: Boolean) : AdvancedOptionsIntent
60 | data class SetNicknameFormat(val format: String) : AdvancedOptionsIntent
61 | data class SetAvatarCacheAge(val avatarCacheAge: AvatarCacheAge) : AdvancedOptionsIntent
62 | data object DeleteAvatarCache : AdvancedOptionsIntent
63 | data object DeleteNevoChannel : AdvancedOptionsIntent
64 | data object ResetUsageTips : AdvancedOptionsIntent
65 | data class SetShowInRecentApps(val v: Boolean) : AdvancedOptionsIntent
66 | data class SetEnableLog(val v: Boolean) : AdvancedOptionsIntent
67 | data object DeleteLog : AdvancedOptionsIntent
68 | }
69 |
70 | class AdvancedOptionsViewModel(application: Application) :
71 | MviAndroidViewModel(
72 | application,
73 | AdvancedOptionsUiState()
74 | ) {
75 | companion object {
76 | private const val TOAST_DURATION = 2000L
77 | }
78 |
79 | init {
80 | viewModelScope.launch {
81 | application.dataStore.data.collectLatest { prefs ->
82 | updateUiStateFromPreferences(prefs)
83 | }
84 | }
85 | viewModelScope.launch {
86 | _uiState.getAndUpdate { it.copy(logSize = calculateLogSize()) }
87 | }
88 | }
89 |
90 | private fun updateUiStateFromPreferences(prefs: Preferences) {
91 | _uiState.getAndUpdate {
92 | it.copy(
93 | specialPrefix = prefs[PREFERENCE_SHOW_SPECIAL_PREFIX]
94 | ?: PREFERENCE_SHOW_SPECIAL_PREFIX_DEFAULT,
95 | specialInGroupChannel = SpecialGroupChannel.fromValue(prefs[PREFERENCE_SPECIAL_GROUP_CHANNEL]),
96 | formatNickname = prefs[PREFERENCE_FORMAT_NICKNAME]
97 | ?: PREFERENCE_FORMAT_NICKNAME_DEFAULT,
98 | nicknameFormat = prefs[PREFERENCE_NICKNAME_FORMAT]
99 | ?: PREFERENCE_NICKNAME_FORMAT_DEFAULT,
100 | avatarCacheAge = AvatarCacheAge.fromValue(prefs[PREFERENCE_AVATAR_CACHE_AGE]),
101 | showInRecentApps = prefs[PREFERENCE_SHOW_IN_RECENT_APPS]
102 | ?: PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT,
103 | enableLog = prefs[PREFERENCE_ENABLE_LOG] ?: PREFERENCE_ENABLE_LOG_DEFAULT,
104 | )
105 | }
106 | }
107 |
108 | override suspend fun handleViewIntent(intent: AdvancedOptionsIntent) {
109 | val ctx: Context = getApplication()
110 | when (intent) {
111 | is AdvancedOptionsIntent.SetSpecialPrefix ->
112 | setBoolPreference(PREFERENCE_SHOW_SPECIAL_PREFIX, intent.showPrefix)
113 |
114 | is AdvancedOptionsIntent.SetSpecialGroupChannel -> {
115 | getApplication().dataStore.edit {
116 | it[PREFERENCE_SPECIAL_GROUP_CHANNEL] = intent.v.v
117 | }
118 | }
119 |
120 | is AdvancedOptionsIntent.SetFormatNickname ->
121 | setBoolPreference(PREFERENCE_FORMAT_NICKNAME, intent.v)
122 |
123 | is AdvancedOptionsIntent.SetNicknameFormat -> {
124 | getApplication().dataStore.edit {
125 | it[PREFERENCE_NICKNAME_FORMAT] = intent.format
126 | }
127 | }
128 |
129 | is AdvancedOptionsIntent.SetAvatarCacheAge -> {
130 | getApplication().dataStore.edit {
131 | it[PREFERENCE_AVATAR_CACHE_AGE] = intent.avatarCacheAge.v
132 | }
133 | }
134 |
135 | AdvancedOptionsIntent.DeleteAvatarCache -> {
136 | AvatarManager
137 | .get(getAvatarDiskCacheDir(ctx), getAvatarCachePeriod(ctx))
138 | .clearCache()
139 | _uiState.getAndUpdate { it.copy(deleteAvatarCacheDone = true) }
140 | viewModelScope.launch {
141 | delay(TOAST_DURATION)
142 | _uiState.getAndUpdate { it.copy(deleteAvatarCacheDone = false) }
143 | }
144 | }
145 |
146 | AdvancedOptionsIntent.DeleteNevoChannel -> {
147 | ctx.sendBroadcast(Intent(ACTION_DELETE_NEVO_CHANNEL))
148 | _uiState.getAndUpdate { it.copy(deleteNevoChannelDone = true) }
149 | viewModelScope.launch {
150 | delay(TOAST_DURATION)
151 | _uiState.getAndUpdate { it.copy(deleteNevoChannelDone = false) }
152 | }
153 | }
154 |
155 | AdvancedOptionsIntent.ResetUsageTips -> {
156 | setBoolPreference(
157 | USAGE_TIP_NEVO_MULTI_MESSAGE,
158 | USAGE_TIP_NEVO_MULTI_MESSAGE_DEFAULT
159 | )
160 | _uiState.getAndUpdate { it.copy(resetUsageTipDone = true) }
161 | viewModelScope.launch {
162 | delay(TOAST_DURATION)
163 | _uiState.getAndUpdate { it.copy(resetUsageTipDone = false) }
164 | }
165 | }
166 |
167 | is AdvancedOptionsIntent.SetShowInRecentApps ->
168 | setBoolPreference(PREFERENCE_SHOW_IN_RECENT_APPS, intent.v)
169 |
170 | is AdvancedOptionsIntent.SetEnableLog ->
171 | setBoolPreference(PREFERENCE_ENABLE_LOG, intent.v)
172 |
173 | AdvancedOptionsIntent.DeleteLog -> {
174 | getApplication().deleteLog()
175 | _uiState.getAndUpdate { it.copy(logSize = calculateLogSize()) }
176 | }
177 | }
178 | }
179 |
180 | private suspend fun setBoolPreference(key: Preferences.Key, v: Boolean) {
181 | getApplication().dataStore.edit {
182 | it[key] = v
183 | }
184 | }
185 |
186 | private fun calculateLogSize(): String {
187 | val files = getLogDir(getApplication()).listFiles { f -> f.isFile }
188 | val size = files?.sumOf { f -> f.length() } ?: 0
189 | return getApplication().getString(
190 | R.string.pref_delete_log_summary,
191 | files?.size ?: 0,
192 | describeFileSize(size)
193 | )
194 | }
195 | }
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/ui/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.ui
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import android.os.Bundle
8 | import androidx.activity.ComponentActivity
9 | import androidx.activity.compose.setContent
10 | import androidx.activity.viewModels
11 | import androidx.compose.animation.core.tween
12 | import androidx.compose.animation.fadeIn
13 | import androidx.compose.animation.fadeOut
14 | import androidx.compose.animation.slideInHorizontally
15 | import androidx.compose.animation.slideOutHorizontally
16 | import androidx.compose.foundation.layout.Box
17 | import androidx.compose.foundation.layout.Column
18 | import androidx.compose.foundation.layout.Row
19 | import androidx.compose.foundation.layout.fillMaxSize
20 | import androidx.compose.foundation.layout.fillMaxWidth
21 | import androidx.compose.foundation.layout.padding
22 | import androidx.compose.material.icons.Icons
23 | import androidx.compose.material.icons.rounded.Info
24 | import androidx.compose.material3.AlertDialog
25 | import androidx.compose.material3.ExperimentalMaterial3Api
26 | import androidx.compose.material3.Icon
27 | import androidx.compose.material3.LinearProgressIndicator
28 | import androidx.compose.material3.MaterialTheme
29 | import androidx.compose.material3.Scaffold
30 | import androidx.compose.material3.Text
31 | import androidx.compose.material3.TextButton
32 | import androidx.compose.material3.TopAppBar
33 | import androidx.compose.runtime.Composable
34 | import androidx.compose.runtime.DisposableEffect
35 | import androidx.compose.runtime.getValue
36 | import androidx.compose.runtime.mutableStateOf
37 | import androidx.compose.runtime.remember
38 | import androidx.compose.runtime.setValue
39 | import androidx.compose.ui.Alignment
40 | import androidx.compose.ui.Modifier
41 | import androidx.compose.ui.platform.LocalContext
42 | import androidx.compose.ui.res.stringResource
43 | import androidx.compose.ui.text.style.TextAlign
44 | import androidx.compose.ui.tooling.preview.Preview
45 | import androidx.compose.ui.unit.dp
46 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
47 | import androidx.localbroadcastmanager.content.LocalBroadcastManager
48 | import androidx.navigation.compose.NavHost
49 | import androidx.navigation.compose.composable
50 | import androidx.navigation.compose.rememberNavController
51 | import cc.chenhe.qqnotifyevo.R
52 | import cc.chenhe.qqnotifyevo.StaticReceiver
53 | import cc.chenhe.qqnotifyevo.service.UpgradeService
54 | import cc.chenhe.qqnotifyevo.ui.advanced.AdvancedOptionsScreen
55 | import cc.chenhe.qqnotifyevo.ui.main.MainPreferenceScreen
56 | import cc.chenhe.qqnotifyevo.ui.permission.PermissionScreen
57 | import cc.chenhe.qqnotifyevo.ui.theme.AppTheme
58 | import cc.chenhe.qqnotifyevo.utils.ACTION_APPLICATION_UPGRADE_COMPLETE
59 | import cc.chenhe.qqnotifyevo.utils.ACTION_MULTI_MSG_DONT_SHOW
60 |
61 | class MainActivity : ComponentActivity() {
62 | companion object {
63 | /**
64 | * 由 Nevo 模式下检测到合并消息所发出使用提示的通知跳转过来。
65 | *
66 | * 值为 [Boolean] 类型 = true.
67 | */
68 | const val EXTRA_NEVO_MULTI_MSG = "nevo_multi_msg"
69 |
70 | private const val ENTER_TRANSACTION = 200
71 | private const val EXIT_TRANSACTION = 200
72 | }
73 |
74 | private val viewModel: MainViewModel by viewModels()
75 |
76 | override fun onCreate(savedInstanceState: Bundle?) {
77 | super.onCreate(savedInstanceState)
78 | val startUpgradeService = UpgradeService.startIfNecessary(this)
79 | setContent {
80 | val ctx = LocalContext.current
81 | var upgrading by remember {
82 | mutableStateOf(startUpgradeService || UpgradeService.isRunningOrPrepared())
83 | }
84 | DisposableEffect(key1 = Unit) {
85 | val receiver = object : BroadcastReceiver() {
86 | override fun onReceive(ctx: Context, i: Intent?) {
87 | if (i?.action == ACTION_APPLICATION_UPGRADE_COMPLETE) {
88 | upgrading = false
89 | }
90 | }
91 | }
92 | LocalBroadcastManager.getInstance(ctx)
93 | .registerReceiver(receiver, IntentFilter(ACTION_APPLICATION_UPGRADE_COMPLETE))
94 | // 避免极端情况下在注册监听器之前更新完成
95 | upgrading = UpgradeService.isRunningOrPrepared()
96 | onDispose {
97 | LocalBroadcastManager.getInstance(ctx).unregisterReceiver(receiver)
98 | }
99 | }
100 |
101 | AppTheme {
102 | if (upgrading) {
103 | MigratingData()
104 | } else {
105 | Frame(viewModel)
106 | }
107 | }
108 | }
109 |
110 | showNevoMultiMsgDialogIfNeeded(intent)
111 | }
112 |
113 | override fun onNewIntent(newIntent: Intent?) {
114 | super.onNewIntent(newIntent)
115 | showNevoMultiMsgDialogIfNeeded(newIntent)
116 | }
117 |
118 | private fun showNevoMultiMsgDialogIfNeeded(intent: Intent?) {
119 | if (intent?.extras?.getBoolean(EXTRA_NEVO_MULTI_MSG, false) == true) {
120 | viewModel.sendIntent(MainViewIntent.ShowMultiMessageWarning(true))
121 | }
122 | }
123 |
124 | @Composable
125 | private fun Frame(modelView: MainViewModel) {
126 | val navController = rememberNavController()
127 | val uiState by modelView.uiState.collectAsStateWithLifecycle()
128 | NavHost(
129 | navController = navController, startDestination = "main",
130 | enterTransition = {
131 | slideInHorizontally(tween(ENTER_TRANSACTION), initialOffsetX = { it }) +
132 | fadeIn(animationSpec = tween(ENTER_TRANSACTION))
133 | },
134 | exitTransition = {
135 | fadeOut(tween(ENTER_TRANSACTION), targetAlpha = 0.5f)
136 | },
137 | popEnterTransition = {
138 | fadeIn(tween(EXIT_TRANSACTION))
139 | },
140 | popExitTransition = {
141 | slideOutHorizontally(tween(EXIT_TRANSACTION), targetOffsetX = { it }) +
142 | fadeOut(animationSpec = tween(EXIT_TRANSACTION))
143 | },
144 | ) {
145 | composable("main") {
146 | MainPreferenceScreen(
147 | navigateToPermissionScreen = { navController.navigate("permission") },
148 | navigateToAdvancedOptionsScreen = { navController.navigate("advancedOptions") },
149 | )
150 | }
151 | composable("permission") {
152 | PermissionScreen(navigateUp = { navController.navigateUp() })
153 | }
154 | composable("advancedOptions") {
155 | AdvancedOptionsScreen(navigateUp = { navController.navigateUp() })
156 | }
157 | }
158 |
159 | if (uiState.showMultiMessageWarning) {
160 | val ctx = LocalContext.current
161 | AlertDialog(
162 | title = { Text(text = stringResource(id = R.string.tip)) },
163 | icon = { Icon(Icons.Rounded.Info, contentDescription = null) },
164 | text = { Text(text = stringResource(id = R.string.multi_msg_dialog)) },
165 | confirmButton = {
166 | TextButton(onClick = {
167 | viewModel.sendIntent(MainViewIntent.ChangeToLegacyMode)
168 | dismissMultiMessageDialog()
169 | }) {
170 | Text(text = stringResource(id = R.string.multi_msg_dialog_positive))
171 | }
172 | },
173 | dismissButton = {
174 | Row {
175 | // 下次再说
176 | TextButton(onClick = { dismissMultiMessageDialog() }) {
177 | Text(text = stringResource(id = R.string.multi_msg_dialog_neutral))
178 | }
179 | // 不再提示
180 | TextButton(onClick = {
181 | Intent(this@MainActivity, StaticReceiver::class.java).also {
182 | it.action = ACTION_MULTI_MSG_DONT_SHOW
183 | }.also { intent -> ctx.sendBroadcast(intent) }
184 | dismissMultiMessageDialog()
185 | }) {
186 | Text(text = stringResource(id = R.string.dont_show))
187 | }
188 | }
189 | },
190 | onDismissRequest = { dismissMultiMessageDialog() },
191 | )
192 | }
193 | }
194 |
195 | @OptIn(ExperimentalMaterial3Api::class)
196 | @Composable
197 | @Preview
198 | private fun MigratingData() {
199 | Scaffold(topBar = {
200 | TopAppBar(title = { Text(text = stringResource(id = R.string.activity_splash)) })
201 | }) { padding ->
202 | Box(
203 | modifier = Modifier
204 | .padding(padding)
205 | .fillMaxSize(),
206 | contentAlignment = Alignment.Center,
207 | ) {
208 | Column(horizontalAlignment = Alignment.CenterHorizontally) {
209 | LinearProgressIndicator(
210 | modifier = Modifier
211 | .fillMaxWidth()
212 | .padding(horizontal = 64.dp)
213 | )
214 | Text(
215 | text = stringResource(id = R.string.upgrade_message),
216 | style = MaterialTheme.typography.titleSmall,
217 | textAlign = TextAlign.Center,
218 | modifier = Modifier.padding(top = 16.dp)
219 | )
220 | }
221 | }
222 | }
223 | }
224 |
225 | private fun dismissMultiMessageDialog() {
226 | viewModel.sendIntent(MainViewIntent.ShowMultiMessageWarning(false))
227 | }
228 |
229 | @Deprecated("Deprecated in Java")
230 | override fun onBackPressed() {
231 | if (viewModel.showInRecent) {
232 | @Suppress("DEPRECATION") // should call super
233 | super.onBackPressed()
234 | } else {
235 | finishAndRemoveTask()
236 | }
237 | }
238 |
239 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | 确定
3 | 取消
4 | 提示
5 | 不再提示
6 | 了解详情
7 | [特别关心]
8 | [特别关注]
9 |
10 | 企鹅通知进化
11 | 企鹅通知进化-通知监听服务
12 | 企鹅通知进化
13 | 企鹅通知进化
14 | 用于在 QQ 打开时清除缓存的历史消息,以免下一个通知重复显示。\n企鹅通知进化内部缓存了历史通知内容,因而能够在通知中显示多个联系人的消息,但无法准确监测何时被标记为已读。启用后会在每次打开 QQ 时清除它们,下一次通知不会再包含之前的消息。\n本服务不会收集保存任何数据。企鹅通知进化未申请网络权限无法发送任何内容,并且是开源应用,请放心使用。
15 |
16 | 企鹅通知进化
17 | 适配原生分渠道通知;使用原生铃声与振动适配可穿戴设备;支持多组会话与历史消息。
18 |
19 |
20 | 检测到合并的消息
21 | 对于旧版 QQ 或 TIM 等会自动合并会话的版本,建议使用传统模式。
22 | 对于旧版 QQ 或 TIM 等部分版本,若有多个未读会话将合并为「有x个联系人给你发过来y条新消息」一个通知。\n由于 Nevo 限制此情况只能显示最近的一个会话,建议改用传统模式或更新 QQ 版本。
23 | 使用传统模式
24 | 下次再说
25 |
26 |
29 |
30 |
31 | 仅传统模式有效
32 | 企鹅进化-
33 | 联系人消息
34 | QQ 私聊消息通知
35 | 特别关心消息
36 | QQ 特别关心好友私聊消息通知
37 | 群消息
38 | QQ 群消息通知
39 | 空间动态
40 | QQ 空间动态通知
41 |
42 | 使用提示
43 | 前台服务
44 | 一些重要的正在执行的操作
45 |
46 |
49 |
50 |
51 | 空间动态
52 | 特别关心动态
53 | %1$d条新消息
54 | %1$d条新动态
55 | 关联账号-%1$s
56 |
57 |
58 | 升级中
59 | 正在迁移数据,请勿强行停止。
60 |
61 |
62 |
65 |
66 | 无法发送通知
67 | 需要发送通知权限来显示优化后的 QQ 通知,否则无法正常工作。
68 | 允许发送通知
69 | Nevo 插件服务未运行
70 | 请尝试在 Nevo 中重新启用本插件,并确认已允许 Nevo 和本应用自启动/后台运行。若不想安装 Nevo 请切换到传统模式。
71 | 转到 Nevo
72 | 通知监听服务未运行
73 | 传统模式依赖此服务来监听 QQ 的通知并优化它们。请到「必要权限」里授予通知访问权,并确认已允许本应用自启动/后台运行。
74 | 查看必要权限
75 |
76 | 检测到不支持的 QQ 版本
77 | 企鹅通知进化当前仅支持 QQ 正式版与 TIM。不再支持轻聊版、QQ HD 等其他版本。
78 | 不再提示
79 |
80 |
83 |
84 |
85 | 基础
86 | 工作模式
87 | Nevo 插件
88 | 传统
89 | 必要权限
90 |
91 |
92 | 通知
93 | 通知设置
94 | 前往系统原生页面设置不同渠道的声音与振动
95 | 前往 Nevo 页面设置不同渠道的声音与振动(内源模式自行前往 QQ 页面设置)\n由于 Nevo 限制需要触发一次通知对应渠道才会显示。
96 | 图标样式
97 | 自动
98 | QQ
99 | TIM
100 |
101 |
102 | 关于
103 | 高级选项
104 | 使用手册
105 | 模式选择 · 双重通知 · 最佳实践
106 | 开放源代码
107 | 版本号:%1$s
108 |
109 |
114 |
115 | GitHub 发布页
116 |
117 |
120 |
121 | 发送通知
122 | 已授权
123 | 未授权,程序异常时可能遗漏 QQ 消息
124 | 未授权,无法优化 QQ 通知
125 | 允许通知
126 | 请在即将打开的窗口中手动授予通知权限。
127 | 前往设置
128 |
129 |
130 | 传统模式
131 | 通知访问权
132 | 已启用,正在监听 QQ 通知
133 | 已禁用,无法监听 QQ 通知
134 | 无障碍服务
135 | 已启用
136 | 已禁用,可能重复显示已读消息
137 |
138 |
139 | 持续运行
140 | 停用电池优化
141 | 已停用
142 | 未停用,可能丢失通知
143 |
144 | 停用应用休眠
145 | 未停用,可能丢失通知
146 | 已停用
147 | 关闭应用休眠
148 | 请在即将打开的窗口中关闭「在应用程序未使用时移除权限」或「休眠未使用的应用」等类似选项。
149 | 前往设置
150 |
151 | 自启动与后台运行
152 | 需要手动检查
153 | 根据设备型号的不同,请前往手机管家、智能管理器等位置允许本应用的自动启动与后台运行。
154 |
155 |
156 |
159 |
160 |
161 | 通知
162 | 显示特别关心前缀
163 | 添加[特别关心]或群聊中[特别关注]前缀
164 | 特别关注群消息的通知渠道
165 | 群消息
166 | 特别关心消息
167 | 格式化昵称
168 | 自定义格式
169 | 使用 $n 指代原始昵称,其他字符将原样保留。如果你喜欢输入 Emoji 也行。
170 | 自定义格式必须包含 $n 变量。
171 |
172 | 其他
173 | 头像缓存刷新间隔
174 | 10分钟
175 | 1天
176 | 7天
177 | 重置使用提示
178 | 已重置
179 | 无法显示使用提示
180 | 是否允许发送使用提示通知?
181 | 启用通知
182 | 忽略提示
183 | 删除头像缓存
184 | 缓存已删除
185 | 删除 Nevo 通知渠道
186 | 已发送删除请求
187 | 在「最近使用的应用」中显示
188 | 便于锁定最近任务以免被杀
189 | 按返回键退出后将从最近任务界面隐藏
190 |
191 | 调试
192 | 记录日志
193 | 开启后将记录应用日志并明文保存在本地,其中包含您的通知详情,请注意隐私安全。\n请不要从应用外部删除日志文件以免影响完整性。
194 | 开启日志
195 | 取消
196 | 删除日志
197 | %1$d个日志 总大小%2$s
198 | 删除所有日志?
199 | 删除
200 |
201 |
204 |
205 | Nevo(女娲石)可能未安装,请先安装或切换到传统模式。\n详情请阅读使用手册。
206 |
207 |
210 |
211 | 升级中\n正在迁移数据,请勿强行停止。
212 |
213 |
214 |
--------------------------------------------------------------------------------
/app/src/main/java/cc/chenhe/qqnotifyevo/service/UpgradeService.kt:
--------------------------------------------------------------------------------
1 | package cc.chenhe.qqnotifyevo.service
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.content.Context
7 | import android.content.Intent
8 | import androidx.core.app.NotificationCompat
9 | import androidx.core.app.NotificationManagerCompat
10 | import androidx.core.content.edit
11 | import androidx.core.os.UserManagerCompat
12 | import androidx.datastore.preferences.core.edit
13 | import androidx.lifecycle.LifecycleService
14 | import androidx.lifecycle.lifecycleScope
15 | import androidx.localbroadcastmanager.content.LocalBroadcastManager
16 | import androidx.preference.PreferenceManager
17 | import cc.chenhe.qqnotifyevo.R
18 | import cc.chenhe.qqnotifyevo.utils.ACTION_APPLICATION_UPGRADE_COMPLETE
19 | import cc.chenhe.qqnotifyevo.utils.AvatarCacheAge
20 | import cc.chenhe.qqnotifyevo.utils.IconStyle
21 | import cc.chenhe.qqnotifyevo.utils.Mode
22 | import cc.chenhe.qqnotifyevo.utils.NOTIFY_ID_UPGRADE
23 | import cc.chenhe.qqnotifyevo.utils.NOTIFY_SELF_FOREGROUND_SERVICE_CHANNEL_ID
24 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_AVATAR_CACHE_AGE
25 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ENABLE_LOG
26 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ENABLE_LOG_DEFAULT
27 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_FORMAT_NICKNAME
28 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_FORMAT_NICKNAME_DEFAULT
29 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_ICON
30 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_MODE
31 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_NICKNAME_FORMAT
32 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_NICKNAME_FORMAT_DEFAULT
33 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_IN_RECENT_APPS
34 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT
35 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_SPECIAL_PREFIX
36 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SHOW_SPECIAL_PREFIX_DEFAULT
37 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SPECIAL_GROUP_CHANNEL
38 | import cc.chenhe.qqnotifyevo.utils.PREFERENCE_SPECIAL_GROUP_CHANNEL_DEFAULT
39 | import cc.chenhe.qqnotifyevo.utils.SpecialGroupChannel
40 | import cc.chenhe.qqnotifyevo.utils.UpgradeUtils
41 | import cc.chenhe.qqnotifyevo.utils.dataStore
42 | import kotlinx.coroutines.Dispatchers
43 | import kotlinx.coroutines.launch
44 | import kotlinx.coroutines.withContext
45 | import timber.log.Timber
46 |
47 | @Suppress("FunctionName")
48 | class UpgradeService : LifecycleService() {
49 |
50 | companion object {
51 |
52 | private const val TAG = "UpgradeService"
53 |
54 | private const val EXTRA_OLD_VERSION = "old"
55 | private const val EXTRA_CURRENT_VERSION = "new"
56 |
57 | private const val VERSION_2_0_2 = 20023
58 | private const val VERSION_2_2_6 = 20043
59 |
60 | @SuppressLint("StaticFieldLeak")
61 | private var instance: UpgradeService? = null
62 | private var preparedToRunning = false
63 |
64 | /**
65 | * 是否正在运行或者正准备运行。
66 | */
67 | fun isRunningOrPrepared(): Boolean {
68 | return preparedToRunning || isRunning()
69 | }
70 |
71 | private fun isRunning(): Boolean {
72 | return try {
73 | // 如果服务被强制结束,标记没有释放,那么此处会抛出异常。
74 | instance?.ping() ?: false
75 | } catch (e: Exception) {
76 | false
77 | }
78 | }
79 |
80 | fun startIfNecessary(context: Context): Boolean {
81 | val old: Long = UpgradeUtils.getOldVersion(context)
82 | val new: Long = UpgradeUtils.getCurrentVersion(context)
83 | if (old == new) {
84 | Timber.tag(TAG).d("Old version equals to the current, no need to upgrade. v=$new")
85 | return false
86 | } else if (old > new) {
87 | // should never happen
88 | Timber.tag(TAG)
89 | .e("Current version is lower than old version! current=$new, old=$old")
90 | return false
91 | }
92 |
93 | // old < new
94 | return if (shouldPerformUpgrade(old)) {
95 | preparedToRunning = true
96 | val i = Intent(context.applicationContext, UpgradeService::class.java).apply {
97 | putExtra(EXTRA_OLD_VERSION, old)
98 | putExtra(EXTRA_CURRENT_VERSION, new)
99 | }
100 | context.startForegroundService(i)
101 | true
102 | } else {
103 | Timber.tag(TAG)
104 | .i("No need to perform data migration, update version code directly $old → $new.")
105 | UpgradeUtils.setOldVersion(context, new)
106 | false
107 | }
108 | }
109 |
110 | private fun shouldPerformUpgrade(old: Long): Boolean {
111 | return old in 1..VERSION_2_2_6
112 | }
113 | }
114 |
115 | private var running = false
116 |
117 | private lateinit var ctx: Context
118 |
119 | private fun ping() = true
120 |
121 | override fun onCreate() {
122 | super.onCreate()
123 | instance = this
124 | ctx = this.application
125 | createNotificationChannel()
126 | }
127 |
128 | override fun onDestroy() {
129 | instance = null
130 | super.onDestroy()
131 | }
132 |
133 | private fun createNotificationChannel() {
134 | val channel = NotificationChannel(
135 | NOTIFY_SELF_FOREGROUND_SERVICE_CHANNEL_ID,
136 | getString(R.string.notify_self_foreground_service_channel_name),
137 | NotificationManager.IMPORTANCE_LOW
138 | ).apply {
139 | description = getString(R.string.notify_self_foreground_service_channel_name_des)
140 | }
141 | NotificationManagerCompat.from(this).createNotificationChannel(channel)
142 | }
143 |
144 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
145 | if (!running) {
146 | running = true
147 | preparedToRunning = false
148 |
149 | val notify = NotificationCompat.Builder(this, NOTIFY_SELF_FOREGROUND_SERVICE_CHANNEL_ID)
150 | .setSmallIcon(R.drawable.ic_notify_upgrade)
151 | .setContentTitle(getString(R.string.notify_upgrade))
152 | .setContentText(getString(R.string.notify_upgrade_text))
153 | .setPriority(NotificationCompat.PRIORITY_LOW)
154 | .setOngoing(true)
155 | .setOnlyAlertOnce(true)
156 | .build()
157 | startForeground(NOTIFY_ID_UPGRADE, notify)
158 |
159 | lifecycleScope.launch {
160 | val old = intent!!.getLongExtra(EXTRA_OLD_VERSION, -10)
161 | val new = intent.getLongExtra(EXTRA_CURRENT_VERSION, -10)
162 | if (old == -10L || new == -10L) {
163 | Timber.tag(TAG).e("onStartCommand: unknown version. old=$old, new=$new")
164 | complete(false, 0)
165 | } else {
166 | startUpgrade(old, new)
167 | }
168 | }
169 | } else {
170 | preparedToRunning = false
171 | }
172 | return super.onStartCommand(intent, flags, startId)
173 | }
174 |
175 | /**
176 | * 升级完成后调用此函数。
177 | */
178 | private fun complete(success: Boolean, currentVersion: Long) {
179 | if (success) {
180 | Timber.tag(TAG).d("Upgrade complete.")
181 | UpgradeUtils.setOldVersion(this, currentVersion)
182 | } else {
183 | Timber.tag(TAG).e("Upgrade error!")
184 | }
185 | LocalBroadcastManager.getInstance(this)
186 | .sendBroadcast(Intent(ACTION_APPLICATION_UPGRADE_COMPLETE))
187 | stopSelf()
188 | }
189 |
190 | private suspend fun startUpgrade(oldVersion: Long, currentVersion: Long) =
191 | withContext(Dispatchers.Main) {
192 | Timber.tag(TAG).d("Start upgrade process. $oldVersion → $currentVersion")
193 |
194 | if (oldVersion in 1..VERSION_2_0_2) {
195 | migrate_1_to_2_0_2()
196 | }
197 | if (oldVersion <= VERSION_2_2_6) {
198 | migrateFrom_2_2_6()
199 | }
200 |
201 | complete(true, currentVersion)
202 | }
203 |
204 | private suspend fun migrate_1_to_2_0_2() = withContext(Dispatchers.Main) {
205 | if (UserManagerCompat.isUserUnlocked(ctx)) {
206 | Timber.tag(TAG).d("Move default preferences to device protected area.")
207 | val deviceCtx = ctx.createDeviceProtectedStorageContext()
208 | deviceCtx.moveSharedPreferencesFrom(ctx, ctx.packageName + "_preferences")
209 | Timber.tag(TAG).d("Remove deprecated preferences.")
210 | PreferenceManager.getDefaultSharedPreferences(deviceCtx).edit {
211 | remove("friend_vibrate")
212 | remove("friend_ringtone")
213 | remove("group_notify")
214 | remove("group_ringtone")
215 | remove("group_vibrate")
216 | remove("qzone_notify")
217 | remove("qzone_ringtone")
218 | remove("qzone_vibrate")
219 | }
220 | }
221 | }
222 |
223 | private suspend fun migrateFrom_2_2_6() {
224 | val sp = PreferenceManager
225 | .getDefaultSharedPreferences(ctx.createDeviceProtectedStorageContext())
226 | ctx.dataStore.edit { prefs ->
227 | if (sp.contains("mode")) {
228 | prefs[PREFERENCE_MODE] = when (sp.getString("mode", null)?.toIntOrNull()) {
229 | 1 -> Mode.Nevo
230 | 2 -> Mode.Legacy
231 | else -> Mode.Nevo
232 | }.v
233 | }
234 | if (sp.contains("icon_mode")) {
235 | prefs[PREFERENCE_ICON] = when (sp.getString("icon_mode", null)?.toIntOrNull()) {
236 | 0 -> IconStyle.Auto
237 | 1 -> IconStyle.QQ
238 | 2 -> IconStyle.TIM
239 | else -> IconStyle.Auto
240 | }.v
241 | }
242 | if (sp.contains("show_special_prefix")) {
243 | prefs[PREFERENCE_SHOW_SPECIAL_PREFIX] =
244 | sp.getBoolean("show_special_prefix", PREFERENCE_SHOW_SPECIAL_PREFIX_DEFAULT)
245 | }
246 | if (sp.contains("special_group_channel")) {
247 | prefs[PREFERENCE_SPECIAL_GROUP_CHANNEL] =
248 | when (sp.getString("special_group_channel", "")) {
249 | "group" -> SpecialGroupChannel.Group
250 | "special" -> SpecialGroupChannel.Special
251 | else -> PREFERENCE_SPECIAL_GROUP_CHANNEL_DEFAULT
252 | }.v
253 | }
254 | if (sp.contains("wrap_nickname")) {
255 | prefs[PREFERENCE_FORMAT_NICKNAME] =
256 | sp.getBoolean("wrap_nickname", PREFERENCE_FORMAT_NICKNAME_DEFAULT)
257 | }
258 | if (sp.contains("nickname_wrapper")) {
259 | prefs[PREFERENCE_NICKNAME_FORMAT] =
260 | sp.getString("nickname_wrapper", PREFERENCE_NICKNAME_FORMAT_DEFAULT)
261 | ?: PREFERENCE_NICKNAME_FORMAT_DEFAULT
262 | }
263 | if (sp.contains("avatar_cache_period")) {
264 | val old = sp.getString("avatar_cache_period", null)?.toLongOrNull()
265 | prefs[PREFERENCE_AVATAR_CACHE_AGE] = AvatarCacheAge.fromValue(old).v
266 | }
267 | if (sp.contains("show_in_recent")) {
268 | prefs[PREFERENCE_SHOW_IN_RECENT_APPS] =
269 | sp.getBoolean("show_in_recent", PREFERENCE_SHOW_IN_RECENT_APPS_DEFAULT)
270 | }
271 | if (sp.contains("log")) {
272 | prefs[PREFERENCE_ENABLE_LOG] = sp.getBoolean("log", PREFERENCE_ENABLE_LOG_DEFAULT)
273 | }
274 | }
275 | sp.edit().clear().apply()
276 | }
277 |
278 | }
--------------------------------------------------------------------------------