From 0714f005cabdd0b63abf96e3df9061f3068868b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 1 Aug 2025 14:40:10 +0800 Subject: [PATCH 001/245] feat: export play apk warn dialog --- .../main/kotlin/li/songe/gkd/ui/AboutPage.kt | 64 ++++++++++++++++--- .../kotlin/li/songe/gkd/util/Constants.kt | 1 + 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 062e973bb4..93fa951d81 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -44,10 +44,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -255,12 +259,17 @@ fun AboutPage() { title = "反馈须知", textContent = { Text(text = buildAnnotatedString { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("感谢您愿意花时间反馈,GKD 默认不携带任何规则,只接受应用本体功能相关的反馈") + val highlightStyle = SpanStyle( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + append("感谢您愿意花时间反馈,") + withStyle(style = highlightStyle) { + append("GKD 默认不携带任何规则,只接受应用本体功能相关的反馈") } append("\n\n") append("请先判断是不是第三方规则订阅的问题,如果是,您应该向规则提供者反馈,而不是在此处反馈。") - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + withStyle(style = highlightStyle) { append("如果您已经确信是 GKD 应用本体的问题") } append(",可点击下方继续反馈") @@ -397,9 +406,14 @@ fun AboutPage() { .clickable(onClick = throttle { showShareAppDlg = false mainVm.viewModelScope.launchTry(Dispatchers.IO) { - val apkFile = sharedDir.resolve("gkd-v${META.versionName}.apk") - File(app.packageCodePath).copyTo(apkFile, overwrite = true) - context.shareFile(apkFile, "分享安装文件") + if (!META.isGkdChannel) { + mainVm.dialogFlow.waitResult( + title = "分享提示", + textContent = { Text(text = exportPlayTipTemplate()) }, + confirmText = "继续", + ) + } + context.shareFile(getShareApkFile(), "分享安装文件") } }) .then(modifier) @@ -409,9 +423,14 @@ fun AboutPage() { .clickable(onClick = throttle { showShareAppDlg = false mainVm.viewModelScope.launchTry(Dispatchers.IO) { - val apkFile = sharedDir.resolve("gkd-v${META.versionName}.apk") - File(app.packageCodePath).copyTo(apkFile, overwrite = true) - context.saveFileToDownloads(apkFile) + if (!META.isGkdChannel) { + mainVm.dialogFlow.waitResult( + title = "保存提示", + textContent = { Text(text = exportPlayTipTemplate()) }, + confirmText = "继续", + ) + } + context.saveFileToDownloads(getShareApkFile()) } }) .then(modifier) @@ -429,6 +448,33 @@ fun AboutPage() { } } +@Composable +private fun exportPlayTipTemplate(): AnnotatedString { + return buildAnnotatedString { + append("当前导出的 APK 文件只能在已安装 Google 框架的设备上才能使用,否则安装打开后会提示报错,") + withLink( + LinkAnnotation.Url( + ShortUrlSet.URL13, + TextLinkStyles( + style = SpanStyle( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + ) + ) + ) { + append("建议点此从官网下载") + } + append(",或点击下方继续操作") + } +} + +private fun getShareApkFile(): File { + return sharedDir.resolve("gkd-v${META.versionName}.apk").apply { + File(app.packageCodePath).copyTo(this, overwrite = true) + } +} + @Composable private fun AnimatedLogoIcon( modifier: Modifier = Modifier diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index 13334d0ae0..d48e390083 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -32,6 +32,7 @@ object ShortUrlSet { const val URL10 = "https://gkd.li?r=10" const val URL11 = "https://gkd.li?r=11" const val URL12 = "https://gkd.li?r=12" + const val URL13 = "https://gkd.li?r=13" } const val shizukuAppId = "moe.shizuku.privileged.api" From 96e9fd92b6e5f9b7beba4cbed831150e7f511dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 1 Aug 2025 23:31:41 +0800 Subject: [PATCH 002/245] perf: update libs --- app/build.gradle.kts | 8 -------- gradle/libs.versions.toml | 10 +++++----- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d5054484fa..707b68853c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -185,14 +185,6 @@ kotlin { room { schemaDirectory("$projectDir/schemas") } -ksp { - arg("room.generateKotlin", "true") -} - -configurations.configureEach { - // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 - exclude("org.jetbrains.kotlinx", "kotlinx-coroutines-debug") -} composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36c6195f6e..c6fb6cc4c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] kotlin = "2.2.0" ksp = "2.2.0-2.0.2" -agp = "8.11.1" +agp = "8.12.0" compose = "1.8.3" rikka = "4.4.0" room = "2.7.2" paging = "3.3.6" -ktor = "3.2.2" +ktor = "3.2.3" destinations = "2.2.0" coil = "3.3.0" shizuku = "13.1.5" @@ -32,13 +32,13 @@ compose_junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = compose_icons = "androidx.compose.material:material-icons-extended:1.7.8" compose_material3 = "androidx.compose.material3:material3:1.3.2" compose_activity = "androidx.activity:activity-compose:1.10.1" -compose_navigation = "androidx.navigation:navigation-compose:2.9.2" +compose_navigation = "androidx.navigation:navigation-compose:2.9.3" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.16.0" androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.2" -androidx_junit = "androidx.test.ext:junit:1.2.1" +androidx_junit = "androidx.test.ext:junit:1.3.0" androidx_annotation = "androidx.annotation:annotation:1.9.1" -androidx_espresso = "androidx.test.espresso:espresso-core:3.6.1" +androidx_espresso = "androidx.test.espresso:espresso-core:3.7.0" androidx_room_runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx_room_compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx_room_ktx = { module = "androidx.room:room-ktx", version.ref = "room" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3f3ad3fcc5..a4e5b78512 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME From 47900e51ce2844db54ad795f1dc0f019cbc1ba13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 1 Aug 2025 23:37:51 +0800 Subject: [PATCH 003/245] perf: hide service change toast in background --- .../main/kotlin/li/songe/gkd/service/A11yService.kt | 13 +++++++++++-- .../kotlin/li/songe/gkd/service/ManageService.kt | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index d899fb0c83..8f930fec63 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -43,6 +43,7 @@ import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RpcError import li.songe.gkd.data.RuleStatus import li.songe.gkd.debug.SnapshotExt +import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.shizuku.safeGetTopActivity import li.songe.gkd.shizuku.serviceWrapperFlow @@ -103,8 +104,16 @@ open class A11yService : AccessibilityService(), OnCreate, OnA11yConnected, OnA1 useAutoCheckShizuku() serviceWrapperFlow useMatchRule() - onCreated { toast("无障碍已启动") } - onDestroyed { toast("无障碍已停止") } + onCreated { + if (isActivityVisible()) { + toast("无障碍已启动") + } + } + onDestroyed { + if (isActivityVisible()) { + toast("无障碍已停止") + } + } } companion object { diff --git a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt b/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt index d1ea03c728..f64b545e9c 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import li.songe.gkd.isActivityVisible import li.songe.gkd.notif.abNotif import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState @@ -41,9 +42,17 @@ class ManageService : Service(), OnCreate, OnDestroy { init { useAliveFlow(isRunning) - onCreated { toast("常驻通知已启动") } - onDestroyed { toast("常驻通知已停止") } useNotif() + onCreated { + if (isActivityVisible()) { + toast("常驻通知已启动") + } + } + onDestroyed { + if (isActivityVisible()) { + toast("常驻通知已停止") + } + } } companion object { From 54dcc012554dabc34bd8b8722d501303de7d6cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 3 Aug 2025 20:46:19 +0800 Subject: [PATCH 004/245] perf: change hideSnapshotStatusBar desc --- app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 10402cf501..dd79a4b34d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -389,7 +389,7 @@ fun AdvancedPage() { TextSwitch( title = "隐藏状态栏", - subtitle = "隐藏截图顶部状态栏", + subtitle = "隐藏快照截图状态栏", checked = store.hideSnapshotStatusBar ) { storeFlow.value = store.copy( From f4f9faac11c0445aad10b554b3ef280a0ded5538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 3 Aug 2025 20:47:59 +0800 Subject: [PATCH 005/245] perf: cropBitmapStatusBar barHeight --- app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt index b5c5a906cf..7dbbaf4536 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt @@ -89,7 +89,6 @@ object SnapshotExt { } private fun cropBitmapStatusBar(bitmap: Bitmap): Bitmap { - val barHeight = BarUtils.getStatusBarHeight() val tempBp = bitmap.run { if (!isMutable || config == Bitmap.Config.HARDWARE) { return copy(Bitmap.Config.ARGB_8888, true) @@ -97,8 +96,9 @@ object SnapshotExt { this } } + val barHeight = min(BarUtils.getStatusBarHeight(), tempBp.height) for (x in 0 until tempBp.width) { - for (y in 0 until min(barHeight, tempBp.height)) { + for (y in 0 until barHeight) { tempBp[x, y] = 0 } } From 225dae6980c522b2724bb665cd395c282302b8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 3 Aug 2025 20:50:29 +0800 Subject: [PATCH 006/245] fix: cropBitmapStatusBar not work (#1098) close #1098 --- app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt index 7dbbaf4536..dbf40aaed6 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt @@ -91,7 +91,7 @@ object SnapshotExt { private fun cropBitmapStatusBar(bitmap: Bitmap): Bitmap { val tempBp = bitmap.run { if (!isMutable || config == Bitmap.Config.HARDWARE) { - return copy(Bitmap.Config.ARGB_8888, true) + copy(Bitmap.Config.ARGB_8888, true) } else { this } From 11b5813ecaa1c6812a94698a922d4b5d2ac25726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 4 Aug 2025 14:26:42 +0800 Subject: [PATCH 007/245] perf: atomic writeStoreText --- .../kotlin/li/songe/gkd/store/StorageExt.kt | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt index d46ffc88c7..149a47587c 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt @@ -3,6 +3,7 @@ package li.songe.gkd.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -11,15 +12,13 @@ import li.songe.gkd.util.json import li.songe.gkd.util.privateStoreFolder import li.songe.gkd.util.storeFolder import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption -private fun getStoreFile(name: String, private: Boolean): File { - return (if (private) privateStoreFolder else storeFolder).resolve(name) -} private fun readStoreText( - name: String, - private: Boolean, -): String? = getStoreFile(name, private).run { + file: File +): String? = file.run { if (exists()) { readText() } else { @@ -27,8 +26,18 @@ private fun readStoreText( } } -private fun writeStoreText(name: String, text: String, private: Boolean) { - getStoreFile(name, private).writeText(text) +private fun writeStoreText(file: File, text: String) { + val tempFile = File("${file.absolutePath}.tmp") + tempFile.outputStream().use { + it.write(text.toByteArray(Charsets.UTF_8)) + it.fd.sync() + } + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) } fun createTextFlow( @@ -39,13 +48,14 @@ fun createTextFlow( scope: CoroutineScope = appScope, ): MutableStateFlow { val name = if (key.contains('.')) key else "$key.txt" - val initText = readStoreText(name, private) + val file = (if (private) privateStoreFolder else storeFolder).resolve(name) + val initText = readStoreText(file) val initValue = decode(initText) val stateFlow = MutableStateFlow(initValue) scope.launch { - stateFlow.drop(1).collect { + stateFlow.drop(1).conflate().collect { withContext(Dispatchers.IO) { - writeStoreText(name, encode(it), private) + writeStoreText(file, encode(it)) } } } From 39258b953351613abc885084cd9b5038961f1b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 4 Aug 2025 21:36:58 +0800 Subject: [PATCH 008/245] perf: notification channel (#1090) --- .../main/kotlin/li/songe/gkd/notif/Notif.kt | 103 ++++++++---------- .../kotlin/li/songe/gkd/notif/NotifChannel.kt | 45 ++++---- 2 files changed, 69 insertions(+), 79 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 8d39b52b91..a5e4341bf9 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -24,7 +24,7 @@ import kotlin.reflect.KClass data class Notif( - val channelId: String = defaultChannel.id, + val channel: NotifChannel = NotifChannel.Default, val id: Int, val smallIcon: Int = SafeR.ic_status, val title: String = META.appName, @@ -57,7 +57,7 @@ data class Notif( PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) } - val notification = NotificationCompat.Builder(app, channelId) + val notification = NotificationCompat.Builder(app, channel.id) .setSmallIcon(smallIcon) .setContentTitle(title) .setContentText(text) @@ -89,63 +89,52 @@ data class Notif( } } -val abNotif by lazy { - Notif( - id = 100, - text = "无障碍正在运行", - ongoing = true, - autoCancel = false, - ) -} +val abNotif = Notif( + id = 100, + text = "无障碍正在运行", + ongoing = true, + autoCancel = false, +) -val screenshotNotif by lazy { - Notif( - id = 101, - text = "截屏服务正在运行", - ongoing = true, - autoCancel = false, - uri = "gkd://page/1", - stopService = ScreenshotService::class, - ) -} +val screenshotNotif = Notif( + id = 101, + text = "截屏服务正在运行", + ongoing = true, + autoCancel = false, + uri = "gkd://page/1", + stopService = ScreenshotService::class, +) -val floatingNotif by lazy { - Notif( - id = 102, - text = "悬浮按钮正在显示", - ongoing = true, - autoCancel = false, - uri = "gkd://page/1", - stopService = FloatingService::class, - ) -} +val floatingNotif = Notif( + id = 102, + text = "悬浮按钮正在显示", + ongoing = true, + autoCancel = false, + uri = "gkd://page/1", + stopService = FloatingService::class, +) -val httpNotif by lazy { - Notif( - id = 103, - text = "HTTP服务正在运行", - ongoing = true, - autoCancel = false, - uri = "gkd://page/1", - stopService = HttpService::class, - ) -} +val httpNotif = Notif( + id = 103, + text = "HTTP服务正在运行", + ongoing = true, + autoCancel = false, + uri = "gkd://page/1", + stopService = HttpService::class, +) -val snapshotNotif by lazy { - Notif( - id = 104, - text = "快照已保存至记录", - ongoing = false, - autoCancel = true, - uri = "gkd://page/2", - ) -} +val snapshotActionNotif = Notif( + id = 105, + text = "快照服务正在运行", + ongoing = true, + autoCancel = false, +) -val snapshotActionNotif by lazy { - Notif( - id = 105, - text = "快照服务正在运行", - ongoing = true, - autoCancel = false, - ) -} \ No newline at end of file +val snapshotNotif = Notif( + channel = NotifChannel.Snapshot, + id = 104, + text = "快照已保存至记录", + ongoing = false, + autoCancel = true, + uri = "gkd://page/2", +) diff --git a/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt b/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt index bce2dc84fd..7e99d2354f 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt @@ -5,37 +5,38 @@ import android.app.NotificationManager import androidx.core.app.NotificationManagerCompat import li.songe.gkd.app -data class NotifChannel( +sealed class NotifChannel( val id: String, val name: String, - val desc: String, -) - -val defaultChannel by lazy { - NotifChannel( - id = "default", + val desc: String? = null, +) { + data object Default : NotifChannel( + id = "0", name = "GKD", - desc = "默认通知渠道", ) -} -private fun createChannel(notifChannel: NotifChannel) { - val channel = NotificationChannel( - notifChannel.id, - notifChannel.name, - NotificationManager.IMPORTANCE_LOW - ).apply { - description = notifChannel.desc - } - NotificationManagerCompat.from(app).createNotificationChannel(channel) + data object Snapshot : NotifChannel( + id = "1", + name = "保存快照通知", + ) } fun initChannel() { - createChannel(defaultChannel) - - // delete old channels + val channels = arrayOf(NotifChannel.Default, NotifChannel.Snapshot) val manager = NotificationManagerCompat.from(app) - manager.notificationChannels.filter { it.id != defaultChannel.id }.forEach { + // delete old channels + manager.notificationChannels.filter { channels.none { c -> c.id == it.id } }.forEach { manager.deleteNotificationChannel(it.id) } + // create/update new channels + channels.forEach { + val channel = NotificationChannel( + it.id, + it.name, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = it.desc + } + manager.createNotificationChannel(channel) + } } From 9d837f5e179d93600a003ac26c4b6cd045845a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:30:00 +0800 Subject: [PATCH 009/245] perf: AppNameText --- .../li/songe/gkd/ui/component/AppNameText.kt | 106 ++++++++++++------ 1 file changed, 74 insertions(+), 32 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt index 507bb30652..36357aa35b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt @@ -1,22 +1,29 @@ package li.songe.gkd.ui.component import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.VerifiedUser import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.withStyle import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.util.appInfoCacheFlow @@ -30,39 +37,74 @@ fun AppNameText( fallbackName: String? = null, ) { val info = appInfo ?: appInfoCacheFlow.collectAsState().value[appId] - Row { - if (info?.isSystem == true) { - val fontSizeDp = LocalDensity.current.run { - LocalTextStyle.current.fontSize.toDp() + val showSystemIcon = info?.isSystem == true + val appName = (info?.name ?: fallbackName ?: appId ?: error("appId is required")) + val userName = info?.userId?.let { + val userInfo = otherUserMapFlow.collectAsState().value[info.userId] + "「${userInfo?.name ?: info.userId}」" + } + if (!showSystemIcon && userName == null) { + Text( + text = appName, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.tertiary + ) + } else { + val userNameColor = MaterialTheme.colorScheme.tertiary + val annotatedString = remember(showSystemIcon, appName, userName, userNameColor) { + buildAnnotatedString { + if (showSystemIcon) { + appendInlineContent("icon") + } + append(appName) + if (userName != null) { + append(" ") + withStyle( + style = SpanStyle( + fontWeight = FontWeight.Bold, + color = userNameColor, + ) + ) { + append(userName) + } + } } - val lineHeightDp = LocalDensity.current.run { - LocalTextStyle.current.lineHeight.toDp() + } + val inlineContent = if (showSystemIcon) { + val textStyle = LocalTextStyle.current + val contentColor = textStyle.color.takeOrElse { LocalContentColor.current } + remember(textStyle, contentColor) { + mapOf( + "icon" to InlineTextContent( + placeholder = Placeholder( + width = textStyle.fontSize, + height = textStyle.lineHeight, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Icon( + imageVector = Icons.Outlined.VerifiedUser, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { toast("当前是系统应用") }) + .fillMaxSize(), + contentDescription = null, + tint = contentColor + ) + } + ) } - Icon( - imageVector = Icons.Outlined.VerifiedUser, - contentDescription = null, - modifier = Modifier - .clickable(onClick = throttle { toast("当前是系统应用") }) - .width(fontSizeDp) - .height(lineHeightDp) - ) + } else { + emptyMap() } Text( - text = info?.name ?: fallbackName ?: appId ?: error("appId is required"), + text = annotatedString, + inlineContent = inlineContent, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) - if (info?.userId != null) { - Spacer(modifier = Modifier.width(4.dp)) - val userInfo = otherUserMapFlow.collectAsState().value[info.userId] - Text( - text = "「${userInfo?.name ?: info.userId}」", - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.tertiary - ) - } } } \ No newline at end of file From d80f15b1defefd9743fb615e957301c9f8dd2901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:42:17 +0800 Subject: [PATCH 010/245] perf: UpsertRuleGroupPage --- .../main/kotlin/li/songe/gkd/MainActivity.kt | 59 +++++++++- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 31 ++++-- .../li/songe/gkd/ui/UpsertRuleGroupPage.kt | 102 ++++++++---------- .../li/songe/gkd/ui/UpsertRuleGroupVm.kt | 20 ++-- 4 files changed, 139 insertions(+), 73 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index fbbef05f7e..ff459e8fc5 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.ActivityManager import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -41,8 +42,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController +import com.blankj.utilcode.util.KeyboardUtils import com.dylanc.activityresult.launcher.PickContentLauncher import com.dylanc.activityresult.launcher.StartActivityLauncher import com.ramcosta.composedestinations.DestinationsNavHost @@ -51,7 +56,10 @@ import com.ramcosta.composedestinations.generated.destinations.AuthA11YPageDesti import com.ramcosta.composedestinations.utils.currentDestinationAsState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -96,6 +104,54 @@ class MainActivity : ComponentActivity() { val launcher by lazy { StartActivityLauncher(this) } val pickContentLauncher by lazy { PickContentLauncher(this) } + val imeFullHiddenFlow = MutableStateFlow(true) + val imeShowingFlow = MutableStateFlow(false) + + private val imeVisible: Boolean + get() = ViewCompat.getRootWindowInsets(window.decorView)!! + .isVisible(WindowInsetsCompat.Type.ime()) + + private fun watchKeyboardVisible() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ViewCompat.setWindowInsetsAnimationCallback( + window.decorView, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + imeShowingFlow.update { imeVisible } + return super.onStart(animation, bounds) + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: List + ): WindowInsetsCompat { + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + imeFullHiddenFlow.update { !imeVisible } + imeShowingFlow.update { false } + super.onEnd(animation) + } + }) + } else { + KeyboardUtils.registerSoftInputChangedListener(window) { height -> + // onEnd + imeFullHiddenFlow.update { height == 0 } + } + } + } + + suspend fun hideSoftInput() { + if (!imeFullHiddenFlow.updateAndGet { !imeVisible }) { + KeyboardUtils.hideSoftInput(this) + imeFullHiddenFlow.drop(1).first() + } + } + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() @@ -116,9 +172,10 @@ class MainActivity : ComponentActivity() { mainVm.handleIntent(it) intent = null } + watchKeyboardVisible() setContent { val navController = rememberNavController() - mainVm.navController = navController + mainVm.updateNavController(navController) CompositionLocalProvider( LocalNavController provides navController, LocalMainViewModel provides mainVm diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index dfd146eb7f..da200c1d44 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -5,11 +5,13 @@ import android.content.ComponentName import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Looper import android.service.quicksettings.TileService import android.webkit.URLUtil import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder import com.blankj.utilcode.util.LogUtils import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import com.ramcosta.composedestinations.generated.destinations.SnapshotPageDestination @@ -63,18 +65,27 @@ import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex import li.songe.gkd.util.updateSubscription import rikka.shizuku.Shizuku -import java.lang.ref.WeakReference private var tempTermsAccepted = false class MainViewModel : ViewModel() { - private var navControllerRef: WeakReference? = null - var navController: NavHostController - get() = navControllerRef?.get() ?: error("not found navController") - set(value) { - navControllerRef = WeakReference(value) + private lateinit var navController: NavHostController + fun updateNavController(navController: NavHostController) { + this.navController = navController + } + + fun popBackStack() { + if (Looper.getMainLooper() == Looper.myLooper()) { + navController.popBackStack() + } else { + viewModelScope.launch { + withContext(Dispatchers.Main) { + navController.popBackStack() + } + } } + } val enableDarkThemeFlow = storeFlow.debounce(300).map { s -> s.enableDarkTheme }.stateIn( viewModelScope, @@ -183,11 +194,15 @@ class MainViewModel : ViewModel() { lastClickTabTime = System.currentTimeMillis() } - fun navigatePage(direction: Direction) { + fun navigatePage(direction: Direction, builder: (NavOptionsBuilder.() -> Unit)? = null) { if (direction.route == navController.currentDestination?.route) { return } - navController.navigate(direction.route) + if (builder != null) { + navController.navigate(direction.route, builder) + } else { + navController.navigate(direction.route) + } } fun navigateWebPage(url: String) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt index 7ac633e5d1..0427269175 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt @@ -22,7 +22,6 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -33,26 +32,23 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import li.songe.gkd.MainActivity import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.local.LocalDarkTheme import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController import li.songe.gkd.ui.style.ProfileTransitions -import li.songe.gkd.ui.style.clearJson5TransformationCache import li.songe.gkd.ui.style.getJson5Transformation import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.launchTry import li.songe.gkd.util.throttle @Suppress("unused") @@ -66,71 +62,56 @@ fun UpsertRuleGroupPage( ) { val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity - val navController = LocalNavController.current val vm = viewModel() val text by vm.textFlow.collectAsState() - fun checkIfSaveText() = mainVm.viewModelScope.launchTry(Dispatchers.Default) { - if (vm.textChanged) { + + val checkIfSaveText = throttle(mainVm.viewModelScope.launchAsFn(Dispatchers.Default) { + if (vm.hasTextChanged()) { + vm.viewModelScope.launch { + context.hideSoftInput() + } mainVm.dialogFlow.waitResult( title = "放弃编辑", text = "当前内容未保存,是否放弃编辑?", ) + } else { + context.hideSoftInput() } - withContext(Dispatchers.Main) { mainVm.navController.popBackStack() } - }.let { } + mainVm.popBackStack() + }) - val onClickSave = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { - vm.saveRule() - if (KeyboardUtils.isSoftInputVisible(context)) { - KeyboardUtils.hideSoftInput(context) - } - withContext(Dispatchers.Main) { - if (forward) { - if (appId == null) { - navController.navigate(SubsGlobalGroupListPageDestination(subsItemId = subsId).route) { - popUpTo(UpsertRuleGroupPageDestination.route) { - inclusive = true - } - } - } else { - navController.navigate( - SubsAppGroupListPageDestination( - subsItemId = subsId, - vm.addAppId ?: appId - ).route - ) { - popUpTo(UpsertRuleGroupPageDestination.route) { - inclusive = true - } + val onClickSave = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Main) { + withContext(Dispatchers.Default) { vm.saveRule() } + context.hideSoftInput() + if (forward) { + if (appId == null) { + mainVm.navigatePage(SubsGlobalGroupListPageDestination(subsItemId = subsId)) { + popUpTo(UpsertRuleGroupPageDestination.route) { + inclusive = true } } } else { - navController.popBackStack() + mainVm.navigatePage( + SubsAppGroupListPageDestination( + subsItemId = subsId, + vm.addAppId ?: appId + ) + ) { + popUpTo(UpsertRuleGroupPageDestination.route) { + inclusive = true + } + } } + } else { + mainVm.popBackStack() } }) - BackHandler(true) { - if (KeyboardUtils.isSoftInputVisible(context)) { - KeyboardUtils.hideSoftInput(context) - return@BackHandler - } - checkIfSaveText() - } - DisposableEffect(null) { - onDispose { - clearJson5TransformationCache() - } - } + BackHandler(true, checkIfSaveText) Scaffold(modifier = Modifier, topBar = { TopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { - IconButton(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - KeyboardUtils.hideSoftInput(context) - } - checkIfSaveText() - }) { + IconButton(onClick = checkIfSaveText) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, @@ -159,14 +140,21 @@ fun UpsertRuleGroupPage( .fillMaxSize(), ) { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { - // need compose 1.9.0 + val imeShowing by context.imeShowingFlow.collectAsState() + val modifier = Modifier + .autoFocus() + .fillMaxSize() + .run { + if (imeShowing) { + this + } else { + imePadding() + } + } TextField( value = text, onValueChange = { vm.textFlow.value = it }, - modifier = Modifier - .autoFocus() - .fillMaxSize() - .imePadding(), + modifier = modifier, shape = RectangleShape, colors = textColors, visualTransformation = getJson5Transformation(LocalDarkTheme.current), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt index 605e26b9ca..da499f9b8f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import li.songe.gkd.data.RawSubscription +import li.songe.gkd.ui.style.clearJson5TransformationCache import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription @@ -40,13 +41,13 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { private val initText = initialGroup?.cacheStr ?: "" val textFlow = MutableStateFlow(initText) - val textChanged: Boolean - get() { - val text = textFlow.value - if (!isEdit) return !text.isBlank() - if (initText == text) return false - return initialGroup?.cacheJsonObject != runCatching { Json5.parseToJson5Element(text) }.getOrNull() - } + fun hasTextChanged(): Boolean { + val text = textFlow.value + if (!isEdit) return !text.isBlank() + if (initText == text) return false + return initialGroup?.cacheJsonObject != runCatching { Json5.parseToJson5Element(text) }.getOrNull() + } + var addAppId: String? = null @@ -213,6 +214,11 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { toast("添加成功") } } + + override fun onCleared() { + super.onCleared() + clearJson5TransformationCache() + } } private fun checkGroupKeyName( From 4608c35fd7d1ecdb34f2491500e0683f98f62d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:43:15 +0800 Subject: [PATCH 011/245] perf: set enableStatusService defaultValue to false --- app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index e36a475920..a3e060fa03 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -11,7 +11,7 @@ import li.songe.gkd.util.UpdateTimeOption data class SettingsStore( val enableService: Boolean = true, val enableMatch: Boolean = true, - val enableStatusService: Boolean = true, + val enableStatusService: Boolean = false, val excludeFromRecents: Boolean = false, val captureScreenshot: Boolean = false, val httpServerPort: Int = 8888, From 415c488e9dd2bb23edb9c60b472ecd9ef18f2af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:45:30 +0800 Subject: [PATCH 012/245] perf: snapshot appId type --- app/schemas/li.songe.gkd.db.AppDb/12.json | 350 ++++++++++++++++++ .../kotlin/li/songe/gkd/data/BaseSnapshot.kt | 2 +- .../li/songe/gkd/data/ComplexSnapshot.kt | 4 +- .../main/kotlin/li/songe/gkd/data/Snapshot.kt | 2 +- app/src/main/kotlin/li/songe/gkd/db/AppDb.kt | 3 +- .../kotlin/li/songe/gkd/debug/SnapshotExt.kt | 8 +- 6 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 app/schemas/li.songe.gkd.db.AppDb/12.json diff --git a/app/schemas/li.songe.gkd.db.AppDb/12.json b/app/schemas/li.songe.gkd.db.AppDb/12.json new file mode 100644 index 0000000000..cff0564201 --- /dev/null +++ b/app/schemas/li.songe.gkd.db.AppDb/12.json @@ -0,0 +1,350 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "58d6b0ebb55bc58ac6016a2b675e3ac4", + "entities": [ + { + "tableName": "subs_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ctime` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `enable_update` INTEGER NOT NULL, `order` INTEGER NOT NULL, `update_url` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableUpdate", + "columnName": "enable_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateUrl", + "columnName": "update_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "snapshot", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, `github_asset_id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "screenHeight", + "columnName": "screen_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenWidth", + "columnName": "screen_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLandscape", + "columnName": "is_landscape", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "githubAssetId", + "columnName": "github_asset_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subs_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, `exclude` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupKey", + "columnName": "group_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "category_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_id` INTEGER NOT NULL, `category_key` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryKey", + "columnName": "category_key", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "action_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ctime` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `subs_version` INTEGER NOT NULL DEFAULT 0, `group_key` INTEGER NOT NULL, `group_type` INTEGER NOT NULL DEFAULT 2, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subsVersion", + "columnName": "subs_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "groupKey", + "columnName": "group_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupType", + "columnName": "group_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2" + }, + { + "fieldPath": "ruleIndex", + "columnName": "rule_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleKey", + "columnName": "rule_key", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "activity_log_v2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ctime` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '58d6b0ebb55bc58ac6016a2b675e3ac4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/data/BaseSnapshot.kt b/app/src/main/kotlin/li/songe/gkd/data/BaseSnapshot.kt index 0e41e5f490..2eac6f1b20 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/BaseSnapshot.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/BaseSnapshot.kt @@ -3,7 +3,7 @@ package li.songe.gkd.data interface BaseSnapshot { val id: Long - val appId: String? + val appId: String val activityId: String? val screenHeight: Int diff --git a/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt b/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt index f101798b37..36b5a95142 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt @@ -6,12 +6,12 @@ import li.songe.gkd.util.getPkgInfo @Serializable data class ComplexSnapshot( override val id: Long, - override val appId: String?, + override val appId: String, override val activityId: String?, override val screenHeight: Int, override val screenWidth: Int, override val isLandscape: Boolean, - val appInfo: AppInfo? = appId?.let { getPkgInfo(appId)?.toAppInfo() }, + val appInfo: AppInfo? = getPkgInfo(appId)?.toAppInfo(), val gkdAppInfo: AppInfo? = selfAppInfo, val device: DeviceInfo = DeviceInfo.instance, val nodes: List, diff --git a/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt b/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt index 4e3cb21eae..401ba33fcc 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt @@ -21,7 +21,7 @@ import li.songe.gkd.util.format data class Snapshot( @PrimaryKey @ColumnInfo(name = "id") override val id: Long, - @ColumnInfo(name = "app_id") override val appId: String?, + @ColumnInfo(name = "app_id") override val appId: String, @ColumnInfo(name = "activity_id") override val activityId: String?, @ColumnInfo(name = "screen_height") override val screenHeight: Int, diff --git a/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt index e1ee634774..1cc48d5891 100644 --- a/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt +++ b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt @@ -15,7 +15,7 @@ import li.songe.gkd.data.SubsConfig import li.songe.gkd.data.SubsItem @Database( - version = 11, + version = 12, entities = [ SubsItem::class, Snapshot::class, @@ -36,6 +36,7 @@ import li.songe.gkd.data.SubsItem AutoMigration(from = 8, to = 9, spec = ActionLog.ActionLogSpec::class), AutoMigration(from = 9, to = 10, spec = Migration9To10Spec::class), AutoMigration(from = 10, to = 11, spec = Migration10To11Spec::class), + AutoMigration(from = 11, to = 12), ] ) abstract class AppDb : RoomDatabase() { diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt index dbf40aaed6..0466a284f2 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt @@ -160,13 +160,7 @@ object SnapshotExt { } toast("快照成功") val desc = snapshot.appInfo?.name ?: snapshot.appId - snapshotNotif.copy( - text = if (desc != null) { - "快照「$desc」已保存至记录" - } else { - snapshotNotif.text - } - ).notifySelf() + snapshotNotif.copy(text = "快照「$desc」已保存至记录").notifySelf() return snapshot } finally { captureLoading.value = false From 227bd52d0686345b1ed869a122c5fdbf64778114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:45:35 +0800 Subject: [PATCH 013/245] perf: float button startLocation --- .../li/songe/gkd/debug/FloatingService.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt b/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt index 669c8c8d73..2b6f6753e8 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt +++ b/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt @@ -35,14 +35,16 @@ class FloatingService : ExpandableBubbleService(), OnCreate, OnDestroy { } override fun configBubble(): BubbleBuilder { - val builder = BubbleBuilder(this).bubbleCompose { - Icon( - imageVector = Icons.Default.CenterFocusWeak, - contentDescription = "capture", - modifier = Modifier.size(40.dp), - tint = Color.Red - ) - }.enableAnimateToEdge(false) + val builder = BubbleBuilder(this) + .startLocation(80, 80) + .bubbleCompose { + Icon( + imageVector = Icons.Default.CenterFocusWeak, + contentDescription = "capture", + modifier = Modifier.size(40.dp), + tint = Color.Red + ) + }.enableAnimateToEdge(false) // https://github.com/gkd-kit/gkd/issues/62 // https://github.com/gkd-kit/gkd/issues/61 From b9a5985c73e3c118548c923fd2ba7662ecbc29af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:53:36 +0800 Subject: [PATCH 014/245] perf: notification title/text/addAction --- .../kotlin/li/songe/gkd/debug/HttpService.kt | 6 +-- .../main/kotlin/li/songe/gkd/notif/Notif.kt | 45 ++++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt index 0d71c44533..bc1b9a8f52 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt @@ -93,7 +93,7 @@ class HttpService : Service(), OnCreate, OnDestroy { } StopServiceReceiver.autoRegister(this) onCreated { - httpNotif.notifyService(this) + httpNotif.copy(text = "端口-${storeFlow.value.httpServerPort}").notifyService(this) scope.launchTry(Dispatchers.IO) { httpServerPortFlow.collect { port -> httpServerFlow.value?.stop() @@ -112,9 +112,9 @@ class HttpService : Service(), OnCreate, OnDestroy { } if (httpServerFlow.value == null) { stopSelf() - return@collect + } else { + httpNotif.copy(text = "端口-$port").notifyService(this@HttpService) } - httpNotif.copy(text = "HTTP服务-$port").notifyService(this@HttpService) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index a5e4341bf9..0b0ea41f78 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -27,8 +27,8 @@ data class Notif( val channel: NotifChannel = NotifChannel.Default, val id: Int, val smallIcon: Int = SafeR.ic_status, - val title: String = META.appName, - val text: String, + val title: String, + val text: String? = null, val ongoing: Boolean, val autoCancel: Boolean, val uri: String? = null, @@ -45,28 +45,29 @@ data class Notif( }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - val deleteIntent = stopService?.let { - PendingIntent.getBroadcast( + val notification = NotificationCompat.Builder(app, channel.id) + .setSmallIcon(smallIcon) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(contextIntent) + .setOngoing(ongoing) + .setAutoCancel(autoCancel) + if (stopService != null) { + val deleteIntent = PendingIntent.getBroadcast( app, 0, Intent().apply { action = StopServiceReceiver.STOP_ACTION - putExtra(StopServiceReceiver.STOP_ACTION, it.componentName.className) + putExtra(StopServiceReceiver.STOP_ACTION, stopService.componentName.className) setPackage(META.appId) }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) + notification + .setDeleteIntent(deleteIntent) + .addAction(0, "停止", deleteIntent) } - val notification = NotificationCompat.Builder(app, channel.id) - .setSmallIcon(smallIcon) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(contextIntent) - .setDeleteIntent(deleteIntent) - .setOngoing(ongoing) - .setAutoCancel(autoCancel) - .build() - return notification + return notification.build() } fun notifySelf() { @@ -91,6 +92,7 @@ data class Notif( val abNotif = Notif( id = 100, + title = "GKD", text = "无障碍正在运行", ongoing = true, autoCancel = false, @@ -98,7 +100,8 @@ val abNotif = Notif( val screenshotNotif = Notif( id = 101, - text = "截屏服务正在运行", + title = "截屏服务正在运行", + text = "保存快照时截取屏幕", ongoing = true, autoCancel = false, uri = "gkd://page/1", @@ -107,7 +110,8 @@ val screenshotNotif = Notif( val floatingNotif = Notif( id = 102, - text = "悬浮按钮正在显示", + title = "悬浮窗服务正在运行", + text = "点击按钮捕获快照", ongoing = true, autoCancel = false, uri = "gkd://page/1", @@ -116,7 +120,7 @@ val floatingNotif = Notif( val httpNotif = Notif( id = 103, - text = "HTTP服务正在运行", + title = "HTTP服务正在运行", ongoing = true, autoCancel = false, uri = "gkd://page/1", @@ -125,7 +129,8 @@ val httpNotif = Notif( val snapshotActionNotif = Notif( id = 105, - text = "快照服务正在运行", + title = "快照服务正在运行", + text = "捕获快照完成后自动关闭", ongoing = true, autoCancel = false, ) @@ -133,7 +138,7 @@ val snapshotActionNotif = Notif( val snapshotNotif = Notif( channel = NotifChannel.Snapshot, id = 104, - text = "快照已保存至记录", + title = "快照已保存", ongoing = false, autoCancel = true, uri = "gkd://page/2", From 46daf5fae9e3594d4d78d2c12eed0c2944cdb907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 6 Aug 2025 21:54:47 +0800 Subject: [PATCH 015/245] perf: AppNameText color --- app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt index 36357aa35b..bc38abae26 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt @@ -49,7 +49,6 @@ fun AppNameText( maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.tertiary ) } else { val userNameColor = MaterialTheme.colorScheme.tertiary From 727d742c45094b3e262d4d4fb1dcd4caa1665767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 10 Aug 2025 04:10:22 +0800 Subject: [PATCH 016/245] feat: RecordService --- app/build.gradle.kts | 3 +- app/src/main/AndroidManifest.xml | 36 +++- app/src/main/kotlin/li/songe/gkd/App.kt | 2 +- .../main/kotlin/li/songe/gkd/MainActivity.kt | 14 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 25 +-- .../main/kotlin/li/songe/gkd/data/Snapshot.kt | 2 +- .../li/songe/gkd/debug/FloatingService.kt | 110 ---------- .../li/songe/gkd/debug/FloatingTileService.kt | 65 ------ .../li/songe/gkd/debug/HttpTileService.kt | 65 ------ .../li/songe/gkd/debug/KtorCorsPlugin.kt | 24 --- .../li/songe/gkd/debug/KtorErrorPlugin.kt | 39 ---- .../main/kotlin/li/songe/gkd/notif/Notif.kt | 38 ++-- .../li/songe/gkd/notif/StopServiceReceiver.kt | 21 +- .../li/songe/gkd/service/A11yService.kt | 43 +--- .../kotlin/li/songe/gkd/service/A11yState.kt | 12 +- .../li/songe/gkd/service/BaseTileService.kt | 41 ++++ .../li/songe/gkd/service/ButtonService.kt | 61 ++++++ .../li/songe/gkd/service/ButtonTileService.kt | 15 ++ .../li/songe/gkd/service/GkdTileService.kt | 76 +------ .../gkd/{debug => service}/HttpService.kt | 102 ++++++--- .../li/songe/gkd/service/HttpTileService.kt | 15 ++ .../li/songe/gkd/service/ManageService.kt | 101 --------- .../li/songe/gkd/service/MatchTileService.kt | 54 +---- .../songe/gkd/service/OverlayWindowService.kt | 194 ++++++++++++++++++ .../li/songe/gkd/service/RecordService.kt | 99 +++++++++ .../li/songe/gkd/service/RecordTileService.kt | 15 ++ .../{debug => service}/ScreenshotService.kt | 17 +- .../SnapshotActionService.kt | 5 +- .../{debug => service}/SnapshotTileService.kt | 14 +- .../li/songe/gkd/service/StatusService.kt | 74 +++++++ .../kotlin/li/songe/gkd/store/StorageExt.kt | 6 +- .../main/kotlin/li/songe/gkd/ui/AboutPage.kt | 6 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 97 +++------ .../kotlin/li/songe/gkd/ui/SnapshotPage.kt | 9 +- .../li/songe/gkd/ui/home/ControlPage.kt | 8 +- .../li/songe/gkd/ui/home/SettingsPage.kt | 22 +- .../kotlin/li/songe/gkd/ui/theme/Theme.kt | 36 ++-- .../kotlin/li/songe/gkd/util/Constants.kt | 1 - .../li/songe/gkd/util/LifecycleCallbacks.kt | 106 +++++----- .../songe/gkd/{debug => util}/SnapshotExt.kt | 16 +- app/src/main/res/drawable/ic_layers.xml | 9 + app/src/main/res/values/strings.xml | 5 +- gradle/libs.versions.toml | 2 +- 43 files changed, 864 insertions(+), 841 deletions(-) delete mode 100644 app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/debug/FloatingTileService.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/debug/HttpTileService.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/debug/KtorCorsPlugin.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/debug/KtorErrorPlugin.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/ButtonTileService.kt rename app/src/main/kotlin/li/songe/gkd/{debug => service}/HttpService.kt (73%) create mode 100644 app/src/main/kotlin/li/songe/gkd/service/HttpTileService.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/service/ManageService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/RecordService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt rename app/src/main/kotlin/li/songe/gkd/{debug => service}/ScreenshotService.kt (80%) rename app/src/main/kotlin/li/songe/gkd/{debug => service}/SnapshotActionService.kt (83%) rename app/src/main/kotlin/li/songe/gkd/{debug => service}/SnapshotTileService.kt (85%) create mode 100644 app/src/main/kotlin/li/songe/gkd/service/StatusService.kt rename app/src/main/kotlin/li/songe/gkd/{debug => util}/SnapshotExt.kt (92%) create mode 100644 app/src/main/res/drawable/ic_layers.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 707b68853c..141fb98b80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -177,6 +177,7 @@ kotlin { "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", + "-Xcontext-parameters" ) } } @@ -201,6 +202,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.service) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) @@ -247,7 +249,6 @@ dependencies { implementation(libs.utilcodex) implementation(libs.activityResultLauncher) - implementation(libs.floatingBubbleView) implementation(libs.destinations.core) ksp(libs.destinations.ksp) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a9078c9a0b..417487c96c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,11 +133,11 @@ + + + @@ -211,9 +219,19 @@ + + + + + diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 2af27401e8..16bdb2eedc 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable import li.songe.gkd.data.selfAppInfo -import li.songe.gkd.debug.clearHttpSubs +import li.songe.gkd.service.clearHttpSubs import li.songe.gkd.notif.initChannel import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.service.A11yService diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index ff459e8fc5..86b78d645b 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -63,13 +63,13 @@ import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import li.songe.gkd.debug.FloatingService -import li.songe.gkd.debug.HttpService -import li.songe.gkd.debug.ScreenshotService +import li.songe.gkd.service.ButtonService +import li.songe.gkd.service.HttpService +import li.songe.gkd.service.ScreenshotService import li.songe.gkd.permission.AuthDialog import li.songe.gkd.permission.updatePermissionState import li.songe.gkd.service.A11yService -import li.songe.gkd.service.ManageService +import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.service.updateDefaultInputAppId import li.songe.gkd.service.updateLauncherAppId @@ -160,7 +160,7 @@ class MainActivity : ComponentActivity() { mainVm launcher pickContentLauncher - ManageService.autoStart() + StatusService.autoStart() lifecycleScope.launch { storeFlow.map(lifecycleScope) { s -> s.excludeFromRecents }.collect { activityManager.appTasks.forEach { task -> @@ -273,8 +273,8 @@ private fun updateServiceRunning() { fun checkRunning(cls: KClass<*>): Boolean { return list.any { it.service.className == cls.jvmName } } - ManageService.isRunning.value = checkRunning(ManageService::class) - FloatingService.isRunning.value = checkRunning(FloatingService::class) + StatusService.isRunning.value = checkRunning(StatusService::class) + ButtonService.isRunning.value = checkRunning(ButtonService::class) ScreenshotService.isRunning.value = checkRunning(ScreenshotService::class) HttpService.isRunning.value = checkRunning(HttpService::class) } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index da200c1d44..04a4e5a176 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -22,10 +22,6 @@ import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -33,15 +29,15 @@ import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet -import li.songe.gkd.debug.FloatingTileService -import li.songe.gkd.debug.HttpTileService -import li.songe.gkd.debug.SnapshotTileService +import li.songe.gkd.service.ButtonTileService +import li.songe.gkd.service.HttpTileService +import li.songe.gkd.service.RecordTileService +import li.songe.gkd.service.SnapshotTileService import li.songe.gkd.permission.AuthReason import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.service.MatchTileService import li.songe.gkd.shizuku.execCommandForResult import li.songe.gkd.store.createTextFlow -import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AlertDialogOptions import li.songe.gkd.ui.component.InputSubsLinkOption import li.songe.gkd.ui.component.RuleGroupState @@ -87,17 +83,6 @@ class MainViewModel : ViewModel() { } } - val enableDarkThemeFlow = storeFlow.debounce(300).map { s -> s.enableDarkTheme }.stateIn( - viewModelScope, - SharingStarted.Eagerly, - storeFlow.value.enableDarkTheme - ) - val enableDynamicColorFlow = storeFlow.debounce(300).map { s -> s.enableDynamicColor }.stateIn( - viewModelScope, - SharingStarted.Eagerly, - storeFlow.value.enableDynamicColor - ) - val dialogFlow = MutableStateFlow(null) val authReasonFlow = MutableStateFlow(null) @@ -247,7 +232,7 @@ class MainViewModel : ViewModel() { } ?: return@launchTry delay(200) when (qsTileCpt) { - HttpTileService::class.componentName, FloatingTileService::class.componentName -> { + HttpTileService::class.componentName, ButtonTileService::class.componentName, RecordTileService::class.componentName -> { navigatePage(AdvancedPageDestination) } diff --git a/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt b/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt index 401ba33fcc..81114a9b90 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt @@ -11,7 +11,7 @@ import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable -import li.songe.gkd.debug.SnapshotExt +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.format @Entity( diff --git a/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt b/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt deleted file mode 100644 index 2b6f6753e8..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt +++ /dev/null @@ -1,110 +0,0 @@ -package li.songe.gkd.debug - -import android.view.ViewConfiguration -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CenterFocusWeak -import androidx.compose.material3.Icon -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.torrydo.floatingbubbleview.FloatingBubbleListener -import com.torrydo.floatingbubbleview.service.expandable.BubbleBuilder -import com.torrydo.floatingbubbleview.service.expandable.ExpandableBubbleService -import kotlinx.coroutines.flow.MutableStateFlow -import li.songe.gkd.appScope -import li.songe.gkd.notif.StopServiceReceiver -import li.songe.gkd.notif.floatingNotif -import li.songe.gkd.permission.canDrawOverlaysState -import li.songe.gkd.util.OnCreate -import li.songe.gkd.util.OnDestroy -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.startForegroundServiceByClass -import li.songe.gkd.util.stopServiceByClass -import li.songe.gkd.util.toast -import li.songe.gkd.util.useAliveFlow -import kotlin.math.sqrt - -class FloatingService : ExpandableBubbleService(), OnCreate, OnDestroy { - override fun configExpandedBubble() = null - - override fun onCreate() { - super.onCreate() - onCreated() - minimize() - } - - override fun configBubble(): BubbleBuilder { - val builder = BubbleBuilder(this) - .startLocation(80, 80) - .bubbleCompose { - Icon( - imageVector = Icons.Default.CenterFocusWeak, - contentDescription = "capture", - modifier = Modifier.size(40.dp), - tint = Color.Red - ) - }.enableAnimateToEdge(false) - - // https://github.com/gkd-kit/gkd/issues/62 - // https://github.com/gkd-kit/gkd/issues/61 - val defaultFingerData = Triple(0L, 0f, 0f) - var fingerDownData = defaultFingerData - val maxDistanceOffset = 50 - builder.addFloatingBubbleListener(object : FloatingBubbleListener { - override fun onFingerDown(x: Float, y: Float) { - fingerDownData = Triple(System.currentTimeMillis(), x, y) - } - - override fun onFingerMove(x: Float, y: Float) { - if (fingerDownData === defaultFingerData) { - return - } - val dx = fingerDownData.second - x - val dy = fingerDownData.third - y - val distance = sqrt(dx * dx + dy * dy) - if (distance > maxDistanceOffset) { - // reset - fingerDownData = defaultFingerData - } - } - - override fun onFingerUp(x: Float, y: Float) { - if (System.currentTimeMillis() - fingerDownData.first < ViewConfiguration.getTapTimeout()) { - // is onClick - appScope.launchTry { - SnapshotExt.captureSnapshot() - } - } - } - }) - return builder - } - - - override fun startNotificationForeground() { - floatingNotif.notifyService(this) - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - init { - useAliveFlow(isRunning) - onCreated { toast("悬浮窗服务已启动") } - onDestroyed { toast("悬浮窗服务已停止") } - StopServiceReceiver.autoRegister(this) - } - - companion object { - val isRunning = MutableStateFlow(false) - fun start() { - if (!canDrawOverlaysState.checkOrToast()) return - startForegroundServiceByClass(FloatingService::class) - } - - fun stop() = stopServiceByClass(FloatingService::class) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/debug/FloatingTileService.kt b/app/src/main/kotlin/li/songe/gkd/debug/FloatingTileService.kt deleted file mode 100644 index 27082bcc65..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/FloatingTileService.kt +++ /dev/null @@ -1,65 +0,0 @@ -package li.songe.gkd.debug - -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import li.songe.gkd.util.OnChangeListen -import li.songe.gkd.util.OnDestroy -import li.songe.gkd.util.OnTileClick -import li.songe.gkd.util.useLogLifecycle - -class FloatingTileService : TileService(), OnDestroy, OnChangeListen, OnTileClick { - override fun onStartListening() { - super.onStartListening() - onStartListened() - } - - override fun onClick() { - super.onClick() - onTileClicked() - } - - override fun onStopListening() { - super.onStopListening() - onStopListened() - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = MainScope().also { scope -> - onDestroyed { scope.cancel() } - } - private val listeningFlow = MutableStateFlow(false).also { listeningFlow -> - onStartListened { listeningFlow.value = true } - onStopListened { listeningFlow.value = false } - } - - init { - useLogLifecycle() - scope.launch { - combine( - FloatingService.isRunning, - listeningFlow - ) { v1, v2 -> v1 to v2 }.collect { (running, listening) -> - if (listening) { - qsTile.state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - qsTile.updateTile() - } - } - } - onTileClicked { - if (FloatingService.isRunning.value) { - FloatingService.stop() - } else { - FloatingService.start() - } - } - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/debug/HttpTileService.kt b/app/src/main/kotlin/li/songe/gkd/debug/HttpTileService.kt deleted file mode 100644 index 2e78730284..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/HttpTileService.kt +++ /dev/null @@ -1,65 +0,0 @@ -package li.songe.gkd.debug - -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import li.songe.gkd.util.OnChangeListen -import li.songe.gkd.util.OnDestroy -import li.songe.gkd.util.OnTileClick -import li.songe.gkd.util.useLogLifecycle - -class HttpTileService : TileService(), OnDestroy, OnChangeListen, OnTileClick { - override fun onStartListening() { - super.onStartListening() - onStartListened() - } - - override fun onClick() { - super.onClick() - onTileClicked() - } - - override fun onStopListening() { - super.onStopListening() - onStopListened() - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = MainScope().also { scope -> - onDestroyed { scope.cancel() } - } - private val listeningFlow = MutableStateFlow(false).also { listeningFlow -> - onStartListened { listeningFlow.value = true } - onStopListened { listeningFlow.value = false } - } - - init { - useLogLifecycle() - scope.launch { - combine( - HttpService.isRunning, - listeningFlow - ) { v1, v2 -> v1 to v2 }.collect { (running, listening) -> - if (listening) { - qsTile.state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - qsTile.updateTile() - } - } - } - onTileClicked { - if (HttpService.isRunning.value) { - HttpService.stop() - } else { - HttpService.start() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/debug/KtorCorsPlugin.kt b/app/src/main/kotlin/li/songe/gkd/debug/KtorCorsPlugin.kt deleted file mode 100644 index ed65817e56..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/KtorCorsPlugin.kt +++ /dev/null @@ -1,24 +0,0 @@ -package li.songe.gkd.debug - -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.server.application.createApplicationPlugin -import io.ktor.server.request.httpMethod -import io.ktor.server.response.header -import io.ktor.server.response.respond - -// allow all cors -val KtorCorsPlugin = createApplicationPlugin(name = "KtorCorsPlugin") { - onCallRespond { call, _ -> - call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") - call.response.header(HttpHeaders.AccessControlAllowMethods, "*") - call.response.header(HttpHeaders.AccessControlAllowHeaders, "*") - call.response.header(HttpHeaders.AccessControlExposeHeaders, "*") - call.response.header("Access-Control-Allow-Private-Network", "true") - } - onCall { call -> - if (call.request.httpMethod == HttpMethod.Options) { - call.respond("all-cors-ok") - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/debug/KtorErrorPlugin.kt b/app/src/main/kotlin/li/songe/gkd/debug/KtorErrorPlugin.kt deleted file mode 100644 index 4a244f9573..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/KtorErrorPlugin.kt +++ /dev/null @@ -1,39 +0,0 @@ -package li.songe.gkd.debug - -import android.util.Log -import com.blankj.utilcode.util.LogUtils -import io.ktor.server.application.createApplicationPlugin -import io.ktor.server.application.hooks.CallFailed -import io.ktor.server.plugins.origin -import io.ktor.server.request.uri -import io.ktor.server.response.respond -import li.songe.gkd.data.RpcError - -val KtorErrorPlugin = createApplicationPlugin(name = "KtorErrorPlugin") { - onCall { call -> - // TODO 在局域网会被扫描工具批量请求多个路径 - if (call.request.uri == "/" || call.request.uri.startsWith("/api/")) { - Log.d("Ktor", "onCall: ${call.request.origin.remoteAddress} -> ${call.request.uri}") - } - } - on(CallFailed) { call, cause -> - when (cause) { - is RpcError -> { - // 主动抛出的错误 - LogUtils.d(call.request.uri, cause.message) - call.respond(cause) - } - - is Exception -> { - // 未知错误 - LogUtils.d(call.request.uri, cause.message) - cause.printStackTrace() - call.respond(RpcError(message = cause.message ?: "unknown error", unknown = true)) - } - - else -> { - cause.printStackTrace() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 0b0ea41f78..4f14a4db97 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -11,13 +11,13 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.core.net.toUri -import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app -import li.songe.gkd.debug.FloatingService -import li.songe.gkd.debug.HttpService -import li.songe.gkd.debug.ScreenshotService import li.songe.gkd.permission.notificationState +import li.songe.gkd.service.ButtonService +import li.songe.gkd.service.HttpService +import li.songe.gkd.service.RecordService +import li.songe.gkd.service.ScreenshotService import li.songe.gkd.util.SafeR import li.songe.gkd.util.componentName import kotlin.reflect.KClass @@ -56,11 +56,7 @@ data class Notif( val deleteIntent = PendingIntent.getBroadcast( app, 0, - Intent().apply { - action = StopServiceReceiver.STOP_ACTION - putExtra(StopServiceReceiver.STOP_ACTION, stopService.componentName.className) - setPackage(META.appId) - }, + StopServiceReceiver.getIntent(stopService), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) notification @@ -76,9 +72,10 @@ data class Notif( NotificationManagerCompat.from(app).notify(id, toNotification()) } - fun notifyService(context: Service) { + context(service: Service) + fun notifyService() { ServiceCompat.startForeground( - context, + service, id, toNotification(), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -108,14 +105,14 @@ val screenshotNotif = Notif( stopService = ScreenshotService::class, ) -val floatingNotif = Notif( +val buttonNotif = Notif( id = 102, - title = "悬浮窗服务正在运行", + title = "快照按钮服务正在运行", text = "点击按钮捕获快照", ongoing = true, autoCancel = false, uri = "gkd://page/1", - stopService = FloatingService::class, + stopService = ButtonService::class, ) val httpNotif = Notif( @@ -128,7 +125,7 @@ val httpNotif = Notif( ) val snapshotActionNotif = Notif( - id = 105, + id = 104, title = "快照服务正在运行", text = "捕获快照完成后自动关闭", ongoing = true, @@ -137,9 +134,18 @@ val snapshotActionNotif = Notif( val snapshotNotif = Notif( channel = NotifChannel.Snapshot, - id = 104, + id = 105, title = "快照已保存", ongoing = false, autoCancel = true, uri = "gkd://page/2", ) + +val recordNotif = Notif( + id = 106, + title = "记录服务正在运行", + ongoing = true, + autoCancel = false, + uri = "gkd://page/1", + stopService = RecordService::class, +) diff --git a/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt b/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt index 4534e263d9..0ac9a3c63e 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt @@ -7,9 +7,9 @@ import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat import li.songe.gkd.META -import li.songe.gkd.util.OnCreate -import li.songe.gkd.util.OnDestroy +import li.songe.gkd.util.OnCreateToDestroy import li.songe.gkd.util.componentName +import kotlin.reflect.KClass class StopServiceReceiver(private val service: Service) : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -21,15 +21,18 @@ class StopServiceReceiver(private val service: Service) : BroadcastReceiver() { } companion object { - val STOP_ACTION by lazy { META.appId + ".STOP_SERVICE" } + private val STOP_ACTION by lazy { META.appId + ".STOP_SERVICE" } - fun autoRegister(service: Service) { - if (service !is OnCreate) { - error("StopServiceReceiver cannot be auto-registered in OnCreate") - } - if (service !is OnDestroy) { - error("StopServiceReceiver cannot be auto-registered in OnDestroy") + fun getIntent(clazz: KClass): Intent { + return Intent().apply { + action = STOP_ACTION + putExtra(STOP_ACTION, clazz.componentName.className) + setPackage(META.appId) } + } + + context(service: T) + fun autoRegister() where T : Service, T : OnCreateToDestroy { val receiver = StopServiceReceiver(service) service.onCreated { ContextCompat.registerReceiver( diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 8f930fec63..67d487e07c 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -42,17 +42,14 @@ import li.songe.gkd.data.GkdAction import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RpcError import li.songe.gkd.data.RuleStatus -import li.songe.gkd.debug.SnapshotExt -import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.shizuku.safeGetTopActivity import li.songe.gkd.shizuku.serviceWrapperFlow import li.songe.gkd.store.shizukuStoreFlow import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.OnA11yConnected -import li.songe.gkd.util.OnA11yEvent -import li.songe.gkd.util.OnCreate -import li.songe.gkd.util.OnDestroy +import li.songe.gkd.util.OnA11yLife +import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.UpdateTimeOption import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.componentName @@ -60,7 +57,6 @@ import li.songe.gkd.util.launchTry import li.songe.gkd.util.map import li.songe.gkd.util.showActionToast import li.songe.gkd.util.toast -import li.songe.gkd.util.useLogLifecycle import li.songe.selector.MatchOption import li.songe.selector.Selector import java.lang.ref.WeakReference @@ -68,16 +64,11 @@ import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -open class A11yService : AccessibilityService(), OnCreate, OnA11yConnected, OnA11yEvent, OnDestroy { - override fun onCreate() { - super.onCreate() - onCreated() - } - - override fun onServiceConnected() { - super.onServiceConnected() - onA11yConnected() - } +open class A11yService : AccessibilityService(), OnCreateToDestroy, OnA11yLife { + override fun onCreate() = onCreated() + override fun onServiceConnected() = onA11yConnected() + override fun onInterrupt() {} + override fun onDestroy() = onDestroyed() override val a11yEventCallbacks = mutableListOf<(AccessibilityEvent) -> Unit>() override fun onAccessibilityEvent(event: AccessibilityEvent?) { @@ -85,11 +76,6 @@ open class A11yService : AccessibilityService(), OnCreate, OnA11yConnected, OnA1 onA11yEvent(event) } - override fun onInterrupt() {} - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } val scope = CoroutineScope(Dispatchers.Default).apply { onDestroyed { cancel() } } @@ -104,16 +90,7 @@ open class A11yService : AccessibilityService(), OnCreate, OnA11yConnected, OnA1 useAutoCheckShizuku() serviceWrapperFlow useMatchRule() - onCreated { - if (isActivityVisible()) { - toast("无障碍已启动") - } - } - onDestroyed { - if (isActivityVisible()) { - toast("无障碍已停止") - } - } + useAliveToast("无障碍", onlyWhenVisible = true) } companion object { @@ -530,7 +507,7 @@ private fun A11yService.useRunningState() { // https://github.com/gkd-kit/gkd/issues/754 storeFlow.update { it.copy(enableService = true) } } - ManageService.autoStart() + StatusService.autoStart() } onDestroyed { if (storeFlow.value.enableService) { diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt index f60f2628ea..f84433947e 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt @@ -36,8 +36,18 @@ data class TopActivity( val activityId: String? = null, val number: Int = 0 ) { + val shortActivityId: String? + get() { + val a = if (activityId != null && activityId.startsWith(appId)) { + activityId.substring(appId.length) + } else { + activityId + } + return a + } + fun format(): String { - return "${appId}/${activityId}/${number}" + return "${appId}/${shortActivityId}/${number}" } fun sameAs(other: TopActivity): Boolean { diff --git a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt new file mode 100644 index 0000000000..334e70214a --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt @@ -0,0 +1,41 @@ +package li.songe.gkd.service + +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.OnTileLife + +abstract class BaseTileService : TileService(), OnCreateToDestroy, OnTileLife { + override fun onCreate() = onCreated() + override fun onStartListening() = onStartListened() + override fun onClick() = onTileClicked() + override fun onStopListening() = onStopListened() + override fun onDestroy() = onDestroyed() + + abstract val activeFlow: StateFlow + + val scope = useScope() + val listeningFlow = MutableStateFlow(false).apply { + onStartListened { value = true } + onStopListened { value = false } + } + + init { + useLogLifecycle() + scope.launch { + combine( + activeFlow, + listeningFlow + ) { v1, v2 -> v1 to v2 }.collect { (active, listening) -> + if (listening) { + qsTile.state = if (active) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + qsTile.updateTile() + } + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt new file mode 100644 index 0000000000..74ae5df04c --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt @@ -0,0 +1,61 @@ +package li.songe.gkd.service + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CenterFocusWeak +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import li.songe.gkd.appScope +import li.songe.gkd.notif.StopServiceReceiver +import li.songe.gkd.notif.buttonNotif +import li.songe.gkd.permission.canDrawOverlaysState +import li.songe.gkd.ui.theme.AppTheme +import li.songe.gkd.util.SnapshotExt +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.startForegroundServiceByClass +import li.songe.gkd.util.stopServiceByClass + +class ButtonService : OverlayWindowService() { + override fun onClickView() = appScope.launchTry { + SnapshotExt.captureSnapshot() + }.let { } + + override val positionStoreKey = "overlay_xy_button" + + @Composable + override fun ComposeContent() = AppTheme(invertedTheme = true) { + val alpha = 0.75f + Icon( + imageVector = Icons.Default.CenterFocusWeak, + contentDescription = null, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = alpha)) + .size(40.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha), + ) + } + + init { + useAliveFlow(isRunning) + useAliveToast("快照按钮服务") + onCreated { buttonNotif.notifyService() } + StopServiceReceiver.autoRegister() + } + + companion object { + val isRunning = MutableStateFlow(false) + fun start() { + if (!canDrawOverlaysState.checkOrToast()) return + startForegroundServiceByClass(ButtonService::class) + } + + fun stop() = stopServiceByClass(ButtonService::class) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/ButtonTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/ButtonTileService.kt new file mode 100644 index 0000000000..d7b1dddad3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ButtonTileService.kt @@ -0,0 +1,15 @@ +package li.songe.gkd.service + +class ButtonTileService : BaseTileService() { + override val activeFlow = ButtonService.isRunning + + init { + onTileClicked { + if (ButtonService.isRunning.value) { + ButtonService.stop() + } else { + ButtonService.start() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 64a449f3ee..532362379e 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -1,16 +1,9 @@ package li.songe.gkd.service import android.provider.Settings -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import li.songe.gkd.accessRestrictedSettingsShowFlow @@ -18,62 +11,16 @@ import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.OnChangeListen -import li.songe.gkd.util.OnDestroy -import li.songe.gkd.util.OnTileClick import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast -import li.songe.gkd.util.useLogLifecycle -class GkdTileService : TileService(), OnDestroy, OnChangeListen, OnTileClick { - override fun onStartListening() { - super.onStartListening() - onStartListened() - } - - override fun onClick() { - super.onClick() - onTileClicked() - } - - override fun onStopListening() { - super.onStopListening() - onStopListened() - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = MainScope().also { scope -> - onDestroyed { scope.cancel() } - } - - private val listeningFlow = MutableStateFlow(false).also { listeningFlow -> - onStartListened { listeningFlow.value = true } - onStopListened { listeningFlow.value = false } - } +class GkdTileService : BaseTileService() { + override val activeFlow = A11yService.isRunning init { useLogLifecycle() - scope.launch { - combine( - A11yService.isRunning, - listeningFlow - ) { v1, v2 -> v1 to v2 }.collect { (running, listening) -> - if (listening) { - qsTile.state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - qsTile.updateTile() - } - } - } - onStartListened { - fixRestartService() - } - onTileClicked { - switchA11yService() - } + onStartListened { fixRestartService() } + onTileClicked { switchA11yService() } } } @@ -98,19 +45,12 @@ private fun updateServiceNames(names: List) { ) } -private fun enableA11yService() { - Settings.Secure.putInt( - app.contentResolver, - Settings.Secure.ACCESSIBILITY_ENABLED, - 1 - ) -} - private val modifyA11yMutex by lazy { Mutex() } private const val A11Y_AWAIT_START_TIME = 1000L private const val A11Y_AWAIT_FIX_TIME = 500L fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { + if (modifyA11yMutex.isLocked) return@launchTry modifyA11yMutex.withLock { val newEnableService = !A11yService.isRunning.value if (A11yService.isRunning.value) { @@ -121,7 +61,11 @@ fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { return@launchTry } val names = getServiceNames() - enableA11yService() + Settings.Secure.putInt( + app.contentResolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 1 + ) if (names.contains(A11yService.a11yClsName)) { // 当前无障碍异常, 重启服务 names.remove(A11yService.a11yClsName) updateServiceNames(names) diff --git a/app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt similarity index 73% rename from app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt rename to app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index bc1b9a8f52..a61cec87f2 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -1,18 +1,27 @@ -package li.songe.gkd.debug +package li.songe.gkd.service import android.app.Service import android.content.Intent +import android.util.Log import com.blankj.utilcode.util.LogUtils import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.createApplicationPlugin +import io.ktor.server.application.hooks.CallFailed import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.origin +import io.ktor.server.request.httpMethod import io.ktor.server.request.receive import io.ktor.server.request.receiveText +import io.ktor.server.request.uri +import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.response.respondFile import io.ktor.server.response.respondText @@ -22,8 +31,6 @@ import io.ktor.server.routing.route import io.ktor.server.routing.routing import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -39,12 +46,11 @@ import li.songe.gkd.data.selfAppInfo import li.songe.gkd.db.DbSet import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.httpNotif -import li.songe.gkd.service.A11yService import li.songe.gkd.store.storeFlow import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID -import li.songe.gkd.util.OnCreate -import li.songe.gkd.util.OnDestroy +import li.songe.gkd.util.OnCreateToDestroy import li.songe.gkd.util.SERVER_SCRIPT_URL +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.deleteSubscription import li.songe.gkd.util.getIpAddressInLocalNetwork import li.songe.gkd.util.isPortAvailable @@ -56,11 +62,9 @@ import li.songe.gkd.util.stopServiceByClass import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription -import li.songe.gkd.util.useAliveFlow -import li.songe.gkd.util.useLogLifecycle -class HttpService : Service(), OnCreate, OnDestroy { +class HttpService : Service(), OnCreateToDestroy { override fun onBind(intent: Intent?) = null override fun onCreate() { @@ -73,27 +77,28 @@ class HttpService : Service(), OnCreate, OnDestroy { onDestroyed() } - val scope = MainScope().apply { onDestroyed { cancel() } } + val scope = useScope() private val httpServerPortFlow = storeFlow.map(scope) { s -> s.httpServerPort } init { useLogLifecycle() useAliveFlow(isRunning) - onCreated { toast("HTTP服务已启动") } - onDestroyed { toast("HTTP服务已停止") } - - onCreated { localNetworkIpsFlow.value = getIpAddressInLocalNetwork() } - + useAliveToast("HTTP服务") + StopServiceReceiver.autoRegister() + onCreated { + scope.launchTry(Dispatchers.IO) { + localNetworkIpsFlow.value = getIpAddressInLocalNetwork() + } + } onDestroyed { if (storeFlow.value.autoClearMemorySubs) { deleteSubscription(LOCAL_HTTP_SUBS_ID) } httpServerFlow.value = null } - StopServiceReceiver.autoRegister(this) onCreated { - httpNotif.copy(text = "端口-${storeFlow.value.httpServerPort}").notifyService(this) + httpNotif.copy(text = "端口-${storeFlow.value.httpServerPort}").notifyService() scope.launchTry(Dispatchers.IO) { httpServerPortFlow.collect { port -> httpServerFlow.value?.stop() @@ -113,7 +118,7 @@ class HttpService : Service(), OnCreate, OnDestroy { if (httpServerFlow.value == null) { stopSelf() } else { - httpNotif.copy(text = "端口-$port").notifyService(this@HttpService) + httpNotif.copy(text = "端口-$port").notifyService() } } } @@ -151,7 +156,7 @@ data class ServerInfo( fun clearHttpSubs() { // 如果 app 被直接在任务列表划掉, HTTP订阅会没有清除, 所以在后续的第一次启动时清除 if (HttpService.isRunning.value) return - appScope.launchTry(Dispatchers.IO) { + appScope.launchTry { delay(1000) if (storeFlow.value.autoClearMemorySubs) { deleteSubscription(LOCAL_HTTP_SUBS_ID) @@ -159,17 +164,15 @@ fun clearHttpSubs() { } } -private val httpSubsItem by lazy { - SubsItem( - id = LOCAL_HTTP_SUBS_ID, - order = -1, - enableUpdate = false, - ) -} +private val httpSubsItem = SubsItem( + id = LOCAL_HTTP_SUBS_ID, + order = -1, + enableUpdate = false, +) private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { - install(KtorCorsPlugin) - install(KtorErrorPlugin) + install(getKtorCorsPlugin()) + install(getKtorErrorPlugin()) install(ContentNegotiation) { json(keepNullJson) } routing { get("/") { call.respondText(ContentType.Text.Html) { "" } } @@ -221,3 +224,46 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { } } } + +private fun getKtorCorsPlugin() = createApplicationPlugin(name = "KtorCorsPlugin") { + onCallRespond { call, _ -> + call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") + call.response.header(HttpHeaders.AccessControlAllowMethods, "*") + call.response.header(HttpHeaders.AccessControlAllowHeaders, "*") + call.response.header(HttpHeaders.AccessControlExposeHeaders, "*") + call.response.header("Access-Control-Allow-Private-Network", "true") + } + onCall { call -> + if (call.request.httpMethod == HttpMethod.Options) { + call.respond("all-cors-ok") + } + } +} + +private fun getKtorErrorPlugin() = createApplicationPlugin(name = "KtorErrorPlugin") { + onCall { call -> + if (call.request.uri == "/" || call.request.uri.startsWith("/api/")) { + Log.d("Ktor", "onCall: ${call.request.origin.remoteAddress} -> ${call.request.uri}") + } + } + on(CallFailed) { call, cause -> + when (cause) { + is RpcError -> { + // 主动抛出的错误 + LogUtils.d(call.request.uri, cause.message) + call.respond(cause) + } + + is Exception -> { + // 未知错误 + LogUtils.d(call.request.uri, cause.message) + cause.printStackTrace() + call.respond(RpcError(message = cause.message ?: "unknown error", unknown = true)) + } + + else -> { + cause.printStackTrace() + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpTileService.kt new file mode 100644 index 0000000000..df40b5a3d2 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpTileService.kt @@ -0,0 +1,15 @@ +package li.songe.gkd.service + +class HttpTileService : BaseTileService() { + override val activeFlow = HttpService.isRunning + + init { + onTileClicked { + if (HttpService.isRunning.value) { + HttpService.stop() + } else { + HttpService.start() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt b/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt deleted file mode 100644 index f64b545e9c..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt +++ /dev/null @@ -1,101 +0,0 @@ -package li.songe.gkd.service - -import android.app.Service -import android.content.Intent -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import li.songe.gkd.isActivityVisible -import li.songe.gkd.notif.abNotif -import li.songe.gkd.permission.foregroundServiceSpecialUseState -import li.songe.gkd.permission.notificationState -import li.songe.gkd.store.actionCountFlow -import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.OnCreate -import li.songe.gkd.util.OnDestroy -import li.songe.gkd.util.getSubsStatus -import li.songe.gkd.util.ruleSummaryFlow -import li.songe.gkd.util.startForegroundServiceByClass -import li.songe.gkd.util.stopServiceByClass -import li.songe.gkd.util.toast -import li.songe.gkd.util.useAliveFlow - -class ManageService : Service(), OnCreate, OnDestroy { - override fun onBind(intent: Intent?) = null - - override fun onCreate() { - super.onCreate() - onCreated() - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = MainScope().apply { onDestroyed { cancel() } } - - init { - useAliveFlow(isRunning) - useNotif() - onCreated { - if (isActivityVisible()) { - toast("常驻通知已启动") - } - } - onDestroyed { - if (isActivityVisible()) { - toast("常驻通知已停止") - } - } - } - - companion object { - val isRunning = MutableStateFlow(false) - fun start() = startForegroundServiceByClass(ManageService::class) - fun stop() = stopServiceByClass(ManageService::class) - - fun autoStart() { - // 重启自动打开通知栏状态服务 - if (storeFlow.value.enableStatusService - && !isRunning.value - && notificationState.updateAndGet() - && foregroundServiceSpecialUseState.updateAndGet() - ) { - start() - } - } - } -} - -private fun ManageService.useNotif() { - onCreated { - abNotif.notifyService(this) - scope.launch { - combine( - A11yService.isRunning, - storeFlow, - ruleSummaryFlow, - actionCountFlow, - ) { abRunning, store, ruleSummary, count -> - if (!abRunning) return@combine "无障碍未授权" - if (!store.enableMatch) return@combine "暂停规则匹配" - if (store.useCustomNotifText) { - return@combine store.customNotifText - .replace("\${i}", ruleSummary.globalGroups.size.toString()) - .replace("\${k}", ruleSummary.appSize.toString()) - .replace("\${u}", ruleSummary.appGroupSize.toString()) - .replace("\${n}", count.toString()) - } - return@combine getSubsStatus(ruleSummary, count) - }.debounce(500L).stateIn(scope, SharingStarted.Eagerly, "").collect { text -> - abNotif.copy(text = text).notifyService(this@useNotif) - } - } - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt index f166a8e61c..0b739b4bcf 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt @@ -1,63 +1,13 @@ package li.songe.gkd.service -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch import li.songe.gkd.store.storeFlow import li.songe.gkd.store.switchStoreEnableMatch -import li.songe.gkd.util.OnChangeListen -import li.songe.gkd.util.OnDestroy -import li.songe.gkd.util.OnTileClick import li.songe.gkd.util.map -import li.songe.gkd.util.useLogLifecycle -class MatchTileService : TileService(), OnDestroy, OnChangeListen, OnTileClick { - override fun onStartListening() { - super.onStartListening() - onStartListened() - } - - override fun onClick() { - super.onClick() - onTileClicked() - } - - override fun onStopListening() { - super.onStopListening() - onStopListened() - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = MainScope().also { scope -> - onDestroyed { scope.cancel() } - } - - private val listeningFlow = MutableStateFlow(false).also { listeningFlow -> - onStartListened { listeningFlow.value = true } - onStopListened { listeningFlow.value = false } - } +class MatchTileService : BaseTileService() { + override val activeFlow = storeFlow.map(scope) { it.enableMatch } init { - useLogLifecycle() - scope.launch { - combine( - storeFlow.map(scope) { it.enableMatch }, - listeningFlow - ) { v1, v2 -> v1 to v2 }.collect { (enableMatch, listening) -> - if (listening) { - qsTile.state = if (enableMatch) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - qsTile.updateTile() - } - } - } onTileClicked { switchStoreEnableMatch() } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt new file mode 100644 index 0000000000..55d7596efb --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -0,0 +1,194 @@ +package li.songe.gkd.service + + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.graphics.PixelFormat +import android.view.Gravity +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.core.animation.doOnEnd +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.blankj.utilcode.util.BarUtils +import com.blankj.utilcode.util.ScreenUtils +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import li.songe.gkd.store.createTextFlow +import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.px + + +abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwner, + OnCreateToDestroy { + override fun onCreate() { + super.onCreate() + onCreated() + } + + override fun onDestroy() { + super.onDestroy() + onDestroyed() + } + + val registryController = SavedStateRegistryController.create(this).apply { + performAttach() + performRestore(null) + } + override val savedStateRegistry = registryController.savedStateRegistry + + val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } + + @Composable + abstract fun ComposeContent() + + open fun onClickView() {} + + val view by lazy { + ComposeView(this).apply { + setViewTreeSavedStateRegistryOwner(this@OverlayWindowService) + setViewTreeLifecycleOwner(this@OverlayWindowService) + setContent { ComposeContent() } + } + } + + abstract val positionStoreKey: String + + val positionFlow by lazy { + createTextFlow( + key = positionStoreKey, + decode = { + val list = (it ?: "").split(',', limit = 2).takeIf { l -> l.size == 2 } + if (list != null) { + val a = list.getOrNull(0)?.toIntOrNull() ?: 0 + val b = list.getOrNull(1)?.toIntOrNull() ?: 0 + a to b + } else { + 0 to 0 + } + }, + encode = { + "${it.first},${it.second}" + }, + scope = lifecycleScope, + debounceMillis = 300, + ) + } + + init { + onCreated { + val marginX = 20.dp.px.toInt() + val marginY = BarUtils.getStatusBarHeight() + 5.dp.px.toInt() + val layoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT + ).apply { + windowAnimations = android.R.style.Animation_Dialog + gravity = Gravity.START or Gravity.TOP + x = positionFlow.value.first + y = positionFlow.value.second + } + var screenWidth = ScreenUtils.getScreenWidth() + var screenHeight = ScreenUtils.getScreenHeight() + var paramsXy = layoutParams.x to layoutParams.y + var fixMoveFlag = 0 + val fixLimitXy = { + val x = layoutParams.x.coerceIn(marginX, screenWidth - view.width - marginX) + val y = layoutParams.y.coerceIn( + marginY, + screenHeight - view.height - marginY + ) + if (x != layoutParams.x || y != layoutParams.y) { + positionFlow.value = x to y + val startX = layoutParams.x + val startY = layoutParams.y + val newX = x + val newY = y + fixMoveFlag++ + val tempFlag = fixMoveFlag + ValueAnimator.ofFloat(0f, 1f).apply { + duration = 300 + addUpdateListener { animator -> + if (tempFlag == fixMoveFlag) { + val fraction = animator.animatedValue as Float + layoutParams.x = (startX + (newX - startX) * fraction).toInt() + layoutParams.y = (startY + (newY - startY) * fraction).toInt() + windowManager.updateViewLayout(view, layoutParams) + } else { + pause() + } + } + doOnEnd { + if (tempFlag == fixMoveFlag) { + fixMoveFlag = 0 + } + } + }.start() + } + } + lifecycleScope.launch { + val sharedFlow = MutableSharedFlow() + view.viewTreeObserver.addOnGlobalLayoutListener { + launch { sharedFlow.emit(Unit) } + } + sharedFlow.debounce(100).collect { fixLimitXy() } + } + var downXy: Pair? = null + @SuppressLint("ClickableViewAccessibility") + view.setOnTouchListener { _, event -> + if (fixMoveFlag > 0) return@setOnTouchListener true + when (event.action) { + MotionEvent.ACTION_DOWN -> { + downXy = event.rawX to event.rawY + screenWidth = ScreenUtils.getScreenWidth() + screenHeight = ScreenUtils.getScreenHeight() + paramsXy = layoutParams.x to layoutParams.y + true + } + + MotionEvent.ACTION_MOVE -> { + downXy?.let { downEvent -> + val x = (event.rawX - downEvent.first).toInt() + paramsXy.first + val y = (event.rawY - downEvent.second).toInt() + paramsXy.second + layoutParams.x = x.coerceIn(marginX, screenWidth - view.width - marginX) + layoutParams.y = y.coerceIn( + marginY, + screenHeight - view.height - marginY + ) + positionFlow.value = layoutParams.x to layoutParams.y + windowManager.updateViewLayout(view, layoutParams) + } + true + } + + MotionEvent.ACTION_UP -> { + val gapTime = event.eventTime - event.downTime + if (gapTime <= ViewConfiguration.getTapTimeout()) { + onClickView() + } + downXy = null + true + } + + else -> false + } + } + windowManager.addView(view, layoutParams) + } + onDestroyed { windowManager.removeView(view) } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt new file mode 100644 index 0000000000..00ca4d7a2d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -0,0 +1,99 @@ +package li.songe.gkd.service + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import li.songe.gkd.notif.StopServiceReceiver +import li.songe.gkd.notif.recordNotif +import li.songe.gkd.permission.canDrawOverlaysState +import li.songe.gkd.ui.component.AppNameText +import li.songe.gkd.ui.theme.AppTheme +import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.startForegroundServiceByClass +import li.songe.gkd.util.stopServiceByClass + + +class RecordService : OverlayWindowService() { + val topAppInfoFlow by lazy { + appInfoCacheFlow.combine(topActivityFlow) { map, topActivity -> + map[topActivity.appId] + }.stateIn(lifecycleScope, SharingStarted.Eagerly, null) + } + + override val positionStoreKey = "overlay_xy_record" + + @Composable + override fun ComposeContent() = AppTheme(invertedTheme = true) { + val bgColor = MaterialTheme.colorScheme.surface + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(bgColor.copy(alpha = 0.9f)) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + CompositionLocalProvider(LocalContentColor provides contentColorFor(bgColor)) { + val topActivity = topActivityFlow.collectAsState().value + Text( + text = topActivity.number.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + .clip(MaterialTheme.shapes.extraSmall) + .padding(horizontal = 2.dp), + ) + Column { + topAppInfoFlow.collectAsState().value?.let { + AppNameText(appInfo = it) + } + Text(text = "${topActivity.appId}\n${topActivity.shortActivityId}") + } + } + } + } + + init { + useLogLifecycle() + useAliveFlow(isRunning) + useAliveToast("记录服务") + StopServiceReceiver.autoRegister() + onCreated { recordNotif.notifyService() } + onCreated { + lifecycleScope.launch { + topActivityFlow.collect { + recordNotif.copy(text = it.format()).notifyService() + } + } + } + } + + companion object { + val isRunning = MutableStateFlow(false) + fun start() { + if (!canDrawOverlaysState.checkOrToast()) return + startForegroundServiceByClass(RecordService::class) + } + + fun stop() = stopServiceByClass(RecordService::class) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt new file mode 100644 index 0000000000..caf164d120 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt @@ -0,0 +1,15 @@ +package li.songe.gkd.service + +class RecordTileService : BaseTileService() { + override val activeFlow = RecordService.isRunning + + init { + onTileClicked { + if (RecordService.isRunning.value) { + RecordService.stop() + } else { + RecordService.start() + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/debug/ScreenshotService.kt b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt similarity index 80% rename from app/src/main/kotlin/li/songe/gkd/debug/ScreenshotService.kt rename to app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt index e887602ad3..4df7ac9b46 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/ScreenshotService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.debug +package li.songe.gkd.service import android.app.Service import android.content.Intent @@ -9,17 +9,13 @@ import kotlinx.coroutines.withTimeoutOrNull import li.songe.gkd.app import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.screenshotNotif -import li.songe.gkd.util.OnCreate -import li.songe.gkd.util.OnDestroy +import li.songe.gkd.util.OnCreateToDestroy import li.songe.gkd.util.ScreenshotUtil import li.songe.gkd.util.componentName import li.songe.gkd.util.stopServiceByClass -import li.songe.gkd.util.toast -import li.songe.gkd.util.useAliveFlow -import li.songe.gkd.util.useLogLifecycle import java.lang.ref.WeakReference -class ScreenshotService : Service(), OnCreate, OnDestroy { +class ScreenshotService : Service(), OnCreateToDestroy { override fun onBind(intent: Intent?) = null override fun onCreate() { @@ -50,13 +46,12 @@ class ScreenshotService : Service(), OnCreate, OnDestroy { init { useLogLifecycle() useAliveFlow(isRunning) - onCreated { toast("截屏服务已启动") } - onDestroyed { toast("截屏服务已停止") } - onCreated { screenshotNotif.notifyService(this) } + useAliveToast("截屏服务") + StopServiceReceiver.autoRegister() + onCreated { screenshotNotif.notifyService() } onCreated { instance = WeakReference(this) } onDestroyed { instance = WeakReference(null) } onDestroyed { screenshotUtil?.destroy() } - StopServiceReceiver.autoRegister(this) } companion object { diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotActionService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt similarity index 83% rename from app/src/main/kotlin/li/songe/gkd/debug/SnapshotActionService.kt rename to app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt index 1cebe5ab3e..9720f996a4 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotActionService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt @@ -1,10 +1,11 @@ -package li.songe.gkd.debug +package li.songe.gkd.service import android.app.Service import android.content.Intent import android.os.Binder import li.songe.gkd.appScope import li.songe.gkd.notif.snapshotActionNotif +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.launchTry /** @@ -14,7 +15,7 @@ class SnapshotActionService : Service() { override fun onBind(intent: Intent?): Binder? = null override fun onCreate() { super.onCreate() - snapshotActionNotif.notifyService(this) + snapshotActionNotif.notifyService() appScope.launchTry { try { SnapshotExt.captureSnapshot() diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt similarity index 85% rename from app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt rename to app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt index e48de6bcf9..524d8eb1c5 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.debug +package li.songe.gkd.service import android.accessibilityservice.AccessibilityService import android.service.quicksettings.TileService @@ -7,12 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import li.songe.gkd.appScope -import li.songe.gkd.debug.SnapshotExt.captureSnapshot -import li.songe.gkd.service.A11yService -import li.songe.gkd.service.TopActivity -import li.songe.gkd.service.getAndUpdateCurrentRules -import li.songe.gkd.service.safeActiveWindowAppId -import li.songe.gkd.service.updateTopActivity +import li.songe.gkd.util.SnapshotExt.captureSnapshot import li.songe.gkd.shizuku.safeGetTopActivity import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast @@ -41,7 +36,7 @@ class SnapshotTileService : TileService() { // https://github.com/gkd-kit/gkd/issues/713 delay(250) if (timeout()) { - toast("当前应用没有无障碍信息,捕获失败") + toast("当前应用没有无障碍信息,捕获失败") break } } else if (latestAppId != oldAppId) { @@ -59,12 +54,11 @@ class SnapshotTileService : TileService() { service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) delay(500) if (timeout()) { - toast("未检测到界面切换,捕获失败") + toast("未检测到界面切换,捕获失败") break } } } } } - } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt new file mode 100644 index 0000000000..e17bdd9f86 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -0,0 +1,74 @@ +package li.songe.gkd.service + +import android.app.Service +import android.content.Intent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import li.songe.gkd.notif.abNotif +import li.songe.gkd.permission.foregroundServiceSpecialUseState +import li.songe.gkd.permission.notificationState +import li.songe.gkd.store.actionCountFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.getSubsStatus +import li.songe.gkd.util.ruleSummaryFlow +import li.songe.gkd.util.startForegroundServiceByClass +import li.songe.gkd.util.stopServiceByClass + +class StatusService : Service(), OnCreateToDestroy { + override fun onBind(intent: Intent?) = null + override fun onCreate() = onCreated() + override fun onDestroy() = onDestroyed() + + val scope = useScope() + + init { + useAliveFlow(isRunning) + useAliveToast("常驻通知", onlyWhenVisible = true) + onCreated { + abNotif.notifyService() + scope.launch { + combine( + A11yService.isRunning, + storeFlow, + ruleSummaryFlow, + actionCountFlow, + ) { abRunning, store, ruleSummary, count -> + if (!abRunning) return@combine "无障碍未授权" + if (!store.enableMatch) return@combine "暂停规则匹配" + if (store.useCustomNotifText) { + return@combine store.customNotifText + .replace("\${i}", ruleSummary.globalGroups.size.toString()) + .replace("\${k}", ruleSummary.appSize.toString()) + .replace("\${u}", ruleSummary.appGroupSize.toString()) + .replace("\${n}", count.toString()) + } + return@combine getSubsStatus(ruleSummary, count) + }.debounce(500L).stateIn(scope, SharingStarted.Eagerly, "").collect { text -> + abNotif.copy(text = text).notifyService() + } + } + } + } + + companion object { + val isRunning = MutableStateFlow(false) + fun start() = startForegroundServiceByClass(StatusService::class) + fun stop() = stopServiceByClass(StatusService::class) + + fun autoStart() { + // 重启自动打开通知栏状态服务 + if (storeFlow.value.enableStatusService + && !isRunning.value + && notificationState.updateAndGet() + && foregroundServiceSpecialUseState.updateAndGet() + ) { + start() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt index 149a47587c..fbc20fbe31 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,6 +47,7 @@ fun createTextFlow( encode: (T) -> String, private: Boolean = false, scope: CoroutineScope = appScope, + debounceMillis: Long = 0, ): MutableStateFlow { val name = if (key.contains('.')) key else "$key.txt" val file = (if (private) privateStoreFolder else storeFolder).resolve(name) @@ -53,7 +55,7 @@ fun createTextFlow( val initValue = decode(initText) val stateFlow = MutableStateFlow(initValue) scope.launch { - stateFlow.drop(1).conflate().collect { + stateFlow.drop(1).conflate().debounce(debounceMillis).collect { withContext(Dispatchers.IO) { writeStoreText(file, encode(it)) } @@ -68,6 +70,7 @@ inline fun createAnyFlow( crossinline initialize: (T) -> T = { it }, private: Boolean = false, scope: CoroutineScope = appScope, + debounceMillis: Long = 0, ): MutableStateFlow { return createTextFlow( key = "$key.json", @@ -82,5 +85,6 @@ inline fun createAnyFlow( }, private = private, scope = scope, + debounceMillis = debounceMillis, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 93fa951d81..fe0aa1e700 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -69,6 +68,7 @@ import li.songe.gkd.ui.component.RotatingLoadingIcon import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.local.LocalDarkTheme import li.songe.gkd.ui.local.LocalMainViewModel import li.songe.gkd.ui.local.LocalNavController import li.songe.gkd.ui.style.EmptyHeight @@ -479,9 +479,7 @@ private fun getShareApkFile(): File { private fun AnimatedLogoIcon( modifier: Modifier = Modifier ) { - val mainVm = LocalMainViewModel.current - val enableDarkTheme by mainVm.enableDarkThemeFlow.collectAsState() - val darkTheme = enableDarkTheme ?: isSystemInDarkTheme() + val darkTheme = LocalDarkTheme.current var atEnd by remember { mutableStateOf(false) } val animation = AnimatedImageVector.animatedVectorResource(id = SafeR.ic_anim_logo) val painter = rememberAnimatedVectorPainter( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index dd79a4b34d..2ad50d7772 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -7,7 +7,6 @@ import android.os.Build import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -21,7 +20,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon @@ -65,14 +63,15 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import li.songe.gkd.MainActivity -import li.songe.gkd.debug.FloatingService -import li.songe.gkd.debug.HttpService -import li.songe.gkd.debug.ScreenshotService import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.service.ButtonService +import li.songe.gkd.service.HttpService +import li.songe.gkd.service.RecordService +import li.songe.gkd.service.ScreenshotService import li.songe.gkd.shizuku.shizukuCheckActivity import li.songe.gkd.shizuku.shizukuCheckUserService import li.songe.gkd.shizuku.shizukuCheckWorkProfile @@ -90,10 +89,7 @@ import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.ShortUrlSet -import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.shizukuAppId -import li.songe.gkd.util.shizukuMiniVersionCode import li.songe.gkd.util.stopCoroutine import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -158,7 +154,7 @@ fun AdvancedPage() { ) showEditPortDlg = false if (HttpService.httpServerFlow.value != null) { - toast("已更新, 重启服务") + toast("已更新,重启服务") } else { toast("已更新") } @@ -201,7 +197,11 @@ fun AdvancedPage() { .verticalScroll(rememberScrollState()) .padding(contentPadding), ) { - ShizukuTitleCard() + Text( + text = "Shizuku", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) val shizukuOk by shizukuOkState.stateFlow.collectAsState() AnimatedVisibility(!shizukuOk) { AuthCard( @@ -296,7 +296,7 @@ fun AdvancedPage() { TextSwitch( title = "清除订阅", - subtitle = "服务关闭时,删除内存订阅", + subtitle = "服务关闭时,删除内存订阅", checked = store.autoClearMemorySubs ) { storeFlow.value = store.copy( @@ -343,19 +343,18 @@ fun AdvancedPage() { ) } - val floatingRunning by FloatingService.isRunning.collectAsState() TextSwitch( - title = "悬浮窗服务", - subtitle = "显示悬浮按钮点击保存快照", - checked = floatingRunning, + title = "快照按钮", + subtitle = "悬浮显示按钮点击保存快照", + checked = ButtonService.isRunning.collectAsState().value, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { requiredPermission(context, foregroundServiceSpecialUseState) requiredPermission(context, notificationState) requiredPermission(context, canDrawOverlaysState) - FloatingService.start() + ButtonService.start() } else { - FloatingService.stop() + ButtonService.stop() } } ) @@ -377,7 +376,7 @@ fun AdvancedPage() { onSuffixClick = { mainVm.dialogFlow.updateDialogOptions( title = "限制说明", - text = "仅支持部分小米设备截屏触发\n\n只保存节点信息不保存图片, 用户需要在快照记录里替换截图", + text = "仅支持部分小米设备截屏触发\n\n只保存节点信息不保存图片,用户需要在快照记录里替换截图", ) }, checked = store.captureScreenshot @@ -436,6 +435,21 @@ fun AdvancedPage() { enableActivityLog = it ) } + TextSwitch( + title = "记录服务", + subtitle = "悬浮显示界面信息", + checked = RecordService.isRunning.collectAsState().value, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + requiredPermission(context, foregroundServiceSpecialUseState) + requiredPermission(context, notificationState) + requiredPermission(context, canDrawOverlaysState) + RecordService.start() + } else { + RecordService.stop() + } + } + ) SettingItem( title = "界面记录", onClick = { @@ -457,7 +471,7 @@ fun AdvancedPage() { onSuffixClick = { mainVm.dialogFlow.updateDialogOptions( title = "悬浮窗作用", - text = "1.提高 GKD 前台优先级, 降低被系统杀死概率\n2.提高点击响应速度, 关闭后可能导致点击缓慢或不点击", + text = "1.提高 GKD 前台优先级,降低被系统杀死概率\n2.提高点击响应速度,关闭后可能导致点击缓慢或不点击", ) }, checked = store.enableAbFloatWindow, @@ -477,7 +491,7 @@ private val checkShizukuMutex by lazy { Mutex() } private suspend fun checkShizukuFeat(block: suspend () -> Boolean) { if (checkShizukuMutex.isLocked) { - toast("正在检测中, 请稍后再试") + toast("正在检测中,请稍后再试") stopCoroutine() } checkShizukuMutex.withLock { @@ -551,46 +565,3 @@ private fun ShizukuFragment(vm: AdvancedVm, enabled: Boolean = true) { }) } - -@Composable -private fun ShizukuTitleCard() { - val mainVm = LocalMainViewModel.current - Row( - modifier = Modifier - .fillMaxWidth() - .titleItemPadding(showTop = false), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Shizuku", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - val appInfoCache by appInfoCacheFlow.collectAsState() - val shizukuVersionCode = appInfoCache[shizukuAppId]?.versionCode - if (shizukuVersionCode != null && shizukuVersionCode < shizukuMiniVersionCode) { - Row( - modifier = Modifier.clickable(onClick = throttle { - mainVm.dialogFlow.updateDialogOptions( - title = "版本过低", - text = "检测到 Shizuku 版本过低, 可能影响 GKD 正常运行, 建议自行更新至最新版本后再使用", - ) - }), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.WarningAmber, - contentDescription = null, - modifier = Modifier.height(MaterialTheme.typography.bodySmall.fontSize.value.dp), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "版本过低", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt index bf5f7ee81b..866b92ef9e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt @@ -53,7 +53,7 @@ import kotlinx.coroutines.withContext import li.songe.gkd.MainActivity import li.songe.gkd.data.Snapshot import li.songe.gkd.db.DbSet -import li.songe.gkd.debug.SnapshotExt +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.permission.canWriteExternalStorage import li.songe.gkd.permission.requiredPermission import li.songe.gkd.ui.component.EmptyText @@ -331,7 +331,7 @@ private fun SnapshotCard( val appInfo = appInfoCacheFlow.collectAsState().value[snapshot.appId] val showAppName = appInfo?.name ?: snapshot.appId Text( - text = showAppName.toString(), + text = showAppName, overflow = TextOverflow.Ellipsis, maxLines = 1, softWrap = false, @@ -342,10 +342,7 @@ private fun SnapshotCard( ) } val showActivityId = if (snapshot.activityId != null) { - if (snapshot.appId != null && snapshot.activityId.startsWith( - snapshot.appId - ) - ) { + if (snapshot.activityId.startsWith(snapshot.appId)) { snapshot.activityId.substring(snapshot.appId.length) } else { snapshot.activityId diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 06ebef01c9..b8d5e69b2e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -58,7 +58,7 @@ import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService -import li.songe.gkd.service.ManageService +import li.songe.gkd.service.StatusService import li.songe.gkd.service.switchA11yService import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.GroupNameText @@ -106,7 +106,7 @@ fun useControlPage(): ScaffoldExt { val store by storeFlow.collectAsState() val a11yRunning by A11yService.isRunning.collectAsState() - val manageRunning by ManageService.isRunning.collectAsState() + val manageRunning by StatusService.isRunning.collectAsState() val a11yServiceEnabled by a11yServiceEnabledFlow.collectAsState() // 无障碍故障: 设置中无障碍开启, 但是实际 service 没有运行 @@ -146,9 +146,9 @@ fun useControlPage(): ScaffoldExt { if (it) { requiredPermission(context, foregroundServiceSpecialUseState) requiredPermission(context, notificationState) - ManageService.start() + StatusService.start() } else { - ManageService.stop() + StatusService.stop() } storeFlow.value = store.copy( enableStatusService = it diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 948a4dc88a..a72df58925 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import li.songe.gkd.appScope import li.songe.gkd.store.storeFlow @@ -55,6 +56,7 @@ import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.ui.theme.supportDynamicColor import li.songe.gkd.util.DarkThemeOption +import li.songe.gkd.util.Option import li.songe.gkd.util.findOption import li.songe.gkd.util.initOrResetAppInfoCache import li.songe.gkd.util.launchAsFn @@ -322,21 +324,23 @@ fun useSettingsPage(): ScaffoldExt { TextMenu( title = "深色模式", - option = DarkThemeOption.allSubObject.findOption(store.enableDarkTheme) - ) { - storeFlow.update { s -> s.copy(enableDarkTheme = it.value) } - } + option = DarkThemeOption.allSubObject.findOption(store.enableDarkTheme), + onOptionChange = vm.viewModelScope.launchAsFn> { + delay(300) + storeFlow.update { s -> s.copy(enableDarkTheme = it.value) } + } + ) if (supportDynamicColor) { TextSwitch( title = "动态配色", subtitle = "配色跟随系统主题", checked = store.enableDynamicColor, - onCheckedChange = { - storeFlow.value = store.copy( - enableDynamicColor = it - ) - }) + onCheckedChange = vm.viewModelScope.launchAsFn { + delay(300) + storeFlow.update { s -> s.copy(enableDarkTheme = it) } + } + ) } Text( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt b/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt index ab9e0d8261..1504f948c1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt @@ -16,10 +16,14 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.core.view.WindowInsetsControllerCompat +import li.songe.gkd.app +import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.local.LocalDarkTheme -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.util.map private val LightColorScheme = lightColorScheme() private val DarkColorScheme = darkColorScheme() @@ -27,26 +31,32 @@ val supportDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @Composable fun AppTheme( + invertedTheme: Boolean = false, content: @Composable () -> Unit, ) { - // https://developer.android.com/jetpack/compose/designsystems/material3?hl=zh-cn - val context = LocalActivity.current!! - val mainVm = LocalMainViewModel.current - val enableDarkTheme by mainVm.enableDarkThemeFlow.collectAsState() - val enableDynamicColor by mainVm.enableDynamicColorFlow.collectAsState() + val scope = rememberCoroutineScope() + val enableDarkThemeFlow = remember { storeFlow.map(scope) { it.enableDarkTheme } } + val enableDynamicColorFlow = remember { storeFlow.map(scope) { it.enableDynamicColor } } + val enableDarkTheme by enableDarkThemeFlow.collectAsState() + val enableDynamicColor by enableDynamicColorFlow.collectAsState() val systemInDarkTheme = isSystemInDarkTheme() - val darkTheme = enableDarkTheme ?: systemInDarkTheme + val darkTheme = (enableDarkTheme ?: systemInDarkTheme).let { + if (invertedTheme) !it else it + } val colorScheme = when { - supportDynamicColor && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(context) - supportDynamicColor && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(context) + supportDynamicColor && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app) + supportDynamicColor && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(app) darkTheme -> DarkColorScheme else -> LightColorScheme } - // https://github.com/gkd-kit/gkd/pull/421 - LaunchedEffect(darkTheme) { - WindowInsetsControllerCompat(context.window, context.window.decorView).apply { - isAppearanceLightStatusBars = !darkTheme + val activity = LocalActivity.current + if (activity != null) { + LaunchedEffect(darkTheme) { + // https://github.com/gkd-kit/gkd/pull/421 + WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply { + isAppearanceLightStatusBars = !darkTheme + } } } CompositionLocalProvider(LocalDarkTheme provides darkTheme) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index d48e390083..358b62c669 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -36,7 +36,6 @@ object ShortUrlSet { } const val shizukuAppId = "moe.shizuku.privileged.api" -const val shizukuMiniVersionCode = 1049 const val LIST_PLACEHOLDER_KEY = PI diff --git a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt index 8de8f8e64d..dd8025a5b8 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -3,28 +3,40 @@ package li.songe.gkd.util import android.view.accessibility.AccessibilityEvent import com.blankj.utilcode.util.LogUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import li.songe.gkd.isActivityVisible import java.util.WeakHashMap -private val callbacksMap by lazy { WeakHashMap>>() } +private val callbacksMap = WeakHashMap>>() @Suppress("UNCHECKED_CAST") -private fun Any.getCallbacks(method: Int): MutableList { +private fun CanOnCallback.getCallbacks(method: Int): MutableList { return callbacksMap.getOrPut(this) { hashMapOf() } .getOrPut(method) { mutableListOf() } as MutableList } -interface CanOnCallback - -interface OnCreate : CanOnCallback { -// fun onBeforeCreate(f: () -> Unit) { -// getCallbacks<() -> Unit>(1).add(f) -// } -// -// fun onBeforeCreate() { -// getCallbacks<() -> Unit>(1).forEach { it() } -// } +interface CanOnCallback { + fun useLogLifecycle() { + LogUtils.d("useLogLifecycle", this) + if (this is OnCreateToDestroy) { + onCreated { LogUtils.d("onCreated", this) } + onDestroyed { LogUtils.d("onDestroyed", this) } + } + if (this is OnA11yLife) { + onA11yConnected { LogUtils.d("onA11yConnected", this) } + } + if (this is OnTileLife) { + onStartListened { LogUtils.d("onStartListened", this) } + onStopListened { LogUtils.d("onStopListened", this) } + onTileClicked { LogUtils.d("onTileClicked", this) } + } + } +} +interface OnCreateToDestroy : CanOnCallback { fun onCreated(f: () -> Unit) { getCallbacks<() -> Unit>(2).add(f) } @@ -32,9 +44,7 @@ interface OnCreate : CanOnCallback { fun onCreated() { getCallbacks<() -> Unit>(2).forEach { it() } } -} -interface OnDestroy : CanOnCallback { fun onDestroyed(f: () -> Unit) { getCallbacks<() -> Unit>(4).add(f) } @@ -42,22 +52,29 @@ interface OnDestroy : CanOnCallback { fun onDestroyed() { getCallbacks<() -> Unit>(4).forEach { it() } } -} -interface OnA11yEvent : CanOnCallback { - val a11yEventCallbacks: MutableList<(AccessibilityEvent) -> Unit> - get() = getCallbacks(6) + fun useScope(): CoroutineScope = MainScope().apply { onDestroyed { cancel() } } - fun onA11yEvent(f: (AccessibilityEvent) -> Unit) { - a11yEventCallbacks.add(f) + fun useAliveFlow(stateFlow: MutableStateFlow) { + onCreated { stateFlow.value = true } + onDestroyed { stateFlow.value = false } } - fun onA11yEvent(event: AccessibilityEvent) { - a11yEventCallbacks.forEach { it(event) } + fun useAliveToast(name: String, onlyWhenVisible: Boolean = false) { + onCreated { + if (isActivityVisible() || !onlyWhenVisible) { + toast("${name}已启动") + } + } + onDestroyed { + if (isActivityVisible() || !onlyWhenVisible) { + toast("${name}已停止") + } + } } } -interface OnA11yConnected : CanOnCallback { +interface OnA11yLife : CanOnCallback { fun onA11yConnected(f: () -> Unit) { getCallbacks<() -> Unit>(8).add(f) } @@ -65,9 +82,19 @@ interface OnA11yConnected : CanOnCallback { fun onA11yConnected() { getCallbacks<() -> Unit>(8).forEach { it() } } + + val a11yEventCallbacks: MutableList<(AccessibilityEvent) -> Unit> + + fun onA11yEvent(f: (AccessibilityEvent) -> Unit) { + a11yEventCallbacks.add(f) + } + + fun onA11yEvent(event: AccessibilityEvent) { + a11yEventCallbacks.forEach { it(event) } + } } -interface OnChangeListen : CanOnCallback { +interface OnTileLife : CanOnCallback { fun onStartListened(f: () -> Unit) { getCallbacks<() -> Unit>(10).add(f) } @@ -83,9 +110,7 @@ interface OnChangeListen : CanOnCallback { fun onStopListened() { getCallbacks<() -> Unit>(12).forEach { it() } } -} -interface OnTileClick : CanOnCallback { fun onTileClicked(f: () -> Unit) { getCallbacks<() -> Unit>(14).add(f) } @@ -93,33 +118,4 @@ interface OnTileClick : CanOnCallback { fun onTileClicked() { getCallbacks<() -> Unit>(14).forEach { it() } } -} - -fun CanOnCallback.useAliveFlow(stateFlow: MutableStateFlow) { - if (this is OnCreate) { - onCreated { stateFlow.value = true } - } - if (this is OnDestroy) { - onDestroyed { stateFlow.value = false } - } -} - -fun CanOnCallback.useLogLifecycle() { - LogUtils.d("useLogLifecycle", this) - if (this is OnCreate) { - onCreated { LogUtils.d("onCreated", this) } - } - if (this is OnDestroy) { - onDestroyed { LogUtils.d("onDestroyed", this) } - } - if (this is OnA11yConnected) { - onA11yConnected { LogUtils.d("onA11yConnected", this) } - } - if (this is OnChangeListen) { - onStartListened { LogUtils.d("onStartListened", this) } - onStopListened { LogUtils.d("onStopListened", this) } - } - if (this is OnTileClick) { - onTileClicked { LogUtils.d("onTileClicked", this) } - } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt similarity index 92% rename from app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt rename to app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt index 0466a284f2..0add1763c6 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.debug +package li.songe.gkd.util import android.graphics.Bitmap import androidx.core.graphics.createBitmap @@ -15,18 +15,12 @@ import li.songe.gkd.data.ComplexSnapshot import li.songe.gkd.data.RpcError import li.songe.gkd.data.info2nodeList import li.songe.gkd.db.DbSet +import li.songe.gkd.service.ScreenshotService import li.songe.gkd.notif.snapshotNotif import li.songe.gkd.service.A11yService import li.songe.gkd.service.getAndUpdateCurrentRules import li.songe.gkd.service.safeActiveWindow import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.appInfoCacheFlow -import li.songe.gkd.util.autoMk -import li.songe.gkd.util.drawTextToBitmap -import li.songe.gkd.util.keepNullJson -import li.songe.gkd.util.sharedDir -import li.songe.gkd.util.snapshotFolder -import li.songe.gkd.util.toast import java.io.File import kotlin.math.min @@ -85,7 +79,7 @@ object SnapshotExt { } private suspend fun screenshot(): Bitmap? { - return A11yService.screenshot() ?: ScreenshotService.screenshot() + return A11yService.Companion.screenshot() ?: ScreenshotService.Companion.screenshot() } private fun cropBitmapStatusBar(bitmap: Bitmap): Bitmap { @@ -107,7 +101,7 @@ object SnapshotExt { private val captureLoading = MutableStateFlow(false) suspend fun captureSnapshot(skipScreenshot: Boolean = false): ComplexSnapshot { - if (!A11yService.isRunning.value) { + if (!A11yService.Companion.isRunning.value) { throw RpcError("无障碍不可用,请先授权") } if (captureLoading.value) { @@ -116,7 +110,7 @@ object SnapshotExt { captureLoading.value = true try { val rootNode = - A11yService.instance?.safeActiveWindow + A11yService.Companion.instance?.safeActiveWindow ?: throw RpcError("当前应用没有无障碍信息,捕获失败") if (storeFlow.value.showSaveSnapshotToast) { toast("正在保存快照...") diff --git a/app/src/main/res/drawable/ic_layers.xml b/app/src/main/res/drawable/ic_layers.xml new file mode 100644 index 0000000000..b4cb725904 --- /dev/null +++ b/app/src/main/res/drawable/ic_layers.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e64e7b0e51..f48cdb3cd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,11 @@ GKD - 基于高级选择器+订阅规则的屏幕自定义点击服务\n\n通过自定义选择器和订阅规则,能帮助你实现点击任意位置控件,自定义快捷操作等高级功能 + 基于高级选择器和订阅规则的屏幕自定义点击服务\n\n通过自定义选择器和订阅规则,能帮助你实现点击任意位置控件,自定义快捷操作等高级功能 导入数据 捕获快照 HTTP服务 - 悬浮按钮 + 快照按钮 规则匹配 + 记录服务 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6fb6cc4c1..0eb85c3ae0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ compose_navigation = "androidx.navigation:navigation-compose:2.9.3" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.16.0" androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.2" +androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.2" androidx_junit = "androidx.test.ext:junit:1.3.0" androidx_annotation = "androidx.annotation:annotation:1.9.1" androidx_espresso = "androidx.test.espresso:espresso-core:3.7.0" @@ -65,7 +66,6 @@ permissions = "com.github.getActivity:XXPermissions:25.2" json5 = "li.songe:json5:0.3.5" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" -floatingBubbleView = "io.github.torrydo:floating-bubble-view:0.6.5" kevinnzouWebview = "io.github.kevinnzou:compose-webview:0.33.6" [plugins] From 9170ad0f49bb58aa06023d4b31e79af7a7828aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 10 Aug 2025 19:39:17 +0800 Subject: [PATCH 017/245] refactor: app initialization --- app/src/main/kotlin/li/songe/gkd/App.kt | 99 ++++--------------- .../main/kotlin/li/songe/gkd/MainActivity.kt | 8 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 9 +- .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 39 ++++++++ .../main/kotlin/li/songe/gkd/notif/Notif.kt | 17 ++-- .../kotlin/li/songe/gkd/notif/NotifChannel.kt | 6 +- .../songe/gkd/permission/PermissionState.kt | 3 +- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 16 +++ .../li/songe/gkd/store/SettingsStore.kt | 2 +- .../kotlin/li/songe/gkd/store/StoreExt.kt | 5 +- .../li/songe/gkd/ui/UpsertRuleGroupVm.kt | 5 +- .../li/songe/gkd/ui/home/ControlPage.kt | 3 +- .../li/songe/gkd/ui/home/SettingsPage.kt | 10 +- .../kotlin/li/songe/gkd/ui/theme/Theme.kt | 17 +++- .../main/kotlin/li/songe/gkd/util/Toast.kt | 7 +- 15 files changed, 129 insertions(+), 117 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 16bdb2eedc..9618a5662b 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -3,38 +3,25 @@ package li.songe.gkd import android.app.ActivityManager import android.app.AppOpsManager import android.app.Application -import android.content.ComponentName import android.content.Context -import android.content.Context.ACTIVITY_SERVICE import android.content.pm.ApplicationInfo import android.content.pm.PackageManager -import android.database.ContentObserver import android.os.Build -import android.provider.Settings -import android.text.TextUtils import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils -import com.hjq.toast.Toaster -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable import li.songe.gkd.data.selfAppInfo -import li.songe.gkd.service.clearHttpSubs import li.songe.gkd.notif.initChannel -import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.service.A11yService +import li.songe.gkd.service.clearHttpSubs import li.songe.gkd.shizuku.initShizuku import li.songe.gkd.store.initStore import li.songe.gkd.util.SafeR import li.songe.gkd.util.initAppState import li.songe.gkd.util.initSubsState -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.setReactiveToastStyle +import li.songe.gkd.util.initToast import li.songe.gkd.util.toJson5String -import li.songe.gkd.util.toast import org.lsposed.hiddenapibypass.HiddenApiBypass -import rikka.shizuku.Shizuku val appScope by lazy { MainScope() } @@ -54,9 +41,6 @@ private fun getMetaString(key: String): String { return applicationInfo.metaData.getString(key) ?: error("Missing meta-data: $key") } -val activityManager by lazy { app.getSystemService(ACTIVITY_SERVICE) as ActivityManager } -val appOpsManager by lazy { app.getSystemService(AppOpsManager::class.java) as AppOpsManager } - @Serializable data class AppMeta( val channel: String = getMetaString("channel"), @@ -80,6 +64,10 @@ data class AppMeta( val META by lazy { AppMeta() } class App : Application() { + init { + innerApp = this + } + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -89,20 +77,12 @@ class App : Application() { val startTime = System.currentTimeMillis() + val activityManager by lazy { app.getSystemService(ACTIVITY_SERVICE) as ActivityManager } + val appOpsManager by lazy { app.getSystemService(APP_OPS_SERVICE) as AppOpsManager } + override fun onCreate() { super.onCreate() - innerApp = this Utils.init(this) - - val errorHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { t, e -> - LogUtils.d("UncaughtExceptionHandler", t, e) - errorHandler?.uncaughtException(t, e) - } - - Toaster.init(this) - setReactiveToastStyle() - LogUtils.getConfig().apply { setConsoleSwitch(META.debuggable) saveDays = 7 @@ -112,57 +92,16 @@ class App : Application() { "META", toJson5String(META), ) - app.contentResolver.registerContentObserver( - Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES), - false, - object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - super.onChange(selfChange) - a11yServiceEnabledFlow.value = getA11yServiceEnabled() - } - } - ) - Shizuku.addBinderReceivedListener { - LogUtils.d("Shizuku.addBinderReceivedListener") - appScope.launchTry(Dispatchers.IO) { - shizukuOkState.updateAndGet() - } - } - Shizuku.addBinderDeadListener { - LogUtils.d("Shizuku.addBinderDeadListener") - shizukuOkState.stateFlow.value = false - val prefix = if (isActivityVisible()) "" else "${META.appName}: " - toast("${prefix}已断开 Shizuku 服务") - } - appScope.launchTry(Dispatchers.IO) { - initStore() - initAppState() - initSubsState() - initChannel() - initShizuku() - clearHttpSubs() - syncFixState() - } - } -} - -val a11yServiceEnabledFlow by lazy { MutableStateFlow(getA11yServiceEnabled()) } -private fun getA11yServiceEnabled(): Boolean { - val value = try { - Settings.Secure.getString( - app.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES - ) - } catch (_: Exception) { - null - } - if (value.isNullOrEmpty()) return false - val colonSplitter = TextUtils.SimpleStringSplitter(':') - colonSplitter.setString(value) - while (colonSplitter.hasNext()) { - if (ComponentName.unflattenFromString(colonSplitter.next()) == A11yService.a11yComponentName) { - return true + Thread.setDefaultUncaughtExceptionHandler { t, e -> + LogUtils.d("UncaughtExceptionHandler", t, e) } + initToast() + initStore() + initChannel() + initAppState() + initShizuku() + initSubsState() + clearHttpSubs() + syncFixState() } - return false } diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 86b78d645b..4846737cab 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -63,12 +63,12 @@ import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import li.songe.gkd.service.ButtonService -import li.songe.gkd.service.HttpService -import li.songe.gkd.service.ScreenshotService import li.songe.gkd.permission.AuthDialog import li.songe.gkd.permission.updatePermissionState import li.songe.gkd.service.A11yService +import li.songe.gkd.service.ButtonService +import li.songe.gkd.service.HttpService +import li.songe.gkd.service.ScreenshotService import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.service.updateDefaultInputAppId @@ -163,7 +163,7 @@ class MainActivity : ComponentActivity() { StatusService.autoStart() lifecycleScope.launch { storeFlow.map(lifecycleScope) { s -> s.excludeFromRecents }.collect { - activityManager.appTasks.forEach { task -> + app.activityManager.appTasks.forEach { task -> task.setExcludeFromRecents(it) } } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 04a4e5a176..e036d06049 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -25,17 +25,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import li.songe.gkd.a11y.useA11yServiceEnabledFlow import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet +import li.songe.gkd.permission.AuthReason +import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.service.ButtonTileService import li.songe.gkd.service.HttpTileService +import li.songe.gkd.service.MatchTileService import li.songe.gkd.service.RecordTileService import li.songe.gkd.service.SnapshotTileService -import li.songe.gkd.permission.AuthReason -import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.service.MatchTileService import li.songe.gkd.shizuku.execCommandForResult import li.songe.gkd.store.createTextFlow import li.songe.gkd.ui.component.AlertDialogOptions @@ -295,6 +296,8 @@ class MainViewModel : ViewModel() { stopCoroutine() } + val a11yServiceEnabledFlow = useA11yServiceEnabledFlow() + init { viewModelScope.launchTry(Dispatchers.IO) { val subsItems = DbSet.subsItemDao.queryAll() diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt new file mode 100644 index 0000000000..59d6e882eb --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -0,0 +1,39 @@ +package li.songe.gkd.a11y + +import android.content.ComponentName +import android.database.ContentObserver +import android.provider.Settings +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import li.songe.gkd.app +import li.songe.gkd.service.A11yService + +context(vm: ViewModel) +fun useA11yServiceEnabledFlow(): StateFlow { + val stateFlow = MutableStateFlow(getA11yServiceEnabled()) + val contextObserver = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + super.onChange(selfChange) + stateFlow.value = getA11yServiceEnabled() + } + } + app.contentResolver.registerContentObserver( + Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES), + false, + contextObserver + ) + vm.addCloseable { + app.contentResolver.unregisterContentObserver(contextObserver) + } + return stateFlow +} + +private fun getA11yServiceEnabled(): Boolean { + return (Settings.Secure.getString( + app.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: "").split(';').any { + ComponentName.unflattenFromString(it) == A11yService.a11yComponentName + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 4f14a4db97..61fd1f449e 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -11,6 +11,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.core.net.toUri +import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app import li.songe.gkd.permission.notificationState @@ -87,13 +88,15 @@ data class Notif( } } -val abNotif = Notif( - id = 100, - title = "GKD", - text = "无障碍正在运行", - ongoing = true, - autoCancel = false, -) +val abNotif by lazy { + Notif( + id = 100, + title = META.appName, + text = "无障碍正在运行", + ongoing = true, + autoCancel = false, + ) +} val screenshotNotif = Notif( id = 101, diff --git a/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt b/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt index 7e99d2354f..f95152147c 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt @@ -3,16 +3,16 @@ package li.songe.gkd.notif import android.app.NotificationChannel import android.app.NotificationManager import androidx.core.app.NotificationManagerCompat +import li.songe.gkd.META import li.songe.gkd.app sealed class NotifChannel( val id: String, - val name: String, + val name: String? = null, val desc: String? = null, ) { data object Default : NotifChannel( id = "0", - name = "GKD", ) data object Snapshot : NotifChannel( @@ -32,7 +32,7 @@ fun initChannel() { channels.forEach { val channel = NotificationChannel( it.id, - it.name, + it.name ?: META.appName, NotificationManager.IMPORTANCE_LOW ).apply { description = it.desc diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 088d1080cb..8e5295972a 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import li.songe.gkd.MainActivity import li.songe.gkd.app -import li.songe.gkd.appOpsManager import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible import li.songe.gkd.shizuku.shizukuCheckGranted @@ -149,7 +148,7 @@ private suspend fun asyncRequestPermission( private fun checkOpNoThrow(op: String): Int { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { try { - return appOpsManager.checkOpNoThrow( + return app.appOpsManager.checkOpNoThrow( op, android.os.Process.myUid(), app.packageName diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 3f4730eb0a..435fa38c47 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -3,6 +3,7 @@ package li.songe.gkd.shizuku import android.content.Intent import android.content.pm.PackageManager +import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -12,10 +13,13 @@ import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.data.toAppInfo +import li.songe.gkd.isActivityVisible +import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.store.shizukuStoreFlow import li.songe.gkd.util.allPackageInfoMapFlow import li.songe.gkd.util.launchTry import li.songe.gkd.util.otherUserAppInfoMapFlow +import li.songe.gkd.util.toast import li.songe.gkd.util.userAppInfoMapFlow import rikka.shizuku.Shizuku @@ -45,6 +49,18 @@ fun shizukuCheckWorkProfile(): Boolean { } fun initShizuku() { + Shizuku.addBinderReceivedListener { + LogUtils.d("Shizuku.addBinderReceivedListener") + appScope.launchTry(Dispatchers.IO) { + shizukuOkState.updateAndGet() + } + } + Shizuku.addBinderDeadListener { + LogUtils.d("Shizuku.addBinderDeadListener") + shizukuOkState.stateFlow.value = false + val prefix = if (isActivityVisible()) "" else "${META.appName}: " + toast("${prefix}已断开 Shizuku 服务") + } serviceWrapperFlow.value appScope.launchTry(Dispatchers.IO) { combine( diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index a3e060fa03..ed6288abcd 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -18,7 +18,7 @@ data class SettingsStore( val updateSubsInterval: Long = UpdateTimeOption.Everyday.value, val captureVolumeChange: Boolean = false, val toastWhenClick: Boolean = true, - val clickToast: String = "GKD", + val clickToast: String = META.appName, val autoClearMemorySubs: Boolean = true, val hideSnapshotStatusBar: Boolean = false, val enableDarkTheme: Boolean? = null, diff --git a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt index be67d73c55..3eb59e8c6f 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt @@ -1,7 +1,10 @@ package li.songe.gkd.store +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import li.songe.gkd.META +import li.songe.gkd.appScope +import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast val storeFlow by lazy { @@ -33,7 +36,7 @@ val actionCountFlow by lazy { ) } -fun initStore() { +fun initStore() = appScope.launchTry(Dispatchers.IO) { // preload storeFlow.value shizukuStoreFlow.value diff --git a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt index da499f9b8f..4a04771fee 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt @@ -215,9 +215,8 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { } } - override fun onCleared() { - super.onCleared() - clearJson5TransformationCache() + init { + addCloseable { clearJson5TransformationCache() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index b8d5e69b2e..d8acde28c8 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -52,7 +52,6 @@ import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDest import com.ramcosta.composedestinations.generated.destinations.AuthA11YPageDestination import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination import li.songe.gkd.MainActivity -import li.songe.gkd.a11yServiceEnabledFlow import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission @@ -107,7 +106,7 @@ fun useControlPage(): ScaffoldExt { val a11yRunning by A11yService.isRunning.collectAsState() val manageRunning by StatusService.isRunning.collectAsState() - val a11yServiceEnabled by a11yServiceEnabledFlow.collectAsState() + val a11yServiceEnabled by mainVm.a11yServiceEnabledFlow.collectAsState() // 无障碍故障: 设置中无障碍开启, 但是实际 service 没有运行 val a11yBroken = !a11yRunning && a11yServiceEnabled diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index a72df58925..d8b6b99784 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -39,7 +39,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import li.songe.gkd.appScope import li.songe.gkd.store.storeFlow @@ -56,7 +55,6 @@ import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.ui.theme.supportDynamicColor import li.songe.gkd.util.DarkThemeOption -import li.songe.gkd.util.Option import li.songe.gkd.util.findOption import li.songe.gkd.util.initOrResetAppInfoCache import li.songe.gkd.util.launchAsFn @@ -325,8 +323,7 @@ fun useSettingsPage(): ScaffoldExt { TextMenu( title = "深色模式", option = DarkThemeOption.allSubObject.findOption(store.enableDarkTheme), - onOptionChange = vm.viewModelScope.launchAsFn> { - delay(300) + onOptionChange = { storeFlow.update { s -> s.copy(enableDarkTheme = it.value) } } ) @@ -336,9 +333,8 @@ fun useSettingsPage(): ScaffoldExt { title = "动态配色", subtitle = "配色跟随系统主题", checked = store.enableDynamicColor, - onCheckedChange = vm.viewModelScope.launchAsFn { - delay(300) - storeFlow.update { s -> s.copy(enableDarkTheme = it) } + onCheckedChange = { + storeFlow.update { s -> s.copy(enableDynamicColor = it) } } ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt b/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt index 1504f948c1..7268844840 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt @@ -20,10 +20,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.core.view.WindowInsetsControllerCompat +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.local.LocalDarkTheme -import li.songe.gkd.util.map private val LightColorScheme = lightColorScheme() private val DarkColorScheme = darkColorScheme() @@ -35,8 +38,16 @@ fun AppTheme( content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() - val enableDarkThemeFlow = remember { storeFlow.map(scope) { it.enableDarkTheme } } - val enableDynamicColorFlow = remember { storeFlow.map(scope) { it.enableDynamicColor } } + val enableDarkThemeFlow = remember { + storeFlow.map { it.enableDarkTheme }.debounce(300).stateIn( + scope, SharingStarted.Eagerly, storeFlow.value.enableDarkTheme + ) + } + val enableDynamicColorFlow = remember { + storeFlow.map { it.enableDynamicColor }.debounce(300).stateIn( + scope, SharingStarted.Eagerly, storeFlow.value.enableDynamicColor + ) + } val enableDarkTheme by enableDarkThemeFlow.collectAsState() val enableDynamicColor by enableDynamicColorFlow.collectAsState() val systemInDarkTheme = isSystemInDarkTheme() diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 2daa107eea..5295bfb5a9 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -83,7 +83,7 @@ private fun View.updateToastView() { clipToOutline = true } -fun setReactiveToastStyle() { +private fun setReactiveToastStyle() { Toaster.setStyle(object : WhiteToastStyle() { override fun getGravity() = Gravity.BOTTOM override fun getYOffset() = toastYOffset @@ -161,3 +161,8 @@ private fun showAccessibilityToast(context: AccessibilityService, message: CharS } }, triggerInterval) } + +fun initToast() { + Toaster.init(app) + setReactiveToastStyle() +} From 188ed3e6327cf569b208a374213271619eab4cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 11 Aug 2025 00:16:48 +0800 Subject: [PATCH 018/245] refactor: BaseViewModel --- .../main/kotlin/li/songe/gkd/MainActivity.kt | 8 ++-- .../songe/gkd/permission/PermissionState.kt | 2 +- .../li/songe/gkd/service/A11yService.kt | 6 +-- .../li/songe/gkd/service/HttpService.kt | 4 +- .../li/songe/gkd/service/MatchTileService.kt | 4 +- .../main/kotlin/li/songe/gkd/ui/AboutPage.kt | 6 +-- .../kotlin/li/songe/gkd/ui/ActionLogPage.kt | 8 ++-- .../kotlin/li/songe/gkd/ui/ActivityLogPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/AppConfigPage.kt | 6 +-- .../kotlin/li/songe/gkd/ui/AppConfigVm.kt | 30 +++++++-------- .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 4 +- .../li/songe/gkd/ui/ImagePreviewPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/SlowGroupPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/SnapshotPage.kt | 8 ++-- .../main/kotlin/li/songe/gkd/ui/SnapshotVm.kt | 13 ++----- .../li/songe/gkd/ui/SubsAppGroupListPage.kt | 4 +- .../li/songe/gkd/ui/SubsAppGroupListVm.kt | 6 +-- .../kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 6 +-- .../kotlin/li/songe/gkd/ui/SubsAppListVm.kt | 27 +++++++------ .../li/songe/gkd/ui/SubsCategoryPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/SubsCategoryVm.kt | 4 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 2 +- .../songe/gkd/ui/SubsGlobalGroupExcludeVm.kt | 16 ++++---- .../songe/gkd/ui/SubsGlobalGroupListPage.kt | 4 +- .../li/songe/gkd/ui/SubsGlobalGroupListVm.kt | 4 +- .../li/songe/gkd/ui/UpsertRuleGroupPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/WebViewPage.kt | 4 +- .../ui/component/EditGroupExcludeDialog.kt | 2 +- .../kotlin/li/songe/gkd/ui/component/Hooks.kt | 4 +- .../gkd/ui/component/InnerDisableSwitch.kt | 2 +- .../gkd/ui/component/InputSubsLinkOption.kt | 2 +- .../gkd/ui/component/ManualAuthDialog.kt | 2 +- .../songe/gkd/ui/component/QueryPkgTipCard.kt | 2 +- .../songe/gkd/ui/component/RuleGroupCard.kt | 2 +- .../songe/gkd/ui/component/RuleGroupDialog.kt | 6 +-- .../songe/gkd/ui/component/RuleGroupState.kt | 2 +- .../li/songe/gkd/ui/component/SubsItemCard.kt | 8 ++-- .../li/songe/gkd/ui/component/SubsSheet.kt | 2 +- .../gkd/ui/component/TermsAcceptDialog.kt | 2 +- .../li/songe/gkd/ui/home/AppListPage.kt | 2 +- .../li/songe/gkd/ui/home/ControlPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/home/HomePage.kt | 2 +- .../kotlin/li/songe/gkd/ui/home/HomeVm.kt | 12 +++--- .../li/songe/gkd/ui/home/SettingsPage.kt | 2 +- .../li/songe/gkd/ui/home/SubsManagePage.kt | 6 +-- .../li/songe/gkd/ui/share/BaseViewModel.kt | 38 +++++++++++++++++++ .../songe/gkd/ui/{local => share}/LocalExt.kt | 2 +- .../kotlin/li/songe/gkd/ui/theme/Theme.kt | 2 +- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 8 ++-- app/src/main/kotlin/li/songe/gkd/util/Copy.kt | 8 ---- .../main/kotlin/li/songe/gkd/util/FlowExt.kt | 6 +-- .../main/kotlin/li/songe/gkd/util/Github.kt | 2 +- .../main/kotlin/li/songe/gkd/util/LinkLoad.kt | 27 ------------- .../main/kotlin/li/songe/gkd/util/Toast.kt | 6 +++ .../kotlin/li/songe/gkd/util/ViewModelExt.kt | 14 ------- 57 files changed, 181 insertions(+), 196 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt rename app/src/main/kotlin/li/songe/gkd/ui/{local => share}/LocalExt.kt (93%) delete mode 100644 app/src/main/kotlin/li/songe/gkd/util/Copy.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/util/ViewModelExt.kt diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 4846737cab..45fb5368e6 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -79,8 +79,8 @@ import li.songe.gkd.ui.component.ShareDataDialog import li.songe.gkd.ui.component.SubsSheet import li.songe.gkd.ui.component.TermsAcceptDialog import li.songe.gkd.ui.component.UrlDetailDialog -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.theme.AppTheme import li.songe.gkd.util.EditGithubCookieDlg import li.songe.gkd.util.ShortUrlSet @@ -89,7 +89,7 @@ import li.songe.gkd.util.componentName import li.songe.gkd.util.copyText import li.songe.gkd.util.fixSomeProblems import li.songe.gkd.util.launchTry -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.openApp import li.songe.gkd.util.openUri import li.songe.gkd.util.shizukuAppId @@ -162,7 +162,7 @@ class MainActivity : ComponentActivity() { pickContentLauncher StatusService.autoStart() lifecycleScope.launch { - storeFlow.map(lifecycleScope) { s -> s.excludeFromRecents }.collect { + storeFlow.mapState(lifecycleScope) { s -> s.excludeFromRecents }.collect { app.activityManager.appTasks.forEach { task -> task.setExcludeFromRecents(it) } diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 8e5295972a..de69381323 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -25,7 +25,7 @@ import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible import li.songe.gkd.shizuku.shizukuCheckGranted -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.forceUpdateAppList import li.songe.gkd.util.initOrResetAppInfoCache import li.songe.gkd.util.launchTry diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 67d487e07c..e37ddd4ae9 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -54,7 +54,7 @@ import li.songe.gkd.util.UpdateTimeOption import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.componentName import li.songe.gkd.util.launchTry -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.showActionToast import li.songe.gkd.util.toast import li.songe.selector.MatchOption @@ -576,7 +576,7 @@ private fun A11yService.useAliveView() { onA11yConnected { scope.launchTry { - storeFlow.map(scope) { s -> s.enableAbFloatWindow }.collect { + storeFlow.mapState(scope) { s -> s.enableAbFloatWindow }.collect { if (it) { addA11View() } else { @@ -627,7 +627,7 @@ private fun A11yService.useCaptureVolume() { var captureVolumeReceiver: BroadcastReceiver? = null onCreated { scope.launch { - storeFlow.map(scope) { s -> s.captureVolumeChange }.collect { + storeFlow.mapState(scope) { s -> s.captureVolumeChange }.collect { if (captureVolumeReceiver != null) { unregisterReceiver(captureVolumeReceiver) } diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index a61cec87f2..7abac5cd34 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -56,7 +56,7 @@ import li.songe.gkd.util.getIpAddressInLocalNetwork import li.songe.gkd.util.isPortAvailable import li.songe.gkd.util.keepNullJson import li.songe.gkd.util.launchTry -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass import li.songe.gkd.util.subsItemsFlow @@ -79,7 +79,7 @@ class HttpService : Service(), OnCreateToDestroy { val scope = useScope() - private val httpServerPortFlow = storeFlow.map(scope) { s -> s.httpServerPort } + private val httpServerPortFlow = storeFlow.mapState(scope) { s -> s.httpServerPort } init { useLogLifecycle() diff --git a/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt index 0b739b4bcf..279faec2ea 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt @@ -2,10 +2,10 @@ package li.songe.gkd.service import li.songe.gkd.store.storeFlow import li.songe.gkd.store.switchStoreEnableMatch -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState class MatchTileService : BaseTileService() { - override val activeFlow = storeFlow.map(scope) { it.enableMatch } + override val activeFlow = storeFlow.mapState(scope) { it.enableMatch } init { onTileClicked { switchStoreEnableMatch() } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index fe0aa1e700..2f056ce65a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -68,9 +68,9 @@ import li.songe.gkd.ui.component.RotatingLoadingIcon import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalDarkTheme -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalDarkTheme +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index be9689c760..a743181c6c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -66,15 +66,15 @@ import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.throttle @@ -368,7 +368,7 @@ private fun ActionLogDialog( if (actionLog.groupType == SubsConfig.GlobalGroupType) { val subs = remember(actionLog.subsId) { - subsIdToRawFlow.map(scope) { it[actionLog.subsId] } + subsIdToRawFlow.mapState(scope) { it[actionLog.subsId] } }.collectAsState().value val group = subs?.globalGroups?.find { g -> g.key == actionLog.groupKey } val appChecked = if (group != null) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt index 28d2da06fe..4227d82c4b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt @@ -49,7 +49,7 @@ import li.songe.gkd.ui.component.LocalNumberCharWidth import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 2ad50d7772..c5f13c7912 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -82,8 +82,8 @@ import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index 4787fe18e0..87d22c8c68 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -69,8 +69,8 @@ import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.icon.BackCloseIcon -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding @@ -96,7 +96,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { val ruleSortType by vm.ruleSortTypeFlow.collectAsState() val groupSize by vm.groupSizeFlow.collectAsState() - val firstLoading by vm.linkLoad.firstLoadingFlow.collectAsState() + val firstLoading by vm.firstLoadingFlow.collectAsState() val (scrollBehavior, listState) = useListScrollState(groupSize > 0, ruleSortType.value) if (focusLog != null && groupSize > 0) { LaunchedEffect(null) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt index edd2a2a25d..7c42c3132b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt @@ -15,35 +15,31 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.ShowGroupState import li.songe.gkd.ui.component.toGroupState -import li.songe.gkd.util.LinkLoad +import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.util.RuleSortOption -import li.songe.gkd.util.ViewModelExt import li.songe.gkd.util.collator import li.songe.gkd.util.findOption -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.usedSubsEntriesFlow -class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { +class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = AppConfigPageDestination.argsFrom(stateHandle) - val linkLoad = LinkLoad(viewModelScope) - - val ruleSortTypeFlow = storeFlow.map(viewModelScope) { + val ruleSortTypeFlow = storeFlow.mapState(viewModelScope) { RuleSortOption.allSubObject.findOption(it.appRuleSortType) } - val appShowInnerDisableFlow = storeFlow.map(viewModelScope) { + val appShowInnerDisableFlow = storeFlow.mapState(viewModelScope) { it.appShowInnerDisable } - private val usedSubsIdsFlow = subsItemsFlow.map(viewModelScope) { list -> + private val usedSubsIdsFlow = subsItemsFlow.mapState(viewModelScope) { list -> list.filter { it.enable }.map { it.id }.sorted() } - private val appConfigsFlow = DbSet.appConfigDao.queryAppUsedList(args.appId) - .let(linkLoad::invoke) + private val appConfigsFlow = DbSet.appConfigDao.queryAppUsedList(args.appId).attachLoad() private val appUsedSubsIdsFlow = combine(usedSubsIdsFlow, appConfigsFlow) { ids, configs -> ids.filter { @@ -57,19 +53,19 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } else { flowOf(emptyList()) } - }.flattenConcat().let(linkLoad::invoke).stateInit(emptyList()) + }.flattenConcat().attachLoad().stateInit(emptyList()) - val globalSubsConfigsFlow = DbSet.subsConfigDao.queryUsedGlobalConfig().let(linkLoad::invoke) + val globalSubsConfigsFlow = DbSet.subsConfigDao.queryUsedGlobalConfig().attachLoad() .stateInit(emptyList()) val appSubsConfigsFlow = appUsedSubsIdsFlow.map { DbSet.subsConfigDao.queryAppConfig(it, args.appId) - }.flattenConcat().let(linkLoad::invoke) + }.flattenConcat().attachLoad() .stateInit(emptyList()) val categoryConfigsFlow = appUsedSubsIdsFlow.map { DbSet.categoryConfigDao.queryBySubsIds(it) - }.flattenConcat().let(linkLoad::invoke) + }.flattenConcat().attachLoad() .stateInit(emptyList()) private val temp1ListFlow = combine( @@ -126,7 +122,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } } } - }.combine(linkLoad.firstLoadingFlow) { list, firstLoading -> + }.combine(firstLoadingFlow) { list, firstLoading -> if (firstLoading) { emptyList() } else { @@ -134,7 +130,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } }.stateInit(emptyList()) - val groupSizeFlow = subsPairsFlow.map(viewModelScope) { list -> + val groupSizeFlow = subsPairsFlow.mapState(viewModelScope) { list -> list.sumOf { it.second.size } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index db073a2818..7916fa9ccf 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -35,8 +35,8 @@ import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.ManualAuthDialog -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.cardHorizontalPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index 58a029fadb..991e97db9b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -42,8 +42,8 @@ import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.ManualAuthDialog import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.cardHorizontalPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt index 019d1addd5..b8f1a1b3a9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt @@ -42,8 +42,8 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.util.imageLoader import li.songe.gkd.util.throttle diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt index b85c10cbed..8c30273b2d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt @@ -32,8 +32,8 @@ import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListP import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt index 866b92ef9e..724dd9efac 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt @@ -53,7 +53,6 @@ import kotlinx.coroutines.withContext import li.songe.gkd.MainActivity import li.songe.gkd.data.Snapshot import li.songe.gkd.db.DbSet -import li.songe.gkd.util.SnapshotExt import li.songe.gkd.permission.canWriteExternalStorage import li.songe.gkd.permission.requiredPermission import li.songe.gkd.ui.component.EmptyText @@ -63,8 +62,8 @@ import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding @@ -72,6 +71,7 @@ import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.IMPORT_SHORT_URL import li.songe.gkd.util.LIST_PLACEHOLDER_KEY +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.copyText import li.songe.gkd.util.launchAsFn @@ -89,7 +89,7 @@ fun SnapshotPage() { val colorScheme = MaterialTheme.colorScheme val vm = viewModel() - val firstLoading by vm.linkLoad.firstLoadingFlow.collectAsState() + val firstLoading by vm.firstLoadingFlow.collectAsState() val snapshots by vm.snapshotsState.collectAsState() var selectedSnapshot by remember { mutableStateOf(null) } val (scrollBehavior, listState) = useListScrollState(snapshots.isNotEmpty(), firstLoading) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotVm.kt index e5e43c1194..ed2298486f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotVm.kt @@ -1,14 +1,9 @@ package li.songe.gkd.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn import li.songe.gkd.db.DbSet -import li.songe.gkd.util.LinkLoad +import li.songe.gkd.ui.share.BaseViewModel -class SnapshotVm : ViewModel() { - val linkLoad = LinkLoad(viewModelScope) - val snapshotsState = DbSet.snapshotDao.query().let(linkLoad::invoke) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) +class SnapshotVm : BaseViewModel() { + val snapshotsState = DbSet.snapshotDao.query().attachLoad() + .stateInit(emptyList()) } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index cf27b8a3bf..79d5a27f0a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -48,8 +48,8 @@ import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt index 924e2faa37..83ae7c9875 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt @@ -10,13 +10,13 @@ import kotlinx.coroutines.flow.stateIn import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.ShowGroupState -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow class SubsAppGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { private val args = SubsAppGroupListPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } + val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { s -> s[args.subsItemId] } val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(args.subsItemId, args.appId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) @@ -24,7 +24,7 @@ class SubsAppGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val subsAppFlow = subsIdToRawFlow.map(viewModelScope) { subsIdToRaw -> + val subsAppFlow = subsIdToRawFlow.mapState(viewModelScope) { subsIdToRaw -> subsIdToRaw[args.subsItemId]?.apps?.find { it.id == args.appId } ?: RawSubscription.RawApp(id = args.appId, name = null) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index ae137199eb..4541489699 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -55,8 +55,8 @@ import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding @@ -260,7 +260,7 @@ fun SubsAppListPage( } item(LIST_PLACEHOLDER_KEY) { Spacer(modifier = Modifier.height(EmptyHeight)) - val firstLoading by vm.linkLoad.firstLoadingFlow.collectAsState() + val firstLoading by vm.firstLoadingFlow.collectAsState() if (appAndConfigs.isEmpty() && !firstLoading) { EmptyText( text = if (searchStr.isNotEmpty()) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt index 40dc155610..5acb75cd3f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt @@ -11,41 +11,40 @@ import li.songe.gkd.data.AppConfig import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.LinkLoad +import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.util.SortTypeOption -import li.songe.gkd.util.ViewModelExt import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.collator import li.songe.gkd.util.findOption import li.songe.gkd.util.getGroupEnable -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow -class SubsAppListVm(stateHandle: SavedStateHandle) : ViewModelExt() { +class SubsAppListVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = SubsAppListPageDestination.argsFrom(stateHandle) - val linkLoad = LinkLoad(viewModelScope) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } + + val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { s -> s[args.subsItemId] } private val appConfigsFlow = DbSet.appConfigDao.queryAppTypeConfig(args.subsItemId) - .let(linkLoad::invoke).stateInit(emptyList()) + .attachLoad().stateInit(emptyList()) private val groupSubsConfigsFlow = DbSet.subsConfigDao.querySubsGroupTypeConfig(args.subsItemId) - .let(linkLoad::invoke).stateInit(emptyList()) + .attachLoad().stateInit(emptyList()) private val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .let(linkLoad::invoke).stateInit(emptyList()) + .attachLoad().stateInit(emptyList()) private val appIdToOrderFlow = - DbSet.actionLogDao.queryLatestUniqueAppIds(args.subsItemId).let(linkLoad::invoke) + DbSet.actionLogDao.queryLatestUniqueAppIds(args.subsItemId).attachLoad() .map { appIds -> appIds.mapIndexed { index, appId -> appId to index }.toMap() } val sortTypeFlow = - storeFlow.map(viewModelScope) { SortTypeOption.allSubObject.findOption(it.subsAppSortType) } + storeFlow.mapState(viewModelScope) { SortTypeOption.allSubObject.findOption(it.subsAppSortType) } - val showUninstallAppFlow = storeFlow.map(viewModelScope) { it.subsAppShowUninstallApp } - private val rawAppsFlow = subsRawFlow.map(viewModelScope) { - (it?.apps ?: emptyList()).run { + val showUninstallAppFlow = storeFlow.mapState(viewModelScope) { it.subsAppShowUninstallApp } + private val rawAppsFlow = subsRawFlow.mapState(viewModelScope) { subs -> + (subs?.apps ?: emptyList()).run { if (any { it.groups.isEmpty() }) { filterNot { it.groups.isEmpty() } } else { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt index 9a82515bdc..0c07dc7af8 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt @@ -58,8 +58,8 @@ import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.ResetSettings -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt index d1412c00cb..adc1ee647c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt @@ -10,13 +10,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow class SubsCategoryVm(stateHandle: SavedStateHandle) : ViewModel() { private val args = SubsCategoryPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { m -> m[args.subsItemId] } + val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { m -> m[args.subsItemId] } val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index f21ffbc297..a1395fdca3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -69,7 +69,7 @@ import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemFlagPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt index 16f31ed243..095df68be9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt @@ -15,17 +15,17 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.findOption -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.orderedAppInfosFlow import li.songe.gkd.util.subsIdToRawFlow class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { private val args = SubsGlobalGroupExcludePageDestination.argsFrom(stateHandle) - val rawSubsFlow = subsIdToRawFlow.map(viewModelScope) { it[args.subsItemId] } + val rawSubsFlow = subsIdToRawFlow.mapState(viewModelScope) { it[args.subsItemId] } val groupFlow = - rawSubsFlow.map(viewModelScope) { r -> r?.globalGroups?.find { g -> g.key == args.groupKey } } + rawSubsFlow.mapState(viewModelScope) { r -> r?.globalGroups?.find { g -> g.key == args.groupKey } } val disabledAppSetFlow = groupFlow.map { g -> (g?.apps ?: emptyList()).filter { a -> a.enable == false }.map { a -> a.id }.toSet() @@ -35,7 +35,7 @@ class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId, args.groupKey) .stateIn(viewModelScope, SharingStarted.Eagerly, null) - val excludeDataFlow = subsConfigFlow.map(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } + val excludeDataFlow = subsConfigFlow.mapState(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } val searchStrFlow = MutableStateFlow("") private val debounceSearchStrFlow = searchStrFlow.debounce(200) @@ -45,12 +45,12 @@ class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { DbSet.actionLogDao.queryLatestUniqueAppIds(args.subsItemId, args.groupKey).map { appIds -> appIds.mapIndexed { index, appId -> appId to index }.toMap() } - val sortTypeFlow = storeFlow.map(viewModelScope) { + val sortTypeFlow = storeFlow.mapState(viewModelScope) { SortTypeOption.allSubObject.findOption(it.subsExcludeSortType) } - val showSystemAppFlow = storeFlow.map(viewModelScope) { it.subsExcludeShowSystemApp } - val showHiddenAppFlow = storeFlow.map(viewModelScope) { it.subsExcludeShowHiddenApp } - val showDisabledAppFlow = storeFlow.map(viewModelScope) { it.subsExcludeShowDisabledApp } + val showSystemAppFlow = storeFlow.mapState(viewModelScope) { it.subsExcludeShowSystemApp } + val showHiddenAppFlow = storeFlow.mapState(viewModelScope) { it.subsExcludeShowHiddenApp } + val showDisabledAppFlow = storeFlow.mapState(viewModelScope) { it.subsExcludeShowDisabledApp } val showAppInfosFlow = orderedAppInfosFlow.combine(showHiddenAppFlow) { appInfos, showHiddenApp -> if (showHiddenApp) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index c65d240dd0..85079dd976 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -47,8 +47,8 @@ import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt index 27a56a3a91..14e225a96c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt @@ -9,12 +9,12 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.ShowGroupState -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow class SubsGlobalGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { private val args = SubsGlobalGroupListPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } + val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { s -> s[args.subsItemId] } val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt index 0427269175..ea48970449 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt @@ -43,8 +43,8 @@ import kotlinx.coroutines.withContext import li.songe.gkd.MainActivity import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalDarkTheme -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalDarkTheme +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.getJson5Transformation import li.songe.gkd.ui.style.scaffoldPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt index 352af82ae0..876ccbcd00 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt @@ -52,8 +52,8 @@ import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.data.Value import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.client diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt index 20485377fc..25f8a68d40 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt @@ -18,7 +18,7 @@ import li.songe.gkd.data.ExcludeData import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.launchTry import li.songe.gkd.util.throttle import li.songe.gkd.util.toast diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt index b14f26a8cd..e2f6c23049 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt @@ -22,13 +22,13 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Density import kotlinx.coroutines.delay import li.songe.gkd.data.RawSubscription -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow @Composable fun useSubs(subsId: Long?): RawSubscription? { val scope = rememberCoroutineScope() - return remember(subsId) { subsIdToRawFlow.map(scope) { it[subsId] } }.collectAsState().value + return remember(subsId) { subsIdToRawFlow.mapState(scope) { it[subsId] } }.collectAsState().value } @Composable diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt index fb4609e1fd..ccb0c9cbc4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.throttle @Composable diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt index 7e2bf2bf43..ae4710c91c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.window.DialogProperties import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination import kotlinx.coroutines.flow.MutableStateFlow -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.throttle diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt index 3ac2c50d7f..e7dcd5581a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.copyText import li.songe.gkd.util.throttle diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt index 5aa878706f..9bd1801c5d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt @@ -24,7 +24,7 @@ import androidx.lifecycle.viewModelScope import li.songe.gkd.MainActivity import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.permission.requiredPermission -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.mayQueryPkgNoAccessFlow diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt index 887749bbe6..81fcc86d38 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt @@ -49,7 +49,7 @@ import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet import li.songe.gkd.ui.getGlobalGroupChecked import li.songe.gkd.ui.icon.ResetSettings -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.getGroupEnable import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt index 66f736b049..dcc8416c09 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt @@ -41,9 +41,9 @@ import com.ramcosta.composedestinations.utils.currentDestinationAsState import kotlinx.coroutines.delay import li.songe.gkd.data.RawSubscription import li.songe.gkd.ui.icon.ResetSettings -import li.songe.gkd.ui.local.LocalDarkTheme -import li.songe.gkd.ui.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +import li.songe.gkd.ui.share.LocalDarkTheme +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.getJson5AnnotatedString import li.songe.gkd.util.copyText import li.songe.gkd.util.throttle diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt index 9656e61920..25fae28c1c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt @@ -20,7 +20,7 @@ import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet import li.songe.gkd.ui.getGlobalGroupChecked -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.getGroupEnable import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.subsIdToRawFlow diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt index 9431009147..6f7c1c9398 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt @@ -33,9 +33,9 @@ import li.songe.gkd.META import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.ui.home.HomeVm -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.formatTimeAgo -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.subsLoadErrorsFlow import li.songe.gkd.util.subsRefreshErrorsFlow import li.songe.gkd.util.throttle @@ -57,10 +57,10 @@ fun SubsItemCard( ) { val mainVm = LocalMainViewModel.current val subsLoadError by remember(subsItem.id) { - subsLoadErrorsFlow.map(vm.viewModelScope) { it[subsItem.id] } + subsLoadErrorsFlow.mapState(vm.viewModelScope) { it[subsItem.id] } }.collectAsState() val subsRefreshError by remember(subsItem.id) { - subsRefreshErrorsFlow.map(vm.viewModelScope) { it[subsItem.id] } + subsRefreshErrorsFlow.mapState(vm.viewModelScope) { it[subsItem.id] } }.collectAsState() val subsRefreshing by updateSubsMutex.state.collectAsState() val dragged by interactionSource.collectIsDraggedAsState() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt index bcfd963647..dedccec0a9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt @@ -49,7 +49,7 @@ import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupLi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import li.songe.gkd.META -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.util.LOCAL_SUBS_ID diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TermsAcceptDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TermsAcceptDialog.kt index 3bf6c5c692..f0dce0f080 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TermsAcceptDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TermsAcceptDialog.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink import li.songe.gkd.MainActivity -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.throttle diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 6a5f9ea002..a5fb4dcfa7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -53,7 +53,7 @@ import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.appItemPadding import li.songe.gkd.ui.style.menuPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index d8acde28c8..f446fadd16 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -62,7 +62,7 @@ import li.songe.gkd.service.switchA11yService import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.GroupNameText import li.songe.gkd.ui.component.textSize -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.itemVerticalPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt index eee0462d90..8f625f25cb 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions data class BottomNavItem( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt index 9721851821..eca080d68c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt @@ -18,7 +18,7 @@ import li.songe.gkd.util.EMPTY_RULE_TIP import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.getSubsStatus -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.orderedAppInfosFlow import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.subsIdToRawFlow @@ -29,7 +29,7 @@ class HomeVm : ViewModel() { val latestRecordFlow = DbSet.actionLogDao.queryLatest().stateIn(viewModelScope, SharingStarted.Eagerly, null) val latestRecordIsGlobalFlow = - latestRecordFlow.map(viewModelScope) { it?.groupType == SubsConfig.GlobalGroupType } + latestRecordFlow.mapState(viewModelScope) { it?.groupType == SubsConfig.GlobalGroupType } val latestRecordDescFlow = combine( latestRecordFlow, subsIdToRawFlow, appInfoCacheFlow ) { latestRecord, subsIdToRaw, appInfoCache -> @@ -63,18 +63,18 @@ class HomeVm : ViewModel() { }.stateIn(appScope, SharingStarted.Eagerly, EMPTY_RULE_TIP) } - val usedSubsItemCountFlow = usedSubsEntriesFlow.map(viewModelScope) { it.size } + val usedSubsItemCountFlow = usedSubsEntriesFlow.mapState(viewModelScope) { it.size } private val appIdToOrderFlow = DbSet.actionLogDao.queryLatestUniqueAppIds().map { appIds -> appIds.mapIndexed { index, appId -> appId to index }.toMap() } - val sortTypeFlow = storeFlow.map(viewModelScope) { s -> + val sortTypeFlow = storeFlow.mapState(viewModelScope) { s -> SortTypeOption.allSubObject.find { o -> o.value == s.sortType } ?: SortTypeOption.SortByName } - val showSystemAppFlow = storeFlow.map(viewModelScope) { s -> s.showSystemApp } - val showHiddenAppFlow = storeFlow.map(viewModelScope) { s -> s.showHiddenApp } + val showSystemAppFlow = storeFlow.mapState(viewModelScope) { s -> s.showSystemApp } + val showHiddenAppFlow = storeFlow.mapState(viewModelScope) { s -> s.showHiddenApp } val showSearchBarFlow = MutableStateFlow(false) val searchStrFlow = MutableStateFlow("") private val debounceSearchStrFlow = searchStrFlow.debounce(200) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index d8b6b99784..e326bf15f3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -49,7 +49,7 @@ import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 9c409bfc6b..b207464d10 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -75,7 +75,7 @@ import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.SubsItemCard import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.util.LIST_PLACEHOLDER_KEY @@ -89,7 +89,7 @@ import li.songe.gkd.util.findOption import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry -import li.songe.gkd.util.map +import li.songe.gkd.util.mapState import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow @@ -279,7 +279,7 @@ fun useSubsManagePage(): ScaffoldExt { IconButton(onClick = throttle { switchStoreEnableMatch() }) { val scope = rememberCoroutineScope() val enableMatch by remember { - storeFlow.map(scope) { s -> s.enableMatch } + storeFlow.mapState(scope) { s -> s.enableMatch } }.collectAsState() val id = if (enableMatch) SafeR.ic_flash_on else SafeR.ic_flash_off Icon( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt new file mode 100644 index 0000000000..4f96fabbf3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt @@ -0,0 +1,38 @@ +package li.songe.gkd.ui.share + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import li.songe.gkd.util.mapState + + +abstract class BaseViewModel : ViewModel() { + private val countFlow by lazy { MutableStateFlow(0) } + val firstLoadingFlow by lazy { countFlow.mapState(viewModelScope) { it > 0 } } + fun Flow.attachLoad(): Flow { + countFlow.update { it + 1 } + var currentUsed = false + return onEach { + if (!currentUsed) { + countFlow.update { + if (!currentUsed) { + currentUsed = true + it - 1 + } else { + it + } + } + } + } + } + + fun Flow.stateInit(initialValue: T): StateFlow { + return stateIn(viewModelScope, SharingStarted.Eagerly, initialValue) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/local/LocalExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt similarity index 93% rename from app/src/main/kotlin/li/songe/gkd/ui/local/LocalExt.kt rename to app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt index 415645fc52..4b46fadd14 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/local/LocalExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.ui.local +package li.songe.gkd.ui.share import androidx.compose.runtime.compositionLocalOf import androidx.navigation.NavHostController diff --git a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt b/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt index 7268844840..63b02d8fdb 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.local.LocalDarkTheme +import li.songe.gkd.ui.share.LocalDarkTheme private val LightColorScheme = lightColorScheme() private val DarkColorScheme = darkColorScheme() diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index a09382ff90..af4b6bbf49 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -34,15 +34,15 @@ val appInfoCacheFlow by lazy { } val systemAppInfoCacheFlow by lazy { - appInfoCacheFlow.map(appScope) { c -> + appInfoCacheFlow.mapState(appScope) { c -> c.filter { a -> a.value.isSystem } } } -val systemAppsFlow by lazy { systemAppInfoCacheFlow.map(appScope) { c -> c.keys } } +val systemAppsFlow by lazy { systemAppInfoCacheFlow.mapState(appScope) { c -> c.keys } } val orderedAppInfosFlow by lazy { - appInfoCacheFlow.map(appScope) { c -> + appInfoCacheFlow.mapState(appScope) { c -> c.values.sortedWith { a, b -> collator.compare(a.name, b.name) } @@ -57,7 +57,7 @@ private fun Map.getMayQueryPkgNoAccess(): Boolean { // 某些设备在应用更新后出现权限错乱/缓存错乱 private const val MINIMUM_NORMAL_APP_SIZE = 8 val mayQueryPkgNoAccessFlow by lazy { - userAppInfoMapFlow.map(appScope) { it.getMayQueryPkgNoAccess() } + userAppInfoMapFlow.mapState(appScope) { it.getMayQueryPkgNoAccess() } } private val willUpdateAppIds by lazy { MutableStateFlow(emptySet()) } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Copy.kt b/app/src/main/kotlin/li/songe/gkd/util/Copy.kt deleted file mode 100644 index 70b493f899..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/util/Copy.kt +++ /dev/null @@ -1,8 +0,0 @@ -package li.songe.gkd.util - -import com.blankj.utilcode.util.ClipboardUtils - -fun copyText(text: String) { - ClipboardUtils.copyText(text) - toast("复制成功") -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/FlowExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FlowExt.kt index 146574e7fa..a18f6f12b9 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FlowExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FlowExt.kt @@ -6,10 +6,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -// https://github.com/Kotlin/kotlinx.coroutines/issues/2514 -fun StateFlow.map( + +fun StateFlow.mapState( coroutineScope: CoroutineScope, mapper: (value: T) -> M, ): StateFlow = map { mapper(it) }.stateIn( coroutineScope, SharingStarted.Eagerly, mapper(value) -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/li/songe/gkd/util/Github.kt b/app/src/main/kotlin/li/songe/gkd/util/Github.kt index bdd77b92cb..3fa30ab66f 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Github.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Github.kt @@ -41,7 +41,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import li.songe.gkd.data.GithubPoliciesAsset import li.songe.gkd.ui.component.autoFocus -import li.songe.gkd.ui.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.json5.Json5 import java.io.File diff --git a/app/src/main/kotlin/li/songe/gkd/util/LinkLoad.kt b/app/src/main/kotlin/li/songe/gkd/util/LinkLoad.kt index e53d45520e..3138a11254 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LinkLoad.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LinkLoad.kt @@ -1,29 +1,2 @@ package li.songe.gkd.util -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update - -// 避免打开页面时短时间内数据未加载完成导致短暂显示的空数据提示 -class LinkLoad(scope: CoroutineScope) { - private val firstLoadCountFlow = MutableStateFlow(0) - val firstLoadingFlow by lazy { firstLoadCountFlow.map(scope) { it > 0 } } - fun invoke(targetFlow: Flow): Flow { - firstLoadCountFlow.update { it + 1 } - var used = false - return targetFlow.onEach { - if (!used) { - firstLoadCountFlow.update { - if (!used) { - used = true - it - 1 - } else { - it - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 5295bfb5a9..9b684dbbad 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -20,6 +20,7 @@ import android.widget.Toast import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt +import com.blankj.utilcode.util.ClipboardUtils import com.blankj.utilcode.util.ScreenUtils import com.hjq.toast.Toaster import com.hjq.toast.style.WhiteToastStyle @@ -162,6 +163,11 @@ private fun showAccessibilityToast(context: AccessibilityService, message: CharS }, triggerInterval) } +fun copyText(text: String) { + ClipboardUtils.copyText(text) + toast("复制成功") +} + fun initToast() { Toaster.init(app) setReactiveToastStyle() diff --git a/app/src/main/kotlin/li/songe/gkd/util/ViewModelExt.kt b/app/src/main/kotlin/li/songe/gkd/util/ViewModelExt.kt deleted file mode 100644 index ce739408c5..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/util/ViewModelExt.kt +++ /dev/null @@ -1,14 +0,0 @@ -package li.songe.gkd.util - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn - -open class ViewModelExt : ViewModel() { - fun Flow.stateInit(initialValue: T): StateFlow { - return stateIn(viewModelScope, SharingStarted.Eagerly, initialValue) - } -} From d94ea6102fe32f5f91facb76ee3af42841133ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 11 Aug 2025 11:04:11 +0800 Subject: [PATCH 019/245] fix: titleItemPadding --- app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index c5f13c7912..c04f6f0149 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -198,6 +198,7 @@ fun AdvancedPage() { .padding(contentPadding), ) { Text( + modifier = Modifier.titleItemPadding(showTop = false), text = "Shizuku", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, From 8dabb6aa66372f8ba2b3b674a3878842905f9aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 17 Aug 2025 18:36:12 +0800 Subject: [PATCH 020/245] refactor: A11yRuleEngine --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 50 +- app/src/main/kotlin/li/songe/gkd/App.kt | 24 + .../main/kotlin/li/songe/gkd/MainActivity.kt | 34 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 65 +- .../kotlin/li/songe/gkd/OpenTileActivity.kt | 11 + .../gkd/{service => a11y}/A11yContext.kt | 7 +- .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 147 +++- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 198 +++++ .../li/songe/gkd/a11y/A11yRuleEngine.kt | 297 ++++++++ .../songe/gkd/{service => a11y}/A11yState.kt | 181 ++--- .../main/kotlin/li/songe/gkd/data/AttrInfo.kt | 2 +- .../kotlin/li/songe/gkd/data/GkdAction.kt | 29 +- .../kotlin/li/songe/gkd/data/GlobalRule.kt | 2 +- .../main/kotlin/li/songe/gkd/data/NodeInfo.kt | 4 +- .../li/songe/gkd/data/RawSubscription.kt | 2 +- .../kotlin/li/songe/gkd/data/ResolvedRule.kt | 67 +- .../main/kotlin/li/songe/gkd/notif/Notif.kt | 27 +- .../li/songe/gkd/notif/StopServiceReceiver.kt | 18 +- .../songe/gkd/permission/PermissionState.kt | 82 +-- .../kotlin/li/songe/gkd/service/A11yEvent.kt | 30 - .../li/songe/gkd/service/A11yService.kt | 689 +----------------- .../li/songe/gkd/service/BaseTileService.kt | 4 +- .../li/songe/gkd/service/GkdTileService.kt | 34 +- .../li/songe/gkd/service/HttpService.kt | 4 +- .../kotlin/li/songe/gkd/service/NodeExt.kt | 89 --- .../songe/gkd/service/OverlayWindowService.kt | 53 +- .../li/songe/gkd/service/RecordService.kt | 1 + .../li/songe/gkd/service/ScreenshotService.kt | 17 +- .../songe/gkd/service/SnapshotTileService.kt | 13 +- .../li/songe/gkd/service/StatusService.kt | 4 +- .../songe/gkd/shizuku/ActivityTaskManager.kt | 156 +--- .../li/songe/gkd/shizuku/AutoStartReceiver.kt | 20 - .../li/songe/gkd/shizuku/CommandResult.kt | 10 - .../li/songe/gkd/shizuku/PackageManager.kt | 69 +- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 95 ++- .../li/songe/gkd/shizuku/TaskListener.kt | 19 - .../li/songe/gkd/shizuku/TaskStackListener.kt | 44 ++ .../li/songe/gkd/shizuku/UserManager.kt | 105 ++- .../li/songe/gkd/shizuku/UserService.kt | 25 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 1 - .../gkd/ui/SubsGlobalGroupExcludePage.kt | 6 +- .../kotlin/li/songe/gkd/util/IntentExt.kt | 9 + .../li/songe/gkd/util/LifecycleCallbacks.kt | 102 +-- .../kotlin/li/songe/gkd/util/PackageExt.kt | 20 - .../kotlin/li/songe/gkd/util/SnapshotExt.kt | 41 +- .../kotlin/li/songe/gkd/util/SubsState.kt | 12 +- .../main/kotlin/li/songe/gkd/util/Toast.kt | 16 +- build.gradle.kts | 1 + gradle/libs.versions.toml | 13 +- .../java/android/app/ITaskStackListener.java | 3 + .../main/java/android/os/IUserManager.java | 8 +- 52 files changed, 1380 insertions(+), 1583 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt rename app/src/main/kotlin/li/songe/gkd/{service => a11y}/A11yContext.kt (99%) create mode 100644 app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt rename app/src/main/kotlin/li/songe/gkd/{service => a11y}/A11yState.kt (66%) delete mode 100644 app/src/main/kotlin/li/songe/gkd/service/A11yEvent.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/service/NodeExt.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/AutoStartReceiver.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/TaskListener.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/util/PackageExt.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 141fb98b80..bc7b773307 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,7 @@ plugins { alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlinx.atomicfu) alias(libs.plugins.google.ksp) } @@ -246,6 +247,8 @@ dependencies { implementation(libs.google.accompanist.drawablepainter) implementation(libs.kotlinx.serialization.json) + // https://github.com/Kotlin/kotlinx-atomicfu/issues/145 + implementation(libs.kotlinx.atomicfu) implementation(libs.utilcodex) implementation(libs.activityResultLauncher) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 417487c96c..0e7990e04a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,11 +65,7 @@ - - - - - + + + + + - + + + + - + @@ -195,6 +209,9 @@ android:icon="@drawable/ic_http" android:label="@string/http_server" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + @@ -205,6 +222,9 @@ android:icon="@drawable/ic_radio_button" android:label="@string/snapshot_button" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + @@ -215,6 +235,9 @@ android:icon="@drawable/ic_flash_on" android:label="@string/rule_match" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + @@ -225,21 +248,14 @@ android:icon="@drawable/ic_layers" android:label="@string/record_activity" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + - - - - { + return (getSecureString(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) ?: "").split( + ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR + ).toHashSet() + } + + fun putSecureA11yServices(services: Set) { + putSecureString( + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + services.joinToString(ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR.toString()) + ) + } + val startTime = System.currentTimeMillis() val activityManager by lazy { app.getSystemService(ACTIVITY_SERVICE) as ActivityManager } @@ -103,5 +126,6 @@ class App : Application() { initSubsState() clearHttpSubs() syncFixState() + StatusService.autoStart() } } diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 45fb5368e6..949d4ef49a 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -63,6 +63,10 @@ import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import li.songe.gkd.a11y.topActivityFlow +import li.songe.gkd.a11y.updateImeAppId +import li.songe.gkd.a11y.updateLauncherAppId +import li.songe.gkd.a11y.updateTopActivity import li.songe.gkd.permission.AuthDialog import li.songe.gkd.permission.updatePermissionState import li.songe.gkd.service.A11yService @@ -71,8 +75,6 @@ import li.songe.gkd.service.HttpService import li.songe.gkd.service.ScreenshotService import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService -import li.songe.gkd.service.updateDefaultInputAppId -import li.songe.gkd.service.updateLauncherAppId import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.BuildDialog import li.songe.gkd.ui.component.ShareDataDialog @@ -95,6 +97,7 @@ import li.songe.gkd.util.openUri import li.songe.gkd.util.shizukuAppId import li.songe.gkd.util.throttle import li.songe.gkd.util.toast +import kotlin.concurrent.Volatile import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName @@ -160,7 +163,6 @@ class MainActivity : ComponentActivity() { mainVm launcher pickContentLauncher - StatusService.autoStart() lifecycleScope.launch { storeFlow.mapState(lifecycleScope) { s -> s.excludeFromRecents }.collect { app.activityManager.appTasks.forEach { task -> @@ -214,7 +216,10 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() - activityVisibleFlow.update { it + 1 } + activityVisibleState++ + if (topActivityFlow.value.appId != META.appId) { + updateTopActivity(META.appId, MainActivity::class.jvmName) + } } var isFirstResume = true @@ -229,7 +234,7 @@ class MainActivity : ComponentActivity() { override fun onStop() { super.onStop() - activityVisibleFlow.update { it - 1 } + activityVisibleState-- } private var lastBackPressedTime = 0L @@ -246,16 +251,19 @@ class MainActivity : ComponentActivity() { } } -private val activityVisibleFlow by lazy { MutableStateFlow(0) } -fun isActivityVisible() = activityVisibleFlow.value > 0 +@Volatile +private var activityVisibleState = 0 +fun isActivityVisible() = activityVisibleState > 0 + +val activityNavSourceName by lazy { META.appId + ".activity.nav.source" } fun Activity.navToMainActivity() { - val intent = this.intent?.cloneFilter() if (intent != null) { - intent.component = MainActivity::class.componentName - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - intent.putExtra("source", this::class.qualifiedName) - startActivity(intent) + val navIntent = Intent(intent) + navIntent.component = MainActivity::class.componentName + navIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + navIntent.putExtra(activityNavSourceName, this::class.jvmName) + startActivity(navIntent) } finish() } @@ -286,7 +294,7 @@ fun syncFixState() { // 每次切换页面更新记录桌面 appId updateLauncherAppId() - updateDefaultInputAppId() + updateImeAppId() // 由于某些机型的进程存在 安装缓存/崩溃缓存 导致服务状态可能不正确, 在此保证每次界面切换都能重新刷新状态 updateServiceRunning() diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index e036d06049..ea6327577b 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -1,12 +1,10 @@ package li.songe.gkd import android.app.Activity -import android.content.ComponentName import android.content.Intent import android.net.Uri -import android.os.Build +import android.os.Handler import android.os.Looper -import android.service.quicksettings.TileService import android.webkit.URLUtil import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -52,6 +50,7 @@ import li.songe.gkd.util.UpdateStatus import li.songe.gkd.util.clearCache import li.songe.gkd.util.client import li.songe.gkd.util.componentName +import li.songe.gkd.util.extraCptName import li.songe.gkd.util.launchTry import li.songe.gkd.util.openUri import li.songe.gkd.util.openWeChatScaner @@ -62,6 +61,7 @@ import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex import li.songe.gkd.util.updateSubscription import rikka.shizuku.Shizuku +import kotlin.reflect.jvm.jvmName private var tempTermsAccepted = false @@ -184,6 +184,12 @@ class MainViewModel : ViewModel() { if (direction.route == navController.currentDestination?.route) { return } + if (Looper.getMainLooper() != Looper.myLooper()) { + Handler(Looper.getMainLooper()).postDelayed({ + navigatePage(direction, builder) + }, 0) + return + } if (builder != null) { navController.navigate(direction.route, builder) } else { @@ -214,35 +220,42 @@ class MainViewModel : ViewModel() { } } - fun handleIntent(intent: Intent) = viewModelScope.launchTry(Dispatchers.Main) { + fun handleIntent(intent: Intent) = viewModelScope.launchTry { LogUtils.d("handleIntent", intent) + val sourceName = intent.getStringExtra(activityNavSourceName) val uri = intent.data?.normalizeScheme() - if (uri?.scheme == "gkd") { - delay(200) - handleGkdUri(uri) - } else if (uri != null && intent.getStringExtra("source") == OpenFileActivity::class.qualifiedName) { - toast("加载导入中...") - tabFlow.value = subsNav - withContext(Dispatchers.IO) { importData(uri) } - } else if (intent.action == TileService.ACTION_QS_TILE_PREFERENCES) { - val qsTileCpt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_COMPONENT_NAME) as ComponentName? - } ?: return@launchTry - delay(200) - when (qsTileCpt) { - HttpTileService::class.componentName, ButtonTileService::class.componentName, RecordTileService::class.componentName -> { - navigatePage(AdvancedPageDestination) + when (sourceName) { + OpenSchemeActivity::class.jvmName -> { + if (uri?.scheme == "gkd") { + delay(200) + handleGkdUri(uri) } + } - SnapshotTileService::class.componentName -> { - navigatePage(SnapshotPageDestination) + OpenFileActivity::class.jvmName -> { + if (uri != null) { + toast("加载导入中...") + tabFlow.value = subsNav + withContext(Dispatchers.IO) { importData(uri) } } + } - MatchTileService::class.componentName -> { - tabFlow.value = subsNav + OpenTileActivity::class.jvmName -> { + val qsTileCpt = intent.extraCptName + when (qsTileCpt) { + HttpTileService::class.componentName, ButtonTileService::class.componentName, RecordTileService::class.componentName -> { + delay(200) + navigatePage(AdvancedPageDestination) + } + + SnapshotTileService::class.componentName -> { + delay(200) + navigatePage(SnapshotPageDestination) + } + + MatchTileService::class.componentName -> { + tabFlow.value = subsNav + } } } } diff --git a/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt b/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt new file mode 100644 index 0000000000..84ef726866 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt @@ -0,0 +1,11 @@ +package li.songe.gkd + +import android.app.Activity +import android.os.Bundle + +class OpenTileActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + navToMainActivity() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yContext.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt similarity index 99% rename from app/src/main/kotlin/li/songe/gkd/service/A11yContext.kt rename to app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt index 284c8cb254..057b58935f 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yContext.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.service +package li.songe.gkd.a11y import android.graphics.Rect import android.util.Log @@ -6,6 +6,7 @@ import android.util.LruCache import android.view.accessibility.AccessibilityNodeInfo import li.songe.gkd.META import li.songe.gkd.data.ResolvedRule +import li.songe.gkd.service.A11yService import li.songe.gkd.util.InterruptRuleMatchException import li.songe.selector.FastQuery import li.songe.selector.MatchOption @@ -133,12 +134,12 @@ class A11yContext( private fun getA11Child(node: AccessibilityNodeInfo, index: Int): AccessibilityNodeInfo? { guardInterrupt() - return node.getChild(index)?.apply { setGeneratedTime() } + return node.getChild(index)?.setGeneratedTime() } private fun getA11Parent(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { guardInterrupt() - return node.parent?.apply { setGeneratedTime() } + return node.parent?.setGeneratedTime() } private fun getA11ByText( diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index 59d6e882eb..16a02ad481 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -1,20 +1,30 @@ package li.songe.gkd.a11y +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.AccessibilityService.ScreenshotResult +import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback import android.content.ComponentName import android.database.ContentObserver +import android.graphics.Bitmap +import android.os.Build import android.provider.Settings +import android.view.Display +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.app import li.songe.gkd.service.A11yService +import li.songe.selector.initDefaultTypeInfo +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine context(vm: ViewModel) fun useA11yServiceEnabledFlow(): StateFlow { val stateFlow = MutableStateFlow(getA11yServiceEnabled()) val contextObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean) { - super.onChange(selfChange) stateFlow.value = getA11yServiceEnabled() } } @@ -29,11 +39,132 @@ fun useA11yServiceEnabledFlow(): StateFlow { return stateFlow } -private fun getA11yServiceEnabled(): Boolean { - return (Settings.Secure.getString( - app.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES - ) ?: "").split(';').any { - ComponentName.unflattenFromString(it) == A11yService.a11yComponentName +private fun getA11yServiceEnabled(): Boolean = app.getSecureA11yServices().any { + ComponentName.unflattenFromString(it) == A11yService.a11yComponentName +} + +const val STATE_CHANGED = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED +const val CONTENT_CHANGED = AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED + +// 某些应用耗时 300ms +private val AccessibilityEvent.safeSource: AccessibilityNodeInfo? + get() = if (className == null) { + null // https://github.com/gkd-kit/gkd/issues/426 event.clear 已被系统调用 + } else { + try { + source?.setGeneratedTime() + } catch (_: Exception) { + // 原因未知, 仍然报错 Cannot perform this action on a not sealed instance. + null + } + } + +fun AccessibilityNodeInfo.getVid(): CharSequence? { + val id = viewIdResourceName ?: return null + val appId = packageName ?: return null + if (id.startsWith(appId) && id.startsWith(":id/", appId.length)) { + return id.subSequence( + appId.length + ":id/".length, + id.length + ) + } + return null +} + +// https://github.com/gkd-kit/gkd/issues/115 +// https://github.com/gkd-kit/gkd/issues/650 +// 限制节点遍历的数量避免内存溢出 +const val MAX_CHILD_SIZE = 512 +const val MAX_DESCENDANTS_SIZE = 4096 + +private const val A11Y_NODE_TIME_KEY = "generatedTime" +fun AccessibilityNodeInfo.setGeneratedTime(): AccessibilityNodeInfo { + extras.putLong(A11Y_NODE_TIME_KEY, System.currentTimeMillis()) + return this +} + +fun AccessibilityNodeInfo.isExpired(expiryMillis: Long): Boolean { + val generatedTime = extras.getLong(A11Y_NODE_TIME_KEY, -1) + if (generatedTime == -1L) { + // https://github.com/gkd-kit/gkd/issues/759 + return true + } + return (System.currentTimeMillis() - generatedTime) > expiryMillis +} + +val typeInfo by lazy { initDefaultTypeInfo().globalType } + +val AccessibilityNodeInfo.compatChecked: Boolean? + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + when (checked) { + AccessibilityNodeInfo.CHECKED_STATE_TRUE -> true + AccessibilityNodeInfo.CHECKED_STATE_FALSE -> false + AccessibilityNodeInfo.CHECKED_STATE_PARTIAL -> null + else -> null + } + } else { + @Suppress("DEPRECATION") + isChecked + } + + +private const val interestedEvents = STATE_CHANGED or CONTENT_CHANGED +val AccessibilityEvent.isUseful: Boolean + get() = packageName != null && className != null && eventType.and(interestedEvents) != 0 + + +suspend fun AccessibilityService.screenshot(): Bitmap? = suspendCoroutine { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + it.resume(null) + } else { + val callback = object : TakeScreenshotCallback { + override fun onSuccess(screenshot: ScreenshotResult) { + try { + it.resume( + Bitmap.wrapHardwareBuffer( + screenshot.hardwareBuffer, screenshot.colorSpace + ) + ) + } finally { + screenshot.hardwareBuffer.close() + } + } + + override fun onFailure(errorCode: Int) = it.resume(null) + } + takeScreenshot( + Display.DEFAULT_DISPLAY, + application.mainExecutor, + callback + ) } -} \ No newline at end of file +} + +data class A11yEvent( + val type: Int, + val time: Long, + val appId: String, + val className: String, + val event: AccessibilityEvent, +) { + val safeSource: AccessibilityNodeInfo? + get() = event.safeSource + + fun sameAs(other: A11yEvent): Boolean { + if (other === this) return true + return type == other.type && appId == other.appId && className == other.className + } +} + +// AccessibilityEvent 的 clear 方法会在后续时间被 某些系统 调用导致内部数据丢失, 导致异步子线程获取到的数据不一致 +fun AccessibilityEvent.toA11yEvent(): A11yEvent? { + val appId = packageName ?: return null + val b = className ?: return null + return A11yEvent( + type = eventType, + time = System.currentTimeMillis(), + appId = appId.toString(), + className = b.toString(), + event = this, + ) +} diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt new file mode 100644 index 0000000000..4e2710035a --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -0,0 +1,198 @@ +package li.songe.gkd.a11y + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.WINDOW_SERVICE +import android.content.Intent +import android.content.IntentFilter +import android.graphics.PixelFormat +import android.view.View +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import androidx.core.content.ContextCompat +import com.blankj.utilcode.util.LogUtils +import com.blankj.utilcode.util.ScreenUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import li.songe.gkd.appScope +import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.service.A11yService +import li.songe.gkd.store.shizukuStoreFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.SnapshotExt +import li.songe.gkd.util.UpdateTimeOption +import li.songe.gkd.util.checkSubsUpdate +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.mapState + + +context(service: A11yService) +fun onA11yFeatInit() = service.run { + useAttachState() + useAliveOverlayView() + useCaptureVolume() + useRuleChangedLog() + onA11yEvent { onA11yFeatEvent(it) } +} + +private fun A11yService.useAttachState() { + useAliveToast("无障碍", onlyWhenVisible = true) + onCreated { storeFlow.update { it.copy(enableService = true) } } + onDestroyed { storeFlow.update { it.copy(enableService = false) } } +} + +private fun onA11yFeatEvent(event: AccessibilityEvent) = event.run { + if (event.eventType == STATE_CHANGED) { + watchCaptureScreenshot() + if (event.packageName == launcherAppId) { + watchCheckShizukuState() + watchAutoUpdateSubs() + } + } +} + +private var lastCheckShizukuTime = 0L + +context(event: AccessibilityEvent) +private fun watchCheckShizukuState() { + // 借助无障碍轮询校验 shizuku 权限, 因为 shizuku 可能无故被关闭 + if (shizukuStoreFlow.value.enableShizukuAnyFeat) { + val t = System.currentTimeMillis() + if (t - lastCheckShizukuTime > 60 * 60_000L) { + lastCheckShizukuTime = t + appScope.launchTry(Dispatchers.IO) { + shizukuOkState.updateAndGet() + } + } + } +} + +context(event: AccessibilityEvent) +private fun watchCaptureScreenshot() { + if (!storeFlow.value.captureScreenshot) return + val appId = event.packageName.toString() + val appCls = event.className.toString() + if (!event.isFullScreen && appId == "com.miui.screenshot" && appCls == "android.widget.RelativeLayout" && event.text.firstOrNull() + ?.contentEquals("截屏缩略图") == true + ) { + appScope.launchTry { + SnapshotExt.captureSnapshot(skipScreenshot = true) + } + } +} + +private var lastUpdateSubsTime = 0L + +context(event: AccessibilityEvent) +private fun watchAutoUpdateSubs() { + val i = storeFlow.value.updateSubsInterval + if (i <= 0) return + val t = System.currentTimeMillis() + if (t - lastUpdateSubsTime > i.coerceAtLeast(UpdateTimeOption.Everyday.value)) { + lastUpdateSubsTime = t + checkSubsUpdate() + } +} + +private fun A11yService.useAliveOverlayView() { + val context = this + var aliveView: View? = null + val wm by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } + fun removeA11View() { + if (aliveView != null) { + wm.removeView(aliveView) + aliveView = null + } + } + + fun addA11View() { + removeA11View() + val tempView = View(context) + val lp = WindowManager.LayoutParams().apply { + type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY + format = PixelFormat.TRANSLUCENT + flags = + flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + width = 1 + height = 1 + packageName = context.packageName + } + wm.addView(tempView, lp) + } + + onA11yConnected { + scope.launchTry(Dispatchers.Main) { + storeFlow.mapState(scope) { s -> s.enableAbFloatWindow }.collect { + if (it) { + addA11View() + } else { + removeA11View() + } + } + } + } + onDestroyed { + removeA11View() + } +} + +private const val volumeChangedAction = "android.media.VOLUME_CHANGED_ACTION" +private fun createVolumeReceiver() = object : BroadcastReceiver() { + var lastVolumeTriggerTime = -1L + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == volumeChangedAction) { + val t = System.currentTimeMillis() + if (t - lastVolumeTriggerTime > 3000 && !ScreenUtils.isScreenLock()) { + lastVolumeTriggerTime = t + appScope.launchTry { + SnapshotExt.captureSnapshot() + } + } + } + } +} + +private fun A11yService.useCaptureVolume() { + var captureVolumeReceiver: BroadcastReceiver? = null + val changeRegister: (Boolean) -> Unit = { + if (captureVolumeReceiver != null) { + unregisterReceiver(captureVolumeReceiver) + } + captureVolumeReceiver = if (it) { + createVolumeReceiver().apply { + ContextCompat.registerReceiver( + this@useCaptureVolume, + this, + IntentFilter(volumeChangedAction), + ContextCompat.RECEIVER_EXPORTED + ) + } + } else { + null + } + } + onCreated { + scope.launch { + storeFlow.mapState(scope) { s -> s.captureVolumeChange }.collect(changeRegister) + } + } + onDestroyed { + if (captureVolumeReceiver != null) { + unregisterReceiver(captureVolumeReceiver) + } + } +} + +private fun A11yService.useRuleChangedLog() { + scope.launch(Dispatchers.Default) { + activityRuleFlow.debounce(300).collect { + if (storeFlow.value.enableMatch && it.currentRules.isNotEmpty()) { + LogUtils.d(it.topActivity, *it.currentRules.map { r -> + r.statusText() + }.toTypedArray()) + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt new file mode 100644 index 0000000000..73b2596667 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -0,0 +1,297 @@ +package li.songe.gkd.a11y + +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeoutOrNull +import li.songe.gkd.META +import li.songe.gkd.data.ActionPerformer +import li.songe.gkd.data.AppRule +import li.songe.gkd.data.ResolvedRule +import li.songe.gkd.data.RuleStatus +import li.songe.gkd.isActivityVisible +import li.songe.gkd.service.A11yService +import li.songe.gkd.shizuku.safeGetTopCpn +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.showActionToast +import java.util.concurrent.Executors + + +private val eventDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() +private val queryDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() +private val actionDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + +class A11yRuleEngine(val service: A11yService) { + init { + service.onA11yEvent { onNewA11yEvent(it) } + } + + val scope = service.scope + + var lastContentEventTime = 0L + var lastEventTime = 0L + val eventDeque = ArrayDeque() + fun onNewA11yEvent(event: AccessibilityEvent) { + if (event.eventType == CONTENT_CHANGED && event.packageName == "com.android.systemui") { + if (event.packageName != topActivityFlow.value.appId) return + } + // 过滤部分输入法事件 + if (event.packageName == imeAppId && topActivityFlow.value.appId != imeAppId) { + if (event.recordCount == 0 && event.action == 0 && !event.isFullScreen) return + } + // 直接丢弃自身事件,自行更新 topActivity + if ((event.eventType == CONTENT_CHANGED || !isActivityVisible()) && event.packageName == META.appId) return + + val a11yEvent = event.toA11yEvent() ?: return + if (a11yEvent.type == CONTENT_CHANGED) { + // 防止 content 类型事件过快 + if (a11yEvent.time - lastContentEventTime < 100 && a11yEvent.time - appChangeTime > 5000 && a11yEvent.time - lastTriggerTime > 3000) { + return + } + lastContentEventTime = a11yEvent.time + } + if (META.debuggable) { + Log.d( + "onNewA11yEvent", + "type:${event.eventType}, time:${event.eventTime - lastEventTime}, app:${event.packageName}, cls:${event.className}" + ) + } + if (event.eventTime < lastEventTime) { + // 某些应用会发送负时间事件, 直接丢弃 + // type:32, time:-104, app:com.miui.home, cls:com.miui.home.launcher.Launcher + return + } + lastEventTime = event.eventTime + synchronized(eventDeque) { eventDeque.addLast(a11yEvent) } + scope.launch(eventDispatcher) { consumeEvent(a11yEvent) } + } + + val queryEvents = mutableListOf() + suspend fun consumeEvent(headEvent: A11yEvent) { + val consumedEvents = synchronized(eventDeque) { + if (eventDeque.firstOrNull() !== headEvent) return + eventDeque.filter { it.sameAs(headEvent) }.apply { + repeat(size) { eventDeque.removeFirst() } + } + } + val latestEvent = consumedEvents.last() + val evAppId = latestEvent.appId + val evActivityId = latestEvent.className + val oldAppId = topActivityFlow.value.appId + val rightAppId = if (oldAppId == evAppId) { + evAppId + } else { + getTimeoutAppId() ?: return + } + if (rightAppId == evAppId) { + if (latestEvent.type == STATE_CHANGED) { + // tv.danmaku.bili, com.miui.home, com.miui.home.launcher.Launcher + if (isActivity(evAppId, evActivityId)) { + updateTopActivity(evAppId, evActivityId) + } + } + } + if (rightAppId != topActivityFlow.value.appId) { + // 从 锁屏,下拉通知栏 返回等情况, 应用不会发送事件, 但是系统组件会发送事件 + val topCpn = safeGetTopCpn() + if (topCpn?.packageName == rightAppId) { + updateTopActivity(topCpn.packageName, topCpn.className) + } else { + updateTopActivity(rightAppId, null) + } + } + val activityRule = activityRuleFlow.value + if (evAppId != rightAppId || activityRule.skipConsumeEvent || !storeFlow.value.enableMatch) { + return + } + synchronized(queryEvents) { queryEvents.addAll(consumedEvents) } + a11yContext.interruptKey++ + startQueryJob(byEvent = latestEvent) + } + + var lastGetAppIdTime = 0L + var lastAppId: String? = null + suspend fun getTimeoutAppId(): String? { + if (lastAppId != null && System.currentTimeMillis() - lastGetAppIdTime <= 100) return lastAppId + // 某些应用通过无障碍获取 safeActiveWindow 耗时长,导致多个事件连续堆积堵塞,无法检测到 appId 切换导致状态异常 + // https://github.com/gkd-kit/gkd/issues/622 + lastAppId = withTimeoutOrNull(100) { + runInterruptible { service.safeActiveWindowAppId } + } ?: safeGetTopCpn()?.packageName + lastGetAppIdTime = System.currentTimeMillis() + return lastAppId + } + + var queryJob: Job? = null + + @Synchronized + fun startQueryJob( + byEvent: A11yEvent? = null, + byForced: Boolean = false, + byDelayRule: ResolvedRule? = null, + ) { + if (!storeFlow.value.enableMatch) return + if (queryJob?.isActive == true) return + queryJob = scope.launchTry(queryDispatcher) { + queryAction(byEvent, byForced, byDelayRule) + } + } + + fun checkFutureStartJob() { + val t = System.currentTimeMillis() + if (t - lastTriggerTime < 3000L || t - appChangeTime < 5000L) { + scope.launch(actionDispatcher) { + delay(300) + startQueryJob() + } + } else if (activityRuleFlow.value.hasFeatureAction) { + scope.launch(actionDispatcher) { + delay(300) + startQueryJob(byForced = true) + } + } + } + + fun fixAppId(rightAppId: String) { + if (topActivityFlow.value.appId == rightAppId) return + val topCpn = safeGetTopCpn() + if (topCpn?.packageName == rightAppId) { + updateTopActivity(topCpn.packageName, topCpn.className) + } else { + updateTopActivity(rightAppId, null) + } + scope.launch(actionDispatcher) { + delay(300) + startQueryJob() + } + } + + fun queryAction( + byEvent: A11yEvent? = null, + byForced: Boolean = false, + delayRule: ResolvedRule? = null, + ) { + val newEvents = if (delayRule != null) {// 延迟规则不消耗事件 + null + } else { + synchronized(queryEvents) { + if (byEvent != null && queryEvents.isEmpty()) { + return checkFutureStartJob() + } + (if (queryEvents.size > 1) { + val hasDiffItem = queryEvents.any { e -> + queryEvents.any { e2 -> !e.sameAs(e2) } + } + if (hasDiffItem) { + // 存在不同的事件节点, 全部丢弃使用 root 查询 + null + } else { + // type,appId,className 一致, 需要在 synchronized 外验证是否是同一节点 + arrayOf( + queryEvents[queryEvents.size - 2], + queryEvents.last(), + ) + } + } else if (queryEvents.size == 1) { + arrayOf(queryEvents.last()) + } else { + null + }).apply { + queryEvents.clear() + } + } + } + val activityRule = activityRuleFlow.value + activityRule.currentRules.forEach { rule -> + if (rule.status == RuleStatus.Status3 && rule.matchDelayJob.value == null) { + rule.matchDelayJob.value = scope.launch(actionDispatcher) { + delay(rule.matchDelay) + rule.matchDelayJob.value = null + startQueryJob(byDelayRule = rule) + } + } + } + if (activityRule.skipMatch) { + // 如果当前应用没有规则/暂停匹配, 则不去调用获取事件节点避免阻塞 + return checkFutureStartJob() + } + var lastNode = if (newEvents == null || newEvents.size <= 1) { + newEvents?.firstOrNull()?.safeSource + } else { + // 获取最后两个事件, 如果最后两个事件的节点不一致, 则丢弃 + // 相等则是同一个节点发出的连续事件, 常见于倒计时界面 + val lastNode = newEvents.last().safeSource + if (lastNode == null || lastNode == newEvents[0].safeSource) { + lastNode + } else { + null + } + } + var lastNodeUsed = false + if (!a11yContext.clearOldAppNodeCache()) { + if (byEvent != null) { // 此为多数情况 + // 新事件到来时, 若缓存清理不及时会导致无法查询到节点 + a11yContext.clearNodeCache(lastNode) + } + } + for (rule in activityRule.priorityRules) { // 规则数量有可能过多导致耗时过长 + if (activityRule !== activityRuleFlow.value) break + if (delayRule != null && delayRule !== rule) continue + if (rule.status != RuleStatus.StatusOk) continue + if (byForced && !rule.checkForced()) continue + lastNode?.let { n -> + val refreshOk = (!lastNodeUsed) || (try { + val e = n.refresh() + if (e) { + n.setGeneratedTime() + } + e + } catch (_: Throwable) { + false + }) + lastNodeUsed = true + if (!refreshOk) { + lastNode = null + } + } + val nodeVal = (lastNode ?: service.safeActiveWindow) ?: continue + val rightAppId = nodeVal.packageName?.toString() ?: break + val matchApp = rule.matchActivity(rightAppId) + if (topActivityFlow.value.appId != rightAppId || (!matchApp && rule is AppRule)) { + scope.launch(eventDispatcher) { fixAppId(rightAppId) } + return checkFutureStartJob() + } + if (!matchApp) continue + val target = a11yContext.queryRule(rule, nodeVal) ?: continue + if (activityRule !== activityRuleFlow.value) break + if (rule.checkDelay() && rule.actionDelayJob.value == null) { + rule.actionDelayJob.value = scope.launch(actionDispatcher) { + delay(rule.actionDelay) + rule.actionDelayJob.value = null + startQueryJob(byDelayRule = rule) + } + continue + } + if (rule.status != RuleStatus.StatusOk) break + val actionResult = rule.performAction(target) + if (actionResult.result) { + val topActivity = topActivityFlow.value + rule.trigger() + scope.launch(actionDispatcher) { + delay(300) + startQueryJob() + } + if (actionResult.action != ActionPerformer.None.action) { + showActionToast() + } + addActionLog(rule, topActivity, target, actionResult) + } + } + checkFutureStartJob() + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt similarity index 66% rename from app/src/main/kotlin/li/songe/gkd/service/A11yState.kt rename to app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index f84433947e..8b30405320 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -1,7 +1,10 @@ -package li.songe.gkd.service +package li.songe.gkd.a11y import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager import android.provider.Settings +import android.util.LruCache import android.view.accessibility.AccessibilityNodeInfo import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers @@ -9,7 +12,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.ActionLog @@ -20,14 +22,11 @@ import li.songe.gkd.data.AttrInfo import li.songe.gkd.data.GlobalRule import li.songe.gkd.data.ResetMatchType import li.songe.gkd.data.ResolvedRule -import li.songe.gkd.data.SubsConfig +import li.songe.gkd.data.RuleStatus import li.songe.gkd.db.DbSet -import li.songe.gkd.isActivityVisible -import li.songe.gkd.shizuku.activityTaskManagerFlow import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.RuleSummary -import li.songe.gkd.util.getDefaultLauncherActivity import li.songe.gkd.util.launchTry import li.songe.gkd.util.ruleSummaryFlow @@ -50,59 +49,41 @@ data class TopActivity( return "${appId}/${shortActivityId}/${number}" } - fun sameAs(other: TopActivity): Boolean { - return appId == other.appId && activityId == other.activityId + fun sameAs(a: String, b: String?): Boolean { + return appId == a && activityId == b } } val topActivityFlow = MutableStateFlow(TopActivity()) -private val activityLogMutex by lazy { Mutex() } +private var lastValidActivity: TopActivity = topActivityFlow.value + set(value) { + if (value.activityId != null) { + field = value + } + } +private val activityLogMutex = Mutex() private var activityLogCount = 0 -private var lastActivityChangeTime = 0L +private var lastActivityUpdateTime = 0L +private var lastActivityForceUpdateTime = 0L -@Synchronized -fun updateTopActivity(topActivity: TopActivity) { - if (topActivity.activityId == null && activityTaskManagerFlow.value != null && topActivity.appId == launcherAppId) { - // 无障碍 appId 改变速度慢于系统 activity 栈变化 - return - } - val isSameActivity = topActivityFlow.value.sameAs(topActivity) - if (isSameActivity) { - if (topActivityFlow.value.number == topActivity.number) { - return - } - if (isActivityVisible() && topActivity.appId == META.appId) { - return - } - val t = System.currentTimeMillis() - if (t - lastActivityChangeTime < 1500) { - return - } - } - if (storeFlow.value.enableActivityLog) { - val ctime = System.currentTimeMillis() - appScope.launchTry(Dispatchers.IO) { - activityLogMutex.withLock { - DbSet.activityLogDao.insert( - ActivityLog( - appId = topActivity.appId, - activityId = topActivity.activityId, - ctime = ctime, - ) - ) - activityLogCount++ - if (activityLogCount % 100 == 0) { - DbSet.activityLogDao.deleteKeepLatest() - } - } - } +private object ActivityCache : LruCache, Boolean>(256) { + override fun create(key: Pair): Boolean = try { + app.packageManager.getActivityInfo( + ComponentName(key.first, key.second), + PackageManager.MATCH_UNINSTALLED_PACKAGES + ) + true + } catch (_: Exception) { + false } - LogUtils.d( - "${topActivityFlow.value.format()} -> ${topActivity.format()}" - ) - topActivityFlow.value = topActivity - lastActivityChangeTime = System.currentTimeMillis() +} + +fun isActivity( + appId: String, + activityId: String, +): Boolean { + return topActivityFlow.value.sameAs(appId, activityId) || ActivityCache.get(appId to activityId) } class ActivityRule( @@ -129,36 +110,64 @@ class ActivityRule( get() { return currentRules.all { r -> !r.status.alive } } + val hasFeatureAction: Boolean + get() = currentRules.any { r -> r.checkForced() && (r.status == RuleStatus.StatusOk || r.status == RuleStatus.Status5) } } -val activityRuleFlow by lazy { MutableStateFlow(ActivityRule()) } - -private var lastTopActivity: TopActivity = topActivityFlow.value +val activityRuleFlow = MutableStateFlow(ActivityRule()) -private fun getFixTopActivity(): TopActivity { - val top = topActivityFlow.value - if (top.activityId == null) { - if (lastTopActivity.appId == top.appId) { - // 当从通知栏上拉返回应用, 从锁屏返回 等时, activityId 的无障碍事件不会触发, 此时复用上一次获得的 activityId 填充 - updateTopActivity(lastTopActivity) +@Synchronized +fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { + val oldActivity = topActivityFlow.value + val forced = type > 0 + val t = System.currentTimeMillis() + val isSame = oldActivity.sameAs(appId, activityId) + if (forced) { + lastActivityForceUpdateTime = t + } else { + if (lastActivityForceUpdateTime > 0) { + // ITaskStackListener 的变速快于无障碍 + if (t - lastActivityForceUpdateTime < 1000) return + if (activityId != null && t - lastActivityForceUpdateTime < 3000) return } + if (isSame && t - lastActivityUpdateTime < 1000) return + } + val number = if (isSame) { + oldActivity.number + 1 } else { - // 仅保留最近的有 activityId 的单个 TopActivity - lastTopActivity = top + 0 } - return topActivityFlow.value -} - -@Synchronized -fun getAndUpdateCurrentRules(): ActivityRule { - val topActivity = getFixTopActivity() + topActivityFlow.value = TopActivity( + appId = appId, + activityId = activityId ?: lastValidActivity.takeIf { it.appId == appId }?.activityId, + number = number, + ) + lastValidActivity = oldActivity + lastActivityUpdateTime = t + if (storeFlow.value.enableActivityLog) { + appScope.launchTry(Dispatchers.IO) { + activityLogMutex.withLock { + DbSet.activityLogDao.insert( + ActivityLog( + appId = appId, + activityId = activityId, + ctime = t, + ) + ) + activityLogCount++ + if (activityLogCount % 100 == 0) { + DbSet.activityLogDao.deleteKeepLatest() + } + } + } + } + val topActivity = topActivityFlow.value val oldActivityRule = activityRuleFlow.value val allRules = ruleSummaryFlow.value val idChanged = topActivity.appId != oldActivityRule.topActivity.appId val topChanged = idChanged || oldActivityRule.topActivity != topActivity val ruleChanged = oldActivityRule.ruleSummary !== allRules if (topChanged || ruleChanged) { - val t = System.currentTimeMillis() val allAppRules = allRules.appIdToRules[topActivity.appId] ?: emptyList() val newActivityRule = ActivityRule( ruleSummary = allRules, @@ -195,10 +204,13 @@ fun getAndUpdateCurrentRules(): ActivityRule { } } activityRuleFlow.value = newActivityRule + LogUtils.d( + "${oldActivity.format()} -> ${topActivityFlow.value.format()} (type=$type)", + ) } - return activityRuleFlow.value } +@Volatile var lastTriggerRule: ResolvedRule? = null @Volatile @@ -207,24 +219,22 @@ var lastTriggerTime = 0L @Volatile var appChangeTime = 0L -var launcherActivity = TopActivity("") -val launcherAppId: String - get() = launcherActivity.appId +var launcherAppId = "" fun updateLauncherAppId() { - launcherActivity = app.packageManager.getDefaultLauncherActivity() + launcherAppId = app.packageManager.resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), + PackageManager.MATCH_DEFAULT_ONLY + )?.activityInfo?.packageName ?: "" } -var defaultInputAppId = "" -fun updateDefaultInputAppId() { - Settings.Secure.getString(app.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD)?.let { - ComponentName.unflattenFromString(it)?.let { comp -> - defaultInputAppId = comp.packageName - } - } +var imeAppId = "" +fun updateImeAppId() { + imeAppId = app.getSecureString(Settings.Secure.DEFAULT_INPUT_METHOD) + ?.let(ComponentName::unflattenFromString)?.packageName ?: "" } -private val clickLogMutex by lazy { Mutex() } +private val actionLogMutex = Mutex() fun addActionLog( rule: ResolvedRule, topActivity: TopActivity, @@ -232,7 +242,7 @@ fun addActionLog( actionResult: ActionResult, ) = appScope.launchTry(Dispatchers.IO) { val ctime = System.currentTimeMillis() - clickLogMutex.withLock { + actionLogMutex.withLock { val actionCount = actionCountFlow.updateAndGet { it + 1 } val actionLog = ActionLog( appId = topActivity.appId, @@ -240,10 +250,7 @@ fun addActionLog( subsId = rule.subsItem.id, subsVersion = rule.rawSubs.version, groupKey = rule.g.group.key, - groupType = when (rule) { - is AppRule -> SubsConfig.AppGroupType - is GlobalRule -> SubsConfig.GlobalGroupType - }, + groupType = rule.g.group.groupType, ruleIndex = rule.index, ruleKey = rule.key, ctime = ctime, diff --git a/app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt index 7f8d5c243f..80459d56ca 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt @@ -3,7 +3,7 @@ package li.songe.gkd.data import android.graphics.Rect import android.view.accessibility.AccessibilityNodeInfo import kotlinx.serialization.Serializable -import li.songe.gkd.service.compatChecked +import li.songe.gkd.a11y.compatChecked @Serializable data class AttrInfo( diff --git a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt index fc5de56d75..25d67c1896 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt @@ -8,6 +8,7 @@ import android.view.ViewConfiguration import android.view.accessibility.AccessibilityNodeInfo import com.blankj.utilcode.util.ScreenUtils import kotlinx.serialization.Serializable +import li.songe.gkd.service.A11yService import li.songe.gkd.shizuku.safeLongTap import li.songe.gkd.shizuku.safeTap @@ -28,15 +29,16 @@ data class ActionResult( ) sealed class ActionPerformer(val action: String) { + val service: AccessibilityService + get() = A11yService.instance!! + abstract fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult data object ClickNode : ActionPerformer("clickNode") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { @@ -49,7 +51,6 @@ sealed class ActionPerformer(val action: String) { data object ClickCenter : ActionPerformer("clickCenter") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { @@ -60,7 +61,6 @@ sealed class ActionPerformer(val action: String) { val y = p?.second ?: ((rect.bottom + rect.top) / 2f) return ActionResult( action = action, - // TODO 在分屏/小窗模式下会点击到应用界面外部导致误触其他应用 result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { val result = safeTap(x, y) if (result != null) { @@ -74,7 +74,7 @@ sealed class ActionPerformer(val action: String) { path, 0, ViewConfiguration.getTapTimeout().toLong() ) ) - context.dispatchGesture(gestureDescription.build(), null, null) + service.dispatchGesture(gestureDescription.build(), null, null) true } else { false @@ -86,23 +86,21 @@ sealed class ActionPerformer(val action: String) { data object Click : ActionPerformer("click") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { if (node.isClickable) { - val result = ClickNode.perform(context, node, position) + val result = ClickNode.perform(node, position) if (result.result) { return result } } - return ClickCenter.perform(context, node, position) + return ClickCenter.perform(node, position) } } data object LongClickNode : ActionPerformer("longClickNode") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { @@ -115,7 +113,6 @@ sealed class ActionPerformer(val action: String) { data object LongClickCenter : ActionPerformer("longClickCenter") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { @@ -143,8 +140,7 @@ sealed class ActionPerformer(val action: String) { path, 0, longClickDuration ) ) - // TODO 传入处理 callback - context.dispatchGesture(gestureDescription.build(), null, null) + service.dispatchGesture(gestureDescription.build(), null, null) true } else { false @@ -156,36 +152,33 @@ sealed class ActionPerformer(val action: String) { data object LongClick : ActionPerformer("longClick") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { if (node.isLongClickable) { - val result = LongClickNode.perform(context, node, position) + val result = LongClickNode.perform(node, position) if (result.result) { return result } } - return LongClickCenter.perform(context, node, position) + return LongClickCenter.perform(node, position) } } data object Back : ActionPerformer("back") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { return ActionResult( action = action, - result = context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) + result = service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) ) } } data object None : ActionPerformer("none") { override fun perform( - context: AccessibilityService, node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { diff --git a/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt b/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt index 3566a6470a..ae70e74ea4 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt @@ -1,6 +1,6 @@ package li.songe.gkd.data -import li.songe.gkd.service.launcherAppId +import li.songe.gkd.a11y.launcherAppId import li.songe.gkd.util.systemAppsFlow data class GlobalApp( diff --git a/app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt index 36fb40d868..a3d678db95 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt @@ -3,8 +3,8 @@ package li.songe.gkd.data import android.view.accessibility.AccessibilityNodeInfo import com.blankj.utilcode.util.LogUtils import kotlinx.serialization.Serializable -import li.songe.gkd.service.MAX_CHILD_SIZE -import li.songe.gkd.service.topActivityFlow +import li.songe.gkd.a11y.MAX_CHILD_SIZE +import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.util.toast import kotlin.system.measureTimeMillis diff --git a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt index c5d9b301bd..62f6a34f99 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long -import li.songe.gkd.service.typeInfo +import li.songe.gkd.a11y.typeInfo import li.songe.gkd.util.LOCAL_SUBS_IDS import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.distinctByIfAny diff --git a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt index 71c0a04314..9c6d73b697 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt @@ -1,11 +1,12 @@ package li.songe.gkd.data -import android.accessibilityservice.AccessibilityService import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update import kotlinx.coroutines.Job -import li.songe.gkd.service.appChangeTime -import li.songe.gkd.service.lastTriggerRule -import li.songe.gkd.service.lastTriggerTime +import li.songe.gkd.a11y.appChangeTime +import li.songe.gkd.a11y.lastTriggerRule +import li.songe.gkd.a11y.lastTriggerTime import li.songe.selector.MatchOption import li.songe.selector.Selector @@ -67,7 +68,7 @@ sealed class ResolvedRule( if (priorityActionMaximum <= actionCount.value) return false if (!status.ok) return false val t = System.currentTimeMillis() - return t - matchChangedTime < priorityTime + matchDelay + return t - matchChangedTime.value < priorityTime + matchDelay } val isSlow by lazy { preKeys.isEmpty() && (matchTime == null || matchTime > 10_000L) && hasSlowSelector } @@ -107,11 +108,11 @@ sealed class ResolvedRule( private var preRules = emptySet() val hasNext = group.rules.any { r -> r.preKeys?.any { k -> k == rule.key } == true } - var actionDelayTriggerTime = 0L - var actionDelayJob: Job? = null + private var actionDelayTriggerTime = atomic(0L) + val actionDelayJob = atomic(null) fun checkDelay(): Boolean { - if (actionDelay > 0 && actionDelayTriggerTime == 0L) { - actionDelayTriggerTime = System.currentTimeMillis() + if (actionDelay > 0 && actionDelayTriggerTime.value == 0L) { + actionDelayTriggerTime.value = System.currentTimeMillis() return true } return false @@ -119,24 +120,23 @@ sealed class ResolvedRule( fun checkForced(): Boolean { if (forcedTime <= 0) return false - return System.currentTimeMillis() < matchChangedTime + matchDelay + forcedTime + return System.currentTimeMillis() < matchChangedTime.value + matchDelay + forcedTime } - private var actionTriggerTime = Value(0L) + private var actionTriggerTime = atomic(0L) fun trigger() { actionTriggerTime.value = System.currentTimeMillis() + actionDelayTriggerTime.value = 0L + actionCount.incrementAndGet() lastTriggerTime = actionTriggerTime.value - // 重置延迟点 - actionDelayTriggerTime = 0L - actionCount.value++ lastTriggerRule = this } - var actionCount = Value(0) + private var actionCount = atomic(0) - private var matchChangedTime = 0L + private val matchChangedTime = atomic(0L) val isFirstMatchApp: Boolean - get() = matchChangedTime == appChangeTime + get() = matchChangedTime.value == appChangeTime private val matchLimitTime = (matchTime ?: 0) + matchDelay @@ -146,31 +146,22 @@ sealed class ResolvedRule( fun resetState(t: Long) { actionCount.value = 0 - actionDelayTriggerTime = 0L + actionDelayTriggerTime.value = 0L actionTriggerTime.value = 0 - actionDelayJob?.run { - cancel() - actionDelayJob = null - } - matchDelayJob?.run { - cancel() - matchDelayJob = null - } - matchChangedTime = t + actionDelayJob.update { it?.cancel(); null } + matchDelayJob.update { it?.cancel(); null } + matchChangedTime.value = t } private val performer = ActionPerformer.getAction(rule.action ?: rule.position?.let { ActionPerformer.ClickCenter.action }) - fun performAction( - context: AccessibilityService, - node: AccessibilityNodeInfo - ): ActionResult { - return performer.perform(context, node, rule.position) + fun performAction(node: AccessibilityNodeInfo): ActionResult { + return performer.perform(node, rule.position) } - var matchDelayJob: Job? = null + val matchDelayJob = atomic(null) val status: RuleStatus get() { @@ -183,17 +174,19 @@ sealed class ResolvedRule( return RuleStatus.Status2 // 需要提前触发某个规则 } val t = System.currentTimeMillis() - if (matchDelay > 0 && t - matchChangedTime < matchDelay) { + val c = matchChangedTime.value + if (matchDelay > 0 && t - c < matchDelay) { return RuleStatus.Status3 // 处于匹配延迟中 } - if (matchTime != null && t - matchChangedTime > matchLimitTime) { + if (matchTime != null && t - c > matchLimitTime) { return RuleStatus.Status4 // 超出匹配时间 } if (actionTriggerTime.value + actionCd > t) { return RuleStatus.Status5 // 处于冷却时间 } - if (actionDelayTriggerTime > 0) { - if (actionDelayTriggerTime + actionDelay > t) { + val d = actionDelayTriggerTime.value + if (d > 0) { + if (d + actionDelay > t) { return RuleStatus.Status6 // 处于触发延迟中 } } diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 61fd1f449e..76942824fd 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -11,6 +11,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.core.net.toUri +import kotlinx.atomicfu.atomic import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app @@ -23,6 +24,8 @@ import li.songe.gkd.util.SafeR import li.songe.gkd.util.componentName import kotlin.reflect.KClass +// 相同的 request code 会导致后续 PendingIntent 失效 +private val pendingIntentReqId = atomic(0) data class Notif( val channel: NotifChannel = NotifChannel.Default, @@ -30,21 +33,21 @@ data class Notif( val smallIcon: Int = SafeR.ic_status, val title: String, val text: String? = null, - val ongoing: Boolean, - val autoCancel: Boolean, + val ongoing: Boolean = true, + val autoCancel: Boolean = false, val uri: String? = null, val stopService: KClass? = null, ) { private fun toNotification(): Notification { val contextIntent = PendingIntent.getActivity( app, - 0, + pendingIntentReqId.incrementAndGet(), Intent().apply { component = MainActivity::class.componentName flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK data = uri?.toUri() }, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(app, channel.id) .setSmallIcon(smallIcon) @@ -56,9 +59,9 @@ data class Notif( if (stopService != null) { val deleteIntent = PendingIntent.getBroadcast( app, - 0, + pendingIntentReqId.incrementAndGet(), StopServiceReceiver.getIntent(stopService), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE ) notification .setDeleteIntent(deleteIntent) @@ -93,8 +96,6 @@ val abNotif by lazy { id = 100, title = META.appName, text = "无障碍正在运行", - ongoing = true, - autoCancel = false, ) } @@ -102,8 +103,6 @@ val screenshotNotif = Notif( id = 101, title = "截屏服务正在运行", text = "保存快照时截取屏幕", - ongoing = true, - autoCancel = false, uri = "gkd://page/1", stopService = ScreenshotService::class, ) @@ -112,8 +111,6 @@ val buttonNotif = Notif( id = 102, title = "快照按钮服务正在运行", text = "点击按钮捕获快照", - ongoing = true, - autoCancel = false, uri = "gkd://page/1", stopService = ButtonService::class, ) @@ -121,8 +118,6 @@ val buttonNotif = Notif( val httpNotif = Notif( id = 103, title = "HTTP服务正在运行", - ongoing = true, - autoCancel = false, uri = "gkd://page/1", stopService = HttpService::class, ) @@ -131,8 +126,6 @@ val snapshotActionNotif = Notif( id = 104, title = "快照服务正在运行", text = "捕获快照完成后自动关闭", - ongoing = true, - autoCancel = false, ) val snapshotNotif = Notif( @@ -147,8 +140,6 @@ val snapshotNotif = Notif( val recordNotif = Notif( id = 106, title = "记录服务正在运行", - ongoing = true, - autoCancel = false, uri = "gkd://page/1", stopService = RecordService::class, ) diff --git a/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt b/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt index 0ac9a3c63e..815a44dc44 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt @@ -7,15 +7,15 @@ import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat import li.songe.gkd.META -import li.songe.gkd.util.OnCreateToDestroy -import li.songe.gkd.util.componentName +import li.songe.gkd.util.OnSimpleLife import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName class StopServiceReceiver(private val service: Service) : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context ?: return intent ?: return - if (intent.action == STOP_ACTION && intent.getStringExtra(STOP_ACTION) == service::class.componentName.className) { + if (intent.action == STOP_ACTION && intent.getStringExtra(STOP_ACTION) == service::class.jvmName) { service.stopSelf() } } @@ -23,16 +23,14 @@ class StopServiceReceiver(private val service: Service) : BroadcastReceiver() { companion object { private val STOP_ACTION by lazy { META.appId + ".STOP_SERVICE" } - fun getIntent(clazz: KClass): Intent { - return Intent().apply { - action = STOP_ACTION - putExtra(STOP_ACTION, clazz.componentName.className) - setPackage(META.appId) - } + fun getIntent(clazz: KClass) = Intent().apply { + action = STOP_ACTION + putExtra(STOP_ACTION, clazz.jvmName) + setPackage(META.appId) } context(service: T) - fun autoRegister() where T : Service, T : OnCreateToDestroy { + fun autoRegister() where T : Service, T : OnSimpleLife { val receiver = StopServiceReceiver(service) service.onCreated { ContextCompat.registerReceiver( diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index de69381323..0dab899eaa 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -7,18 +7,13 @@ import android.content.pm.PackageManager import android.os.Build import android.provider.Settings import androidx.core.content.ContextCompat -import com.blankj.utilcode.util.LogUtils -import com.hjq.permissions.OnPermissionCallback import com.hjq.permissions.XXPermissions import com.hjq.permissions.permission.PermissionLists import com.hjq.permissions.permission.base.IPermission import com.ramcosta.composedestinations.generated.destinations.AppOpsAllowPageDestination +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import li.songe.gkd.MainActivity import li.songe.gkd.app @@ -65,23 +60,6 @@ private fun checkSelfPermission(permission: String): Boolean { ) == PackageManager.PERMISSION_GRANTED } -private sealed class XXPermissionResult { - data class Granted( - val permissions: MutableList, - val allGranted: Boolean, - ) : XXPermissionResult() - - data class Denied( - val permissions: MutableList, - val doNotAskAgain: Boolean, - ) : XXPermissionResult() - - data class Both( - val granted: Granted, - val denied: Denied, - ) : XXPermissionResult() -} - private suspend fun asyncRequestPermission( context: Activity, permission: IPermission, @@ -89,59 +67,23 @@ private suspend fun asyncRequestPermission( if (XXPermissions.isGrantedPermission(context, permission)) { return PermissionResult.Granted } - val permissionResultFlow = MutableStateFlow(null) + val deferred = CompletableDeferred() XXPermissions.with(context) .unchecked() .permission(permission) - .request(object : OnPermissionCallback { - override fun onGranted(permissions: MutableList, allGranted: Boolean) { - LogUtils.d("allGranted: $allGranted", permissions) - permissionResultFlow.update { - val granted = XXPermissionResult.Granted(permissions, allGranted) - if (it == null) { - granted - } else { - XXPermissionResult.Both( - granted = granted, - denied = it as XXPermissionResult.Denied - ) - } - } - } - - override fun onDenied(permissions: MutableList, doNotAskAgain: Boolean) { - LogUtils.d("doNotAskAgain: $doNotAskAgain", permissions) - permissionResultFlow.update { - val denied = XXPermissionResult.Denied(permissions, doNotAskAgain) - if (it == null) { - denied - } else { - XXPermissionResult.Both( - granted = it as XXPermissionResult.Granted, - denied = denied - ) - } - } - } - }) - val result = permissionResultFlow.debounce(100L).filterNotNull().first() - return when (result) { - is XXPermissionResult.Granted -> { - if (result.allGranted) { + .request { grantedList, _ -> + if (grantedList.contains(permission)) { PermissionResult.Granted } else { - PermissionResult.Denied(false) - } - } - - is XXPermissionResult.Denied -> { - PermissionResult.Denied(result.doNotAskAgain) - } - - is XXPermissionResult.Both -> { - PermissionResult.Denied(result.denied.doNotAskAgain) + PermissionResult.Denied( + XXPermissions.isDoNotAskAgainPermissions( + context, + arrayOf(permission) + ) + ) + }.let { deferred.complete(it) } } - } + return deferred.await() } @Suppress("SameParameterValue") diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yEvent.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yEvent.kt deleted file mode 100644 index 6872ba1205..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yEvent.kt +++ /dev/null @@ -1,30 +0,0 @@ -package li.songe.gkd.service - -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo - -data class A11yEvent( - val type: Int, - val time: Long, - val appId: String, - val className: String, - val event: AccessibilityEvent, -) { - val safeSource: AccessibilityNodeInfo? - get() = event.safeSource -} - -fun A11yEvent.sameAs(other: A11yEvent): Boolean { - if (other === this) return true - return type == other.type && appId == other.appId && className == other.className -} - -fun AccessibilityEvent.toA11yEvent(): A11yEvent? { - return A11yEvent( - type = eventType, - time = System.currentTimeMillis(), - appId = packageName?.toString() ?: return null, - className = className?.toString() ?: return null, - event = this, - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index e37ddd4ae9..99c5432ae2 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -1,700 +1,89 @@ package li.songe.gkd.service import android.accessibilityservice.AccessibilityService -import android.accessibilityservice.AccessibilityService.WINDOW_SERVICE -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.Bitmap -import android.graphics.PixelFormat -import android.os.Build -import android.util.Log -import android.util.LruCache -import android.view.Display -import android.view.View -import android.view.WindowManager import android.view.accessibility.AccessibilityEvent -import com.blankj.utilcode.util.LogUtils -import com.blankj.utilcode.util.ScreenUtils +import android.view.accessibility.AccessibilityNodeInfo import com.google.android.accessibility.selecttospeak.SelectToSpeakService -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import li.songe.gkd.META -import li.songe.gkd.app -import li.songe.gkd.appScope +import li.songe.gkd.a11y.A11yContext +import li.songe.gkd.a11y.A11yRuleEngine +import li.songe.gkd.a11y.a11yContext +import li.songe.gkd.a11y.isUseful +import li.songe.gkd.a11y.onA11yFeatInit +import li.songe.gkd.a11y.setGeneratedTime +import li.songe.gkd.a11y.typeInfo import li.songe.gkd.data.ActionPerformer import li.songe.gkd.data.ActionResult -import li.songe.gkd.data.AppRule -import li.songe.gkd.data.AttrInfo import li.songe.gkd.data.GkdAction -import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RpcError -import li.songe.gkd.data.RuleStatus -import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.shizuku.safeGetTopActivity -import li.songe.gkd.shizuku.serviceWrapperFlow -import li.songe.gkd.store.shizukuStoreFlow -import li.songe.gkd.store.storeFlow import li.songe.gkd.util.OnA11yLife -import li.songe.gkd.util.OnCreateToDestroy -import li.songe.gkd.util.SnapshotExt -import li.songe.gkd.util.UpdateTimeOption -import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.componentName -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.mapState -import li.songe.gkd.util.showActionToast -import li.songe.gkd.util.toast import li.songe.selector.MatchOption import li.songe.selector.Selector import java.lang.ref.WeakReference -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -open class A11yService : AccessibilityService(), OnCreateToDestroy, OnA11yLife { +abstract class A11yService : AccessibilityService(), OnA11yLife { override fun onCreate() = onCreated() override fun onServiceConnected() = onA11yConnected() override fun onInterrupt() {} override fun onDestroy() = onDestroyed() - - override val a11yEventCallbacks = mutableListOf<(AccessibilityEvent) -> Unit>() + override val a11yEventCbs = mutableListOf<(AccessibilityEvent) -> Unit>() override fun onAccessibilityEvent(event: AccessibilityEvent?) { - if (event == null || !event.isUseful()) return + if (event == null || !event.isUseful) return onA11yEvent(event) } + val safeActiveWindow: AccessibilityNodeInfo? + get() = try { + // 某些应用耗时 554ms + // java.lang.SecurityException: Call from user 0 as user -2 without permission INTERACT_ACROSS_USERS or INTERACT_ACROSS_USERS_FULL not allowed. + rootInActiveWindow?.setGeneratedTime() + } catch (_: Throwable) { + null + }.apply { + a11yContext.rootCache = this + } + + val safeActiveWindowAppId: String? + get() = safeActiveWindow?.packageName?.toString() - val scope = CoroutineScope(Dispatchers.Default).apply { onDestroyed { cancel() } } + val scope = useScope() init { useLogLifecycle() - useRunningState() - useAliveView() - useCaptureVolume() - onA11yEvent(::handleCaptureScreenshot) - useAutoUpdateSubs() - useRuleChangedLog() - useAutoCheckShizuku() - serviceWrapperFlow - useMatchRule() - useAliveToast("无障碍", onlyWhenVisible = true) + useAliveFlow(isRunning) + onCreated { a11yWeakRef = WeakReference(this) } + onDestroyed { a11yWeakRef = null } + A11yRuleEngine(this) + onA11yFeatInit() } companion object { - val a11yComponentName by lazy { SelectToSpeakService::class.componentName } val a11yClsName by lazy { a11yComponentName.flattenToShortString() } - internal var weakInstance = WeakReference(null) - val instance: A11yService? - get() = weakInstance.get() val isRunning = MutableStateFlow(false) - - // AccessibilityInteractionClient.getInstanceForThread(threadId) - val queryThread by lazy { Executors.newSingleThreadExecutor().asCoroutineDispatcher() } - val eventThread by lazy { Executors.newSingleThreadExecutor().asCoroutineDispatcher() } - val actionThread by lazy { Executors.newSingleThreadExecutor().asCoroutineDispatcher() } + private var a11yWeakRef: WeakReference? = null + val instance: A11yService? + get() = a11yWeakRef?.get() fun execAction(gkdAction: GkdAction): ActionResult { - val serviceVal = instance ?: throw RpcError("无障碍没有运行") + val service = instance ?: throw RpcError("无障碍没有运行") val selector = Selector.parseOrNull(gkdAction.selector) ?: throw RpcError("非法选择器") runCatching { selector.checkType(typeInfo) }.exceptionOrNull()?.let { throw RpcError("选择器类型错误:${it.message}") } - val matchOption = MatchOption( - fastQuery = gkdAction.fastQuery, - ) - val cache = A11yContext(true) - - val targetNode = serviceVal.safeActiveWindow?.let { - cache.querySelfOrSelector( + val matchOption = MatchOption(fastQuery = gkdAction.fastQuery) + val targetNode = service.safeActiveWindow?.let { + A11yContext(true).querySelfOrSelector( it, selector, matchOption ) } ?: throw RpcError("没有查询到节点") - LogUtils.d("查询到节点", gkdAction, AttrInfo.info2data(targetNode, 0, 0)) return ActionPerformer .getAction(gkdAction.action ?: ActionPerformer.None.action) - .perform(serviceVal, targetNode, gkdAction.position) - } - - - suspend fun screenshot(): Bitmap? = suspendCoroutine { - if (instance == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - it.resume(null) - } else { - val callback = object : TakeScreenshotCallback { - override fun onSuccess(screenshot: ScreenshotResult) { - try { - it.resume( - Bitmap.wrapHardwareBuffer( - screenshot.hardwareBuffer, screenshot.colorSpace - ) - ) - } finally { - screenshot.hardwareBuffer.close() - } - } - - override fun onFailure(errorCode: Int) = it.resume(null) - } - instance!!.takeScreenshot( - Display.DEFAULT_DISPLAY, instance!!.application.mainExecutor, callback - ) - } - } - } -} - -private fun A11yService.useMatchRule() { - val context = this - - var lastTriggerShizukuTime = 0L - var lastContentEventTime = 0L - val queryEvents = mutableListOf() - var queryTaskJob: Job? - - fun newQueryTask( - byEvent: A11yEvent? = null, - byForced: Boolean = false, - delayRule: ResolvedRule? = null, - ): Job = scope.launchTry(A11yService.queryThread) launchQuery@{ - queryTaskJob = coroutineContext[Job] - if (!storeFlow.value.enableMatch) return@launchQuery - fun checkFutureJob() { - val t = System.currentTimeMillis() - if (t - lastTriggerTime < 3000L || t - appChangeTime < 5000L) { - scope.launch(A11yService.actionThread) { - delay(300) - if (queryTaskJob?.isActive != true) { - newQueryTask() - } - } - } else { - if (getAndUpdateCurrentRules().currentRules.any { r -> r.checkForced() && r.status.let { s -> s == RuleStatus.StatusOk || s == RuleStatus.Status5 } }) { - scope.launch(A11yService.actionThread) { - delay(300) - if (queryTaskJob?.isActive != true) { - newQueryTask(byForced = true) - } - } - } - } - } - - val newEvents = if (delayRule != null) {// 延迟规则不消耗事件 - null - } else { - synchronized(queryEvents) { - // 不能在 synchronized 内获取节点, 否则将阻塞事件处理 - if (byEvent != null && queryEvents.isEmpty()) { - return@launchQuery checkFutureJob() - } - (if (queryEvents.size > 1) { - val hasDiffItem = queryEvents.any { e -> - queryEvents.any { e2 -> !e.sameAs(e2) } - } - if (hasDiffItem) {// 存在不同的事件节点, 全部丢弃使用 root 查询 - if (META.debuggable) { - Log.d( - "queryEvents", "全部丢弃事件:${queryEvents.size}" - ) - } - null - } else { - // type,appId,className 一致, 需要在 synchronized 外验证是否是同一节点 - arrayOf( - queryEvents[queryEvents.size - 2], - queryEvents.last(), - ).apply { - if (META.debuggable) { - Log.d( - "queryEvents", - "保留最后两个事件:${queryEvents.first().appId}=${map { it.className }}" - ) - } - } - } - } else if (queryEvents.size == 1) { - if (META.debuggable) { - Log.d( - "queryEvents", - "只有1个事件:${queryEvents.first().appId}${queryEvents.map { it.className }}" - ) - } - arrayOf(queryEvents.last()) - } else { - null - }).apply { - queryEvents.clear() - } - } - } - val activityRule = getAndUpdateCurrentRules() - activityRule.currentRules.forEach { rule -> - if (rule.status == RuleStatus.Status3 && rule.matchDelayJob == null) { - rule.matchDelayJob = scope.launch(A11yService.actionThread) { - delay(rule.matchDelay) - rule.matchDelayJob = null - newQueryTask(delayRule = rule) - } - } - } - if (activityRule.skipMatch) { - // 如果当前应用没有规则/暂停匹配, 则不去调用获取事件节点避免阻塞 - return@launchQuery checkFutureJob() - } - var lastNode = if (newEvents == null || newEvents.size <= 1) { - newEvents?.firstOrNull()?.safeSource - } else { - // 获取最后两个事件, 如果最后两个事件的节点不一致, 则丢弃 - // 相等则是同一个节点发出的连续事件, 常见于倒计时界面 - val lastNode = newEvents.last().safeSource - if (lastNode == null || lastNode == newEvents[0].safeSource) { - lastNode - } else { - null - } - } - var lastNodeUsed = false - if (!a11yContext.clearOldAppNodeCache()) { - if (byEvent != null) { // 此为多数情况 - // 新事件到来时, 若缓存清理不及时会导致无法查询到节点 - a11yContext.clearNodeCache(lastNode) - } - } - for (rule in activityRule.priorityRules) { // 规则数量有可能过多导致耗时过长 - // https://github.com/gkd-kit/gkd/issues/915 - if (activityRule !== getAndUpdateCurrentRules()) break - if (delayRule != null && delayRule !== rule) continue - if (rule.status != RuleStatus.StatusOk) continue - if (byForced && !rule.checkForced()) continue - lastNode?.let { n -> - val refreshOk = (!lastNodeUsed) || (try { - val e = n.refresh() - if (e) { - n.setGeneratedTime() - } - e - } catch (_: Exception) { - false - }) - lastNodeUsed = true - if (!refreshOk) { - if (META.debuggable) { - Log.d("latestEvent", "最新事件已过期") - } - lastNode = null - } - } - val nodeVal = (lastNode ?: safeActiveWindow) ?: continue - val rightAppId = nodeVal.packageName?.toString() ?: break - val matchApp = rule.matchActivity(rightAppId) - if (topActivityFlow.value.appId != rightAppId || (!matchApp && rule is AppRule)) { - scope.launch(A11yService.eventThread) { - if (topActivityFlow.value.appId != rightAppId) { - val shizukuTop = safeGetTopActivity() - if (shizukuTop?.appId == rightAppId) { - updateTopActivity(shizukuTop) - } else { - updateTopActivity(TopActivity(appId = rightAppId)) - } - getAndUpdateCurrentRules() - scope.launch(A11yService.actionThread) { - delay(300) - if (queryTaskJob?.isActive != true) { - newQueryTask() - } - } - } - } - return@launchQuery checkFutureJob() - } - if (!matchApp) continue - val target = a11yContext.queryRule(rule, nodeVal) ?: continue - if (activityRule !== getAndUpdateCurrentRules()) break - if (rule.checkDelay() && rule.actionDelayJob == null) { - LogUtils.d("startDelay", rule.statusText(), AttrInfo.info2data(target, 0, 0)) - rule.actionDelayJob = scope.launchTry(A11yService.actionThread) { - delay(rule.actionDelay) - rule.actionDelayJob = null - newQueryTask(delayRule = rule) - } - continue - } - if (rule.status != RuleStatus.StatusOk) break - val actionResult = rule.performAction(context, target) - if (actionResult.result) { - val topActivity = topActivityFlow.value - rule.trigger() - scope.launch(A11yService.actionThread) { - delay(300) - if (queryTaskJob?.isActive != true) { - newQueryTask() - } - } - if (actionResult.action != ActionPerformer.None.action) { - showActionToast(context) - } - addActionLog(rule, topActivity, target, actionResult) - } - } - checkFutureJob() - } - - var lastGetAppIdTime = 0L - var lastAppId: String? = null - suspend fun getAppIdByCache(fixedEvent: A11yEvent): String? { - if (fixedEvent.type == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && fixedEvent.appId == META.appId && !fixedEvent.className.endsWith( - "Activity" - ) - ) { - // 剔除非法事件 - return lastAppId - } - val t = System.currentTimeMillis() - if (t - lastGetAppIdTime > 50) { - // 某些应用获取 safeActiveWindow 耗时长, 导致多个事件连续堆积堵塞, 无法检测到 appId 切换导致状态异常 - // https://github.com/gkd-kit/gkd/issues/622 - lastGetAppIdTime = t - lastAppId = if (shizukuStoreFlow.value.enableActivity) { - withTimeoutOrNull(100) { safeActiveWindowAppId } ?: safeGetTopActivity()?.appId - } else { - null - } ?: safeActiveWindowAppId - } - return lastAppId - } - - val eventDeque = ArrayDeque() - fun consumeEvent( - headEvent: A11yEvent, - ) = scope.launchTry(A11yService.eventThread) launchEvent@{ - val consumedEvents = synchronized(eventDeque) { - if (eventDeque.firstOrNull() !== headEvent) return@launchEvent - eventDeque.filter { it.sameAs(headEvent) }.apply { - // 如果有多个连续的事件, 全部取出 - repeat(size) { eventDeque.removeFirst() } - } - } - if (META.debuggable && consumedEvents.size > 1) { - Log.d("consumeEvent", "合并连续事件:${consumedEvents.size}") - } - val a11yEvent = consumedEvents.last() - val evAppId = a11yEvent.appId - val evActivityId = a11yEvent.className - val oldAppId = topActivityFlow.value.appId - val rightAppId = if (oldAppId == evAppId) { - evAppId - } else { - getAppIdByCache(a11yEvent) ?: return@launchEvent - } - if (rightAppId == evAppId) { - if (a11yEvent.type == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { - // tv.danmaku.bili, com.miui.home, com.miui.home.launcher.Launcher - if (isActivity(evAppId, evActivityId)) { - updateTopActivity( - TopActivity( - evAppId, evActivityId, topActivityFlow.value.number + 1 - ) - ) - } - } else { - if (shizukuStoreFlow.value.enableActivity && a11yEvent.time - lastTriggerShizukuTime > 300) { - val shizukuTop = safeGetTopActivity() - if (shizukuTop?.appId == rightAppId) { - if (shizukuTop.activityId == evActivityId) { - updateTopActivity( - TopActivity( - evAppId, evActivityId, topActivityFlow.value.number + 1 - ) - ) - } - updateTopActivity(shizukuTop) - } - lastTriggerShizukuTime = a11yEvent.time - } - } - } - if (rightAppId != topActivityFlow.value.appId) { - // 从 锁屏,下拉通知栏 返回等情况, 应用不会发送事件, 但是系统组件会发送事件 - val shizukuTop = safeGetTopActivity() - if (shizukuTop != null) { - updateTopActivity(shizukuTop) - } else { - updateTopActivity(TopActivity(rightAppId)) - } - } - val activityRule = getAndUpdateCurrentRules() - // 放在 evAppId != rightAppId 的前面使得 TopActivity 能借助 lastTopActivity 恢复 - if (evAppId != rightAppId || activityRule.skipConsumeEvent || !storeFlow.value.enableMatch) { - return@launchEvent - } - - synchronized(queryEvents) { queryEvents.addAll(consumedEvents) } - a11yContext.interruptKey++ - newQueryTask(a11yEvent) - } - - onA11yEvent { event -> - if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED && event.packageName == "com.android.systemui") { - return@onA11yEvent - } -// if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -// && event.packageName == defaultInputAppId -// && event.className == "android.inputmethodservice.SoftInputWindow" -// ) { -// return@onA11yEvent -// } - if (event.packageName == defaultInputAppId && topActivityFlow.value.appId != defaultInputAppId) { - if (event.className == "android.inputmethodservice.SoftInputWindow") { - return@onA11yEvent - } - if (event.recordCount == 0 && event.action == 0 && !event.isFullScreen) { - return@onA11yEvent - } - } - // AccessibilityEvent 的 clear 方法会在后续时间被 某些系统 调用导致内部数据丢失, 导致异步子线程获取到的数据不一致 - val a11yEvent = event.toA11yEvent() ?: return@onA11yEvent - if (a11yEvent.type == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) { - if (a11yEvent.time - lastContentEventTime < 100 && a11yEvent.time - appChangeTime > 5000 && a11yEvent.time - lastTriggerTime > 3000) { - return@onA11yEvent - } - lastContentEventTime = a11yEvent.time - } - if (META.debuggable) { - Log.d( - "A11yEvent", - "type:${event.eventType},app:${event.packageName},cls:${event.className}" - ) - } - synchronized(eventDeque) { eventDeque.add(a11yEvent) } - consumeEvent(a11yEvent) - } -} - -private fun A11yService.useRuleChangedLog() { - scope.launch(Dispatchers.IO) { - activityRuleFlow.debounce(300).collect { - if (storeFlow.value.enableMatch && it.currentRules.isNotEmpty()) { - LogUtils.d(it.topActivity, *it.currentRules.map { r -> - r.statusText() - }.toTypedArray()) - } - } - } -} - -private fun A11yService.useRunningState() { - onCreated { - A11yService.weakInstance = WeakReference(this) - A11yService.isRunning.value = true - if (!storeFlow.value.enableService) { - // https://github.com/gkd-kit/gkd/issues/754 - storeFlow.update { it.copy(enableService = true) } - } - StatusService.autoStart() - } - onDestroyed { - if (storeFlow.value.enableService) { - storeFlow.update { it.copy(enableService = false) } - } - A11yService.weakInstance = WeakReference(null) - A11yService.isRunning.value = false - } -} - -private fun A11yService.useAutoCheckShizuku() { - var lastCheckShizukuTime = 0L - onA11yEvent { - // 借助无障碍轮询校验 shizuku 权限, 因为 shizuku 可能无故被关闭 - if (shizukuStoreFlow.value.enableShizukuAnyFeat && it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && it.packageName == launcherAppId) {// 筛选降低判断频率 - val t = System.currentTimeMillis() - if (t - lastCheckShizukuTime > 60 * 60_000L) { - lastCheckShizukuTime = t - appScope.launchTry(Dispatchers.IO) { - shizukuOkState.updateAndGet() - } - } - } - } -} - -private fun A11yService.useAliveView() { - val context = this - var aliveView: View? = null - val wm by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } - suspend fun removeA11View() { - if (aliveView != null) { - withContext(Dispatchers.Main) { - wm.removeView(aliveView) - } - aliveView = null - } - } - - suspend fun addA11View() { - removeA11View() - val tempView = View(context) - val lp = WindowManager.LayoutParams().apply { - type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY - format = PixelFormat.TRANSLUCENT - flags = - flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - width = 1 - height = 1 - packageName = context.packageName - } - withContext(Dispatchers.Main) { - try { - // 在某些机型创建失败, 原因未知 - wm.addView(tempView, lp) - aliveView = tempView - } catch (e: Exception) { - LogUtils.d("创建无障碍悬浮窗失败", e) - toast("创建无障碍悬浮窗失败") - storeFlow.update { store -> - store.copy(enableAbFloatWindow = false) - } - } - } - } - - onA11yConnected { - scope.launchTry { - storeFlow.mapState(scope) { s -> s.enableAbFloatWindow }.collect { - if (it) { - addA11View() - } else { - removeA11View() - } - } - } - } - onDestroyed { - if (aliveView != null) { - wm.removeView(aliveView) - } - } -} - -private fun A11yService.useAutoUpdateSubs() { - var lastUpdateSubsTime = System.currentTimeMillis() - 25000 - onA11yEvent {// 借助 无障碍事件 触发自动检测更新 - if (it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && it.packageName == launcherAppId) {// 筛选降低判断频率 - val i = storeFlow.value.updateSubsInterval - if (i <= 0) return@onA11yEvent - val t = System.currentTimeMillis() - if (t - lastUpdateSubsTime > i.coerceAtLeast(UpdateTimeOption.Everyday.value)) { - lastUpdateSubsTime = t - checkSubsUpdate() - } - } - } -} - -private const val volumeChangedAction = "android.media.VOLUME_CHANGED_ACTION" -private fun createVolumeReceiver() = object : BroadcastReceiver() { - var lastTriggerTime = -1L - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == volumeChangedAction) { - val t = System.currentTimeMillis() - if (t - lastTriggerTime > 3000 && !ScreenUtils.isScreenLock()) { - lastTriggerTime = t - appScope.launchTry { - SnapshotExt.captureSnapshot() - } - } - } - } -} - -private fun A11yService.useCaptureVolume() { - var captureVolumeReceiver: BroadcastReceiver? = null - onCreated { - scope.launch { - storeFlow.mapState(scope) { s -> s.captureVolumeChange }.collect { - if (captureVolumeReceiver != null) { - unregisterReceiver(captureVolumeReceiver) - } - captureVolumeReceiver = if (it) { - createVolumeReceiver().apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - this, IntentFilter(volumeChangedAction), Context.RECEIVER_EXPORTED - ) - } else { - registerReceiver(this, IntentFilter(volumeChangedAction)) - } - } - } else { - null - } - } - } - } - onDestroyed { - if (captureVolumeReceiver != null) { - unregisterReceiver(captureVolumeReceiver) - } - } -} - -private const val interestedEvents = - AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED - -private fun AccessibilityEvent.isUseful(): Boolean { - return packageName != null && className != null && eventType.and(interestedEvents) != 0 -} - -private val activityCache = object : LruCache, Boolean>(128) { - override fun create(key: Pair): Boolean { - return runCatching { - app.packageManager.getActivityInfo( - ComponentName( - key.first, key.second - ), 0 - ) - }.getOrNull() != null - } -} - -private fun isActivity( - appId: String, - activityId: String, -): Boolean { - if (appId == topActivityFlow.value.appId && activityId == topActivityFlow.value.activityId) return true - val cacheKey = Pair(appId, activityId) - return activityCache.get(cacheKey) -} - -private fun handleCaptureScreenshot(event: AccessibilityEvent) { - if (!storeFlow.value.captureScreenshot) return - val appId = event.packageName!! - val appCls = event.className!! - if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && !event.isFullScreen && appId.contentEquals( - "com.miui.screenshot" - ) && appCls.contentEquals( - "android.widget.RelativeLayout" - ) && event.text.firstOrNull()?.contentEquals("截屏缩略图") == true // [截屏缩略图, 截长屏, 发送] - ) { - LogUtils.d("captureScreenshot", event) - appScope.launchTry { - SnapshotExt.captureSnapshot(skipScreenshot = true) + .perform(targetNode, gkdAction.position) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt index 334e70214a..10a8b974ab 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt @@ -6,10 +6,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch -import li.songe.gkd.util.OnCreateToDestroy import li.songe.gkd.util.OnTileLife -abstract class BaseTileService : TileService(), OnCreateToDestroy, OnTileLife { +abstract class BaseTileService : TileService(), OnTileLife { override fun onCreate() = onCreated() override fun onStartListening() = onStartListened() override fun onClick() = onTileClicked() @@ -25,7 +24,6 @@ abstract class BaseTileService : TileService(), OnCreateToDestroy, OnTileLife { } init { - useLogLifecycle() scope.launch { combine( activeFlow, diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 532362379e..c72a01d51e 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -18,33 +18,11 @@ class GkdTileService : BaseTileService() { override val activeFlow = A11yService.isRunning init { - useLogLifecycle() onStartListened { fixRestartService() } onTileClicked { switchA11yService() } } } -private fun getServiceNames(): MutableList { - val value = try { - Settings.Secure.getString( - app.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES - ) - } catch (_: Exception) { - null - } ?: "" - if (value.isEmpty()) return mutableListOf() - return value.split(':').toMutableList() -} - -private fun updateServiceNames(names: List) { - Settings.Secure.putString( - app.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, - names.joinToString(":") - ) -} - private val modifyA11yMutex by lazy { Mutex() } private const val A11Y_AWAIT_START_TIME = 1000L private const val A11Y_AWAIT_FIX_TIME = 500L @@ -60,7 +38,7 @@ fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { toast("请先授予「写入安全设置权限」") return@launchTry } - val names = getServiceNames() + val names = app.getSecureA11yServices() Settings.Secure.putInt( app.contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED, @@ -68,11 +46,11 @@ fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { ) if (names.contains(A11yService.a11yClsName)) { // 当前无障碍异常, 重启服务 names.remove(A11yService.a11yClsName) - updateServiceNames(names) + app.putSecureA11yServices(names) delay(A11Y_AWAIT_FIX_TIME) } names.add(A11yService.a11yClsName) - updateServiceNames(names) + app.putSecureA11yServices(names) delay(A11Y_AWAIT_START_TIME) // https://github.com/orgs/gkd-kit/discussions/799 if (!A11yService.isRunning.value) { @@ -92,17 +70,17 @@ fun fixRestartService() = appScope.launchTry(Dispatchers.IO) { // 2. 用户配置开启了服务 // 3. 有写入系统设置权限 if (!A11yService.isRunning.value && storeFlow.value.enableService && writeSecureSettingsState.updateAndGet()) { - val names = getServiceNames() + val names = app.getSecureA11yServices() val a11yBroken = names.contains(A11yService.a11yClsName) if (a11yBroken) { // 无障碍出现故障, 重启服务 names.remove(A11yService.a11yClsName) - updateServiceNames(names) + app.putSecureA11yServices(names) // 必须等待一段时间, 否则概率不会触发系统重启无障碍服务 delay(A11Y_AWAIT_FIX_TIME) } names.add(A11yService.a11yClsName) - updateServiceNames(names) + app.putSecureA11yServices(names) delay(A11Y_AWAIT_START_TIME) if (!A11yService.isRunning.value) { toast("重启无障碍失败") diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index 7abac5cd34..9ef3b9ffe7 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -48,7 +48,7 @@ import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.httpNotif import li.songe.gkd.store.storeFlow import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID -import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.SERVER_SCRIPT_URL import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.deleteSubscription @@ -64,7 +64,7 @@ import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription -class HttpService : Service(), OnCreateToDestroy { +class HttpService : Service(), OnSimpleLife { override fun onBind(intent: Intent?) = null override fun onCreate() { diff --git a/app/src/main/kotlin/li/songe/gkd/service/NodeExt.kt b/app/src/main/kotlin/li/songe/gkd/service/NodeExt.kt deleted file mode 100644 index 1393f414aa..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/NodeExt.kt +++ /dev/null @@ -1,89 +0,0 @@ -package li.songe.gkd.service - -import android.accessibilityservice.AccessibilityService -import android.os.Build -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo -import li.songe.selector.initDefaultTypeInfo - -// 在主线程调用任意获取新节点或刷新节点的API会阻塞界面导致卡顿 - -// 某些应用耗时 554ms -val AccessibilityService.safeActiveWindow: AccessibilityNodeInfo? - get() = try { - // java.lang.SecurityException: Call from user 0 as user -2 without permission INTERACT_ACROSS_USERS or INTERACT_ACROSS_USERS_FULL not allowed. - rootInActiveWindow?.apply { - // https://github.com/gkd-kit/gkd/issues/759 - setGeneratedTime() - } - } catch (e: Exception) { - e.printStackTrace() - null - }.apply { - a11yContext.rootCache = this - } - -val AccessibilityService.safeActiveWindowAppId: String? - get() = safeActiveWindow?.packageName?.toString() - -// 某些应用耗时 300ms -val AccessibilityEvent.safeSource: AccessibilityNodeInfo? - get() = if (className == null) { - null // https://github.com/gkd-kit/gkd/issues/426 event.clear 已被系统调用 - } else { - try { - // 原因未知, 仍然报错 Cannot perform this action on a not sealed instance. - source?.apply { - setGeneratedTime() - } - } catch (_: Exception) { - null - } - } - -fun AccessibilityNodeInfo.getVid(): CharSequence? { - val id = viewIdResourceName ?: return null - val appId = packageName ?: return null - if (id.startsWith(appId) && id.startsWith(":id/", appId.length)) { - return id.subSequence( - appId.length + ":id/".length, - id.length - ) - } - return null -} - -// https://github.com/gkd-kit/gkd/issues/115 -// https://github.com/gkd-kit/gkd/issues/650 -// 限制节点遍历的数量避免内存溢出 -const val MAX_CHILD_SIZE = 512 -const val MAX_DESCENDANTS_SIZE = 4096 - -private const val A11Y_NODE_TIME_KEY = "generatedTime" -fun AccessibilityNodeInfo.setGeneratedTime() { - extras.putLong(A11Y_NODE_TIME_KEY, System.currentTimeMillis()) -} - -fun AccessibilityNodeInfo.isExpired(expiryMillis: Long): Boolean { - val generatedTime = extras.getLong(A11Y_NODE_TIME_KEY, -1) - if (generatedTime == -1L) { - // https://github.com/gkd-kit/gkd/issues/759 - return true - } - return (System.currentTimeMillis() - generatedTime) > expiryMillis -} - -val typeInfo by lazy { initDefaultTypeInfo().globalType } - -val AccessibilityNodeInfo.compatChecked: Boolean? - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { - when (checked) { - AccessibilityNodeInfo.CHECKED_STATE_TRUE -> true - AccessibilityNodeInfo.CHECKED_STATE_FALSE -> false - AccessibilityNodeInfo.CHECKED_STATE_PARTIAL -> null - else -> null - } - } else { - @Suppress("DEPRECATION") - isChecked - } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index 55d7596efb..9a7ddcccc3 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -3,6 +3,7 @@ package li.songe.gkd.service import android.animation.ValueAnimator import android.annotation.SuppressLint +import android.content.res.Configuration import android.graphics.PixelFormat import android.view.Gravity import android.view.MotionEvent @@ -20,21 +21,36 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.blankj.utilcode.util.BarUtils import com.blankj.utilcode.util.ScreenUtils +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import li.songe.gkd.a11y.topActivityFlow +import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.store.createTextFlow -import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.OnSimpleLife +import li.songe.gkd.util.mapState import li.songe.gkd.util.px +import li.songe.gkd.util.toast +private var instanceFlags = mutableListOf() abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwner, - OnCreateToDestroy { + OnSimpleLife { override fun onCreate() { super.onCreate() onCreated() } + private val resizeFlow = MutableSharedFlow() + + override fun onConfigurationChanged(newConfig: Configuration) { + lifecycleScope.launch { resizeFlow.emit(Unit) } + } + override fun onDestroy() { super.onDestroy() onDestroyed() @@ -85,6 +101,30 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne } init { + val flag = System.currentTimeMillis() + onCreated { instanceFlags.add(flag) } + onDestroyed { instanceFlags.remove(flag) } + onCreated { + lifecycleScope.launch { + var canDrawOverlays = canDrawOverlaysState.updateAndGet() + topActivityFlow.mapState(lifecycleScope) { it.appId to it.activityId } + .filter { flag == instanceFlags.last() } + .collectLatest { + var i = 0 + while (i < 6 && isActive) { + val oldV = canDrawOverlays + val newV = canDrawOverlaysState.updateAndGet() + canDrawOverlays = newV + if (!newV && oldV) { + toast("当前界面拒绝显示悬浮窗") + break + } + delay(500) + i++ + } + } + } + } onCreated { val marginX = 20.dp.px.toInt() val marginY = BarUtils.getStatusBarHeight() + 5.dp.px.toInt() @@ -107,6 +147,8 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne var paramsXy = layoutParams.x to layoutParams.y var fixMoveFlag = 0 val fixLimitXy = { + screenWidth = ScreenUtils.getScreenWidth() + screenHeight = ScreenUtils.getScreenHeight() val x = layoutParams.x.coerceIn(marginX, screenWidth - view.width - marginX) val y = layoutParams.y.coerceIn( marginY, @@ -141,11 +183,8 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne } } lifecycleScope.launch { - val sharedFlow = MutableSharedFlow() - view.viewTreeObserver.addOnGlobalLayoutListener { - launch { sharedFlow.emit(Unit) } - } - sharedFlow.debounce(100).collect { fixLimitXy() } + view.viewTreeObserver.addOnGlobalLayoutListener { launch { resizeFlow.emit(Unit) } } + resizeFlow.debounce(100).collect { fixLimitXy() } } var downXy: Pair? = null @SuppressLint("ClickableViewAccessibility") diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt index 00ca4d7a2d..7a3876ebee 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.recordNotif import li.songe.gkd.permission.canDrawOverlaysState diff --git a/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt index 4df7ac9b46..3168744594 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt @@ -9,20 +9,16 @@ import kotlinx.coroutines.withTimeoutOrNull import li.songe.gkd.app import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.screenshotNotif -import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.ScreenshotUtil import li.songe.gkd.util.componentName import li.songe.gkd.util.stopServiceByClass import java.lang.ref.WeakReference -class ScreenshotService : Service(), OnCreateToDestroy { +class ScreenshotService : Service(), OnSimpleLife { override fun onBind(intent: Intent?) = null - - override fun onCreate() { - super.onCreate() - onCreated() - } - + override fun onCreate() = onCreated() + override fun onDestroy() = onDestroyed() override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { try { @@ -36,11 +32,6 @@ class ScreenshotService : Service(), OnCreateToDestroy { } } - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - private var screenshotUtil: ScreenshotUtil? = null init { diff --git a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt index 524d8eb1c5..c41d5d12ab 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt @@ -5,10 +5,8 @@ import android.service.quicksettings.TileService import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import li.songe.gkd.appScope -import li.songe.gkd.util.SnapshotExt.captureSnapshot -import li.songe.gkd.shizuku.safeGetTopActivity +import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast @@ -41,14 +39,7 @@ class SnapshotTileService : TileService() { } } else if (latestAppId != oldAppId) { LogUtils.d("SnapshotTileService::eventExecutor.execute") - appScope.launch(A11yService.eventThread) { - val topActivity = safeGetTopActivity() ?: TopActivity(appId = latestAppId) - updateTopActivity(topActivity) - getAndUpdateCurrentRules() - appScope.launchTry(Dispatchers.IO) { - captureSnapshot() - } - } + appScope.launchTry { SnapshotExt.captureSnapshot() } break } else { service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index e17bdd9f86..8315340021 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -13,13 +13,13 @@ import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.OnCreateToDestroy +import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.getSubsStatus import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass -class StatusService : Service(), OnCreateToDestroy { +class StatusService : Service(), OnSimpleLife { override fun onBind(intent: Intent?) = null override fun onCreate() = onCreated() override fun onDestroy() = onDestroyed() diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt index a05ce62b71..16daeeb109 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt @@ -2,155 +2,55 @@ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.IActivityTaskManager +import android.content.ComponentName import android.view.Display -import com.blankj.utilcode.util.LogUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import li.songe.gkd.appScope -import li.songe.gkd.data.DeviceInfo -import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.service.A11yService -import li.songe.gkd.service.TopActivity -import li.songe.gkd.service.topActivityFlow -import li.songe.gkd.service.updateTopActivity -import li.songe.gkd.store.shizukuStoreFlow -import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper import kotlin.reflect.full.declaredMemberFunctions +import kotlin.reflect.full.valueParameters import kotlin.reflect.typeOf -/** - * -1: invalid fc - * 1: (int) -> List - * 3: (int, boolean, boolean) -> List - * 4: (int, boolean, boolean, int) -> List - */ -private var getTasksFcType: Int? = null -private fun IActivityTaskManager.compatGetTasks(maxNum: Int = 1): List { - if (getTasksFcType == null) { - val fcs = this::class.declaredMemberFunctions - val parameters = fcs.find { d -> d.name == "getTasks" }?.parameters - if (parameters != null) { - if (parameters.size == 5 && parameters[1].type == typeOf() && parameters[2].type == typeOf() && parameters[3].type == typeOf() && parameters[4].type == typeOf()) { - getTasksFcType = 4 - } else if (parameters.size == 4 && parameters[1].type == typeOf() && parameters[2].type == typeOf() && parameters[3].type == typeOf()) { - getTasksFcType = 3 - } else if (parameters.size == 2 && parameters[1].type == typeOf()) { - getTasksFcType = 1 - } else { - getTasksFcType = -1 - LogUtils.d(DeviceInfo.instance) - LogUtils.d(fcs) - toast("Shizuku获取方法签名错误") +private var tasksFcType: Int? = null +private fun IActivityTaskManager.compatGetTasks(maxNum: Int): List { + if (tasksFcType == null) { + for (f in this::class.declaredMemberFunctions.filter { it.name == "getTasks" }) { + tasksFcType = when (f.valueParameters.map { it.type }) { + listOf(typeOf()) -> 1 + listOf(typeOf(), typeOf(), typeOf()) -> 3 + listOf(typeOf(), typeOf(), typeOf(), typeOf()) -> 4 + else -> null } + if (tasksFcType != null) { + break + } + } + if (tasksFcType == null) { + tasksFcType = -1 + toast("获取 IActivityTaskManager:getTasks 签名错误") } } return try { - // https://bugly.qq.com/v2/crash-reporting/crashes/d0ce46b353/106137?pid=1 - // binder haven't been received - when (getTasksFcType) { + when (tasksFcType) { 1 -> this.getTasks(maxNum) 3 -> this.getTasks(maxNum, false, true) 4 -> this.getTasks(maxNum, false, true, Display.DEFAULT_DISPLAY) else -> emptyList() } - } catch (e: Throwable) { - LogUtils.d(e) + } catch (_: Throwable) { emptyList() } } // https://github.com/gkd-kit/gkd/issues/44 -// fix java.lang.ClassNotFoundException:Didn't find class "android.app.IActivityTaskManager" on path: DexPathList -interface SafeActivityTaskManager { - val value: Any - fun compatGetTasks(maxNum: Int): List - fun compatGetTasks(): List - fun registerTaskStackListener(listener: TaskListener) - fun unregisterTaskStackListener(listener: TaskListener) - - fun getTopActivity(): TopActivity? { - val top = compatGetTasks().firstOrNull()?.topActivity ?: return null - return TopActivity(appId = top.packageName, activityId = top.className) - } -} - -private fun newActivityTaskManager(): SafeActivityTaskManager? { - val service = SystemServiceHelper.getSystemService("activity_task") - if (service == null) { - LogUtils.d("shizuku 无法获取 activity_task") - return null - } - val manager = service.let(::ShizukuBinderWrapper).let(IActivityTaskManager.Stub::asInterface) - return object : SafeActivityTaskManager { - override val value = manager - override fun compatGetTasks(maxNum: Int) = manager.compatGetTasks(maxNum) - override fun compatGetTasks() = manager.compatGetTasks() - override fun registerTaskStackListener(listener: TaskListener) { - manager.registerTaskStackListener(listener) - } +// java.lang.ClassNotFoundException:Didn't find class "android.app.IActivityTaskManager" on path: DexPathList +class SafeActivityTaskManager(private val value: IActivityTaskManager) { + fun compatGetTasks(maxNum: Int = 1) = value.compatGetTasks(maxNum) + fun getTopCpn(): ComponentName? = compatGetTasks().firstOrNull()?.topActivity - override fun unregisterTaskStackListener(listener: TaskListener) { - manager.unregisterTaskStackListener(listener) - } + fun registerTaskStackListener(listener: FixedTaskStackListener) { + value.registerTaskStackListener(listener) } -} - -private val shizukuActivityUsedFlow by lazy { - combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> - shizukuOk && store.enableActivity - }.stateIn(appScope, SharingStarted.Eagerly, false) -} - -private val taskListener by lazy { - TaskListener(onStackChanged = { - safeGetTopActivity()?.let { - appScope.launchTry(A11yService.eventThread) { - delay(200) - if (topActivityFlow.value != it) { - updateTopActivity(it) - } - } - } - }) -} - -val activityTaskManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launchTry(Dispatchers.IO) { - shizukuActivityUsedFlow.collect { - if (shizukuOkState.value) { - stateFlow.value?.unregisterTaskStackListener(taskListener) - } - stateFlow.value = if (it) newActivityTaskManager() else null - stateFlow.value?.registerTaskStackListener(taskListener) - } - } - stateFlow -} - -fun shizukuCheckActivity(): Boolean { - return (try { - newActivityTaskManager()?.compatGetTasks()?.isNotEmpty() == true - } catch (e: Throwable) { - e.printStackTrace() - false - }) -} -fun safeGetTopActivity(): TopActivity? { - if (!shizukuActivityUsedFlow.value) return null - try { - return activityTaskManagerFlow.value?.getTopActivity() - } catch (e: Throwable) { - e.printStackTrace() - return null + fun unregisterTaskStackListener(listener: FixedTaskStackListener) { + value.unregisterTaskStackListener(listener) } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/AutoStartReceiver.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AutoStartReceiver.kt deleted file mode 100644 index 5780fd038b..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/AutoStartReceiver.kt +++ /dev/null @@ -1,20 +0,0 @@ -package li.songe.gkd.shizuku - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import rikka.shizuku.Shizuku - -class AutoStartReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BOOT_COMPLETED || intent?.action == Intent.ACTION_LOCKED_BOOT_COMPLETED) { - Shizuku.addBinderReceivedListenerSticky(oneShotBinderReceivedListener) - } - } - - private val oneShotBinderReceivedListener = object : Shizuku.OnBinderReceivedListener { - override fun onBinderReceived() { - Shizuku.removeBinderReceivedListener(this) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt deleted file mode 100644 index 901d5c9057..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package li.songe.gkd.shizuku - -import kotlinx.serialization.Serializable - -@Serializable -data class CommandResult( - val code: Int, - val result: String, - val error: String? -) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 019fdd942a..8858a88fda 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -3,72 +3,33 @@ package li.songe.gkd.shizuku import android.content.IntentFilter import android.content.pm.IPackageManager import android.content.pm.PackageInfo -import com.blankj.utilcode.util.LogUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import li.songe.gkd.appScope -import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.store.shizukuStoreFlow -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper -import kotlin.reflect.full.declaredFunctions +import kotlin.reflect.full.declaredMemberFunctions import kotlin.reflect.typeOf -private var packageFlagsParamsLongType: Boolean? = null +private var packageFcType: Boolean? = null private fun IPackageManager.compatGetInstalledPackages( flags: Long, userId: Int ): List { - if (packageFlagsParamsLongType == null) { - val method = this::class.declaredFunctions.find { it.name == "getInstalledPackages" }!! - packageFlagsParamsLongType = method.parameters[1].type == typeOf() + if (packageFcType == null) { + packageFcType = this::class.declaredMemberFunctions.find { + it.name == "getInstalledPackages" + }!!.parameters[1].type == typeOf() } - return if (packageFlagsParamsLongType == true) { - getInstalledPackages(flags, userId).list + return if (packageFcType == true) { + getInstalledPackages(flags, userId) } else { - getInstalledPackages(flags.toInt(), userId).list - } -} - -interface SafePackageManager { - fun compatGetInstalledPackages(flags: Long, userId: Int): List - fun getAllIntentFilters(packageName: String): List + getInstalledPackages(flags.toInt(), userId) + }.list } -fun newPackageManager(): SafePackageManager? { - val service = SystemServiceHelper.getSystemService("package") - if (service == null) { - LogUtils.d("shizuku 无法获取 package") - return null - } - val manager = service.let(::ShizukuBinderWrapper).let(IPackageManager.Stub::asInterface) - return object : SafePackageManager { - override fun compatGetInstalledPackages(flags: Long, userId: Int) = - manager.compatGetInstalledPackages(flags, userId) - - override fun getAllIntentFilters(packageName: String) = - manager.getAllIntentFilters(packageName).list +class SafePackageManager(private val value: IPackageManager) { + fun compatGetInstalledPackages(flags: Long, userId: Int): List { + return value.compatGetInstalledPackages(flags, userId) } -} - -val shizukuWorkProfileUsedFlow by lazy { - combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> - shizukuOk && store.enableWorkProfile - }.stateIn(appScope, SharingStarted.Eagerly, false) -} -val packageManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launch(Dispatchers.IO) { - shizukuWorkProfileUsedFlow.collect { - stateFlow.value = if (it) newPackageManager() else null - } + fun getAllIntentFilters(packageName: String): List { + return value.getAllIntentFilters(packageName).list } - stateFlow } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 435fa38c47..9dca10cd7c 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -1,13 +1,22 @@ package li.songe.gkd.shizuku +import android.app.IActivityTaskManager +import android.content.Context import android.content.Intent +import android.content.pm.IPackageManager import android.content.pm.PackageManager +import android.os.IUserManager import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import li.songe.gkd.META import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo @@ -22,6 +31,84 @@ import li.songe.gkd.util.otherUserAppInfoMapFlow import li.songe.gkd.util.toast import li.songe.gkd.util.userAppInfoMapFlow import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper + +private fun getStubService(name: String): ShizukuBinderWrapper? { + val service = SystemServiceHelper.getSystemService(name) + if (service == null) { + LogUtils.d("获取 $name 失败") + return null + } + return ShizukuBinderWrapper(service) +} + +private fun newUserManager() = getStubService(Context.USER_SERVICE)?.let { + SafeUserManager(IUserManager.Stub.asInterface(it)) +} + +private fun newActivityTaskManager() = getStubService("activity_task")?.let { + SafeActivityTaskManager(IActivityTaskManager.Stub.asInterface(it)) +} + +private fun newPackageManager() = getStubService("package")?.let { + SafePackageManager(IPackageManager.Stub.asInterface(it)) +} + +private val shizukuActivityUsedFlow by lazy { + combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> + shizukuOk && store.enableActivity + }.stateIn(appScope, SharingStarted.Eagerly, false) +} + +val userManagerFlow by lazy> { + val stateFlow = MutableStateFlow(null) + appScope.launch(Dispatchers.IO) { + shizukuWorkProfileUsedFlow.collect { + stateFlow.value = if (it) newUserManager() else null + } + } + stateFlow +} + +val activityTaskManagerFlow by lazy> { + val stateFlow = MutableStateFlow(null) + appScope.launchTry(Dispatchers.IO) { + shizukuActivityUsedFlow.collect { + if (shizukuOkState.value) { + stateFlow.value?.unregisterTaskStackListener(MyTaskListener) + } + stateFlow.value = if (it) newActivityTaskManager() else null + stateFlow.value?.registerTaskStackListener(MyTaskListener) + } + } + stateFlow +} + +val shizukuWorkProfileUsedFlow by lazy { + combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> + shizukuOk && store.enableWorkProfile + }.stateIn(appScope, SharingStarted.Eagerly, false) +} + +val packageManagerFlow by lazy> { + val stateFlow = MutableStateFlow(null) + appScope.launch(Dispatchers.IO) { + shizukuWorkProfileUsedFlow.collect { + stateFlow.value = if (it) newPackageManager() else null + } + } + stateFlow +} + +fun shizukuCheckActivity(): Boolean { + return (try { + newActivityTaskManager()?.compatGetTasks()?.isNotEmpty() == true + } catch (e: Throwable) { + e.printStackTrace() + false + }) +} fun shizukuCheckGranted(): Boolean { val granted = try { @@ -31,7 +118,7 @@ fun shizukuCheckGranted(): Boolean { } if (!granted) return false if (shizukuStoreFlow.value.enableActivity) { - return safeGetTopActivity() != null || shizukuCheckActivity() + return safeGetTopCpn() != null || shizukuCheckActivity() } return true } @@ -48,6 +135,12 @@ fun shizukuCheckWorkProfile(): Boolean { }) } +fun safeGetTopCpn() = try { + activityTaskManagerFlow.value?.getTopCpn() +} catch (_: Throwable) { + null +} + fun initShizuku() { Shizuku.addBinderReceivedListener { LogUtils.d("Shizuku.addBinderReceivedListener") diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskListener.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskListener.kt deleted file mode 100644 index dd0b1c08d5..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskListener.kt +++ /dev/null @@ -1,19 +0,0 @@ -package li.songe.gkd.shizuku - -import android.app.ITaskStackListener -import android.os.Parcel - - -class TaskListener(private val onStackChanged: () -> Unit) : ITaskStackListener.Stub() { - - public override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { - // https://github.com/gkd-kit/gkd/issues/941#issuecomment-2784035441 - return try { - super.onTransact(code, data, reply, flags) - } catch (_: Throwable) { - true - } - } - - override fun onTaskStackChanged() = onStackChanged() -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt new file mode 100644 index 0000000000..dfbd264f86 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt @@ -0,0 +1,44 @@ +package li.songe.gkd.shizuku + +import android.app.ActivityManager +import android.app.ITaskStackListener +import android.os.Parcel +import li.songe.gkd.a11y.updateTopActivity + +class FixedTaskStackListener : ITaskStackListener.Stub() { + + // https://github.com/gkd-kit/gkd/issues/941#issuecomment-2784035441 + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = try { + super.onTransact(code, data, reply, flags) + } catch (_: Throwable) { + true + } + + override fun onTaskStackChanged() { + if (lastFront > 0 && System.currentTimeMillis() - lastFront < 200) { + lastFront = 0 + return + } + val cpn = safeGetTopCpn() ?: return + updateTopActivity( + appId = cpn.packageName, + activityId = cpn.className, + type = 1, + ) + } + + private var lastFront = 0L + override fun onTaskMovedToFront(taskInfo: ActivityManager.RunningTaskInfo) { + lastFront = System.currentTimeMillis() + val cpn = taskInfo.topActivity ?: safeGetTopCpn() ?: return + updateTopActivity( + appId = cpn.packageName, + activityId = cpn.className, + type = 2, + ) + } +} + +val MyTaskListener by lazy { + FixedTaskStackListener() +} diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt index d7e5141e57..0692543a17 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt @@ -1,77 +1,54 @@ package li.songe.gkd.shizuku -import android.annotation.SuppressLint -import android.content.Context -import android.os.Build import android.os.IUserManager -import com.blankj.utilcode.util.LogUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import li.songe.gkd.appScope -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper - +import li.songe.gkd.data.UserInfo +import li.songe.gkd.util.toast +import kotlin.reflect.full.declaredMemberFunctions +import kotlin.reflect.full.valueParameters +import kotlin.reflect.typeOf +private var getUsersFcType: Int? = null private fun IUserManager.compatGetUsers( - excludePartial: Boolean = true, - excludeDying: Boolean = true, - excludePreCreated: Boolean = true -): List { - return (if (Build.VERSION.SDK_INT >= 30) { - getUsers(excludePartial, excludeDying, excludePreCreated) - } else { - try { - getUsers(excludeDying) - } catch (e: NoSuchFieldError) { - LogUtils.d(e) - @SuppressLint("NewApi") - getUsers(excludePartial, excludeDying, excludePreCreated) + excludePartial: Boolean, + excludeDying: Boolean, + excludePreCreated: Boolean, +): List { + if (getUsersFcType == null) { + for (f in this::class.declaredMemberFunctions.filter { it.name == "getUsers" }) { + getUsersFcType = when (f.valueParameters.map { it.type }) { + listOf(typeOf()) -> 1 + listOf(typeOf(), typeOf(), typeOf()) -> 3 + else -> null + } + if (getUsersFcType != null) { + break + } + } + if (getUsersFcType == null) { + getUsersFcType = -1 + toast("获取 IUserManager:getTasks 签名错误") + } + } + return try { + when (getUsersFcType) { + 1 -> this.getUsers(excludeDying) + 3 -> this.getUsers(excludePartial, excludeDying, excludePreCreated) + else -> emptyList() } - }).map { - li.songe.gkd.data.UserInfo( + } catch (_: Throwable) { + emptyList() + }.map { + UserInfo( id = it.id, - name = it.name.trim(), + name = it.name, ) } } -interface SafeUserManager { +class SafeUserManager(private val value: IUserManager) { fun compatGetUsers( - excludePartial: Boolean, - excludeDying: Boolean, - excludePreCreated: Boolean - ): List - - fun compatGetUsers(): List -} - -fun newUserManager(): SafeUserManager? { - val service = SystemServiceHelper.getSystemService(Context.USER_SERVICE) - if (service == null) { - LogUtils.d("shizuku 无法获取 user") - return null - } - val manager = service.let(::ShizukuBinderWrapper).let(IUserManager.Stub::asInterface) - return object : SafeUserManager { - override fun compatGetUsers( - excludePartial: Boolean, - excludeDying: Boolean, - excludePreCreated: Boolean - ) = manager.compatGetUsers(excludePartial, excludeDying, excludePreCreated) - - override fun compatGetUsers() = manager.compatGetUsers() - } -} - - -val userManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launch(Dispatchers.IO) { - shizukuWorkProfileUsedFlow.collect { - stateFlow.value = if (it) newUserManager() else null - } - } - stateFlow + excludePartial: Boolean = true, + excludeDying: Boolean = true, + excludePreCreated: Boolean = true + ): List = value.compatGetUsers(excludePartial, excludeDying, excludePreCreated) } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index 30e700093d..8851352829 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.appScope import li.songe.gkd.permission.shizukuOkState @@ -114,7 +115,6 @@ private fun IUserService.execCommandForResult(command: String): Boolean? { } } - private fun unbindUserService(serviceArgs: Shizuku.UserServiceArgs, connection: ServiceConnection) { if (!shizukuOkState.stateFlow.value) return LogUtils.d("unbindUserService", serviceArgs) @@ -127,6 +127,13 @@ private fun unbindUserService(serviceArgs: Shizuku.UserServiceArgs, connection: } } +@Serializable +data class CommandResult( + val code: Int, + val result: String, + val error: String? +) + data class UserServiceWrapper( val userService: IUserService, val connection: ServiceConnection, @@ -182,11 +189,16 @@ suspend fun buildServiceWrapper(): UserServiceWrapper? { return withTimeoutOrNull(3000) { suspendCoroutine { continuation -> resumeCallback = { continuation.resume(it) } - Shizuku.bindUserService(serviceArgs, connection) + try { + Shizuku.bindUserService(serviceArgs, connection) + } catch (_: Throwable) { + resumeCallback = null + continuation.resume(null) + } } }.apply { if (this == null) { - toast("获取 Shizuku 服务超时失败") + toast("获取 Shizuku 服务失败") unbindUserService(serviceArgs, connection) } } @@ -216,8 +228,7 @@ val serviceWrapperFlow by lazy { suspend fun shizukuCheckUserService(): Boolean { return try { execCommandForResult("input tap 0 0") - } catch (e: Throwable) { - e.printStackTrace() + } catch (_: Throwable) { false } } @@ -228,10 +239,6 @@ suspend fun execCommandForResult(command: String): Boolean { }?.execCommandForResult(command) == true } -// 在 大麦 https://i.gkd.li/i/14605104 上测试产生如下 3 种情况 -// 1. 点击不生效: 使用传统无障碍屏幕点击, 此种点击可被 大麦 通过 View.setAccessibilityDelegate 屏蔽 -// 2. 点击概率生效: 使用 Shizuku 获取到的 InputManager.injectInputEvent 发出点击, 概率失效/生效, 原因未知 -// 3. 点击生效: 使用 Shizuku 获取到的 shell input tap x y 发出点击 by safeTap, 暂未找到屏蔽方案 fun safeTap(x: Float, y: Float): Boolean? { return serviceWrapperFlow.value?.execCommandForResult("input tap $x $y") } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index c04f6f0149..fb109a282c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -485,7 +485,6 @@ fun AdvancedPage() { Spacer(modifier = Modifier.height(EmptyHeight)) } } - } private val checkShizukuMutex by lazy { Mutex() } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index a1395fdca3..f464c956e2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -52,11 +52,11 @@ import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import kotlinx.coroutines.flow.update +import li.songe.gkd.a11y.launcherAppId import li.songe.gkd.data.ExcludeData import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet -import li.songe.gkd.service.launcherAppId import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AnimatedIcon import li.songe.gkd.ui.component.AppBarTextField @@ -358,7 +358,9 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { OutlinedTextField( value = source, onValueChange = { source = it }, - modifier = Modifier.fillMaxWidth().autoFocus(), + modifier = Modifier + .fillMaxWidth() + .autoFocus(), placeholder = { Text( text = tipText, diff --git a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt index 112336b60d..8eca1e1be7 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt @@ -1,6 +1,7 @@ package li.songe.gkd.util import android.app.Service +import android.content.ComponentName import android.content.ContentValues import android.content.Context import android.content.Intent @@ -137,3 +138,11 @@ fun startForegroundServiceByClass(clazz: KClass) { toast("启动服务失败: ${e.message}") } } + +val Intent.extraCptName: ComponentName? + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(Intent.EXTRA_COMPONENT_NAME) as? ComponentName? + } diff --git a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt index dd8025a5b8..b97aa2d93a 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -9,49 +9,35 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.isActivityVisible import java.util.WeakHashMap +import kotlin.reflect.jvm.jvmName -private val callbacksMap = WeakHashMap>>() +private val cbMap = WeakHashMap>>() + +typealias CbFn = () -> Unit @Suppress("UNCHECKED_CAST") -private fun CanOnCallback.getCallbacks(method: Int): MutableList { - return callbacksMap.getOrPut(this) { hashMapOf() } +private fun OnSimpleLife.cbs(method: Int): MutableList = synchronized(cbMap) { + return cbMap.getOrPut(this) { hashMapOf() } .getOrPut(method) { mutableListOf() } as MutableList } -interface CanOnCallback { +interface OnSimpleLife { + fun onCreated(f: CbFn) = cbs(1).add(f) + fun onCreated() = cbs(1).forEach { it() } + + fun onDestroyed(f: CbFn) = cbs(2).add(f) + fun onDestroyed() = cbs(2).forEach { it() } + fun useLogLifecycle() { - LogUtils.d("useLogLifecycle", this) - if (this is OnCreateToDestroy) { - onCreated { LogUtils.d("onCreated", this) } - onDestroyed { LogUtils.d("onDestroyed", this) } - } + onCreated { LogUtils.d("onCreated:" + this::class.jvmName) } + onDestroyed { LogUtils.d("onDestroyed:" + this::class.jvmName) } if (this is OnA11yLife) { - onA11yConnected { LogUtils.d("onA11yConnected", this) } + onA11yConnected { LogUtils.d("onA11yConnected:" + this::class.jvmName) } } if (this is OnTileLife) { - onStartListened { LogUtils.d("onStartListened", this) } - onStopListened { LogUtils.d("onStopListened", this) } - onTileClicked { LogUtils.d("onTileClicked", this) } + onTileClicked { LogUtils.d("onTileClicked:" + this::class.jvmName) } } } -} - -interface OnCreateToDestroy : CanOnCallback { - fun onCreated(f: () -> Unit) { - getCallbacks<() -> Unit>(2).add(f) - } - - fun onCreated() { - getCallbacks<() -> Unit>(2).forEach { it() } - } - - fun onDestroyed(f: () -> Unit) { - getCallbacks<() -> Unit>(4).add(f) - } - - fun onDestroyed() { - getCallbacks<() -> Unit>(4).forEach { it() } - } fun useScope(): CoroutineScope = MainScope().apply { onDestroyed { cancel() } } @@ -74,48 +60,22 @@ interface OnCreateToDestroy : CanOnCallback { } } -interface OnA11yLife : CanOnCallback { - fun onA11yConnected(f: () -> Unit) { - getCallbacks<() -> Unit>(8).add(f) - } - - fun onA11yConnected() { - getCallbacks<() -> Unit>(8).forEach { it() } - } - - val a11yEventCallbacks: MutableList<(AccessibilityEvent) -> Unit> - - fun onA11yEvent(f: (AccessibilityEvent) -> Unit) { - a11yEventCallbacks.add(f) - } +interface OnA11yLife : OnSimpleLife { + fun onA11yConnected(f: CbFn) = cbs(3).add(f) + fun onA11yConnected() = cbs(3).forEach { it() } - fun onA11yEvent(event: AccessibilityEvent) { - a11yEventCallbacks.forEach { it(event) } - } + val a11yEventCbs: MutableList<(AccessibilityEvent) -> Unit> + fun onA11yEvent(f: (AccessibilityEvent) -> Unit) = a11yEventCbs.add(f) + fun onA11yEvent(event: AccessibilityEvent) = a11yEventCbs.forEach { it(event) } } -interface OnTileLife : CanOnCallback { - fun onStartListened(f: () -> Unit) { - getCallbacks<() -> Unit>(10).add(f) - } +interface OnTileLife : OnSimpleLife { + fun onStartListened(f: CbFn) = cbs(4).add(f) + fun onStartListened() = cbs(4).forEach { it() } - fun onStartListened() { - getCallbacks<() -> Unit>(10).forEach { it() } - } + fun onStopListened(f: CbFn) = cbs(5).add(f) + fun onStopListened() = cbs(5).forEach { it() } - fun onStopListened(f: () -> Unit) { - getCallbacks<() -> Unit>(12).add(f) - } - - fun onStopListened() { - getCallbacks<() -> Unit>(12).forEach { it() } - } - - fun onTileClicked(f: () -> Unit) { - getCallbacks<() -> Unit>(14).add(f) - } - - fun onTileClicked() { - getCallbacks<() -> Unit>(14).forEach { it() } - } -} \ No newline at end of file + fun onTileClicked(f: CbFn) = cbs(6).add(f) + fun onTileClicked() = cbs(6).forEach { it() } +} diff --git a/app/src/main/kotlin/li/songe/gkd/util/PackageExt.kt b/app/src/main/kotlin/li/songe/gkd/util/PackageExt.kt deleted file mode 100644 index 362ea8a082..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/util/PackageExt.kt +++ /dev/null @@ -1,20 +0,0 @@ -package li.songe.gkd.util - -import android.content.Intent -import android.content.pm.PackageManager -import li.songe.gkd.service.TopActivity - -fun PackageManager.getDefaultLauncherActivity(): TopActivity { - val intent = Intent(Intent.ACTION_MAIN) - intent.addCategory(Intent.CATEGORY_HOME) - val info = - this.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)?.activityInfo - ?: return TopActivity("") - val appId = info.packageName ?: "" - val name = info.name ?: "" - val activityId = if (name.startsWith('.')) appId + name else name - return TopActivity( - appId = appId, - activityId = activityId - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt index 0add1763c6..6d547a3f00 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -9,17 +9,20 @@ import com.blankj.utilcode.util.ZipUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext +import li.songe.gkd.a11y.TopActivity +import li.songe.gkd.a11y.screenshot +import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.data.ComplexSnapshot import li.songe.gkd.data.RpcError import li.songe.gkd.data.info2nodeList import li.songe.gkd.db.DbSet -import li.songe.gkd.service.ScreenshotService import li.songe.gkd.notif.snapshotNotif import li.songe.gkd.service.A11yService -import li.songe.gkd.service.getAndUpdateCurrentRules -import li.songe.gkd.service.safeActiveWindow +import li.songe.gkd.service.ScreenshotService +import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.store.storeFlow import java.io.File import kotlin.math.min @@ -79,7 +82,7 @@ object SnapshotExt { } private suspend fun screenshot(): Bitmap? { - return A11yService.Companion.screenshot() ?: ScreenshotService.Companion.screenshot() + return A11yService.instance?.screenshot() ?: ScreenshotService.screenshot() } private fun cropBitmapStatusBar(bitmap: Bitmap): Bitmap { @@ -101,27 +104,43 @@ object SnapshotExt { private val captureLoading = MutableStateFlow(false) suspend fun captureSnapshot(skipScreenshot: Boolean = false): ComplexSnapshot { - if (!A11yService.Companion.isRunning.value) { - throw RpcError("无障碍不可用,请先授权") + if (!A11yService.isRunning.value) { + throw RpcError("无障碍不可用,请先授权") } if (captureLoading.value) { - throw RpcError("正在保存快照,不可重复操作") + throw RpcError("正在保存快照,不可重复操作") } captureLoading.value = true try { val rootNode = - A11yService.Companion.instance?.safeActiveWindow - ?: throw RpcError("当前应用没有无障碍信息,捕获失败") + A11yService.instance?.safeActiveWindow + ?: throw RpcError("当前应用没有无障碍信息,捕获失败") if (storeFlow.value.showSaveSnapshotToast) { toast("正在保存快照...") } val (snapshot, bitmap) = coroutineScope { val d1 = async(Dispatchers.IO) { + val appId = rootNode.packageName.toString() + var activityId = safeGetTopCpn()?.className + if (activityId == null) { + var topActivity = topActivityFlow.value + var i = 0L + while (topActivity.appId != appId) { + delay(100) + topActivity = topActivityFlow.value + i += 100 + if (i >= 2000) { + topActivity = TopActivity(appId = appId) + break + } + } + activityId = topActivity.activityId + } ComplexSnapshot( id = System.currentTimeMillis(), - appId = rootNode.packageName.toString(), - activityId = getAndUpdateCurrentRules().topActivity.activityId, + appId = appId, + activityId = activityId, screenHeight = ScreenUtils.getScreenHeight(), screenWidth = ScreenUtils.getScreenWidth(), isLandscape = ScreenUtils.isLandscape(), diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index 1f1970187f..9f15b68a25 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -82,8 +82,8 @@ val subsEntriesFlow by lazy { } val usedSubsEntriesFlow by lazy { - subsEntriesFlow.map { - it.filter { s -> s.subsItem.enable && s.subscription != null } + subsEntriesFlow.map { list -> + list.filter { s -> s.subsItem.enable && s.subscription != null } .map { UsedSubsEntry(it.subsItem, it.subscription!!) } }.stateIn(appScope, SharingStarted.Eagerly, emptyList()) } @@ -413,15 +413,11 @@ private suspend fun updateSubs(subsEntry: SubsEntry): RawSubscription? { val subsVersion = json.decodeFromJson5String( client.get(checkUpdateUrl).bodyAsText() ) - LogUtils.d( - "快速检测更新:id=${subsRaw.id},version=${subsRaw.version}", - subsVersion - ) if (subsVersion.id == subsRaw.id && subsVersion.version <= subsRaw.version) { return null } } catch (e: Exception) { - LogUtils.d("快速检测更新失败", subsItem, e) + LogUtils.d("快速检测更新失败", subsItem, e.message) } } val updateUrl = subsRaw?.updateUrl ?: subsItem.updateUrl @@ -487,7 +483,7 @@ fun checkSubsUpdate(showToast: Boolean = false) = appScope.launchTry(Dispatchers set(subsEntry.subsItem.id, e) } } - LogUtils.d("检测更新失败", e) + LogUtils.d("检测更新失败", e.message) } } if (showToast) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 9b684dbbad..2d8d2098d7 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -1,6 +1,5 @@ package li.songe.gkd.util -import android.accessibilityservice.AccessibilityService import android.content.Context import android.content.res.Configuration import android.graphics.Color @@ -27,6 +26,7 @@ import com.hjq.toast.style.WhiteToastStyle import kotlinx.coroutines.Dispatchers import li.songe.gkd.app import li.songe.gkd.appScope +import li.songe.gkd.service.A11yService import li.songe.gkd.store.storeFlow @@ -99,7 +99,7 @@ private fun setReactiveToastStyle() { private var triggerTime = 0L private const val triggerInterval = 2000L -fun showActionToast(context: AccessibilityService) { +fun showActionToast() { if (!storeFlow.value.toastWhenClick) return appScope.launchTry(Dispatchers.Main) { val t = System.currentTimeMillis() @@ -108,8 +108,7 @@ fun showActionToast(context: AccessibilityService) { if (storeFlow.value.useSystemToast) { showSystemToast(storeFlow.value.clickToast) } else { - showAccessibilityToast( - context, + showA11yToast( storeFlow.value.clickToast ) } @@ -129,9 +128,10 @@ private fun showSystemToast(message: CharSequence) { // 2.使用协程 delay + cacheView 也可能导致无法取消 // https://github.com/gkd-kit/gkd/issues/697 // https://github.com/gkd-kit/gkd/issues/698 -private fun showAccessibilityToast(context: AccessibilityService, message: CharSequence) { - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - val textView = TextView(context).apply { +private fun showA11yToast(message: CharSequence) { + val service = A11yService.instance ?: return + val wm = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val textView = TextView(service).apply { text = message id = android.R.id.message gravity = Gravity.CENTER @@ -147,7 +147,7 @@ private fun showAccessibilityToast(context: AccessibilityService, message: CharS WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, ).reduce { acc, i -> acc or i } - packageName = context.packageName + packageName = service.packageName width = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT gravity = Gravity.BOTTOM diff --git a/build.gradle.kts b/build.gradle.kts index 663b8f746e..60c9c6c3b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlinx.atomicfu) apply false alias(libs.plugins.rikka.refine) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0eb85c3ae0..6213461b89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -kotlin = "2.2.0" -ksp = "2.2.0-2.0.2" +kotlin = "2.2.10" +ksp = "2.2.10-2.0.2" agp = "8.12.0" -compose = "1.8.3" +compose = "1.9.0" rikka = "4.4.0" room = "2.7.2" paging = "3.3.6" @@ -10,11 +10,13 @@ ktor = "3.2.3" destinations = "2.2.0" coil = "3.3.0" shizuku = "13.1.5" +atomicfu = "0.29.0" [libraries] kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin_test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx_serialization_json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" +kotlinx_atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } ktor_server_core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor_server_cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor_server_content_negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } @@ -34,7 +36,7 @@ compose_material3 = "androidx.compose.material3:material3:1.3.2" compose_activity = "androidx.activity:activity-compose:1.10.1" compose_navigation = "androidx.navigation:navigation-compose:2.9.3" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" -androidx_core_ktx = "androidx.core:core-ktx:1.16.0" +androidx_core_ktx = "androidx.core:core-ktx:1.17.0" androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.2" androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.2" androidx_junit = "androidx.test.ext:junit:1.3.0" @@ -62,7 +64,7 @@ coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } reorderable = "sh.calvin.reorderable:reorderable:2.5.1" exp4j = "net.objecthunter:exp4j:0.4.8" toaster = "com.github.getActivity:Toaster:13.2" -permissions = "com.github.getActivity:XXPermissions:25.2" +permissions = "com.github.getActivity:XXPermissions:26.0" json5 = "li.songe:json5:0.3.5" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" @@ -74,6 +76,7 @@ kotlin_multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin_parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin_compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinx_atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } android_library = { id = "com.android.library", version.ref = "agp" } android_application = { id = "com.android.application", version.ref = "agp" } androidx_room = { id = "androidx.room", version.ref = "room" } diff --git a/hidden_api/src/main/java/android/app/ITaskStackListener.java b/hidden_api/src/main/java/android/app/ITaskStackListener.java index 4d90c13f87..017a072af4 100644 --- a/hidden_api/src/main/java/android/app/ITaskStackListener.java +++ b/hidden_api/src/main/java/android/app/ITaskStackListener.java @@ -13,5 +13,8 @@ public static ITaskStackListener asInterface(IBinder obj) { } } + // 应用->桌面不会回调,分屏下切换窗口不会回调,但从最近任务界面移除窗口会回调 void onTaskStackChanged(); + + void onTaskMovedToFront(ActivityManager.RunningTaskInfo taskInfo); } \ No newline at end of file diff --git a/hidden_api/src/main/java/android/os/IUserManager.java b/hidden_api/src/main/java/android/os/IUserManager.java index 531a8d20f9..3d55aa182c 100644 --- a/hidden_api/src/main/java/android/os/IUserManager.java +++ b/hidden_api/src/main/java/android/os/IUserManager.java @@ -2,17 +2,15 @@ import android.content.pm.UserInfo; -import androidx.annotation.RequiresApi; - import java.util.List; @SuppressWarnings("unused") public interface IUserManager extends IInterface { - List getUsers(boolean excludeDying) throws RemoteException; + List getUsers(boolean excludeDying); - @RequiresApi(30) - List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated) throws RemoteException; + // @RequiresApi(Build.VERSION_CODES.R) + List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); abstract class Stub extends Binder implements IUserManager { public static IUserManager asInterface(IBinder obj) { From 6574f7b2716cac2694ec174e7e89c3e4f9c68e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 19 Aug 2025 11:10:11 +0800 Subject: [PATCH 021/245] perf: rm while true --- app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt | 13 ++++++------- .../li/songe/gkd/service/SnapshotTileService.kt | 3 ++- app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt index 039beb6b43..a7f64d65c8 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt @@ -20,14 +20,13 @@ private var lastId = 0L @Synchronized private fun buildUniqueTimeMillisId(): Long { - while (true) { - val id = System.currentTimeMillis() - if (id != lastId) { - lastId = id - return id - } - Thread.sleep(1) + val id = System.currentTimeMillis() + if (id > lastId) { + lastId = id + } else { + lastId += 1 } + return lastId } @Serializable diff --git a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt index c41d5d12ab..fe16c3c199 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt @@ -5,6 +5,7 @@ import android.service.quicksettings.TileService import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import li.songe.gkd.appScope import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.launchTry @@ -28,7 +29,7 @@ class SnapshotTileService : TileService() { return System.currentTimeMillis() - startTime > 3000L } - while (true) { + while (isActive) { val latestAppId = service.safeActiveWindowAppId if (latestAppId == null) { // https://github.com/gkd-kit/gkd/issues/713 diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 2f056ce65a..19c3a952a4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -60,6 +60,7 @@ import com.ramcosta.composedestinations.annotation.RootGraph import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app @@ -487,7 +488,7 @@ private fun AnimatedLogoIcon( atEnd ) LaunchedEffect(Unit) { - while (true) { + while (isActive) { atEnd = !atEnd delay(animation.totalDuration.toLong()) } From 2bf1eb819966f1105051cbb38ed035516158e0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 23 Aug 2025 21:48:56 +0800 Subject: [PATCH 022/245] perf: use QS_TILE_URI --- app/src/main/AndroidManifest.xml | 23 +++++++ .../main/kotlin/li/songe/gkd/MainViewModel.kt | 67 +++++-------------- .../kotlin/li/songe/gkd/OpenTileActivity.kt | 12 ++++ .../li/songe/gkd/ui/home/AppListPage.kt | 9 +-- .../li/songe/gkd/ui/home/ControlPage.kt | 5 +- .../kotlin/li/songe/gkd/ui/home/HomePage.kt | 61 ++++++++++++----- .../li/songe/gkd/ui/home/SettingsPage.kt | 9 +-- .../li/songe/gkd/ui/home/SubsManagePage.kt | 9 +-- 8 files changed, 103 insertions(+), 92 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e7990e04a..a132c06722 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -189,6 +189,10 @@ + + @@ -199,6 +203,9 @@ android:icon="@drawable/ic_capture" android:label="@string/capture_snapshot" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + @@ -212,6 +219,10 @@ + + @@ -225,6 +236,10 @@ + + @@ -238,6 +253,10 @@ + + @@ -251,6 +270,10 @@ + + diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index ea6327577b..0111e7714e 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -18,7 +18,6 @@ import com.ramcosta.composedestinations.spec.Direction import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,11 +29,6 @@ import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.permission.AuthReason import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.service.ButtonTileService -import li.songe.gkd.service.HttpTileService -import li.songe.gkd.service.MatchTileService -import li.songe.gkd.service.RecordTileService -import li.songe.gkd.service.SnapshotTileService import li.songe.gkd.shizuku.execCommandForResult import li.songe.gkd.store.createTextFlow import li.songe.gkd.ui.component.AlertDialogOptions @@ -42,15 +36,10 @@ import li.songe.gkd.ui.component.InputSubsLinkOption import li.songe.gkd.ui.component.RuleGroupState import li.songe.gkd.ui.component.UploadOptions import li.songe.gkd.ui.home.BottomNavItem -import li.songe.gkd.ui.home.appListNav -import li.songe.gkd.ui.home.controlNav -import li.songe.gkd.ui.home.subsNav import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.UpdateStatus import li.songe.gkd.util.clearCache import li.songe.gkd.util.client -import li.songe.gkd.util.componentName -import li.songe.gkd.util.extraCptName import li.songe.gkd.util.launchTry import li.songe.gkd.util.openUri import li.songe.gkd.util.openWeChatScaner @@ -167,16 +156,16 @@ class MainViewModel : ViewModel() { } val appListKeyFlow = MutableStateFlow(0) - val tabFlow = MutableStateFlow(controlNav) + val tabFlow = MutableStateFlow(BottomNavItem.Control.key) private var lastClickTabTime = 0L fun updateTab(navItem: BottomNavItem) { - if (navItem == appListNav && navItem == tabFlow.value) { + if (navItem == BottomNavItem.AppList && navItem.key == tabFlow.value) { // double click if (System.currentTimeMillis() - lastClickTabTime < 500) { appListKeyFlow.update { it + 1 } } } - tabFlow.value = navItem + tabFlow.value = navItem.key lastClickTabTime = System.currentTimeMillis() } @@ -205,7 +194,13 @@ class MainViewModel : ViewModel() { val notFoundToast = { toast("未知URI\n${uri}") } when (uri.host) { "page" -> when (uri.path) { - "" -> {} + "" -> { + val tab = uri.getQueryParameter("tab")?.toIntOrNull() + if (tab != null && BottomNavItem.allSubObjects.any { it.key == tab }) { + tabFlow.value = tab + } + } + "/1" -> navigatePage(AdvancedPageDestination) "/2" -> navigatePage(SnapshotPageDestination()) else -> notFoundToast() @@ -222,42 +217,14 @@ class MainViewModel : ViewModel() { fun handleIntent(intent: Intent) = viewModelScope.launchTry { LogUtils.d("handleIntent", intent) - val sourceName = intent.getStringExtra(activityNavSourceName) val uri = intent.data?.normalizeScheme() - when (sourceName) { - OpenSchemeActivity::class.jvmName -> { - if (uri?.scheme == "gkd") { - delay(200) - handleGkdUri(uri) - } - } - - OpenFileActivity::class.jvmName -> { - if (uri != null) { - toast("加载导入中...") - tabFlow.value = subsNav - withContext(Dispatchers.IO) { importData(uri) } - } - } - - OpenTileActivity::class.jvmName -> { - val qsTileCpt = intent.extraCptName - when (qsTileCpt) { - HttpTileService::class.componentName, ButtonTileService::class.componentName, RecordTileService::class.componentName -> { - delay(200) - navigatePage(AdvancedPageDestination) - } - - SnapshotTileService::class.componentName -> { - delay(200) - navigatePage(SnapshotPageDestination) - } - - MatchTileService::class.componentName -> { - tabFlow.value = subsNav - } - } - } + val source = intent.getStringExtra(activityNavSourceName) + if (uri?.scheme == "gkd") { + handleGkdUri(uri) + } else if (source == OpenFileActivity::class.jvmName && uri != null) { + toast("加载导入中...") + tabFlow.value = BottomNavItem.SubsManage.key + withContext(Dispatchers.IO) { importData(uri) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt b/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt index 84ef726866..61c97b8bbf 100644 --- a/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt @@ -1,11 +1,23 @@ package li.songe.gkd import android.app.Activity +import android.content.pm.PackageManager import android.os.Bundle +import androidx.core.net.toUri +import li.songe.gkd.util.extraCptName class OpenTileActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val qsTileCpt = intent?.extraCptName + if (qsTileCpt != null && intent.data == null) { + val serviceInfo = + app.packageManager.getServiceInfo(qsTileCpt, PackageManager.GET_META_DATA) + val uriValue = serviceInfo.metaData.getString("QS_TILE_URI") + if (uriValue != null) { + intent.data = uriValue.toUri() + } + } navToMainActivity() } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index a5fb4dcfa7..8a28f74a0b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.filled.Apps import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -64,10 +63,6 @@ import li.songe.gkd.util.mapHashCode import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle -val appListNav = BottomNavItem( - label = "应用", icon = Icons.Default.Apps -) - @Composable fun useAppListPage(): ScaffoldExt { val context = LocalActivity.current as MainActivity @@ -92,7 +87,7 @@ fun useAppListPage(): ScaffoldExt { val resetKey = orderedAppInfos.mapHashCode { it.id } val (scrollBehavior, listState) = useListScrollState(resetKey, appListKey) return ScaffoldExt( - navItem = appListNav, + navItem = BottomNavItem.AppList, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { DisposableEffect(null) { @@ -126,7 +121,7 @@ fun useAppListPage(): ScaffoldExt { mainVm.appListKeyFlow.update { it + 1 } } ), - text = appListNav.label, + text = BottomNavItem.AppList.label, ) } }, actions = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index f446fadd16..b22f35083e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -22,7 +22,6 @@ import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.outlined.Equalizer -import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.RocketLaunch @@ -72,8 +71,6 @@ import li.songe.gkd.util.SafeR import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle -val controlNav = BottomNavItem(label = "主页", icon = Icons.Outlined.Home) - @Composable fun useControlPage(): ScaffoldExt { val context = LocalActivity.current as MainActivity @@ -83,7 +80,7 @@ fun useControlPage(): ScaffoldExt { val scrollState = rememberScrollState() val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() return ScaffoldExt( - navItem = controlNav, + navItem = BottomNavItem.Control, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, title = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt index 8f625f25cb..2a816ef4d0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt @@ -1,5 +1,10 @@ package li.songe.gkd.ui.home +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -16,36 +21,58 @@ import com.ramcosta.composedestinations.annotation.RootGraph import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions -data class BottomNavItem( +sealed class BottomNavItem( + val key: Int, val label: String, val icon: ImageVector, -) +) { + object Control : BottomNavItem( + key = 0, + label = "主页", + icon = Icons.Outlined.Home, + ) + + object SubsManage : BottomNavItem( + key = 1, + label = "订阅", + icon = Icons.AutoMirrored.Filled.FormatListBulleted, + ) + + object AppList : BottomNavItem( + key = 2, + label = "应用", + icon = Icons.Default.Apps, + ) + + object Settings : BottomNavItem( + key = 3, + label = "设置", + icon = Icons.Outlined.Settings, + ) + + companion object { + val allSubObjects by lazy { arrayOf(Control, SubsManage, AppList, Settings) } + } +} @Destination(style = ProfileTransitions::class, start = true) @Composable fun HomePage() { val mainVm = LocalMainViewModel.current - viewModel() + viewModel() // init state val tab by mainVm.tabFlow.collectAsState() - - val controlPage = useControlPage() - val subsPage = useSubsManagePage() - val appListPage = useAppListPage() - val settingsPage = useSettingsPage() - - val pages = arrayOf(controlPage, subsPage, appListPage, settingsPage) - - val currentPage = pages.find { p -> p.navItem.label == tab.label } ?: controlPage + val pages = arrayOf(useControlPage(), useSubsManagePage(), useAppListPage(), useSettingsPage()) + val page = pages.find { p -> p.navItem.key == tab } ?: pages.first() Scaffold( - modifier = currentPage.modifier, - topBar = currentPage.topBar, - floatingActionButton = currentPage.floatingActionButton, + modifier = page.modifier, + topBar = page.topBar, + floatingActionButton = page.floatingActionButton, bottomBar = { NavigationBar { pages.forEach { page -> NavigationBarItem( - selected = tab.label == page.navItem.label, + selected = page.navItem.key == tab, modifier = Modifier, onClick = { mainVm.updateTab(page.navItem) @@ -62,6 +89,6 @@ fun HomePage() { } } }, - content = currentPage.content + content = page.content ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index e326bf15f3..8ca2327614 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -63,10 +62,6 @@ import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateAppMutex -val settingsNav = BottomNavItem( - label = "设置", icon = Icons.Outlined.Settings -) - @Composable fun useSettingsPage(): ScaffoldExt { val mainVm = LocalMainViewModel.current @@ -205,12 +200,12 @@ fun useSettingsPage(): ScaffoldExt { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollState = rememberScrollState() return ScaffoldExt( - navItem = settingsNav, + navItem = BottomNavItem.Settings, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, title = { Text( - text = settingsNav.label, + text = BottomNavItem.Settings.label, ) }) }, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index b207464d10..776a6963d7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert @@ -100,10 +99,6 @@ import li.songe.gkd.util.usedSubsEntriesFlow import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState -val subsNav = BottomNavItem( - label = "订阅", icon = Icons.AutoMirrored.Filled.FormatListBulleted -) - @Composable fun useSubsManagePage(): ScaffoldExt { val context = LocalActivity.current as MainActivity @@ -194,7 +189,7 @@ fun useSubsManagePage(): ScaffoldExt { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() return ScaffoldExt( - navItem = subsNav, + navItem = BottomNavItem.SubsManage, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { @@ -213,7 +208,7 @@ fun useSubsManagePage(): ScaffoldExt { ) } else { Text( - text = subsNav.label, + text = BottomNavItem.SubsManage.label, ) } }, actions = { From 6ea4a18ada41b48d2f95a2ef38aa660b076f9226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 26 Aug 2025 20:46:31 +0800 Subject: [PATCH 023/245] perf: positionFlow decode --- .../li/songe/gkd/service/OverlayWindowService.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index 9a7ddcccc3..5b9de62b20 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -83,13 +83,12 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne createTextFlow( key = positionStoreKey, decode = { - val list = (it ?: "").split(',', limit = 2).takeIf { l -> l.size == 2 } - if (list != null) { - val a = list.getOrNull(0)?.toIntOrNull() ?: 0 - val b = list.getOrNull(1)?.toIntOrNull() ?: 0 - a to b - } else { - 0 to 0 + (it ?: "").split(',', limit = 2).mapNotNull { v -> v.toIntOrNull() }.run { + if (size == 2) { + get(0) to get(1) + } else { + 0 to 0 + } } }, encode = { From d0c4b126b82f217c66e1b1a9a7f2659db74627ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 30 Aug 2025 18:53:03 +0800 Subject: [PATCH 024/245] perf: minMargin --- .../kotlin/li/songe/gkd/service/OverlayWindowService.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index 5b9de62b20..e3c166f46d 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -79,6 +79,9 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne abstract val positionStoreKey: String + private val minMargin: Int + get() = 10.dp.px.toInt() + val positionFlow by lazy { createTextFlow( key = positionStoreKey, @@ -87,7 +90,7 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne if (size == 2) { get(0) to get(1) } else { - 0 to 0 + minMargin to BarUtils.getStatusBarHeight() } } }, @@ -125,8 +128,8 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne } } onCreated { - val marginX = 20.dp.px.toInt() - val marginY = BarUtils.getStatusBarHeight() + 5.dp.px.toInt() + val marginX = minMargin + val marginY = minMargin val layoutParams = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, From 724a47cc5162643798dbb22cf77f7a83ba500529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 31 Aug 2025 02:38:06 +0800 Subject: [PATCH 025/245] refactor: shizuku --- app/src/main/kotlin/li/songe/gkd/App.kt | 2 - .../main/kotlin/li/songe/gkd/MainActivity.kt | 13 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 46 +-- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 22 +- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 3 +- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 49 ++- .../li/songe/gkd/data/ComplexSnapshot.kt | 4 +- .../kotlin/li/songe/gkd/data/GkdAction.kt | 10 +- .../songe/gkd/permission/PermissionState.kt | 12 +- .../li/songe/gkd/service/BaseTileService.kt | 1 + .../li/songe/gkd/service/RecordService.kt | 37 ++- .../songe/gkd/service/SnapshotTileService.kt | 79 ++--- .../li/songe/gkd/service/StatusService.kt | 6 +- .../li/songe/gkd/shizuku/ActivityManager.kt | 23 ++ .../songe/gkd/shizuku/ActivityTaskManager.kt | 84 ++--- .../li/songe/gkd/shizuku/PackageManager.kt | 53 +++- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 289 +++++++++--------- .../li/songe/gkd/shizuku/TaskStackListener.kt | 15 +- .../li/songe/gkd/shizuku/UserManager.kt | 65 ++-- .../li/songe/gkd/shizuku/UserService.kt | 130 ++------ .../li/songe/gkd/store/SettingsStore.kt | 2 +- .../kotlin/li/songe/gkd/store/ShizukuStore.kt | 15 - .../kotlin/li/songe/gkd/store/StoreExt.kt | 16 - .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 202 ++++-------- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 4 +- .../li/songe/gkd/ui/component/AppIcon.kt | 14 +- .../li/songe/gkd/ui/component/SettingItem.kt | 9 +- .../li/songe/gkd/ui/component/SubsAppCard.kt | 2 +- .../li/songe/gkd/ui/component/TextSwitch.kt | 10 +- .../li/songe/gkd/ui/home/AppListPage.kt | 130 ++++---- .../li/songe/gkd/ui/home/SettingsPage.kt | 40 --- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 124 ++++---- .../kotlin/li/songe/gkd/util/Constants.kt | 4 +- .../kotlin/li/songe/gkd/util/IntentExt.kt | 5 +- .../kotlin/li/songe/gkd/util/MutexState.kt | 39 ++- .../main/kotlin/li/songe/gkd/util/Others.kt | 26 ++ .../kotlin/li/songe/gkd/util/SubsState.kt | 8 +- .../IAccessibilityServiceClient.aidl | 5 - .../IAccessibilityServiceConnection.aidl | 7 - .../android/app/IUiAutomationConnection.aidl | 19 -- .../AccessibilityServiceHidden.java | 43 --- .../android/app/ActivityManagerHidden.java | 48 --- .../android/app/ActivityManagerNative.java | 11 - .../main/java/android/app/ActivityThread.java | 13 - .../main/java/android/app/ContextImpl.java | 8 - .../java/android/app/IActivityManager.java | 12 +- .../android/app/IActivityTaskManager.java | 24 +- .../java/android/app/ITaskStackListener.java | 9 +- .../android/app/UiAutomationConnection.java | 9 - .../java/android/app/UiAutomationHidden.java | 31 -- .../android/content/pm/IPackageManager.java | 23 +- .../java/android/content/pm/UserInfo.java | 1 + .../android/hardware/input/IInputManager.java | 19 -- .../main/java/android/os/IUserManager.java | 12 +- .../java/android/window/TaskSnapshot.java | 32 -- 55 files changed, 788 insertions(+), 1131 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/store/ShizukuStore.kt delete mode 100644 hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceClient.aidl delete mode 100644 hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceConnection.aidl delete mode 100644 hidden_api/src/main/aidl/android/app/IUiAutomationConnection.aidl delete mode 100644 hidden_api/src/main/java/android/accessibilityservice/AccessibilityServiceHidden.java delete mode 100644 hidden_api/src/main/java/android/app/ActivityManagerHidden.java delete mode 100644 hidden_api/src/main/java/android/app/ActivityManagerNative.java delete mode 100644 hidden_api/src/main/java/android/app/ActivityThread.java delete mode 100644 hidden_api/src/main/java/android/app/ContextImpl.java delete mode 100644 hidden_api/src/main/java/android/app/UiAutomationConnection.java delete mode 100644 hidden_api/src/main/java/android/app/UiAutomationHidden.java delete mode 100644 hidden_api/src/main/java/android/hardware/input/IInputManager.java delete mode 100644 hidden_api/src/main/java/android/window/TaskSnapshot.java diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index aff1b69397..f5a9e4d476 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.MainScope import kotlinx.serialization.Serializable import li.songe.gkd.data.selfAppInfo import li.songe.gkd.notif.initChannel -import li.songe.gkd.service.StatusService import li.songe.gkd.service.clearHttpSubs import li.songe.gkd.shizuku.initShizuku import li.songe.gkd.store.initStore @@ -126,6 +125,5 @@ class App : Application() { initSubsState() clearHttpSubs() syncFixState() - StatusService.autoStart() } } diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 949d4ef49a..0230a6aa26 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -175,6 +175,7 @@ class MainActivity : ComponentActivity() { intent = null } watchKeyboardVisible() + StatusService.autoStart() setContent { val navController = rememberNavController() mainVm.updateNavController(navController) @@ -227,9 +228,9 @@ class MainActivity : ComponentActivity() { super.onResume() if (isFirstResume && startTime - app.startTime < 2000) { isFirstResume = false - return + } else { + syncFixState() } - syncFixState() } override fun onStop() { @@ -291,18 +292,10 @@ private val syncStateMutex = Mutex() fun syncFixState() { appScope.launchTry(Dispatchers.IO) { syncStateMutex.withLock { - // 每次切换页面更新记录桌面 appId updateLauncherAppId() - updateImeAppId() - - // 由于某些机型的进程存在 安装缓存/崩溃缓存 导致服务状态可能不正确, 在此保证每次界面切换都能重新刷新状态 updateServiceRunning() - - // 用户在系统权限设置中切换权限后再切换回应用时能及时更新状态 updatePermissionState() - - // 自动重启无障碍服务 fixRestartService() } } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 0111e7714e..b9613e9978 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -18,6 +18,7 @@ import com.ramcosta.composedestinations.spec.Direction import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -29,8 +30,10 @@ import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.permission.AuthReason import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.shizuku.execCommandForResult +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.createTextFlow +import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AlertDialogOptions import li.songe.gkd.ui.component.InputSubsLinkOption import li.songe.gkd.ui.component.RuleGroupState @@ -95,7 +98,7 @@ class MainViewModel : ViewModel() { oldItem: SubsItem? = null, ) = viewModelScope.launchTry(Dispatchers.IO) { if (updateSubsMutex.mutex.isLocked) return@launchTry - updateSubsMutex.withLock { + updateSubsMutex.withStateLock { val subItems = subsItemsFlow.value val text = try { client.get(url).bodyAsText() @@ -256,24 +259,33 @@ class MainViewModel : ViewModel() { ) } + + fun requestShizuku() = try { + Shizuku.requestPermission(Activity.RESULT_OK) + } catch (e: Throwable) { + shizukuErrorFlow.value = e + } + suspend fun grantPermissionByShizuku(command: String) { - if (shizukuOkState.stateFlow.value) { - try { - execCommandForResult(command) - return - } catch (e: Exception) { - toast("运行失败:${e.message}") - LogUtils.d(e) - } - } else { - try { - Shizuku.requestPermission(Activity.RESULT_OK) - } catch (e: Throwable) { - LogUtils.d("Shizuku授权错误", e.message) - shizukuErrorFlow.value = e + if (updateBinderMutex.mutex.isLocked) { + toast("正在连接 Shizuku 服务,请稍后") + stopCoroutine() + } + if (!shizukuOkState.stateFlow.value) { + requestShizuku() + stopCoroutine() + } + if (!storeFlow.value.enableShizuku) { + storeFlow.update { it.copy(enableShizuku = true) } + delay(500) + while (updateBinderMutex.mutex.isLocked) { + delay(100) } } - stopCoroutine() + val service = shizukuContextFlow.value.serviceWrapper ?: stopCoroutine() + if (!service.execCommandForResult(command).ok) { + stopCoroutine() + } } val a11yServiceEnabledFlow = useA11yServiceEnabledFlow() diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt index 4e2710035a..a40a414871 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch import li.songe.gkd.appScope import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.service.A11yService -import li.songe.gkd.store.shizukuStoreFlow +import li.songe.gkd.service.StatusService import li.songe.gkd.store.storeFlow import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.UpdateTimeOption @@ -35,6 +35,7 @@ fun onA11yFeatInit() = service.run { useCaptureVolume() useRuleChangedLog() onA11yEvent { onA11yFeatEvent(it) } + onCreated { StatusService.autoStart() } } private fun A11yService.useAttachState() { @@ -58,7 +59,7 @@ private var lastCheckShizukuTime = 0L context(event: AccessibilityEvent) private fun watchCheckShizukuState() { // 借助无障碍轮询校验 shizuku 权限, 因为 shizuku 可能无故被关闭 - if (shizukuStoreFlow.value.enableShizukuAnyFeat) { + if (storeFlow.value.enableShizuku) { val t = System.currentTimeMillis() if (t - lastCheckShizukuTime > 60 * 60_000L) { lastCheckShizukuTime = t @@ -121,21 +122,8 @@ private fun A11yService.useAliveOverlayView() { } wm.addView(tempView, lp) } - - onA11yConnected { - scope.launchTry(Dispatchers.Main) { - storeFlow.mapState(scope) { s -> s.enableAbFloatWindow }.collect { - if (it) { - addA11View() - } else { - removeA11View() - } - } - } - } - onDestroyed { - removeA11View() - } + onA11yConnected { addA11View() } + onDestroyed { removeA11View() } } private const val volumeChangedAction = "android.media.VOLUME_CHANGED_ACTION" diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index 8b30405320..1901aa6c65 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -26,6 +26,7 @@ import li.songe.gkd.data.RuleStatus import li.songe.gkd.db.DbSet import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.RuleSummary import li.songe.gkd.util.launchTry import li.songe.gkd.util.ruleSummaryFlow @@ -71,7 +72,7 @@ private object ActivityCache : LruCache, Boolean>(256) { override fun create(key: Pair): Boolean = try { app.packageManager.getActivityInfo( ComponentName(key.first, key.second), - PackageManager.MATCH_UNINSTALLED_PACKAGES + PKG_FLAGS ) true } catch (_: Exception) { diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index 2b5debb06d..de37e0f81a 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -4,65 +4,60 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager -import android.graphics.drawable.Drawable import android.os.Build import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import li.songe.gkd.app @Serializable data class AppInfo( val id: String, val name: String, - @Transient - val icon: Drawable? = null, val versionCode: Int, val versionName: String?, val isSystem: Boolean, val mtime: Long, val hidden: Boolean, - val userId: Int? = null, // null -> current user -) + val userId: Int? = null, +) { + override fun equals(other: Any?): Boolean { + if (other !is AppInfo) return false + return id == other.id && mtime == other.mtime + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + mtime.hashCode() + return result + } +} val selfAppInfo by lazy { - app.packageManager.getPackageInfo( - app.packageName, - PackageManager.MATCH_UNINSTALLED_PACKAGES - ).toAppInfo() + app.packageManager.getPackageInfo(app.packageName, 0).toAppInfo() } -fun Drawable.safeGet(): Drawable? { - return if (intrinsicHeight <= 0 || intrinsicWidth <= 0) { - // https://github.com/gkd-kit/gkd/issues/924 - null +val PackageInfo.compatVersionCode: Int + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode.toInt() } else { - this + @Suppress("DEPRECATION") + versionCode } -} -/** - * 平均单次调用时间 11ms - */ fun PackageInfo.toAppInfo( userId: Int? = null, hidden: Boolean? = null, ): AppInfo { return AppInfo( id = packageName, - versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - longVersionCode.toInt() - } else { - @Suppress("DEPRECATION") - versionCode - }, + versionCode = compatVersionCode, versionName = versionName, mtime = lastUpdateTime, isSystem = applicationInfo?.let { it.flags and ApplicationInfo.FLAG_SYSTEM != 0 } ?: false, name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, - icon = applicationInfo?.loadIcon(app.packageManager)?.safeGet(), userId = userId, hidden = hidden ?: app.packageManager.queryIntentActivities( - Intent(Intent.ACTION_MAIN).setPackage(packageName).addCategory(Intent.CATEGORY_LAUNCHER), + Intent(Intent.ACTION_MAIN).setPackage(packageName) + .addCategory(Intent.CATEGORY_LAUNCHER), PackageManager.MATCH_DISABLED_COMPONENTS ).isEmpty(), ) diff --git a/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt b/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt index 36b5a95142..36e8f00d7b 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt @@ -1,7 +1,7 @@ package li.songe.gkd.data import kotlinx.serialization.Serializable -import li.songe.gkd.util.getPkgInfo +import li.songe.gkd.util.appInfoCacheFlow @Serializable data class ComplexSnapshot( @@ -11,7 +11,7 @@ data class ComplexSnapshot( override val screenHeight: Int, override val screenWidth: Int, override val isLandscape: Boolean, - val appInfo: AppInfo? = getPkgInfo(appId)?.toAppInfo(), + val appInfo: AppInfo? = appInfoCacheFlow.value[appId], val gkdAppInfo: AppInfo? = selfAppInfo, val device: DeviceInfo = DeviceInfo.instance, val nodes: List, diff --git a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt index 25d67c1896..121031f9fb 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt @@ -9,8 +9,7 @@ import android.view.accessibility.AccessibilityNodeInfo import com.blankj.utilcode.util.ScreenUtils import kotlinx.serialization.Serializable import li.songe.gkd.service.A11yService -import li.songe.gkd.shizuku.safeLongTap -import li.songe.gkd.shizuku.safeTap +import li.songe.gkd.shizuku.shizukuContextFlow @Serializable data class GkdAction( @@ -62,7 +61,7 @@ sealed class ActionPerformer(val action: String) { return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - val result = safeTap(x, y) + val result = shizukuContextFlow.value.serviceWrapper?.safeTap(x, y) if (result != null) { return ActionResult(action, result, true, position = x to y) } @@ -121,14 +120,13 @@ sealed class ActionPerformer(val action: String) { val p = position?.calc(rect) val x = p?.first ?: ((rect.right + rect.left) / 2f) val y = p?.second ?: ((rect.bottom + rect.top) / 2f) - // 500 https://cs.android.com/android/platform/superproject/+/android-8.1.0_r81:frameworks/base/core/java/android/view/ViewConfiguration.java;l=65 - // 400 https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/ViewConfiguration.java;drc=8b948e548b782592ae280a3cd9a91798afe6df9d;l=82 // 某些系统的 ViewConfiguration.getLongPressTimeout() 返回 300 , 这将导致触发普通的 click 事件 val longClickDuration = 500L return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - val result = safeLongTap(x, y, longClickDuration) + val result = + shizukuContextFlow.value.serviceWrapper?.safeTap(x, y, longClickDuration) if (result != null) { return ActionResult(action, result, true, position = x to y) } diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 0dab899eaa..9113b01769 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -12,20 +12,16 @@ import com.hjq.permissions.permission.PermissionLists import com.hjq.permissions.permission.base.IPermission import com.ramcosta.composedestinations.generated.destinations.AppOpsAllowPageDestination import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.updateAndGet import li.songe.gkd.MainActivity import li.songe.gkd.app -import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible import li.songe.gkd.shizuku.shizukuCheckGranted import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.util.forceUpdateAppList -import li.songe.gkd.util.initOrResetAppInfoCache -import li.songe.gkd.util.launchTry import li.songe.gkd.util.mayQueryPkgNoAccessFlow import li.songe.gkd.util.toast +import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex class PermissionState( @@ -222,11 +218,7 @@ val shizukuOkState by lazy { fun updatePermissionState() { val stateChanged = canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet() if (!updateAppMutex.mutex.isLocked && (stateChanged || mayQueryPkgNoAccessFlow.value)) { - appScope.launchTry(Dispatchers.IO) { - initOrResetAppInfoCache() - } - } else { - forceUpdateAppList() + updateAllAppInfo() } arrayOf( notificationState, diff --git a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt index 10a8b974ab..9d0c2e8a96 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt @@ -24,6 +24,7 @@ abstract class BaseTileService : TileService(), OnTileLife { } init { + onTileClicked { StatusService.autoStart() } scope.launch { combine( activeFlow, diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt index 7a3876ebee..568e1fdd24 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -52,22 +52,29 @@ class RecordService : OverlayWindowService() { .padding(horizontal = 4.dp, vertical = 2.dp) ) { CompositionLocalProvider(LocalContentColor provides contentColorFor(bgColor)) { - val topActivity = topActivityFlow.collectAsState().value - Text( - text = topActivity.number.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.tertiary, - modifier = Modifier - .align(Alignment.TopEnd) - .zIndex(1f) - .clip(MaterialTheme.shapes.extraSmall) - .padding(horizontal = 2.dp), - ) - Column { - topAppInfoFlow.collectAsState().value?.let { - AppNameText(appInfo = it) + if (A11yService.isRunning.collectAsState().value) { + val topActivity = topActivityFlow.collectAsState().value + Text( + text = topActivity.number.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + .clip(MaterialTheme.shapes.extraSmall) + .padding(horizontal = 2.dp), + ) + Column { + topAppInfoFlow.collectAsState().value?.let { + AppNameText(appInfo = it) + } + Text(text = "${topActivity.appId}\n${topActivity.shortActivityId}") + } + } else { + Column { + Text(text = "记录服务") + Text(text = "无障碍服务未运行") } - Text(text = "${topActivity.appId}\n${topActivity.shortActivityId}") } } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt index fe16c3c199..e6da046de4 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt @@ -1,56 +1,61 @@ package li.songe.gkd.service import android.accessibilityservice.AccessibilityService -import android.service.quicksettings.TileService import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.isActive import li.songe.gkd.appScope import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast -class SnapshotTileService : TileService() { - override fun onClick() { - super.onClick() - LogUtils.d("SnapshotTileService::onClick") - val service = A11yService.instance - if (service == null) { - toast("无障碍没有开启") - return - } - appScope.launchTry(Dispatchers.IO) { - val oldAppId = service.safeActiveWindowAppId - ?: return@launchTry toast("获取界面信息根节点失败") +class SnapshotTileService() : BaseTileService() { + override val activeFlow = MutableStateFlow(false) - val startTime = System.currentTimeMillis() - fun timeout(): Boolean { - return System.currentTimeMillis() - startTime > 3000L - } + init { + onTileClicked { execSnapshot() } + } +} + +private fun execSnapshot() { + LogUtils.d("SnapshotTileService::onClick") + val service = A11yService.instance + if (service == null) { + toast("无障碍没有开启") + return + } + appScope.launchTry(Dispatchers.IO) { + val oldAppId = service.safeActiveWindowAppId + ?: return@launchTry toast("获取界面信息根节点失败") - while (isActive) { - val latestAppId = service.safeActiveWindowAppId - if (latestAppId == null) { - // https://github.com/gkd-kit/gkd/issues/713 - delay(250) - if (timeout()) { - toast("当前应用没有无障碍信息,捕获失败") - break - } - } else if (latestAppId != oldAppId) { - LogUtils.d("SnapshotTileService::eventExecutor.execute") - appScope.launchTry { SnapshotExt.captureSnapshot() } + val startTime = System.currentTimeMillis() + fun timeout(): Boolean { + return System.currentTimeMillis() - startTime > 3000L + } + + while (isActive) { + val latestAppId = service.safeActiveWindowAppId + if (latestAppId == null) { + // https://github.com/gkd-kit/gkd/issues/713 + delay(250) + if (timeout()) { + toast("当前应用没有无障碍信息,捕获失败") + break + } + } else if (latestAppId != oldAppId) { + LogUtils.d("SnapshotTileService::eventExecutor.execute") + appScope.launchTry { SnapshotExt.captureSnapshot() } + break + } else { + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) + delay(500) + if (timeout()) { + toast("未检测到界面切换,捕获失败") break - } else { - service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) - delay(500) - if (timeout()) { - toast("未检测到界面切换,捕获失败") - break - } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index 8315340021..d250c6ea46 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -60,15 +60,19 @@ class StatusService : Service(), OnSimpleLife { fun start() = startForegroundServiceByClass(StatusService::class) fun stop() = stopServiceByClass(StatusService::class) + private var lastAutoStart = 0L fun autoStart() { + if (System.currentTimeMillis() - lastAutoStart < 1000) return // 重启自动打开通知栏状态服务 + // 需要已有服务或前台才能自主启动,否则报错 startForegroundService() not allowed due to mAllowStartForeground false if (storeFlow.value.enableStatusService && !isRunning.value && notificationState.updateAndGet() && foregroundServiceSpecialUseState.updateAndGet() ) { start() + lastAutoStart = System.currentTimeMillis() } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt new file mode 100644 index 0000000000..f3d84c5b8c --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt @@ -0,0 +1,23 @@ +package li.songe.gkd.shizuku + +import android.app.IActivityManager +import android.content.ComponentName +import li.songe.gkd.util.checkExistClass + +class SafeActivityManager(private val value: IActivityManager) { + companion object { + val isAvailable: Boolean + get() = checkExistClass("android.app.IActivityManager") + + fun newBinder() = getStubService( + "activity", + isAvailable, + )?.let { + SafeActivityManager(IActivityManager.Stub.asInterface(it)) + } + } + + fun getTopCpn(): ComponentName? = safeInvokeMethod { + value.getTasks(1).firstOrNull()?.topActivity + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt index 16daeeb109..e767321821 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt @@ -4,53 +4,63 @@ import android.app.ActivityManager import android.app.IActivityTaskManager import android.content.ComponentName import android.view.Display -import li.songe.gkd.util.toast -import kotlin.reflect.full.declaredMemberFunctions -import kotlin.reflect.full.valueParameters +import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.util.checkExistClass import kotlin.reflect.typeOf private var tasksFcType: Int? = null private fun IActivityTaskManager.compatGetTasks(maxNum: Int): List { - if (tasksFcType == null) { - for (f in this::class.declaredMemberFunctions.filter { it.name == "getTasks" }) { - tasksFcType = when (f.valueParameters.map { it.type }) { - listOf(typeOf()) -> 1 - listOf(typeOf(), typeOf(), typeOf()) -> 3 - listOf(typeOf(), typeOf(), typeOf(), typeOf()) -> 4 - else -> null - } - if (tasksFcType != null) { - break - } - } - if (tasksFcType == null) { - tasksFcType = -1 - toast("获取 IActivityTaskManager:getTasks 签名错误") - } - } - return try { - when (tasksFcType) { - 1 -> this.getTasks(maxNum) - 3 -> this.getTasks(maxNum, false, true) - 4 -> this.getTasks(maxNum, false, true, Display.DEFAULT_DISPLAY) - else -> emptyList() - } - } catch (_: Throwable) { - emptyList() + tasksFcType = tasksFcType ?: findCompatMethod( + "getTasks", + listOf( + 1 to listOf(typeOf()), + 3 to listOf(typeOf(), typeOf(), typeOf()), + 4 to listOf(typeOf(), typeOf(), typeOf(), typeOf()), + ) + ) + return when (tasksFcType) { + 1 -> getTasks(maxNum) + 3 -> getTasks(maxNum, false, false) + 4 -> getTasks(maxNum, false, false, Display.INVALID_DISPLAY) + else -> emptyList() } } -// https://github.com/gkd-kit/gkd/issues/44 -// java.lang.ClassNotFoundException:Didn't find class "android.app.IActivityTaskManager" on path: DexPathList +object SafeTaskListener { + val isAvailable: Boolean + get() = checkExistClass("android.app.ITaskStackListener") + val instance by lazy { FixedTaskStackListener() } +} + class SafeActivityTaskManager(private val value: IActivityTaskManager) { - fun compatGetTasks(maxNum: Int = 1) = value.compatGetTasks(maxNum) - fun getTopCpn(): ComponentName? = compatGetTasks().firstOrNull()?.topActivity + companion object { + val isAvailable: Boolean + get() = checkExistClass("android.app.IActivityTaskManager") - fun registerTaskStackListener(listener: FixedTaskStackListener) { - value.registerTaskStackListener(listener) + fun newBinder() = getStubService( + "activity_task", + isAvailable, + )?.let { + SafeActivityTaskManager(IActivityTaskManager.Stub.asInterface(it)) + } } - fun unregisterTaskStackListener(listener: FixedTaskStackListener) { - value.unregisterTaskStackListener(listener) + fun getTopCpn(): ComponentName? = safeInvokeMethod { + value.compatGetTasks(1).firstOrNull()?.topActivity + } + + fun registerDefault() { + if (!SafeTaskListener.isAvailable) return + safeInvokeMethod { + value.registerTaskStackListener(SafeTaskListener.instance) + } + } + + fun unregisterDefault() { + if (!shizukuOkState.stateFlow.value) return + if (!SafeTaskListener.isAvailable) return + safeInvokeMethod { + value.unregisterTaskStackListener(SafeTaskListener.instance) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 8858a88fda..9b5b43ccad 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -1,35 +1,58 @@ package li.songe.gkd.shizuku +import android.content.Intent import android.content.IntentFilter import android.content.pm.IPackageManager import android.content.pm.PackageInfo -import kotlin.reflect.full.declaredMemberFunctions +import li.songe.gkd.util.checkExistClass import kotlin.reflect.typeOf -private var packageFcType: Boolean? = null +private var pkgFcType: Int? = null private fun IPackageManager.compatGetInstalledPackages( - flags: Long, + flags: Int, userId: Int ): List { - if (packageFcType == null) { - packageFcType = this::class.declaredMemberFunctions.find { - it.name == "getInstalledPackages" - }!!.parameters[1].type == typeOf() + pkgFcType = pkgFcType ?: findCompatMethod( + "getInstalledPackages", + listOf( + 1 to listOf(typeOf(), typeOf()), + 2 to listOf(typeOf(), typeOf()), + ) + ) + return when (pkgFcType) { + 1 -> getInstalledPackages(flags, userId).list + 2 -> getInstalledPackages(flags.toLong(), userId).list + else -> emptyList() } - return if (packageFcType == true) { - getInstalledPackages(flags, userId) - } else { - getInstalledPackages(flags.toInt(), userId) - }.list } class SafePackageManager(private val value: IPackageManager) { - fun compatGetInstalledPackages(flags: Long, userId: Int): List { - return value.compatGetInstalledPackages(flags, userId) + companion object { + val isAvailable: Boolean + get() = checkExistClass("android.content.pm.IPackageManager") + + fun newBinder() = getStubService( + "package", + isAvailable + )?.let { + SafePackageManager(IPackageManager.Stub.asInterface(it)) + } + } + + fun getInstalledPackages(flags: Int, userId: Int): List { + return safeInvokeMethod { value.compatGetInstalledPackages(flags, userId) } ?: emptyList() } fun getAllIntentFilters(packageName: String): List { - return value.getAllIntentFilters(packageName).list + return safeInvokeMethod { value.getAllIntentFilters(packageName).list } ?: emptyList() + } + + fun checkAppHidden(appId: String): Boolean { + return !getAllIntentFilters(appId).any { f -> + f.hasAction(Intent.ACTION_MAIN) && f.hasCategory( + Intent.CATEGORY_LAUNCHER + ) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 9dca10cd7c..d8bed7b7c7 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -1,22 +1,18 @@ package li.songe.gkd.shizuku -import android.app.IActivityTaskManager -import android.content.Context -import android.content.Intent -import android.content.pm.IPackageManager +import android.content.ComponentName import android.content.pm.PackageManager -import android.os.IUserManager +import android.graphics.drawable.Drawable +import android.os.IInterface import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import li.songe.gkd.META import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo @@ -24,121 +20,171 @@ import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.data.toAppInfo import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.store.shizukuStoreFlow -import li.songe.gkd.util.allPackageInfoMapFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.MutexState +import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.launchTry +import li.songe.gkd.util.otherUserAppIconMapFlow import li.songe.gkd.util.otherUserAppInfoMapFlow +import li.songe.gkd.util.pkgIcon import li.songe.gkd.util.toast import li.songe.gkd.util.userAppInfoMapFlow import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper - -private fun getStubService(name: String): ShizukuBinderWrapper? { - val service = SystemServiceHelper.getSystemService(name) - if (service == null) { - LogUtils.d("获取 $name 失败") - return null +import kotlin.reflect.KType +import kotlin.reflect.full.declaredMemberFunctions +import kotlin.reflect.full.valueParameters +import kotlin.reflect.jvm.jvmName + +private val hiddenFunctionMap = HashMap() +fun IInterface.findCompatMethod( + name: String, + typePairs: List>> +): Int { + val key = "${this::class.jvmName}::$name" + hiddenFunctionMap[key]?.let { return it } + val functions = this::class.declaredMemberFunctions.filter { it.name == name } + for (f in functions) { + val types = f.valueParameters.map { it.type } + typePairs.find { it.second == types }?.first?.let { + hiddenFunctionMap[key] = it + return it + } } - return ShizukuBinderWrapper(service) -} - -private fun newUserManager() = getStubService(Context.USER_SERVICE)?.let { - SafeUserManager(IUserManager.Stub.asInterface(it)) + LogUtils.d( + "获取签名 ${this::class.jvmName}::$name 失败", + functions.joinToString("\n") { + it.valueParameters.map { p -> p.type.toString() }.toString() + } + ) + hiddenFunctionMap[key] = -1 + return -1 } -private fun newActivityTaskManager() = getStubService("activity_task")?.let { - SafeActivityTaskManager(IActivityTaskManager.Stub.asInterface(it)) +// shizuku 会概率断开 +inline fun safeInvokeMethod( + block: () -> T +): T? = try { + block() +} catch (_: Throwable) { + null } -private fun newPackageManager() = getStubService("package")?.let { - SafePackageManager(IPackageManager.Stub.asInterface(it)) +fun getStubService(name: String, condition: Boolean): ShizukuBinderWrapper? { + if (!condition) return null + val service = SystemServiceHelper.getSystemService(name) ?: return null + return ShizukuBinderWrapper(service) } -private val shizukuActivityUsedFlow by lazy { - combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> - shizukuOk && store.enableActivity +private val shizukuUsedFlow by lazy { + combine( + shizukuOkState.stateFlow, + storeFlow.map { it.enableShizuku }, + ) { a, b -> + a && b }.stateIn(appScope, SharingStarted.Eagerly, false) } -val userManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launch(Dispatchers.IO) { - shizukuWorkProfileUsedFlow.collect { - stateFlow.value = if (it) newUserManager() else null - } - } - stateFlow -} +class ShizukuContext( + val serviceWrapper: UserServiceWrapper? = null, + val packageManager: SafePackageManager? = null, + val userManager: SafeUserManager? = null, + val activityManager: SafeActivityManager? = null, + val activityTaskManager: SafeActivityTaskManager? = null, +) -val activityTaskManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launchTry(Dispatchers.IO) { - shizukuActivityUsedFlow.collect { - if (shizukuOkState.value) { - stateFlow.value?.unregisterTaskStackListener(MyTaskListener) - } - stateFlow.value = if (it) newActivityTaskManager() else null - stateFlow.value?.registerTaskStackListener(MyTaskListener) - } - } - stateFlow -} +private val defaultShizukuContext = ShizukuContext() -val shizukuWorkProfileUsedFlow by lazy { - combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> - shizukuOk && store.enableWorkProfile - }.stateIn(appScope, SharingStarted.Eagerly, false) -} +val currentUserId by lazy { android.os.Process.myUserHandle().hashCode() } -val packageManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launch(Dispatchers.IO) { - shizukuWorkProfileUsedFlow.collect { - stateFlow.value = if (it) newPackageManager() else null - } - } - stateFlow -} +val shizukuContextFlow = MutableStateFlow(defaultShizukuContext) -fun shizukuCheckActivity(): Boolean { - return (try { - newActivityTaskManager()?.compatGetTasks()?.isNotEmpty() == true - } catch (e: Throwable) { - e.printStackTrace() - false - }) +fun safeGetTopCpn(): ComponentName? = shizukuContextFlow.value.run { + activityTaskManager?.getTopCpn() ?: activityManager?.getTopCpn() } fun shizukuCheckGranted(): Boolean { val granted = try { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - } catch (_: Exception) { + } catch (_: Throwable) { false } if (!granted) return false - if (shizukuStoreFlow.value.enableActivity) { - return safeGetTopCpn() != null || shizukuCheckActivity() - } - return true + val u = shizukuContextFlow.value.activityManager ?: SafeActivityManager.newBinder() + return u?.getTopCpn() != null } -fun shizukuCheckWorkProfile(): Boolean { - return (try { - arrayOf( - newPackageManager()?.getAllIntentFilters(META.appId)?.isNotEmpty() == true, - newUserManager()?.compatGetUsers()?.isNotEmpty() == true - ).all { it } - } catch (e: Throwable) { - e.printStackTrace() - false - }) +val updateBinderMutex = MutexState() +private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { + updateBinderMutex.withStateLock { + if (shizukuUsedFlow.value) { + if (isActivityVisible()) { + toast("正在连接 Shizuku 服务...") + } + shizukuContextFlow.value = ShizukuContext( + serviceWrapper = buildServiceWrapper(), + packageManager = SafePackageManager.newBinder(), + userManager = SafeUserManager.newBinder(), + activityManager = SafeActivityManager.newBinder(), + activityTaskManager = SafeActivityTaskManager.newBinder()?.apply { + registerDefault() + }, + ) + if (isActivityVisible()) { + if (shizukuContextFlow.value.serviceWrapper == null) { + toast("Shizuku 服务连接失败") + } else { + toast("Shizuku 服务连接成功") + } + } + } else if (shizukuContextFlow.value != defaultShizukuContext) { + shizukuContextFlow.value.run { + serviceWrapper?.destroy() + activityTaskManager?.unregisterDefault() + } + val prefix = if (isActivityVisible()) "" else "${META.appName}: " + toast("${prefix}Shizuku 服务已断开") + } + } } -fun safeGetTopCpn() = try { - activityTaskManagerFlow.value?.getTopCpn() -} catch (_: Throwable) { - null +fun updateOtherUserAppInfo() { + val pkgManager = shizukuContextFlow.value.packageManager + val userManager = shizukuContextFlow.value.userManager + if (pkgManager == null || userManager == null) { + otherUserMapFlow.value = emptyMap() + otherUserAppIconMapFlow.value = emptyMap() + otherUserAppInfoMapFlow.value = emptyMap() + return + } + val otherUsers = userManager.getUsers().filter { it.id != currentUserId }.sortedBy { it.id } + otherUserMapFlow.value = otherUsers.associateBy { it.id } + val userPackageInfoMap = otherUsers.associate { user -> + user.id to pkgManager.getInstalledPackages( + PKG_FLAGS, + user.id + ) + } + val newIconMap = HashMap() + val userAppInfoMap = userAppInfoMapFlow.value + val newAppMap = HashMap() + userPackageInfoMap.forEach { (userId, pkgInfoList) -> + val diffPkgList = pkgInfoList.filter { + !userAppInfoMap.contains(it.packageName) && !newAppMap.contains( + it.packageName + ) + } + diffPkgList.forEach { pkgInfo -> + newAppMap[pkgInfo.packageName] = pkgInfo.toAppInfo( + userId = userId, + hidden = pkgManager.checkAppHidden(pkgInfo.packageName), + ) + pkgInfo.pkgIcon?.let { newIconMap[pkgInfo.packageName] = it } + } + } + otherUserAppInfoMapFlow.value = newAppMap + otherUserAppIconMapFlow.value = newIconMap } fun initShizuku() { @@ -151,67 +197,16 @@ fun initShizuku() { Shizuku.addBinderDeadListener { LogUtils.d("Shizuku.addBinderDeadListener") shizukuOkState.stateFlow.value = false - val prefix = if (isActivityVisible()) "" else "${META.appName}: " - toast("${prefix}已断开 Shizuku 服务") } - serviceWrapperFlow.value - appScope.launchTry(Dispatchers.IO) { - combine( - packageManagerFlow, - userManagerFlow, - ) { a, b -> a to b }.collect { (pkgManager, userManager) -> - if (pkgManager != null && userManager != null) { - val otherUsers = userManager.compatGetUsers() - .filter { it.id != 0 }.sortedBy { it.id } - otherUserMapFlow.value = otherUsers.associateBy { it.id } - allPackageInfoMapFlow.value = otherUsers - .map { - it.id to pkgManager.compatGetInstalledPackages( - PackageManager.MATCH_UNINSTALLED_PACKAGES.toLong(), - it.id - ) - } - .associate { it } - } else { - otherUserMapFlow.value = emptyMap() - allPackageInfoMapFlow.value = emptyMap() - } - } + appScope.launchTry { + shizukuUsedFlow.collect { updateShizukuBinder() } } appScope.launchTry(Dispatchers.IO) { combine( - packageManagerFlow, + shizukuContextFlow, userAppInfoMapFlow, - allPackageInfoMapFlow, - ) { a, b, c -> Triple(a, b, c) }.debounce(3000) - .collect { (pkgManager, userAppInfoMap, allPackageInfoMap) -> - otherUserAppInfoMapFlow.update { - if (pkgManager != null) { - val map = HashMap() - allPackageInfoMap.forEach { (userId, pkgInfoList) -> - val diffPkgList = pkgInfoList.filter { - !userAppInfoMap.contains(it.packageName) && !map.contains( - it.packageName - ) - } - diffPkgList.forEach { pkgInfo -> - val hidden = - !pkgManager.getAllIntentFilters(pkgInfo.packageName).any { f -> - f.hasAction(Intent.ACTION_MAIN) && f.hasCategory( - Intent.CATEGORY_LAUNCHER - ) - } - map[pkgInfo.packageName] = pkgInfo.toAppInfo( - userId = userId, - hidden = hidden, - ) - } - } - map - } else { - emptyMap() - } - } - } + ) { a, b -> a to b } + .debounce(3000) + .collect { updateOtherUserAppInfo() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt index dfbd264f86..2bdd74d644 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt @@ -2,6 +2,7 @@ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.ITaskStackListener +import android.content.ComponentName import android.os.Parcel import li.songe.gkd.a11y.updateTopActivity @@ -28,17 +29,21 @@ class FixedTaskStackListener : ITaskStackListener.Stub() { } private var lastFront = 0L - override fun onTaskMovedToFront(taskInfo: ActivityManager.RunningTaskInfo) { + fun onTaskMovedToFrontCompat(cpn: ComponentName? = null) { lastFront = System.currentTimeMillis() - val cpn = taskInfo.topActivity ?: safeGetTopCpn() ?: return + val cpn = cpn ?: safeGetTopCpn() ?: return updateTopActivity( appId = cpn.packageName, activityId = cpn.className, type = 2, ) } -} -val MyTaskListener by lazy { - FixedTaskStackListener() + override fun onTaskMovedToFront(taskId: Int) { + onTaskMovedToFrontCompat() + } + + override fun onTaskMovedToFront(taskInfo: ActivityManager.RunningTaskInfo) { + onTaskMovedToFrontCompat(taskInfo.topActivity) + } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt index 0692543a17..7b1fc26aa6 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt @@ -1,10 +1,9 @@ package li.songe.gkd.shizuku +import android.content.Context import android.os.IUserManager import li.songe.gkd.data.UserInfo -import li.songe.gkd.util.toast -import kotlin.reflect.full.declaredMemberFunctions -import kotlin.reflect.full.valueParameters +import li.songe.gkd.util.checkExistClass import kotlin.reflect.typeOf private var getUsersFcType: Int? = null @@ -13,42 +12,40 @@ private fun IUserManager.compatGetUsers( excludeDying: Boolean, excludePreCreated: Boolean, ): List { - if (getUsersFcType == null) { - for (f in this::class.declaredMemberFunctions.filter { it.name == "getUsers" }) { - getUsersFcType = when (f.valueParameters.map { it.type }) { - listOf(typeOf()) -> 1 - listOf(typeOf(), typeOf(), typeOf()) -> 3 - else -> null - } - if (getUsersFcType != null) { - break - } - } - if (getUsersFcType == null) { - getUsersFcType = -1 - toast("获取 IUserManager:getTasks 签名错误") - } - } - return try { - when (getUsersFcType) { - 1 -> this.getUsers(excludeDying) - 3 -> this.getUsers(excludePartial, excludeDying, excludePreCreated) - else -> emptyList() - } - } catch (_: Throwable) { - emptyList() - }.map { - UserInfo( - id = it.id, - name = it.name, + getUsersFcType = getUsersFcType ?: findCompatMethod( + "getUsers", + listOf( + 1 to listOf(typeOf()), + 3 to listOf(typeOf(), typeOf(), typeOf()), ) - } + ) + return when (getUsersFcType) { + 1 -> getUsers(excludeDying) + 3 -> getUsers(excludePartial, excludeDying, excludePreCreated) + else -> emptyList() + }.map { UserInfo(id = it.id, name = it.name) } } class SafeUserManager(private val value: IUserManager) { - fun compatGetUsers( + companion object { + val isAvailable: Boolean + get() = checkExistClass("android.os.IUserManager") + + fun newBinder() = getStubService( + Context.USER_SERVICE, + isAvailable, + )?.let { + SafeUserManager(IUserManager.Stub.asInterface(it)) + } + } + + fun getUsers( excludePartial: Boolean = true, excludeDying: Boolean = true, excludePreCreated: Boolean = true - ): List = value.compatGetUsers(excludePartial, excludeDying, excludePreCreated) + ): List { + return safeInvokeMethod { + value.compatGetUsers(excludePartial, excludeDying, excludePreCreated) + } ?: emptyList() + } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index 8851352829..da1d42941a 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -4,29 +4,15 @@ import android.content.ComponentName import android.content.Context import android.content.ServiceConnection import android.os.IBinder -import android.os.RemoteException import android.util.Log import androidx.annotation.Keep import com.blankj.utilcode.util.LogUtils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import li.songe.gkd.META -import li.songe.gkd.appScope import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.store.shizukuStoreFlow import li.songe.gkd.util.componentName import li.songe.gkd.util.json -import li.songe.gkd.util.toast import rikka.shizuku.Shizuku import java.io.DataOutputStream import kotlin.coroutines.resume @@ -57,7 +43,6 @@ class UserService : IUserService.Stub { destroy() } - @Throws(RemoteException::class) override fun execCommand(command: String): String { val process = Runtime.getRuntime().exec("sh") val outputStream = DataOutputStream(process.outputStream) @@ -101,20 +86,6 @@ class UserService : IUserService.Stub { } } -private fun IUserService.execCommandForResult(command: String): Boolean? { - return try { - val result = execCommand(command) - if (result != null) { - json.decodeFromString(result).code == 0 - } else { - null - } - } catch (e: Exception) { - e.printStackTrace() - null - } -} - private fun unbindUserService(serviceArgs: Shizuku.UserServiceArgs, connection: ServiceConnection) { if (!shizukuOkState.stateFlow.value) return LogUtils.d("unbindUserService", serviceArgs) @@ -123,37 +94,47 @@ private fun unbindUserService(serviceArgs: Shizuku.UserServiceArgs, connection: Shizuku.unbindUserService(serviceArgs, connection, false) Shizuku.unbindUserService(serviceArgs, connection, true) } catch (e: Exception) { - LogUtils.d(e) + e.printStackTrace() } } @Serializable data class CommandResult( - val code: Int, + val code: Int?, val result: String, val error: String? -) +) { + val ok: Boolean + get() = code == 0 +} data class UserServiceWrapper( val userService: IUserService, val connection: ServiceConnection, val serviceArgs: Shizuku.UserServiceArgs ) { - fun destroy() { - unbindUserService(serviceArgs, connection) + fun destroy() = unbindUserService(serviceArgs, connection) + + fun execCommandForResult(command: String): CommandResult = try { + val resultStr = userService.execCommand(command) + val result = json.decodeFromString(resultStr) + result + } catch (e: Throwable) { + e.printStackTrace() + CommandResult(code = null, result = "", error = e.message) } - fun execCommandForResult(command: String): Boolean? { - return userService.execCommandForResult(command) + fun safeTap(x: Float, y: Float, duration: Long? = null): Boolean? { + val command = if (duration != null) { + "input swipe $x $y $x $y $duration" + } else { + "input tap $x $y" + } + return execCommandForResult(command).ok } } -private val bindServiceMutex by lazy { Mutex() } suspend fun buildServiceWrapper(): UserServiceWrapper? { - if (bindServiceMutex.isLocked) { - toast("正在获取 Shizuku 服务,请稍后再试") - return null - } val serviceArgs = Shizuku .UserServiceArgs(UserService::class.componentName) .daemon(false) @@ -185,64 +166,19 @@ suspend fun buildServiceWrapper(): UserServiceWrapper? { LogUtils.d("onServiceDisconnected", componentName) } } - bindServiceMutex.withLock { - return withTimeoutOrNull(3000) { - suspendCoroutine { continuation -> - resumeCallback = { continuation.resume(it) } - try { - Shizuku.bindUserService(serviceArgs, connection) - } catch (_: Throwable) { - resumeCallback = null - continuation.resume(null) - } - } - }.apply { - if (this == null) { - toast("获取 Shizuku 服务失败") - unbindUserService(serviceArgs, connection) + return withTimeoutOrNull(3000) { + suspendCoroutine { continuation -> + resumeCallback = { continuation.resume(it) } + try { + Shizuku.bindUserService(serviceArgs, connection) + } catch (_: Throwable) { + resumeCallback = null + continuation.resume(null) } } - } -} - -private val shizukuServiceUsedFlow by lazy { - combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> - shizukuOk && store.enableTapClick - }.stateIn(appScope, SharingStarted.Eagerly, false) -} - -val serviceWrapperFlow by lazy { - val stateFlow = MutableStateFlow(null) - appScope.launch(Dispatchers.IO) { - shizukuServiceUsedFlow.collect { - if (it) { - stateFlow.update { s -> s ?: buildServiceWrapper() } - } else { - stateFlow.update { s -> s?.destroy(); null } - } + }.apply { + if (this == null) { + unbindUserService(serviceArgs, connection) } } - stateFlow -} - -suspend fun shizukuCheckUserService(): Boolean { - return try { - execCommandForResult("input tap 0 0") - } catch (_: Throwable) { - false - } -} - -suspend fun execCommandForResult(command: String): Boolean { - return serviceWrapperFlow.updateAndGet { - it ?: buildServiceWrapper() - }?.execCommandForResult(command) == true -} - -fun safeTap(x: Float, y: Float): Boolean? { - return serviceWrapperFlow.value?.execCommandForResult("input tap $x $y") -} - -fun safeLongTap(x: Float, y: Float, duration: Long): Boolean? { - return serviceWrapperFlow.value?.execCommandForResult("input swipe $x $y $x $y $duration") } diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index ed6288abcd..18b80584c2 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -23,7 +23,6 @@ data class SettingsStore( val hideSnapshotStatusBar: Boolean = false, val enableDarkTheme: Boolean? = null, val enableDynamicColor: Boolean = true, - val enableAbFloatWindow: Boolean = true, val showSaveSnapshotToast: Boolean = true, val useSystemToast: Boolean = false, val useCustomNotifText: Boolean = false, @@ -42,4 +41,5 @@ data class SettingsStore( val subsExcludeShowHiddenApp: Boolean = false, val subsExcludeShowDisabledApp: Boolean = false, val subsPowerWarn: Boolean = true, + val enableShizuku: Boolean = false, ) diff --git a/app/src/main/kotlin/li/songe/gkd/store/ShizukuStore.kt b/app/src/main/kotlin/li/songe/gkd/store/ShizukuStore.kt deleted file mode 100644 index dbd38064c7..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/store/ShizukuStore.kt +++ /dev/null @@ -1,15 +0,0 @@ -package li.songe.gkd.store - -import kotlinx.serialization.Serializable -import li.songe.gkd.META - -@Serializable -data class ShizukuStore( - val versionCode: Int = META.versionCode, - val enableActivity: Boolean = false, - val enableTapClick: Boolean = false, - val enableWorkProfile: Boolean = false, -) { - val enableShizukuAnyFeat: Boolean - get() = enableActivity || enableTapClick || enableWorkProfile -} diff --git a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt index 3eb59e8c6f..e79eeecdd5 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt @@ -2,7 +2,6 @@ package li.songe.gkd.store import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update -import li.songe.gkd.META import li.songe.gkd.appScope import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast @@ -14,20 +13,6 @@ val storeFlow by lazy { ) } -val shizukuStoreFlow by lazy { - createAnyFlow( - key = "shizuku", - default = { ShizukuStore() }, - initialize = { - if (it.versionCode != META.versionCode) { - ShizukuStore() - } else { - it - } - } - ) -} - val actionCountFlow by lazy { createTextFlow( key = "action_count", @@ -39,7 +24,6 @@ val actionCountFlow by lazy { fun initStore() = appScope.launchTry(Dispatchers.IO) { // preload storeFlow.value - shizukuStoreFlow.value actionCountFlow.value } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index fb109a282c..48843eef89 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -7,6 +7,7 @@ import android.os.Build import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,12 +15,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Api import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon @@ -42,7 +45,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -50,18 +55,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.LogUtils import com.dylanc.activityresult.launcher.launchForResult import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.ActivityLogPageDestination import com.ramcosta.composedestinations.generated.destinations.SnapshotPageDestination -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull import li.songe.gkd.MainActivity import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.permission.foregroundServiceSpecialUseState @@ -72,10 +70,7 @@ import li.songe.gkd.service.ButtonService import li.songe.gkd.service.HttpService import li.songe.gkd.service.RecordService import li.songe.gkd.service.ScreenshotService -import li.songe.gkd.shizuku.shizukuCheckActivity -import li.songe.gkd.shizuku.shizukuCheckUserService -import li.songe.gkd.shizuku.shizukuCheckWorkProfile -import li.songe.gkd.store.shizukuStoreFlow +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthCard import li.songe.gkd.ui.component.SettingItem @@ -90,10 +85,8 @@ import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.stopCoroutine import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -import rikka.shizuku.Shizuku @Destination(style = ProfileTransitions::class) @Composable @@ -197,27 +190,68 @@ fun AdvancedPage() { .verticalScroll(rememberScrollState()) .padding(contentPadding), ) { - Text( - modifier = Modifier.titleItemPadding(showTop = false), - text = "Shizuku", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .titleItemPadding(showTop = false), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier, + text = "Shizuku", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + val lineHeightDp = LocalDensity.current.run { + MaterialTheme.typography.titleSmall.lineHeight.toDp() + } + Icon( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + val c = shizukuContextFlow.value + mainVm.dialogFlow.updateDialogOptions( + title = "授权状态", + text = arrayOf( + "绑定服务" to c.serviceWrapper, + "IUserManager" to c.userManager, + "IPackageManager" to c.packageManager, + "IActivityManager" to c.activityManager, + "IActivityTaskManager" to c.activityTaskManager, + ).joinToString("\n") { (name, state) -> + name + " " + if (state != null) "✅" else "❎" + } + ) + }) + .size(lineHeightDp), + imageVector = Icons.Outlined.Api, + tint = MaterialTheme.colorScheme.primary, + contentDescription = Icons.Outlined.Api.name, + ) + } val shizukuOk by shizukuOkState.stateFlow.collectAsState() - AnimatedVisibility(!shizukuOk) { + if (!shizukuOk) { AuthCard( - title = "授权使用", - subtitle = "授权后可使用下列功能", + title = "未授权", + subtitle = "点击授权以优化体验", onAuthClick = { - try { - Shizuku.requestPermission(Activity.RESULT_OK) - } catch (e: Throwable) { - LogUtils.d("Shizuku授权错误", e.message) - mainVm.shizukuErrorFlow.value = e - } - }) + mainVm.requestShizuku() + } + ) + } + TextSwitch( + title = "启用优化", + subtitle = "提升权限优化体验", + suffix = "了解更多", + suffixUnderline = true, + onSuffixClick = { mainVm.navigateWebPage(ShortUrlSet.URL14) }, + checked = store.enableShizuku, + ) { + if (it && !shizukuOk) { + toast("未授权") + } + storeFlow.value = store.copy(enableShizuku = it) } - ShizukuFragment(vm, shizukuOk) val server by HttpService.httpServerFlow.collectAsState() val httpServerRunning = server != null @@ -320,7 +354,6 @@ fun AdvancedPage() { } ) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { val screenshotRunning by ScreenshotService.isRunning.collectAsState() TextSwitch( @@ -411,8 +444,9 @@ fun AdvancedPage() { title = "Github Cookie", subtitle = "生成快照/日志链接", suffix = "获取教程", + suffixUnderline = true, onSuffixClick = { - mainVm.navigatePage(WebViewPageDestination(initUrl = (ShortUrlSet.URL1))) + mainVm.navigateWebPage(ShortUrlSet.URL1) }, imageVector = Icons.Outlined.Edit, onClick = { @@ -457,111 +491,7 @@ fun AdvancedPage() { mainVm.navigatePage(ActivityLogPageDestination) } ) - - Text( - text = "其他", - modifier = Modifier.titleItemPadding(), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - - TextSwitch( - title = "前台悬浮窗", - subtitle = "添加透明悬浮窗", - suffix = "查看作用", - onSuffixClick = { - mainVm.dialogFlow.updateDialogOptions( - title = "悬浮窗作用", - text = "1.提高 GKD 前台优先级,降低被系统杀死概率\n2.提高点击响应速度,关闭后可能导致点击缓慢或不点击", - ) - }, - checked = store.enableAbFloatWindow, - onCheckedChange = { - storeFlow.value = store.copy( - enableAbFloatWindow = it - ) - }) - Spacer(modifier = Modifier.height(EmptyHeight)) } } } - -private val checkShizukuMutex by lazy { Mutex() } - -private suspend fun checkShizukuFeat(block: suspend () -> Boolean) { - if (checkShizukuMutex.isLocked) { - toast("正在检测中,请稍后再试") - stopCoroutine() - } - checkShizukuMutex.withLock { - toast("检测中") - val r = withTimeoutOrNull(3000) { - block() - } - if (r == null) { - toast("检测超时,请重试") - stopCoroutine() - } - if (!r) { - toast("检测失败,无法使用") - stopCoroutine() - } - toast("已启用") - } -} - -@Composable -private fun ShizukuFragment(vm: AdvancedVm, enabled: Boolean = true) { - val shizukuStore by shizukuStoreFlow.collectAsState() - val mainVm = LocalMainViewModel.current - TextSwitch( - title = "界面识别", - subtitle = "更准确识别界面ID", - suffix = "使用说明", - onSuffixClick = { - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL7)) - }, - checked = shizukuStore.enableActivity, - enabled = enabled, - onCheckedChange = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - if (it) { - checkShizukuFeat { shizukuCheckActivity() } - } - shizukuStoreFlow.update { s -> s.copy(enableActivity = it) } - }) - - TextSwitch( - title = "强制点击", - subtitle = "执行强制模拟点击", - suffix = "使用说明", - onSuffixClick = { - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL8)) - }, - checked = shizukuStore.enableTapClick, - enabled = enabled, - onCheckedChange = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - if (it) { - checkShizukuFeat { shizukuCheckUserService() } - } - shizukuStoreFlow.update { s -> s.copy(enableTapClick = it) } - }) - - - TextSwitch( - title = "工作空间", - subtitle = "扩展工作空间应用列表", - suffix = "使用说明", - onSuffixClick = { - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL9)) - }, - checked = shizukuStore.enableWorkProfile, - enabled = enabled, - onCheckedChange = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - if (it) { - checkShizukuFeat { shizukuCheckWorkProfile() } - } - shizukuStoreFlow.update { s -> s.copy(enableWorkProfile = it) } - }) - -} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index f464c956e2..ac44ab28f2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -277,7 +277,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - AppIcon(appInfo = appInfo) + AppIcon(appId = appInfo.id) Spacer(modifier = Modifier.width(12.dp)) Column( @@ -310,7 +310,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { key(appInfo.id) { Switch( checked = checked, - onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newChecked -> + onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newChecked -> val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = subsItemId, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt index f3e58e7d98..d89f9bc142 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt @@ -10,22 +10,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter -import li.songe.gkd.data.AppInfo -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appIconMapFlow @Composable fun AppIcon( modifier: Modifier = Modifier, - appId: String? = null, - appInfo: AppInfo? = null, + appId: String, ) { - val icon = if (appInfo != null) { - appInfo.icon - } else if (appId != null) { - appInfoCacheFlow.collectAsState().value[appId]?.icon - } else { - null - } + val icon = appIconMapFlow.collectAsState().value[appId] val iconModifier = modifier.size(32.dp) if (icon != null) { Image( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt index 65fc4bdfa1..5d84b298e1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt @@ -25,6 +25,7 @@ fun SettingItem( title: String, subtitle: String? = null, suffix: String? = null, + suffixUnderline: Boolean = false, onSuffixClick: (() -> Unit)? = null, imageVector: ImageVector? = Icons.AutoMirrored.Filled.KeyboardArrowRight, onClick: (() -> Unit)? = null, @@ -58,7 +59,13 @@ fun SettingItem( Spacer(modifier = Modifier.width(4.dp)) Text( text = suffix, - style = MaterialTheme.typography.bodyMedium.copy(textDecoration = TextDecoration.Underline), + style = MaterialTheme.typography.bodyMedium.run { + if (suffixUnderline) { + copy(textDecoration = TextDecoration.Underline) + } else { + this + } + }, color = MaterialTheme.colorScheme.primary, modifier = if (onSuffixClick != null) Modifier.clickable( onClick = throttle(fn = onSuffixClick), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt index 2ab5dc8de6..83833052c1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt @@ -40,7 +40,7 @@ fun SubsAppCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - AppIcon(appInfo = appInfo) + AppIcon(appId = rawApp.id) Spacer(modifier = Modifier.width(12.dp)) Column( modifier = Modifier diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt index 1b6e34d945..d7f1a13046 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt @@ -22,6 +22,7 @@ fun TextSwitch( title: String, subtitle: String? = null, suffix: String? = null, + suffixUnderline: Boolean = false, onSuffixClick: (() -> Unit)? = null, checked: Boolean = true, enabled: Boolean = true, @@ -46,8 +47,13 @@ fun TextSwitch( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = suffix, - style = MaterialTheme.typography.bodyMedium.copy(textDecoration = TextDecoration.Underline), + text = suffix, style = MaterialTheme.typography.bodyMedium.run { + if (suffixUnderline) { + copy(textDecoration = TextDecoration.Underline) + } else { + this + } + }, color = MaterialTheme.colorScheme.primary, modifier = if (onSuffixClick != null) Modifier.clickable( onClick = throttle(fn = onSuffixClick), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 8a28f74a0b..bfde75c467 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -25,6 +26,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState @@ -62,6 +65,8 @@ import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.mapHashCode import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle +import li.songe.gkd.util.updateAllAppInfo +import li.songe.gkd.util.updateAppMutex @Composable fun useAppListPage(): ScaffoldExt { @@ -86,6 +91,8 @@ fun useAppListPage(): ScaffoldExt { val showSearchBar by vm.showSearchBarFlow.collectAsState() val resetKey = orderedAppInfos.mapHashCode { it.id } val (scrollBehavior, listState) = useListScrollState(resetKey, appListKey) + val refreshing by updateAppMutex.state.collectAsState() + val pullToRefreshState = rememberPullToRefreshState() return ScaffoldExt( navItem = BottomNavItem.AppList, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -225,78 +232,85 @@ fun useAppListPage(): ScaffoldExt { }) } ) { contentPadding -> - LazyColumn( + PullToRefreshBox( modifier = Modifier.padding(contentPadding), - state = listState + state = pullToRefreshState, + isRefreshing = refreshing, + onRefresh = { updateAllAppInfo(true) } ) { - items(orderedAppInfos, { it.id }) { appInfo -> - Row( - modifier = Modifier - .clickable(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } - mainVm.navigatePage(AppConfigPageDestination(appInfo.id)) - }) - .appItemPadding(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - AppIcon(appInfo = appInfo) - Spacer(modifier = Modifier.width(12.dp)) - Column( + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + items(orderedAppInfos, { it.id }) { appInfo -> + Row( modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - AppNameText(appInfo = appInfo) - val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList() - val appDesc = if (appGroups.isNotEmpty()) { - when (val disabledCount = appGroups.count { g -> !g.enable }) { - 0 -> { - "${appGroups.size}组规则" + .clickable(onClick = throttle { + if (KeyboardUtils.isSoftInputVisible(context)) { + softwareKeyboardController?.hide() } + mainVm.navigatePage(AppConfigPageDestination(appInfo.id)) + }) + .appItemPadding(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(appId = appInfo.id) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + AppNameText(appInfo = appInfo) + val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList() + val appDesc = if (appGroups.isNotEmpty()) { + when (val disabledCount = appGroups.count { g -> !g.enable }) { + 0 -> { + "${appGroups.size}组规则" + } - appGroups.size -> { - "${appGroups.size}组规则/${disabledCount}关闭" - } + appGroups.size -> { + "${appGroups.size}组规则/${disabledCount}关闭" + } - else -> { - "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" + else -> { + "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" + } } + } else { + null } - } else { - null - } - val desc = if (globalDesc != null) { - if (appDesc != null) { - "$globalDesc/$appDesc" + val desc = if (globalDesc != null) { + if (appDesc != null) { + "$globalDesc/$appDesc" + } else { + globalDesc + } } else { - globalDesc + appDesc + } + if (desc != null) { + Text( + text = desc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) } - } else { - appDesc - } - if (desc != null) { - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - softWrap = false - ) } } } - } - item(LIST_PLACEHOLDER_KEY) { - Spacer(modifier = Modifier.height(EmptyHeight)) - if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = showSystemApp && showHiddenApp - EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + item(LIST_PLACEHOLDER_KEY) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) { + val hasShowAll = showSystemApp && showHiddenApp + EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + } + QueryPkgAuthCard() } - QueryPkgAuthCard() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 8ca2327614..bed204f958 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -33,34 +33,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update -import li.songe.gkd.appScope import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.RotatingLoadingIcon import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight -import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.ui.theme.supportDynamicColor import li.songe.gkd.util.DarkThemeOption import li.songe.gkd.util.findOption -import li.songe.gkd.util.initOrResetAppInfoCache -import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.launchTry import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast -import li.songe.gkd.util.updateAppMutex @Composable fun useSettingsPage(): ScaffoldExt { @@ -279,35 +268,6 @@ fun useSettingsPage(): ScaffoldExt { ) }) - Row( - modifier = Modifier - .clickable( - onClick = throttle(vm.viewModelScope.launchAsFn { - if (updateAppMutex.mutex.isLocked) return@launchAsFn - mainVm.dialogFlow.waitResult( - title = "重载列表", - text = "是否重新加载应用列表? \n\n如果应用信息不正确或切换了图标主题, 可使用此项同步最新状态", - dismissRequest = true, - ) - if (updateAppMutex.mutex.isLocked) return@launchAsFn - appScope.launchTry(Dispatchers.IO) { - initOrResetAppInfoCache() - toast("重载成功") - } - }) - ) - .fillMaxWidth() - .itemPadding(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "重载应用列表", - style = MaterialTheme.typography.bodyLarge, - ) - RotatingLoadingIcon(loading = updateAppMutex.state.collectAsState().value) - } - Text( text = "主题", modifier = Modifier.titleItemPadding(), diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index af4b6bbf49..b855cc5f35 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -6,8 +6,8 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.graphics.drawable.Drawable import androidx.core.content.ContextCompat -import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -16,23 +16,28 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.toAppInfo import li.songe.gkd.permission.canQueryPkgState -import kotlin.time.Duration.Companion.days val userAppInfoMapFlow = MutableStateFlow(emptyMap()) -val allPackageInfoMapFlow = MutableStateFlow(emptyMap>()) +val userAppIconMapFlow = MutableStateFlow(emptyMap()) val otherUserAppInfoMapFlow = MutableStateFlow(emptyMap()) +val otherUserAppIconMapFlow = MutableStateFlow(emptyMap()) + val appInfoCacheFlow by lazy { combine(otherUserAppInfoMapFlow, userAppInfoMapFlow) { a, b -> a + b } .stateIn(appScope, SharingStarted.Eagerly, emptyMap()) } +val appIconMapFlow by lazy { + combine(userAppIconMapFlow, otherUserAppIconMapFlow) { a, b -> a + b } + .stateIn(appScope, SharingStarted.Eagerly, emptyMap()) +} + val systemAppInfoCacheFlow by lazy { appInfoCacheFlow.mapState(appScope) { c -> c.filter { a -> a.value.isSystem } @@ -71,14 +76,9 @@ private val packageReceiver by lazy { ) override fun onReceive(context: Context?, intent: Intent?) { + // PACKAGE_REMOVED->PACKAGE_ADDED->PACKAGE_REPLACED val appId = intent?.data?.schemeSpecificPart ?: return - if (actions.contains(intent.action)) { - /** - * 例: 小米应用商店更新应用产生连续 3个事件: PACKAGE_REMOVED->PACKAGE_ADDED->PACKAGE_REPLACED - * 使用 Flow + debounce 优化合并 - */ - willUpdateAppIds.update { it + appId } - } + willUpdateAppIds.update { it + appId } } }.apply { val intentFilter = IntentFilter().apply { @@ -94,78 +94,76 @@ private val packageReceiver by lazy { } } -fun getPkgInfo(appId: String): PackageInfo? { - return try { - app.packageManager.getPackageInfo(appId, PackageManager.MATCH_UNINSTALLED_PACKAGES) - } catch (_: PackageManager.NameNotFoundException) { - null - } +const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES + +private fun getPkgInfo(appId: String): PackageInfo? = try { + app.packageManager.getPackageInfo(appId, PKG_FLAGS) +} catch (_: PackageManager.NameNotFoundException) { + null } val updateAppMutex = MutexState() -private var lastUpdateAppListTime = 0L -private suspend fun updateAppInfo(appIds: Set) { - if (appIds.isEmpty()) return +private suspend fun updateAppInfo(appIds: Set) = updateAppMutex.withStateLock { willUpdateAppIds.update { it - appIds } - updateAppMutex.withLock { - LogUtils.d("updateAppInfo", appIds) - val newMap = userAppInfoMapFlow.value.toMutableMap() - appIds.forEach { appId -> - val info = getPkgInfo(appId) - if (info != null) { - newMap[appId] = info.toAppInfo() - } else { - newMap.remove(appId) - } + val newAppMap = HashMap(userAppInfoMapFlow.value) + val newIconMap = HashMap(userAppIconMapFlow.value) + appIds.forEach { appId -> + val info = getPkgInfo(appId) + if (info != null) { + newAppMap[appId] = info.toAppInfo() + } else { + newAppMap.remove(appId) + } + val icon = info?.pkgIcon + if (icon != null) { + newIconMap[appId] = icon + } else { + newIconMap.remove(appId) } - userAppInfoMapFlow.value = newMap } + userAppInfoMapFlow.value = newAppMap + userAppIconMapFlow.value = newIconMap } -suspend fun initOrResetAppInfoCache() = updateAppMutex.withLock { - LogUtils.d("initOrResetAppInfoCache start") - val oldMap = userAppInfoMapFlow.value - val appMap = userAppInfoMapFlow.value.toMutableMap() - withContext(Dispatchers.IO) { - app.packageManager.getInstalledPackages(PackageManager.MATCH_UNINSTALLED_PACKAGES) - .forEach { packageInfo -> - appMap[packageInfo.packageName] = packageInfo.toAppInfo() +fun updateAllAppInfo(showToast: Boolean = false) = appScope.launchTry(Dispatchers.IO) { + updateAppMutex.withStateLock { + val newAppMap = HashMap() + val newIconMap = HashMap() + val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) + pkgList.forEach { packageInfo -> + newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() + packageInfo.pkgIcon?.let { icon -> + newIconMap[packageInfo.packageName] = icon } - } - if (!canQueryPkgState.updateAndGet() || appMap.getMayQueryPkgNoAccess()) { - withContext(Dispatchers.IO) { - arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> + } + if (!canQueryPkgState.updateAndGet() || newAppMap.getMayQueryPkgNoAccess()) { + val visiblePkgList = arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> app.packageManager.queryIntentActivities( Intent(action), - PackageManager.MATCH_DISABLED_COMPONENTS, + PackageManager.MATCH_DISABLED_COMPONENTS ) - }.flatten().map { it.activityInfo.packageName }.toSet().forEach { appId -> - if (!appMap.contains(appId)) { - getPkgInfo(appId)?.let { - appMap[appId] = it.toAppInfo() - } + }.flatten() + .map { it.activityInfo.packageName }.toSet() + .filter { !newAppMap.contains(it) }.mapNotNull { getPkgInfo(it) } + visiblePkgList.forEach { packageInfo -> + newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() + packageInfo.pkgIcon?.let { icon -> + newIconMap[packageInfo.packageName] = icon } } } + userAppInfoMapFlow.value = newAppMap + userAppIconMapFlow.value = newIconMap + if (showToast) { + toast("应用列表更新成功") + } } - userAppInfoMapFlow.value = appMap - lastUpdateAppListTime = System.currentTimeMillis() - LogUtils.d("initOrResetAppInfoCache end ${oldMap.size}->${appMap.size}") -} - - -fun forceUpdateAppList() { - if (updateAppMutex.mutex.isLocked) return - val interval = System.currentTimeMillis() - lastUpdateAppListTime - if (interval > 7.days.inWholeMilliseconds) { - // 每 7 天强制更新一次应用列表数据 - appScope.launchTry(Dispatchers.IO) { initOrResetAppInfoCache() } - } -} +}.let { } fun initAppState() { packageReceiver + updateAllAppInfo() appScope.launchTry(Dispatchers.IO) { willUpdateAppIds.debounce(3000) .filter { it.isNotEmpty() } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index 358b62c669..c878936962 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -26,13 +26,11 @@ object ShortUrlSet { const val URL4 = "https://gkd.li?r=4" const val URL5 = "https://gkd.li?r=5" const val URL6 = "https://gkd.li?r=6" - const val URL7 = "https://gkd.li?r=7" - const val URL8 = "https://gkd.li?r=8" - const val URL9 = "https://gkd.li?r=9" const val URL10 = "https://gkd.li?r=10" const val URL11 = "https://gkd.li?r=11" const val URL12 = "https://gkd.li?r=12" const val URL13 = "https://gkd.li?r=13" + const val URL14 = "https://gkd.li?r=14" } const val shizukuAppId = "moe.shizuku.privileged.api" diff --git a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt index 8eca1e1be7..718cc23fdf 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt @@ -16,8 +16,10 @@ import androidx.core.net.toUri import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app +import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.canWriteExternalStorage import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState @@ -135,7 +137,8 @@ fun startForegroundServiceByClass(clazz: KClass) { app.startForegroundService(intent) } catch (e: Throwable) { LogUtils.d(e) - toast("启动服务失败: ${e.message}") + val prefix = if (isActivityVisible()) "" else "${META.appName}: " + toast("${prefix}启动服务失败: ${e.message}") } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt b/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt index 8912615997..5e71a1338a 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt @@ -1,23 +1,48 @@ package li.songe.gkd.util +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -class MutexState { - val mutex = Mutex() - val state = MutableStateFlow(false) - suspend inline fun withLock(block: () -> Unit): Unit = mutex.withLock { - state.value = true +class MutexState() { + val mutex: Mutex = Mutex() + val intState = MutableStateFlow(0) + + @OptIn(ExperimentalForInheritanceCoroutinesApi::class) + val state = object : StateFlow { + override val value: Boolean + get() = intState.value > 0 + override val replayCache: List + get() = listOf(value) + + override suspend fun collect(collector: FlowCollector): Nothing { + var currentValue = value + collector.emit(currentValue) + intState.collect { + val newValue = it > 0 + if (newValue != currentValue) { + currentValue = newValue + collector.emit(currentValue) + } + } + } + } + + suspend inline fun withStateLock(block: () -> Unit): Unit = mutex.withLock { + intState.update { it + 1 } try { block() } finally { - state.value = false + intState.update { it - 1 } } } suspend inline fun whenUnLock(block: () -> Unit) { if (mutex.isLocked) return - withLock(block) + withStateLock(block) } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Others.kt b/app/src/main/kotlin/li/songe/gkd/util/Others.kt index 1b5773b543..1a118273c6 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -2,10 +2,12 @@ package li.songe.gkd.util import android.app.Activity import android.content.ComponentName +import android.content.pm.PackageInfo import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.drawable.Drawable import android.os.Build import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform @@ -20,6 +22,7 @@ import androidx.core.graphics.get import com.blankj.utilcode.util.LogUtils import li.songe.gkd.META import li.songe.gkd.MainActivity +import li.songe.gkd.app import li.songe.json5.Json5EncoderConfig import li.songe.json5.encodeToJson5String import java.io.DataOutputStream @@ -130,3 +133,26 @@ fun drawTextToBitmap(text: String, bitmap: Bitmap) { ) } } + +// https://github.com/gkd-kit/gkd/issues/44 +// java.lang.ClassNotFoundException:Didn't find class "android.app.IActivityTaskManager" on path: DexPathList +private val clazzMap = HashMap() +fun checkExistClass(className: String): Boolean = clazzMap[className] ?: try { + Class.forName(className) + true +} catch (_: Throwable) { + false +}.apply { + clazzMap[className] = this +} + +// https://github.com/gkd-kit/gkd/issues/924 +private val Drawable.safeDrawable: Drawable? + get() = if (intrinsicHeight > 0 && intrinsicWidth > 0) { + this + } else { + null + } + +val PackageInfo.pkgIcon: Drawable? + get() = applicationInfo?.loadIcon(app.packageManager)?.safeDrawable diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index 9f15b68a25..972b53570b 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -90,7 +90,7 @@ val usedSubsEntriesFlow by lazy { fun updateSubscription(subscription: RawSubscription) { appScope.launchTry { - updateSubsMutex.withLock { + updateSubsMutex.withStateLock { val subsId = subscription.id val subsName = subscription.name val newMap = subsIdToRawFlow.value.toMutableMap() @@ -394,7 +394,7 @@ private fun refreshRawSubsList(items: List): Boolean { fun initSubsState() { subsItemsFlow.value appScope.launchTry(Dispatchers.IO) { - updateSubsMutex.withLock { + updateSubsMutex.withStateLock { val items = DbSet.subsItemDao.queryAll() refreshRawSubsList(items) } @@ -448,12 +448,12 @@ fun checkSubsUpdate(showToast: Boolean = false) = appScope.launchTry(Dispatchers if (updateSubsMutex.mutex.isLocked) { return@launchTry } - updateSubsMutex.withLock { + updateSubsMutex.withStateLock { if (subsEntriesFlow.value.any { !it.subsItem.isLocal } && !NetworkUtils.isAvailable()) { if (showToast) { toast("网络不可用") } - return@withLock + return@withStateLock } LogUtils.d("开始检测更新") // 文件不存在, 重新加载 diff --git a/hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceClient.aidl b/hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceClient.aidl deleted file mode 100644 index cbb1295dd3..0000000000 --- a/hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceClient.aidl +++ /dev/null @@ -1,5 +0,0 @@ -// IAccessibilityServiceClient.aidl -package android.accessibilityservice; - -interface IAccessibilityServiceClient { -} diff --git a/hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceConnection.aidl b/hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceConnection.aidl deleted file mode 100644 index 55a1458670..0000000000 --- a/hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceConnection.aidl +++ /dev/null @@ -1,7 +0,0 @@ -// IAccessibilityServiceConnection.aidl -package android.accessibilityservice; - -// Declare any non-default types here with import statements - -interface IAccessibilityServiceConnection { -} \ No newline at end of file diff --git a/hidden_api/src/main/aidl/android/app/IUiAutomationConnection.aidl b/hidden_api/src/main/aidl/android/app/IUiAutomationConnection.aidl deleted file mode 100644 index 1e661904df..0000000000 --- a/hidden_api/src/main/aidl/android/app/IUiAutomationConnection.aidl +++ /dev/null @@ -1,19 +0,0 @@ -package android.app; - -import android.accessibilityservice.IAccessibilityServiceClient; -import android.graphics.Bitmap; -import android.graphics.Rect; -import android.view.InputEvent; -import android.os.ParcelFileDescriptor; - -interface IUiAutomationConnection { - void connect(IAccessibilityServiceClient client, int flags); - void disconnect(); - boolean injectInputEvent(in InputEvent event, boolean sync); - void syncInputTransactions(); - boolean setRotation(int rotation); - Bitmap takeScreenshot(in Rect crop, int rotation); - void executeShellCommand(String command, in ParcelFileDescriptor sink, - in ParcelFileDescriptor source); - oneway void shutdown(); -} diff --git a/hidden_api/src/main/java/android/accessibilityservice/AccessibilityServiceHidden.java b/hidden_api/src/main/java/android/accessibilityservice/AccessibilityServiceHidden.java deleted file mode 100644 index f66e949507..0000000000 --- a/hidden_api/src/main/java/android/accessibilityservice/AccessibilityServiceHidden.java +++ /dev/null @@ -1,43 +0,0 @@ - -package android.accessibilityservice; - -import android.graphics.Region; -import android.os.IBinder; -import android.view.KeyEvent; -import android.view.accessibility.AccessibilityEvent; - -import dev.rikka.tools.refine.RefineAs; - -@SuppressWarnings("unused") -@RefineAs(AccessibilityService.class) -public class AccessibilityServiceHidden { - - public interface Callbacks { - void onAccessibilityEvent(AccessibilityEvent event); - - void onInterrupt(); - - void onServiceConnected(); - - void init(int connectionId, IBinder windowToken); - - boolean onGesture(int gestureId); - - boolean onKeyEvent(KeyEvent event); - - void onMagnificationChanged(int displayId, Region region, - float scale, float centerX, float centerY); - - void onSoftKeyboardShowModeChanged(int showMode); - - void onPerformGestureResult(int sequence, boolean completedSuccessfully); - - void onFingerprintCapturingGesturesChanged(boolean active); - - void onFingerprintGesture(int gesture); - - void onAccessibilityButtonClicked(); - - void onAccessibilityButtonAvailabilityChanged(boolean available); - } -} diff --git a/hidden_api/src/main/java/android/app/ActivityManagerHidden.java b/hidden_api/src/main/java/android/app/ActivityManagerHidden.java deleted file mode 100644 index bdb3184311..0000000000 --- a/hidden_api/src/main/java/android/app/ActivityManagerHidden.java +++ /dev/null @@ -1,48 +0,0 @@ -package android.app; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; - -import dev.rikka.tools.refine.RefineAs; - -@RefineAs(ActivityManager.class) -public class ActivityManagerHidden { - - public static boolean isHighEndGfx() { - throw new RuntimeException("Stub!"); - } - - /** - * Represents a task snapshot. - */ - public static class TaskSnapshot implements Parcelable { - - protected TaskSnapshot(Parcel in) { - //Stub - } - - public static final Creator CREATOR = new Creator() { - @Override - public TaskSnapshot createFromParcel(Parcel in) { - return new TaskSnapshot(in); - } - - @Override - public TaskSnapshot[] newArray(int size) { - return new TaskSnapshot[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel parcel, int i) { - } - } - -} \ No newline at end of file diff --git a/hidden_api/src/main/java/android/app/ActivityManagerNative.java b/hidden_api/src/main/java/android/app/ActivityManagerNative.java deleted file mode 100644 index c3cb9a86b7..0000000000 --- a/hidden_api/src/main/java/android/app/ActivityManagerNative.java +++ /dev/null @@ -1,11 +0,0 @@ -package android.app; - -import android.os.IBinder; - -@SuppressWarnings("unused") -public class ActivityManagerNative { - - public static IActivityManager asInterface(IBinder obj) { - throw new RuntimeException("Stub!"); - } -} diff --git a/hidden_api/src/main/java/android/app/ActivityThread.java b/hidden_api/src/main/java/android/app/ActivityThread.java deleted file mode 100644 index fa77aff7c6..0000000000 --- a/hidden_api/src/main/java/android/app/ActivityThread.java +++ /dev/null @@ -1,13 +0,0 @@ -package android.app; - -@SuppressWarnings("unused") -public class ActivityThread { - - public static ActivityThread currentActivityThread() { - throw new RuntimeException("Stub!"); - } - - public ContextImpl getSystemContext() { - throw new RuntimeException("Stub!"); - } -} diff --git a/hidden_api/src/main/java/android/app/ContextImpl.java b/hidden_api/src/main/java/android/app/ContextImpl.java deleted file mode 100644 index 7575dab6ae..0000000000 --- a/hidden_api/src/main/java/android/app/ContextImpl.java +++ /dev/null @@ -1,8 +0,0 @@ -package android.app; - -import android.content.Context; - -@SuppressWarnings("unused") -public abstract class ContextImpl extends Context { - -} diff --git a/hidden_api/src/main/java/android/app/IActivityManager.java b/hidden_api/src/main/java/android/app/IActivityManager.java index 96ad5746c8..880a47294e 100644 --- a/hidden_api/src/main/java/android/app/IActivityManager.java +++ b/hidden_api/src/main/java/android/app/IActivityManager.java @@ -3,24 +3,16 @@ import android.os.Binder; import android.os.IBinder; import android.os.IInterface; -import android.os.RemoteException; import java.util.List; @SuppressWarnings("unused") public interface IActivityManager extends IInterface { - List getRunningAppProcesses() throws RemoteException; - - List getTasks(int maxNum) throws RemoteException; - - List getFilteredTasks(int maxNum, int ignoreActivityType, int ignoreWindowingMode) throws RemoteException; - - void forceStopPackage(String packageName, int userId); - abstract class Stub extends Binder implements IActivityManager { - public static IActivityManager asInterface(IBinder obj) { throw new RuntimeException("Stub!"); } } + + List getTasks(int maxNum); } diff --git a/hidden_api/src/main/java/android/app/IActivityTaskManager.java b/hidden_api/src/main/java/android/app/IActivityTaskManager.java index 6ba74db9f4..c9a02f7156 100644 --- a/hidden_api/src/main/java/android/app/IActivityTaskManager.java +++ b/hidden_api/src/main/java/android/app/IActivityTaskManager.java @@ -8,21 +8,23 @@ @SuppressWarnings("unused") public interface IActivityTaskManager extends IInterface { - // XIAOMI + // android10+ + abstract class Stub extends Binder implements IActivityTaskManager { + public static IActivityTaskManager asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } + + // android10 - android11 + List getTasks(int maxNum); + + // android12 List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra); - // https://github.com/gkd-kit/gkd/issues/58#issuecomment-1736843795 + // android13 List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); - // https://github.com/gkd-kit/gkd/issues/58#issuecomment-1732245703 - List getTasks(int maxNum); - void registerTaskStackListener(ITaskStackListener listener); - void unregisterTaskStackListener(ITaskStackListener listener); - abstract class Stub extends Binder implements IActivityTaskManager { - public static IActivityTaskManager asInterface(IBinder obj) { - throw new RuntimeException("Stub!"); - } - } + void unregisterTaskStackListener(ITaskStackListener listener); } diff --git a/hidden_api/src/main/java/android/app/ITaskStackListener.java b/hidden_api/src/main/java/android/app/ITaskStackListener.java index 017a072af4..78e0e832a0 100644 --- a/hidden_api/src/main/java/android/app/ITaskStackListener.java +++ b/hidden_api/src/main/java/android/app/ITaskStackListener.java @@ -2,11 +2,8 @@ import android.os.IBinder; -/** - * @noinspection unused - */ +@SuppressWarnings("unused") public interface ITaskStackListener { - abstract class Stub extends android.os.Binder implements ITaskStackListener { public static ITaskStackListener asInterface(IBinder obj) { throw new RuntimeException("Stub!"); @@ -16,5 +13,9 @@ public static ITaskStackListener asInterface(IBinder obj) { // 应用->桌面不会回调,分屏下切换窗口不会回调,但从最近任务界面移除窗口会回调 void onTaskStackChanged(); + // android8 - android9 + void onTaskMovedToFront(int taskId); + + // android10+ void onTaskMovedToFront(ActivityManager.RunningTaskInfo taskInfo); } \ No newline at end of file diff --git a/hidden_api/src/main/java/android/app/UiAutomationConnection.java b/hidden_api/src/main/java/android/app/UiAutomationConnection.java deleted file mode 100644 index 406964854f..0000000000 --- a/hidden_api/src/main/java/android/app/UiAutomationConnection.java +++ /dev/null @@ -1,9 +0,0 @@ - -package android.app; - -@SuppressWarnings("unused") -public class UiAutomationConnection extends IUiAutomationConnection.Default { - public UiAutomationConnection() { - throw new RuntimeException("Stub!"); - } -} diff --git a/hidden_api/src/main/java/android/app/UiAutomationHidden.java b/hidden_api/src/main/java/android/app/UiAutomationHidden.java deleted file mode 100644 index ecd9f0bc9e..0000000000 --- a/hidden_api/src/main/java/android/app/UiAutomationHidden.java +++ /dev/null @@ -1,31 +0,0 @@ -package android.app; - -import android.os.Looper; -import android.os.ParcelFileDescriptor; - -import dev.rikka.tools.refine.RefineAs; - -@SuppressWarnings("unused") -@RefineAs(UiAutomation.class) -public class UiAutomationHidden { - - public UiAutomationHidden(Looper looper, IUiAutomationConnection connection) { - throw new RuntimeException("Stub!"); - } - - public void connect() { - throw new RuntimeException("Stub!"); - } - - public void connect(int flag) { - throw new RuntimeException("Stub!"); - } - - public void disconnect() { - throw new RuntimeException("Stub!"); - } - - public ParcelFileDescriptor[] executeShellCommandRwe(String command) { - throw new RuntimeException("Stub!"); - } -} diff --git a/hidden_api/src/main/java/android/content/pm/IPackageManager.java b/hidden_api/src/main/java/android/content/pm/IPackageManager.java index 9eb0e590f0..d7643b58cf 100644 --- a/hidden_api/src/main/java/android/content/pm/IPackageManager.java +++ b/hidden_api/src/main/java/android/content/pm/IPackageManager.java @@ -1,6 +1,5 @@ package android.content.pm; -import android.content.ComponentName; import android.content.IntentFilter; import android.os.Binder; import android.os.IBinder; @@ -8,24 +7,20 @@ @SuppressWarnings("unused") public interface IPackageManager extends IInterface { + abstract class Stub extends Binder implements IPackageManager { + public static IPackageManager asInterface(IBinder binder) { + throw new IllegalArgumentException("Stub!"); + } + } + // android8 - android12 -> int flags + // android13+ -> long flags - ApplicationInfo getApplicationInfo(String packageName, long flags, int userId); - - PackageInfo getPackageInfo(String packageName, long flags, int userId); - + // android8 - android12 ParceledListSlice getInstalledPackages(int flags, int userId); + // android13+ ParceledListSlice getInstalledPackages(long flags, int userId); ParceledListSlice getAllIntentFilters(String packageName); - ActivityInfo getActivityInfo(ComponentName component, int flags) throws PackageManager.NameNotFoundException; - - void grantRuntimePermission(String packageName, String permName, int userId); - - abstract class Stub extends Binder implements IPackageManager { - public static IPackageManager asInterface(IBinder binder) { - throw new IllegalArgumentException("Stub!"); - } - } } diff --git a/hidden_api/src/main/java/android/content/pm/UserInfo.java b/hidden_api/src/main/java/android/content/pm/UserInfo.java index 2e73eec68e..3edbb05fab 100644 --- a/hidden_api/src/main/java/android/content/pm/UserInfo.java +++ b/hidden_api/src/main/java/android/content/pm/UserInfo.java @@ -1,5 +1,6 @@ package android.content.pm; +@SuppressWarnings("unused") public class UserInfo { public int id; public String name; diff --git a/hidden_api/src/main/java/android/hardware/input/IInputManager.java b/hidden_api/src/main/java/android/hardware/input/IInputManager.java deleted file mode 100644 index bb53e4fcdb..0000000000 --- a/hidden_api/src/main/java/android/hardware/input/IInputManager.java +++ /dev/null @@ -1,19 +0,0 @@ -package android.hardware.input; - -import android.os.Binder; -import android.os.IBinder; -import android.os.IInterface; -import android.os.RemoteException; -import android.view.InputEvent; - -@SuppressWarnings("unused") -public interface IInputManager extends IInterface { - boolean injectInputEvent(InputEvent event, int mode) throws RemoteException; - - abstract class Stub extends Binder implements IInputManager { - - public static IInputManager asInterface(IBinder obj) { - throw new RuntimeException("Stub!"); - } - } -} diff --git a/hidden_api/src/main/java/android/os/IUserManager.java b/hidden_api/src/main/java/android/os/IUserManager.java index 3d55aa182c..8fc4ef45a7 100644 --- a/hidden_api/src/main/java/android/os/IUserManager.java +++ b/hidden_api/src/main/java/android/os/IUserManager.java @@ -6,15 +6,15 @@ @SuppressWarnings("unused") public interface IUserManager extends IInterface { - - List getUsers(boolean excludeDying); - - // @RequiresApi(Build.VERSION_CODES.R) - List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); - abstract class Stub extends Binder implements IUserManager { public static IUserManager asInterface(IBinder obj) { throw new RuntimeException("STUB"); } } + + // android8 - android10 + List getUsers(boolean excludeDying); + + // android11+ + List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); } diff --git a/hidden_api/src/main/java/android/window/TaskSnapshot.java b/hidden_api/src/main/java/android/window/TaskSnapshot.java deleted file mode 100644 index 581eb78948..0000000000 --- a/hidden_api/src/main/java/android/window/TaskSnapshot.java +++ /dev/null @@ -1,32 +0,0 @@ -package android.window; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; - -public class TaskSnapshot implements Parcelable { - protected TaskSnapshot(Parcel in) { - } - - public static final Creator CREATOR = new Creator() { - @Override - public TaskSnapshot createFromParcel(Parcel in) { - return new TaskSnapshot(in); - } - - @Override - public TaskSnapshot[] newArray(int size) { - return new TaskSnapshot[size]; - } - }; - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel parcel, int i) { - } -} \ No newline at end of file From b3fb279decf77a7cd17422f056ba4f45ffe1ea61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 31 Aug 2025 16:16:12 +0800 Subject: [PATCH 026/245] perf: updateOtherUserAppInfo --- app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 7 ++++--- app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt | 8 +++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index d8bed7b7c7..038963ecbf 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -149,7 +149,9 @@ private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { } } -fun updateOtherUserAppInfo() { +fun updateOtherUserAppInfo( + userAppInfoMap: Map = userAppInfoMapFlow.value, +) { val pkgManager = shizukuContextFlow.value.packageManager val userManager = shizukuContextFlow.value.userManager if (pkgManager == null || userManager == null) { @@ -159,7 +161,6 @@ fun updateOtherUserAppInfo() { return } val otherUsers = userManager.getUsers().filter { it.id != currentUserId }.sortedBy { it.id } - otherUserMapFlow.value = otherUsers.associateBy { it.id } val userPackageInfoMap = otherUsers.associate { user -> user.id to pkgManager.getInstalledPackages( PKG_FLAGS, @@ -167,7 +168,6 @@ fun updateOtherUserAppInfo() { ) } val newIconMap = HashMap() - val userAppInfoMap = userAppInfoMapFlow.value val newAppMap = HashMap() userPackageInfoMap.forEach { (userId, pkgInfoList) -> val diffPkgList = pkgInfoList.filter { @@ -183,6 +183,7 @@ fun updateOtherUserAppInfo() { pkgInfo.pkgIcon?.let { newIconMap[pkgInfo.packageName] = it } } } + otherUserMapFlow.value = otherUsers.associateBy { it.id } otherUserAppInfoMapFlow.value = newAppMap otherUserAppIconMapFlow.value = newIconMap } diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index b855cc5f35..6923438f71 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -22,6 +22,7 @@ import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.toAppInfo import li.songe.gkd.permission.canQueryPkgState +import li.songe.gkd.shizuku.updateOtherUserAppInfo val userAppInfoMapFlow = MutableStateFlow(emptyMap()) val userAppIconMapFlow = MutableStateFlow(emptyMap()) @@ -153,7 +154,12 @@ fun updateAllAppInfo(showToast: Boolean = false) = appScope.launchTry(Dispatcher } } } - userAppInfoMapFlow.value = newAppMap + val oldAppMap = userAppInfoMapFlow.value + if (oldAppMap == newAppMap) { + updateOtherUserAppInfo(newAppMap) + } else { + userAppInfoMapFlow.value = newAppMap + } userAppIconMapFlow.value = newIconMap if (showToast) { toast("应用列表更新成功") From ae1e2f498338baa667d8d5d5b2dd85a5d4fa57ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 31 Aug 2025 18:59:41 +0800 Subject: [PATCH 027/245] perf: preload appIconMapFlow --- app/src/main/kotlin/li/songe/gkd/MainViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index b9613e9978..e707bf6756 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -41,6 +41,7 @@ import li.songe.gkd.ui.component.UploadOptions import li.songe.gkd.ui.home.BottomNavItem import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.UpdateStatus +import li.songe.gkd.util.appIconMapFlow import li.songe.gkd.util.clearCache import li.songe.gkd.util.client import li.songe.gkd.util.launchTry @@ -291,6 +292,8 @@ class MainViewModel : ViewModel() { val a11yServiceEnabledFlow = useA11yServiceEnabledFlow() init { + // preload + appIconMapFlow.value viewModelScope.launchTry(Dispatchers.IO) { val subsItems = DbSet.subsItemDao.queryAll() if (!subsItems.any { s -> s.id == LOCAL_SUBS_ID }) { From e46ea6285682c5fdea36c41e13ac5d44f3d21d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 31 Aug 2025 19:44:13 +0800 Subject: [PATCH 028/245] feat: customNotifTitle --- .../li/songe/gkd/service/StatusService.kt | 36 +++-- .../li/songe/gkd/store/SettingsStore.kt | 1 + .../ui/component/CustomOutlinedTextField.kt | 141 ++++++++++++++++++ .../li/songe/gkd/ui/home/SettingsPage.kt | 107 +++++++++---- 4 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/CustomOutlinedTextField.kt diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index d250c6ea46..e040cbf375 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import li.songe.gkd.META import li.songe.gkd.notif.abNotif import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState @@ -38,19 +39,30 @@ class StatusService : Service(), OnSimpleLife { ruleSummaryFlow, actionCountFlow, ) { abRunning, store, ruleSummary, count -> - if (!abRunning) return@combine "无障碍未授权" - if (!store.enableMatch) return@combine "暂停规则匹配" - if (store.useCustomNotifText) { - return@combine store.customNotifText - .replace("\${i}", ruleSummary.globalGroups.size.toString()) - .replace("\${k}", ruleSummary.appSize.toString()) - .replace("\${u}", ruleSummary.appGroupSize.toString()) - .replace("\${n}", count.toString()) + if (!abRunning) { + META.appName to "无障碍未授权" + } else if (!store.enableMatch) { + META.appName to "暂停规则匹配" + } else if (store.useCustomNotifText) { + listOf(store.customNotifTitle, store.customNotifText).map { + it.replace("\${i}", ruleSummary.globalGroups.size.toString()) + .replace("\${k}", ruleSummary.appSize.toString()) + .replace("\${u}", ruleSummary.appGroupSize.toString()) + .replace("\${n}", count.toString()) + }.run { + first() to last() + } + } else { + META.appName to getSubsStatus(ruleSummary, count) + } + }.debounce(1000L) + .stateIn(scope, SharingStarted.Eagerly, "" to "") + .collect { (title, text) -> + abNotif.copy( + title = title, + text = text + ).notifyService() } - return@combine getSubsStatus(ruleSummary, count) - }.debounce(500L).stateIn(scope, SharingStarted.Eagerly, "").collect { text -> - abNotif.copy(text = text).notifyService() - } } } } diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index 18b80584c2..074da26461 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -26,6 +26,7 @@ data class SettingsStore( val showSaveSnapshotToast: Boolean = true, val useSystemToast: Boolean = false, val useCustomNotifText: Boolean = false, + val customNotifTitle: String = META.appName, val customNotifText: String = "\${i}全局/\${k}应用/\${u}规则组/\${n}触发", val enableActivityLog: Boolean = false, val updateChannel: Int = if (META.versionName.contains("beta")) UpdateChannelOption.Beta.value else UpdateChannelOption.Stable.value, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/CustomOutlinedTextField.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomOutlinedTextField.kt new file mode 100644 index 0000000000..96e9fb6d23 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomOutlinedTextField.kt @@ -0,0 +1,141 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.sp + +// copy from androidx/compose/material3/OutlinedTextField.kt + +@Composable +fun CustomOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource? = null, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors(), + contentPadding: PaddingValues = OutlinedTextFieldDefaults.contentPadding() +) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } + // If color is not provided via the text style, use content color as a default + val textColor = + textStyle.color.takeOrElse { + val focused = interactionSource.collectIsFocusedAsState().value + colors.run { + when { + !enabled -> disabledTextColor + isError -> errorTextColor + focused -> focusedTextColor + else -> unfocusedTextColor + } + } + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + val density = LocalDensity.current + + CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { + BasicTextField( + value = value, + modifier = + modifier + .then( + if (label != null) { + Modifier + // Merge semantics at the beginning of the modifier chain to ensure + // padding is considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = with(density) { 8.sp.toDp() }) + } else { + Modifier + } + ) +// .defaultErrorSemantics(isError, getString(Strings.DefaultErrorMessage)) + .defaultMinSize( + minWidth = OutlinedTextFieldDefaults.MinWidth, + minHeight = OutlinedTextFieldDefaults.MinHeight + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(colors.run { if (isError) errorCursorColor else cursorColor }), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = + @Composable { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + shape = shape, + ) + }, + contentPadding = contentPadding, + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index bed204f958..543f717aad 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -1,9 +1,11 @@ package li.songe.gkd.ui.home +import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -32,12 +34,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel +import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import kotlinx.coroutines.flow.update import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.component.CustomOutlinedTextField import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.TextSwitch @@ -50,10 +55,12 @@ import li.songe.gkd.ui.theme.supportDynamicColor import li.songe.gkd.util.DarkThemeOption import li.songe.gkd.util.findOption import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast @Composable fun useSettingsPage(): ScaffoldExt { val mainVm = LocalMainViewModel.current + val activity = LocalActivity.current val store by storeFlow.collectAsState() val vm = viewModel() @@ -64,7 +71,6 @@ fun useSettingsPage(): ScaffoldExt { mutableStateOf(false) } - if (showToastInputDlg) { var value by remember { mutableStateOf(store.clickToast) @@ -116,9 +122,8 @@ fun useSettingsPage(): ScaffoldExt { ) } if (showNotifTextInputDlg) { - var value by remember { - mutableStateOf(store.customNotifText) - } + var titleValue by remember { mutableStateOf(store.customNotifTitle) } + var textValue by remember { mutableStateOf(store.customNotifText) } AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), title = { @@ -129,9 +134,17 @@ fun useSettingsPage(): ScaffoldExt { ) { Text(text = "通知文案") IconButton(onClick = throttle { + KeyboardUtils.hideSoftInput(activity) + showNotifTextInputDlg = false + val confirmAction = { + mainVm.dialogFlow.value = null + showNotifTextInputDlg = true + } mainVm.dialogFlow.updateDialogOptions( title = "文案规则", - text = "通知文案支持变量替换,规则如下\n\${i} 全局规则数\n\${k} 应用数\n\${u} 应用规则组数\n\${n} 触发次数\n\n示例模板\n\${i}全局/\${k}应用/\${u}规则组/\${n}触发\n\n替换结果\n0全局/1应用/2规则组/3触发", + text = "通知文案支持变量替换,规则如下\n\${i} 全局规则数\n\${k} 应用数\n\${u} 应用规则组数\n\${n} 触发次数\n\n示例模板\n\${i}全局/\${k}应用/\${u}规则组/\${n}触发\n\n替换结果\n0全局/1应用/2规则组/3触发", + confirmAction = confirmAction, + onDismissRequest = confirmAction, ) }) { Icon( @@ -142,35 +155,67 @@ fun useSettingsPage(): ScaffoldExt { } }, text = { - val maxCharLen = 64 - OutlinedTextField( - value = value, - placeholder = { - Text(text = "请输入文案内容,支持变量替换") - }, - onValueChange = { - value = if (it.length > maxCharLen) it.take(maxCharLen) else it - }, - maxLines = 4, - supportingText = { - Text( - text = "${value.length} / $maxCharLen", - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, - ) - }, - modifier = Modifier - .fillMaxWidth() - .autoFocus() - ) + val titleMaxLen = 32 + val textMaxLen = 64 + Column( + modifier = Modifier.fillMaxWidth(), + ) { + CustomOutlinedTextField( + label = { Text("主标题") }, + value = titleValue, + placeholder = { Text(text = "请输入内容,支持变量替换") }, + onValueChange = { + titleValue = (if (it.length > titleMaxLen) it.take(titleMaxLen) else it) + .filter { c -> c !in "\n\r" } + }, + supportingText = { + Text( + text = "${titleValue.length} / $titleMaxLen", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + ) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(12.dp), + ) + Spacer(modifier = Modifier.height(4.dp)) + CustomOutlinedTextField( + label = { Text("副标题") }, + value = textValue, + placeholder = { Text(text = "请输入内容,支持变量替换") }, + onValueChange = { + textValue = if (it.length > textMaxLen) it.take(textMaxLen) else it + }, + supportingText = { + Text( + text = "${textValue.length} / $textMaxLen", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + ) + }, + maxLines = 4, + modifier = Modifier + .fillMaxWidth() + .autoFocus(), + contentPadding = PaddingValues(12.dp), + ) + } }, onDismissRequest = { showNotifTextInputDlg = false }, confirmButton = { - TextButton(enabled = value.isNotEmpty(), onClick = { - storeFlow.update { it.copy(customNotifText = value) } + TextButton(onClick = { + KeyboardUtils.hideSoftInput(activity) + storeFlow.update { + it.copy( + customNotifTitle = titleValue, + customNotifText = textValue + ) + } showNotifTextInputDlg = false + toast("更新成功") }) { Text( text = "确认", @@ -247,7 +292,11 @@ fun useSettingsPage(): ScaffoldExt { val subsStatus by vm.subsStatusFlow.collectAsState() TextSwitch( title = "通知文案", - subtitle = if (store.useCustomNotifText) store.customNotifText else subsStatus, + subtitle = if (store.useCustomNotifText) { + store.customNotifTitle + " / " + store.customNotifText + } else { + subsStatus + }, checked = store.useCustomNotifText, modifier = Modifier.clickable { showNotifTextInputDlg = true From 4c36cf2445ef5fda812516cb07a503b2268fb44b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 31 Aug 2025 19:59:40 +0800 Subject: [PATCH 029/245] perf: update libs --- .../main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt | 2 +- gradle/libs.versions.toml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 776a6963d7..2f5181356c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -438,7 +438,7 @@ fun useSubsManagePage(): ScaffoldExt { itemsIndexed(orderSubItems, { _, subItem -> subItem.id }) { index, subItem -> val canDrag = !refreshing && orderSubItems.size > 1 ReorderableItem( - reorderableLazyColumnState, + state = reorderableLazyColumnState, key = subItem.id, enabled = canDrag, ) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6213461b89..e01a67e868 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.2.10" ksp = "2.2.10-2.0.2" -agp = "8.12.0" +agp = "8.12.2" compose = "1.9.0" rikka = "4.4.0" room = "2.7.2" @@ -37,8 +37,8 @@ compose_activity = "androidx.activity:activity-compose:1.10.1" compose_navigation = "androidx.navigation:navigation-compose:2.9.3" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.17.0" -androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.2" -androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.2" +androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.3" +androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.3" androidx_junit = "androidx.test.ext:junit:1.3.0" androidx_annotation = "androidx.annotation:annotation:1.9.1" androidx_espresso = "androidx.test.espresso:espresso-core:3.7.0" @@ -61,10 +61,10 @@ destinations_ksp = { module = "io.github.raamcosta.compose-destinations:ksp", ve coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil_network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } -reorderable = "sh.calvin.reorderable:reorderable:2.5.1" +reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" toaster = "com.github.getActivity:Toaster:13.2" -permissions = "com.github.getActivity:XXPermissions:26.0" +permissions = "com.github.getActivity:XXPermissions:26.5" json5 = "li.songe:json5:0.3.5" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" From 507b7b51e582f8957113a96bfad1ff2c981cd6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 31 Aug 2025 20:39:13 +0800 Subject: [PATCH 030/245] perf: toast delayMillis --- app/src/main/kotlin/li/songe/gkd/App.kt | 7 +++++++ .../main/kotlin/li/songe/gkd/service/GkdTileService.kt | 2 +- .../main/kotlin/li/songe/gkd/service/StatusService.kt | 7 ++++++- app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 8 +++++--- .../main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/util/Toast.kt | 9 ++++++--- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index f5a9e4d476..42a92d25f8 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -98,6 +98,13 @@ class App : Application() { } val startTime = System.currentTimeMillis() + var justStarted: Boolean = true + get() { + if (field) { + field = System.currentTimeMillis() - startTime < 3_000 + } + return field + } val activityManager by lazy { app.getSystemService(ACTIVITY_SERVICE) as ActivityManager } val appOpsManager by lazy { app.getSystemService(APP_OPS_SERVICE) as AppOpsManager } diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index c72a01d51e..a72ed147cb 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -24,7 +24,7 @@ class GkdTileService : BaseTileService() { } private val modifyA11yMutex by lazy { Mutex() } -private const val A11Y_AWAIT_START_TIME = 1000L +private const val A11Y_AWAIT_START_TIME = 2000L private const val A11Y_AWAIT_FIX_TIME = 500L fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index e040cbf375..b7ccdefd37 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import li.songe.gkd.META +import li.songe.gkd.app import li.songe.gkd.notif.abNotif import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState @@ -29,7 +30,11 @@ class StatusService : Service(), OnSimpleLife { init { useAliveFlow(isRunning) - useAliveToast("常驻通知", onlyWhenVisible = true) + useAliveToast( + name = "常驻通知", + onlyWhenVisible = true, + delayMillis = if (app.justStarted) 1000 else 0 + ) onCreated { abNotif.notifyService() scope.launch { diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 038963ecbf..b7a0d5cf34 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import li.songe.gkd.META +import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow @@ -119,7 +120,7 @@ val updateBinderMutex = MutexState() private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { updateBinderMutex.withStateLock { if (shizukuUsedFlow.value) { - if (isActivityVisible()) { + if (!app.justStarted && isActivityVisible()) { toast("正在连接 Shizuku 服务...") } shizukuContextFlow.value = ShizukuContext( @@ -132,10 +133,11 @@ private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { }, ) if (isActivityVisible()) { + val delayMillis = if (app.justStarted) 1200L else 0L if (shizukuContextFlow.value.serviceWrapper == null) { - toast("Shizuku 服务连接失败") + toast("Shizuku 服务连接失败", delayMillis) } else { - toast("Shizuku 服务连接成功") + toast("Shizuku 服务连接成功", delayMillis) } } } else if (shizukuContextFlow.value != defaultShizukuContext) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt index b97aa2d93a..f502e37663 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -46,10 +46,10 @@ interface OnSimpleLife { onDestroyed { stateFlow.value = false } } - fun useAliveToast(name: String, onlyWhenVisible: Boolean = false) { + fun useAliveToast(name: String, onlyWhenVisible: Boolean = false, delayMillis: Long = 0L) { onCreated { if (isActivityVisible() || !onlyWhenVisible) { - toast("${name}已启动") + toast("${name}已启动", delayMillis) } } onDestroyed { diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 2d8d2098d7..356bfff73f 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -29,9 +29,12 @@ import li.songe.gkd.appScope import li.songe.gkd.service.A11yService import li.songe.gkd.store.storeFlow - -fun toast(text: CharSequence) { - Toaster.show(text) +fun toast(text: CharSequence, delayMillis: Long = 0L) { + if (delayMillis > 0) { + Toaster.delayedShow(text, delayMillis) + } else { + Toaster.show(text) + } } private val darkTheme: Boolean From 0d36c19ac8412c68c7b209f34006ba1936919078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 1 Sep 2025 00:38:05 +0800 Subject: [PATCH 031/245] perf: fixRestartService --- .../main/kotlin/li/songe/gkd/service/BaseTileService.kt | 9 +++++++++ .../main/kotlin/li/songe/gkd/service/GkdTileService.kt | 1 - 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt index 9d0c2e8a96..2a624f896b 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt @@ -24,6 +24,13 @@ abstract class BaseTileService : TileService(), OnTileLife { } init { + onStartListened { + val t = System.currentTimeMillis() + if (t - lastA11yFixTime > 3_000L) { + lastA11yFixTime = t + fixRestartService() + } + } onTileClicked { StatusService.autoStart() } scope.launch { combine( @@ -38,3 +45,5 @@ abstract class BaseTileService : TileService(), OnTileLife { } } } + +private var lastA11yFixTime = 0L diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index a72ed147cb..37e0334d57 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -18,7 +18,6 @@ class GkdTileService : BaseTileService() { override val activeFlow = A11yService.isRunning init { - onStartListened { fixRestartService() } onTileClicked { switchA11yService() } } } From bc9be7dadb4173558d472c44b58a1e2c7e6189eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 1 Sep 2025 00:52:32 +0800 Subject: [PATCH 032/245] perf: activityOkFlow --- .../kotlin/li/songe/gkd/service/A11yService.kt | 9 ++++----- .../kotlin/li/songe/gkd/service/RecordService.kt | 14 +++++++++++--- .../main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 7 ++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 99c5432ae2..9e6f56cf34 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -20,7 +20,6 @@ import li.songe.gkd.util.OnA11yLife import li.songe.gkd.util.componentName import li.songe.selector.MatchOption import li.songe.selector.Selector -import java.lang.ref.WeakReference abstract class A11yService : AccessibilityService(), OnA11yLife { override fun onCreate() = onCreated() @@ -52,8 +51,8 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { init { useLogLifecycle() useAliveFlow(isRunning) - onCreated { a11yWeakRef = WeakReference(this) } - onDestroyed { a11yWeakRef = null } + onCreated { a11yRef = this } + onDestroyed { a11yRef = null } A11yRuleEngine(this) onA11yFeatInit() } @@ -63,9 +62,9 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { val a11yClsName by lazy { a11yComponentName.flattenToShortString() } val isRunning = MutableStateFlow(false) - private var a11yWeakRef: WeakReference? = null + private var a11yRef: A11yService? = null val instance: A11yService? - get() = a11yWeakRef?.get() + get() = a11yRef fun execAction(gkdAction: GkdAction): ActionResult { val service = instance ?: throw RpcError("无障碍没有运行") diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt index 568e1fdd24..7a2f1a6529 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -26,6 +26,8 @@ import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.recordNotif import li.songe.gkd.permission.canDrawOverlaysState +import li.songe.gkd.shizuku.SafeTaskListener +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.theme.AppTheme import li.songe.gkd.util.appInfoCacheFlow @@ -34,13 +36,19 @@ import li.songe.gkd.util.stopServiceByClass class RecordService : OverlayWindowService() { + override val positionStoreKey = "overlay_xy_record" + val topAppInfoFlow by lazy { appInfoCacheFlow.combine(topActivityFlow) { map, topActivity -> map[topActivity.appId] }.stateIn(lifecycleScope, SharingStarted.Eagerly, null) } - override val positionStoreKey = "overlay_xy_record" + val activityOkFlow by lazy { + combine(A11yService.isRunning, shizukuContextFlow) { a, b -> + a || (b.activityTaskManager != null && SafeTaskListener.isAvailable) + }.stateIn(scope = lifecycleScope, started = SharingStarted.Eagerly, initialValue = false) + } @Composable override fun ComposeContent() = AppTheme(invertedTheme = true) { @@ -52,7 +60,7 @@ class RecordService : OverlayWindowService() { .padding(horizontal = 4.dp, vertical = 2.dp) ) { CompositionLocalProvider(LocalContentColor provides contentColorFor(bgColor)) { - if (A11yService.isRunning.collectAsState().value) { + if (activityOkFlow.collectAsState().value) { val topActivity = topActivityFlow.collectAsState().value Text( text = topActivity.number.toString(), @@ -73,7 +81,7 @@ class RecordService : OverlayWindowService() { } else { Column { Text(text = "记录服务") - Text(text = "无障碍服务未运行") + Text(text = "无权限检测界面切换") } } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index b7a0d5cf34..4b40a5d7fe 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo @@ -145,8 +144,10 @@ private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { serviceWrapper?.destroy() activityTaskManager?.unregisterDefault() } - val prefix = if (isActivityVisible()) "" else "${META.appName}: " - toast("${prefix}Shizuku 服务已断开") + shizukuContextFlow.value = defaultShizukuContext + if (isActivityVisible()) { + toast("Shizuku 服务已断开") + } } } } From ebc82ec489784021b1c37e8e9fdcee2bae5f6d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 1 Sep 2025 12:19:01 +0800 Subject: [PATCH 033/245] perf: a11y onDestroyed updateTopActivity --- app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt index a40a414871..99fe1898c4 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -20,6 +20,7 @@ import li.songe.gkd.appScope import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.service.A11yService import li.songe.gkd.service.StatusService +import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.store.storeFlow import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.UpdateTimeOption @@ -36,6 +37,14 @@ fun onA11yFeatInit() = service.run { useRuleChangedLog() onA11yEvent { onA11yFeatEvent(it) } onCreated { StatusService.autoStart() } + onDestroyed { + safeGetTopCpn()?.let { + // com.android.systemui + if (!topActivityFlow.value.sameAs(it.packageName, it.className)) { + updateTopActivity(it.packageName, it.className) + } + } + } } private fun A11yService.useAttachState() { From 145474058a33f09c54d18124e989a6ffc1aeef13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 1 Sep 2025 13:18:09 +0800 Subject: [PATCH 034/245] fix: api getSnapshot respondFile --- .../li/songe/gkd/service/HttpService.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index 9ef3b9ffe7..e659069f15 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -184,7 +184,7 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { if (!fp.exists()) { throw RpcError("对应快照不存在") } - call.respond(fp) + call.respondFile(fp) } post("/getScreenshot") { val data = call.receive() @@ -226,14 +226,18 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { } private fun getKtorCorsPlugin() = createApplicationPlugin(name = "KtorCorsPlugin") { - onCallRespond { call, _ -> - call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") - call.response.header(HttpHeaders.AccessControlAllowMethods, "*") - call.response.header(HttpHeaders.AccessControlAllowHeaders, "*") - call.response.header(HttpHeaders.AccessControlExposeHeaders, "*") - call.response.header("Access-Control-Allow-Private-Network", "true") - } onCall { call -> + mapOf( + HttpHeaders.AccessControlAllowOrigin to "*", + HttpHeaders.AccessControlAllowMethods to "*", + HttpHeaders.AccessControlAllowHeaders to "*", + HttpHeaders.AccessControlExposeHeaders to "*", + "Access-Control-Allow-Private-Network" to "true", + ).forEach { (k, v) -> + if (!call.response.headers.contains(k)) { + call.response.header(k, v) + } + } if (call.request.httpMethod == HttpMethod.Options) { call.respond("all-cors-ok") } From 013f8797d5b8a5f5757a5151d4ef6ef85e39160c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 1 Sep 2025 14:11:36 +0800 Subject: [PATCH 035/245] chore: add android version comment --- hidden_api/src/main/java/android/app/IActivityTaskManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hidden_api/src/main/java/android/app/IActivityTaskManager.java b/hidden_api/src/main/java/android/app/IActivityTaskManager.java index c9a02f7156..a174279d9e 100644 --- a/hidden_api/src/main/java/android/app/IActivityTaskManager.java +++ b/hidden_api/src/main/java/android/app/IActivityTaskManager.java @@ -21,7 +21,7 @@ public static IActivityTaskManager asInterface(IBinder obj) { // android12 List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra); - // android13 + // android13+ List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); void registerTaskStackListener(ITaskStackListener listener); From 47ccd5f1c5394c25ea22b58c5d0c44bb56befd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 2 Sep 2025 22:33:07 +0800 Subject: [PATCH 036/245] feat: show app disabled --- app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt | 2 ++ app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index de37e0f81a..1d3cee1390 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -17,6 +17,7 @@ data class AppInfo( val isSystem: Boolean, val mtime: Long, val hidden: Boolean, + val enabled: Boolean, val userId: Int? = null, ) { override fun equals(other: Any?): Boolean { @@ -60,5 +61,6 @@ fun PackageInfo.toAppInfo( .addCategory(Intent.CATEGORY_LAUNCHER), PackageManager.MATCH_DISABLED_COMPONENTS ).isEmpty(), + enabled = applicationInfo?.enabled ?: true, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt index bc38abae26..39a49af4a2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import li.songe.gkd.data.AppInfo @@ -43,12 +44,14 @@ fun AppNameText( val userInfo = otherUserMapFlow.collectAsState().value[info.userId] "「${userInfo?.name ?: info.userId}」" } + val textDecoration = if (info?.enabled == false) TextDecoration.LineThrough else null if (!showSystemIcon && userName == null) { Text( text = appName, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, + textDecoration = textDecoration, ) } else { val userNameColor = MaterialTheme.colorScheme.tertiary @@ -104,6 +107,7 @@ fun AppNameText( maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, + textDecoration = textDecoration, ) } } \ No newline at end of file From ffa832eb56cfec83d947d3466ee9fa7de637d90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 2 Sep 2025 23:04:51 +0800 Subject: [PATCH 037/245] perf: a11y isInteractive --- .../li/songe/gkd/a11y/A11yRuleEngine.kt | 1 + .../li/songe/gkd/service/A11yService.kt | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index 73b2596667..5138ecba6b 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -38,6 +38,7 @@ class A11yRuleEngine(val service: A11yService) { val eventDeque = ArrayDeque() fun onNewA11yEvent(event: AccessibilityEvent) { if (event.eventType == CONTENT_CHANGED && event.packageName == "com.android.systemui") { + if (!service.isInteractive) return // 屏幕关闭后仍然有无障碍事件 if (event.packageName != topActivityFlow.value.appId) return } // 过滤部分输入法事件 diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 9e6f56cf34..1609b22b07 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -1,8 +1,15 @@ package li.songe.gkd.service import android.accessibilityservice.AccessibilityService +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.content.ContextCompat +import com.blankj.utilcode.util.LogUtils import com.google.android.accessibility.selecttospeak.SelectToSpeakService import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.a11y.A11yContext @@ -47,12 +54,41 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { get() = safeActiveWindow?.packageName?.toString() val scope = useScope() + val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } + var isInteractive = true + private val screenStateReceiver = object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent? + ) { + val action = intent?.action ?: return + LogUtils.d("screenStateReceiver->${action}") + isInteractive = when (action) { + Intent.ACTION_SCREEN_ON -> true + Intent.ACTION_SCREEN_OFF -> false + else -> isInteractive + } + } + } init { useLogLifecycle() useAliveFlow(isRunning) onCreated { a11yRef = this } onDestroyed { a11yRef = null } + onCreated { + isInteractive = powerManager.isInteractive + ContextCompat.registerReceiver( + this, + screenStateReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }, + ContextCompat.RECEIVER_EXPORTED + ) + } + onDestroyed { unregisterReceiver(screenStateReceiver) } A11yRuleEngine(this) onA11yFeatInit() } From c1b908b7fb704b06888b801b74e76e3fba320b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 2 Sep 2025 23:36:06 +0800 Subject: [PATCH 038/245] perf: sync status --- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 7 +- .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 6 +- .../li/songe/gkd/service/StatusService.kt | 95 ++++++++++++++----- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 5 + .../li/songe/gkd/ui/home/ControlPage.kt | 16 ++-- 5 files changed, 96 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index e707bf6756..0493c8b04c 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -40,6 +40,7 @@ import li.songe.gkd.ui.component.RuleGroupState import li.songe.gkd.ui.component.UploadOptions import li.songe.gkd.ui.home.BottomNavItem import li.songe.gkd.util.LOCAL_SUBS_ID +import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.UpdateStatus import li.songe.gkd.util.appIconMapFlow import li.songe.gkd.util.clearCache @@ -58,7 +59,7 @@ import kotlin.reflect.jvm.jvmName private var tempTermsAccepted = false -class MainViewModel : ViewModel() { +class MainViewModel : ViewModel(), OnSimpleLife { private lateinit var navController: NavHostController fun updateNavController(navController: NavHostController) { @@ -328,5 +329,9 @@ class MainViewModel : ViewModel() { // preload githubCookieFlow.value } + + // for OnSimpleLife + onCreated() + addCloseable { onDestroyed() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index 16a02ad481..3ecca01b07 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -11,16 +11,16 @@ import android.provider.Settings import android.view.Display import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo -import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.app import li.songe.gkd.service.A11yService +import li.songe.gkd.util.OnSimpleLife import li.songe.selector.initDefaultTypeInfo import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -context(vm: ViewModel) +context(context: OnSimpleLife) fun useA11yServiceEnabledFlow(): StateFlow { val stateFlow = MutableStateFlow(getA11yServiceEnabled()) val contextObserver = object : ContentObserver(null) { @@ -33,7 +33,7 @@ fun useA11yServiceEnabledFlow(): StateFlow { false, contextObserver ) - vm.addCloseable { + context.onDestroyed { app.contentResolver.unregisterContentObserver(contextObserver) } return stateFlow diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index b7ccdefd37..622d17d95c 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -6,16 +6,21 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import li.songe.gkd.META +import li.songe.gkd.a11y.useA11yServiceEnabledFlow import li.songe.gkd.app import li.songe.gkd.notif.abNotif import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState +import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.OnSimpleLife +import li.songe.gkd.util.RuleSummary import li.songe.gkd.util.getSubsStatus import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.startForegroundServiceByClass @@ -28,6 +33,50 @@ class StatusService : Service(), OnSimpleLife { val scope = useScope() + val shizukuWarnFlow = combine( + shizukuOkState.stateFlow, + storeFlow.map { it.enableShizuku }, + ) { a, b -> + !a && b + }.stateIn(scope, SharingStarted.Eagerly, false) + + val a11yServiceEnabledFlow = useA11yServiceEnabledFlow() + + fun statusTriple(): Triple { + val abRunning = A11yService.isRunning.value + val store = storeFlow.value + val ruleSummary = ruleSummaryFlow.value + val count = actionCountFlow.value + val shizukuWarn = shizukuWarnFlow.value + val title = if (store.useCustomNotifText) { + store.customNotifTitle.replaceTemplate(ruleSummary, count) + } else { + META.appName + } + return if (!abRunning) { + val text = if (a11yServiceEnabledFlow.value) { + "无障碍服务发生故障" + } else if (writeSecureSettingsState.updateAndGet()) { + "无障碍服务已关闭" + } else { + "无障碍服务未授权" + } + Triple(title, text, abNotif.uri) + } else if (!store.enableMatch) { + Triple(title, "暂停规则匹配", "gkd://page?tab=1") + } else if (shizukuWarn) { + Triple(title, "Shizuku 未连接,请授权或关闭优化", "gkd://page/1") + } else if (store.useCustomNotifText) { + Triple( + title, + store.customNotifText.replaceTemplate(ruleSummary, count), + abNotif.uri + ) + } else { + Triple(title, getSubsStatus(ruleSummary, count), abNotif.uri) + } + } + init { useAliveFlow(isRunning) useAliveToast( @@ -42,30 +91,23 @@ class StatusService : Service(), OnSimpleLife { A11yService.isRunning, storeFlow, ruleSummaryFlow, - actionCountFlow, - ) { abRunning, store, ruleSummary, count -> - if (!abRunning) { - META.appName to "无障碍未授权" - } else if (!store.enableMatch) { - META.appName to "暂停规则匹配" - } else if (store.useCustomNotifText) { - listOf(store.customNotifTitle, store.customNotifText).map { - it.replace("\${i}", ruleSummary.globalGroups.size.toString()) - .replace("\${k}", ruleSummary.appSize.toString()) - .replace("\${u}", ruleSummary.appGroupSize.toString()) - .replace("\${n}", count.toString()) - }.run { - first() to last() - } - } else { - META.appName to getSubsStatus(ruleSummary, count) - } - }.debounce(1000L) - .stateIn(scope, SharingStarted.Eagerly, "" to "") - .collect { (title, text) -> + shizukuWarnFlow, + a11yServiceEnabledFlow, + writeSecureSettingsState.stateFlow, + actionCountFlow.debounce(1000L), + ) { + statusTriple() + } + .stateIn( + scope, + SharingStarted.Eagerly, + Triple(abNotif.title, abNotif.text, abNotif.uri) + ) + .collect { abNotif.copy( - title = title, - text = text + title = it.first, + text = it.second, + uri = it.third, ).notifyService() } } @@ -93,3 +135,10 @@ class StatusService : Service(), OnSimpleLife { } } } + +private fun String.replaceTemplate(ruleSummary: RuleSummary, count: Long): String { + return replace("\${i}", ruleSummary.globalGroups.size.toString()) + .replace("\${k}", ruleSummary.appSize.toString()) + .replace("\${u}", ruleSummary.appGroupSize.toString()) + .replace("\${n}", count.toString()) +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 48843eef89..c4f2715acd 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -71,6 +71,7 @@ import li.songe.gkd.service.HttpService import li.songe.gkd.service.RecordService import li.songe.gkd.service.ScreenshotService import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthCard import li.songe.gkd.ui.component.SettingItem @@ -247,6 +248,10 @@ fun AdvancedPage() { onSuffixClick = { mainVm.navigateWebPage(ShortUrlSet.URL14) }, checked = store.enableShizuku, ) { + if (updateBinderMutex.mutex.isLocked) { + toast("正在连接中,请稍后") + return@TextSwitch + } if (it && !shizukuOk) { toast("未授权") } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index b22f35083e..77c3978f07 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -78,7 +78,6 @@ fun useControlPage(): ScaffoldExt { val vm = viewModel() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollState = rememberScrollState() - val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() return ScaffoldExt( navItem = BottomNavItem.Control, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -103,10 +102,7 @@ fun useControlPage(): ScaffoldExt { val a11yRunning by A11yService.isRunning.collectAsState() val manageRunning by StatusService.isRunning.collectAsState() - val a11yServiceEnabled by mainVm.a11yServiceEnabledFlow.collectAsState() - - // 无障碍故障: 设置中无障碍开启, 但是实际 service 没有运行 - val a11yBroken = !a11yRunning && a11yServiceEnabled + val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() Column( modifier = Modifier @@ -116,7 +112,15 @@ fun useControlPage(): ScaffoldExt { PageItemCard( imageVector = Icons.Default.Memory, title = "服务状态", - subtitle = if (a11yRunning) "无障碍服务正在运行" else if (a11yBroken) "无障碍服务发生故障" else if (writeSecureSettings) "无障碍服务已关闭" else "无障碍服务未授权", + subtitle = if (a11yRunning) { + "无障碍服务正在运行" + } else if (mainVm.a11yServiceEnabledFlow.collectAsState().value) { + "无障碍服务发生故障" + } else if (writeSecureSettings) { + "无障碍服务已关闭" + } else { + "无障碍服务未授权" + }, rightContent = { Switch( checked = a11yRunning, From e81f24afd34d192317435d55bde492c2c455d190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 3 Sep 2025 14:06:02 +0800 Subject: [PATCH 039/245] perf: update text --- .../li/songe/gkd/service/HttpService.kt | 21 ++++++++++------- .../li/songe/gkd/store/SettingsStore.kt | 2 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 20 ++++++++-------- .../li/songe/gkd/ui/home/SettingsPage.kt | 23 +++++++++++-------- .../kotlin/li/songe/gkd/util/NetworkExt.kt | 5 ++-- .../main/kotlin/li/songe/gkd/util/Toast.kt | 4 ++-- 6 files changed, 42 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index e659069f15..1d36188a5a 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -79,7 +79,7 @@ class HttpService : Service(), OnSimpleLife { val scope = useScope() - private val httpServerPortFlow = storeFlow.mapState(scope) { s -> s.httpServerPort } + val httpServerPortFlow = storeFlow.mapState(scope) { s -> s.httpServerPort } init { useLogLifecycle() @@ -88,7 +88,9 @@ class HttpService : Service(), OnSimpleLife { StopServiceReceiver.autoRegister() onCreated { scope.launchTry(Dispatchers.IO) { - localNetworkIpsFlow.value = getIpAddressInLocalNetwork() + httpServerPortFlow.collect { + localNetworkIpsFlow.value = getIpAddressInLocalNetwork() + } } } onDestroyed { @@ -98,13 +100,16 @@ class HttpService : Service(), OnSimpleLife { httpServerFlow.value = null } onCreated { - httpNotif.copy(text = "端口-${storeFlow.value.httpServerPort}").notifyService() + httpNotif.notifyService() scope.launchTry(Dispatchers.IO) { httpServerPortFlow.collect { port -> - httpServerFlow.value?.stop() - httpServerFlow.value = null + val isReboot = httpServerFlow.value != null + httpServerFlow.apply { + value?.stop() + value = null + } if (!isPortAvailable(port)) { - toast("端口 $port 被占用, 请更换后重试") + toast("端口 $port 被占用,请更换后重试") stopSelf() return@collect } @@ -117,8 +122,8 @@ class HttpService : Service(), OnSimpleLife { } if (httpServerFlow.value == null) { stopSelf() - } else { - httpNotif.copy(text = "端口-$port").notifyService() + } else if (isReboot) { + toast("HTTP服务重启成功") } } } diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index 074da26461..8c48244e1e 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -18,7 +18,7 @@ data class SettingsStore( val updateSubsInterval: Long = UpdateTimeOption.Everyday.value, val captureVolumeChange: Boolean = false, val toastWhenClick: Boolean = true, - val clickToast: String = META.appName, + val actionToast: String = META.appName, val autoClearMemorySubs: Boolean = true, val hideSnapshotStatusBar: Boolean = false, val enableDarkTheme: Boolean? = null, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index c4f2715acd..20539c50a0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -102,6 +102,8 @@ fun AdvancedPage() { mutableStateOf(false) } if (showEditPortDlg) { + val portRange = remember { 1000 to 65535 } + val placeholderText = remember { "请输入 ${portRange.first}-${portRange.second} 的整数" } var value by remember { mutableStateOf(store.httpServerPort.toString()) } @@ -112,7 +114,7 @@ fun AdvancedPage() { OutlinedTextField( value = value, placeholder = { - Text(text = "请输入 5000-65535 的整数") + Text(text = placeholderText) }, onValueChange = { value = it.filter { c -> c.isDigit() }.take(5) @@ -139,18 +141,16 @@ fun AdvancedPage() { enabled = value.isNotEmpty(), onClick = { val newPort = value.toIntOrNull() - if (newPort == null || !(5000 <= newPort && newPort <= 65535)) { - toast("请输入 5000-65535 的整数") + if (newPort == null || !(portRange.first <= newPort && newPort <= portRange.second)) { + toast(placeholderText) return@TextButton } - storeFlow.value = store.copy( - httpServerPort = newPort - ) showEditPortDlg = false - if (HttpService.httpServerFlow.value != null) { - toast("已更新,重启服务") - } else { - toast("已更新") + if (newPort != store.httpServerPort) { + storeFlow.value = store.copy( + httpServerPort = newPort + ) + toast("更新成功") } } ) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 543f717aad..8cf9f6701f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -73,7 +73,7 @@ fun useSettingsPage(): ScaffoldExt { if (showToastInputDlg) { var value by remember { - mutableStateOf(store.clickToast) + mutableStateOf(store.actionToast) } val maxCharLen = 32 AlertDialog( @@ -104,7 +104,10 @@ fun useSettingsPage(): ScaffoldExt { onDismissRequest = { showToastInputDlg = false }, confirmButton = { TextButton(enabled = value.isNotEmpty(), onClick = { - storeFlow.update { it.copy(clickToast = value) } + if (value != storeFlow.value.actionToast) { + storeFlow.update { it.copy(actionToast = value) } + toast("更新成功") + } showToastInputDlg = false }) { Text( @@ -208,14 +211,16 @@ fun useSettingsPage(): ScaffoldExt { confirmButton = { TextButton(onClick = { KeyboardUtils.hideSoftInput(activity) - storeFlow.update { - it.copy( - customNotifTitle = titleValue, - customNotifText = textValue - ) + if (store.customNotifTitle != textValue || store.customNotifText != textValue) { + storeFlow.update { + it.copy( + customNotifTitle = titleValue, + customNotifText = textValue + ) + } + toast("更新成功") } showNotifTextInputDlg = false - toast("更新成功") }) { Text( text = "确认", @@ -259,7 +264,7 @@ fun useSettingsPage(): ScaffoldExt { TextSwitch( title = "触发提示", - subtitle = store.clickToast, + subtitle = store.actionToast, checked = store.toastWhenClick, modifier = Modifier.clickable { showToastInputDlg = true diff --git a/app/src/main/kotlin/li/songe/gkd/util/NetworkExt.kt b/app/src/main/kotlin/li/songe/gkd/util/NetworkExt.kt index 44ce3dfb0d..b44fe1e5a7 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/NetworkExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/NetworkExt.kt @@ -8,7 +8,7 @@ fun getIpAddressInLocalNetwork(): List { NetworkInterface.getNetworkInterfaces().asSequence() } catch (e: Exception) { // android.system.ErrnoException: getifaddrs failed: EACCES (Permission denied) - toast("获取host失败:" + e.message) + toast("获取HOST失败:" + e.message) return emptyList() } val localAddresses = networkInterfaces.flatMap { @@ -27,8 +27,7 @@ fun isPortAvailable(port: Int): Boolean { serverSocket = ServerSocket(port) serverSocket.reuseAddress = true true - } catch (e: Exception) { - e.printStackTrace() + } catch (_: Exception) { false } finally { serverSocket?.close() diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 356bfff73f..9dca6e74f3 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -109,10 +109,10 @@ fun showActionToast() { if (t - triggerTime > triggerInterval + 100) { // 100ms 保证二次显示的时候上一次已经完全消失 triggerTime = t if (storeFlow.value.useSystemToast) { - showSystemToast(storeFlow.value.clickToast) + showSystemToast(storeFlow.value.actionToast) } else { showA11yToast( - storeFlow.value.clickToast + storeFlow.value.actionToast ) } } From e2527e474a36607d5c677f6ac5aab704e7154e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 5 Sep 2025 16:24:27 +0800 Subject: [PATCH 040/245] perf: popBackStack --- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 54 ++++++++++--------- .../main/kotlin/li/songe/gkd/ui/AboutPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/ActionLogPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/ActivityLogPage.kt | 6 +-- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/AppConfigPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 4 +- .../li/songe/gkd/ui/ImagePreviewPage.kt | 4 +- .../kotlin/li/songe/gkd/ui/SlowGroupPage.kt | 22 ++++---- .../kotlin/li/songe/gkd/ui/SnapshotPage.kt | 4 +- .../li/songe/gkd/ui/SubsAppGroupListPage.kt | 10 ++-- .../kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 4 +- .../li/songe/gkd/ui/SubsCategoryPage.kt | 4 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 6 +-- .../songe/gkd/ui/SubsGlobalGroupListPage.kt | 15 +++--- .../kotlin/li/songe/gkd/ui/WebViewPage.kt | 4 +- .../songe/gkd/ui/component/RuleGroupDialog.kt | 2 +- .../li/songe/gkd/ui/home/AppListPage.kt | 10 ++++ .../main/kotlin/li/songe/gkd/util/TimeExt.kt | 3 +- 20 files changed, 77 insertions(+), 95 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 0493c8b04c..136b24a413 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -1,5 +1,6 @@ package li.songe.gkd +import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.net.Uri @@ -41,6 +42,7 @@ import li.songe.gkd.ui.component.UploadOptions import li.songe.gkd.ui.home.BottomNavItem import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.OnSimpleLife +import li.songe.gkd.util.ThrottleTimer import li.songe.gkd.util.UpdateStatus import li.songe.gkd.util.appIconMapFlow import li.songe.gkd.util.clearCache @@ -66,18 +68,41 @@ class MainViewModel : ViewModel(), OnSimpleLife { this.navController = navController } + private val backThrottleTimer = ThrottleTimer() fun popBackStack() { + if (!backThrottleTimer.expired()) return + @SuppressLint("RestrictedApi") + if (navController.currentBackStack.value.size == 1) return if (Looper.getMainLooper() == Looper.myLooper()) { navController.popBackStack() } else { - viewModelScope.launch { - withContext(Dispatchers.Main) { - navController.popBackStack() - } + Handler(Looper.getMainLooper()).post { + navController.popBackStack() + } + } + } + + fun navigatePage(direction: Direction, builder: (NavOptionsBuilder.() -> Unit)? = null) { + if (direction.route == navController.currentDestination?.route) { + return + } + if (Looper.getMainLooper() != Looper.myLooper()) { + Handler(Looper.getMainLooper()).post { + navigatePage(direction, builder) } + return + } + if (builder != null) { + navController.navigate(direction.route, builder) + } else { + navController.navigate(direction.route) } } + fun navigateWebPage(url: String) { + navigatePage(WebViewPageDestination(url)) + } + val dialogFlow = MutableStateFlow(null) val authReasonFlow = MutableStateFlow(null) @@ -174,27 +199,6 @@ class MainViewModel : ViewModel(), OnSimpleLife { lastClickTabTime = System.currentTimeMillis() } - fun navigatePage(direction: Direction, builder: (NavOptionsBuilder.() -> Unit)? = null) { - if (direction.route == navController.currentDestination?.route) { - return - } - if (Looper.getMainLooper() != Looper.myLooper()) { - Handler(Looper.getMainLooper()).postDelayed({ - navigatePage(direction, builder) - }, 0) - return - } - if (builder != null) { - navController.navigate(direction.route, builder) - } else { - navController.navigate(direction.route) - } - } - - fun navigateWebPage(url: String) { - navigatePage(WebViewPageDestination(url)) - } - fun handleGkdUri(uri: Uri) { val notFoundToast = { toast("未知URI\n${uri}") } when (uri.host) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 19c3a952a4..9c6fda31d3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -71,7 +71,6 @@ import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalDarkTheme import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding @@ -98,7 +97,6 @@ import java.io.File @Destination(style = ProfileTransitions::class) @Composable fun AboutPage() { - val navController = LocalNavController.current val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current val store by storeFlow.collectAsState() @@ -158,7 +156,7 @@ fun AboutPage() { scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index a743181c6c..b61139bdc7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -67,7 +67,6 @@ import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding @@ -87,7 +86,6 @@ fun ActionLogPage( appId: String? = null, ) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val actionDataItems = vm.pagingDataFlow.collectAsLazyPagingItems() @@ -99,7 +97,7 @@ fun ActionLogPage( scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt index 4227d82c4b..257173452d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt @@ -49,7 +49,6 @@ import li.songe.gkd.ui.component.LocalNumberCharWidth import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding @@ -66,7 +65,6 @@ fun ActivityLogPage() { val context = LocalActivity.current as MainActivity val mainVm = context.mainVm val vm = viewModel() - val navController = LocalNavController.current val logCount by vm.logCountFlow.collectAsState() val list = vm.pagingDataFlow.collectAsLazyPagingItems() @@ -77,8 +75,8 @@ fun ActivityLogPage() { TopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = throttle { - navController.popBackStack() + IconButton(onClick = { + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 20539c50a0..188001b261 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -79,7 +79,6 @@ import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding @@ -95,7 +94,6 @@ fun AdvancedPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current val vm = viewModel() - val navController = LocalNavController.current val store by storeFlow.collectAsState() var showEditPortDlg by remember { @@ -175,7 +173,7 @@ fun AdvancedPage() { topBar = { TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index 87d22c8c68..14fa158dc4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -70,7 +70,6 @@ import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding @@ -91,7 +90,6 @@ import li.songe.gkd.util.toJson5String @Composable fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val ruleSortType by vm.ruleSortTypeFlow.collectAsState() @@ -143,7 +141,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { if (isSelectedMode) { vm.isSelectedModeFlow.value = false } else { - navController.popBackStack() + mainVm.popBackStack() } }) { BackCloseIcon(backOrClose = !isSelectedMode) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index 7916fa9ccf..759be048cb 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -36,7 +36,6 @@ import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.ManualAuthDialog import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.cardHorizontalPadding @@ -50,7 +49,6 @@ import li.songe.gkd.util.toast @Composable fun AppOpsAllowPage() { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val foregroundServiceSpecialUse by foregroundServiceSpecialUseState.stateFlow.collectAsStateWithLifecycle() @@ -58,7 +56,7 @@ fun AppOpsAllowPage() { Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index 991e97db9b..fc5d292563 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -43,7 +43,6 @@ import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.ManualAuthDialog import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.cardHorizontalPadding @@ -60,7 +59,6 @@ import li.songe.gkd.util.toast @Composable fun AuthA11yPage() { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val showCopyDlg by vm.showCopyDlgFlow.collectAsState() @@ -71,7 +69,7 @@ fun AuthA11yPage() { Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt index b8f1a1b3a9..2975868f63 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt @@ -43,7 +43,6 @@ import coil3.request.crossfade import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.util.imageLoader import li.songe.gkd.util.throttle @@ -56,7 +55,6 @@ fun ImagePreviewPage( uris: Array = emptyArray(), ) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current Box( modifier = Modifier .background(MaterialTheme.colorScheme.background) @@ -70,7 +68,7 @@ fun ImagePreviewPage( .fillMaxWidth(), navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt index 8c30273b2d..24bf697b1d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt @@ -33,7 +33,6 @@ import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupLi import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding @@ -47,7 +46,6 @@ import li.songe.gkd.util.throttle @Composable fun SlowGroupPage() { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val ruleSummary by ruleSummaryFlow.collectAsState() val appInfoCache by appInfoCacheFlow.collectAsState() @@ -59,7 +57,7 @@ fun SlowGroupPage() { scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, @@ -96,11 +94,11 @@ fun SlowGroupPage() { modifier = Modifier .clickable(onClick = throttle { mainVm.navigatePage( - SubsGlobalGroupListPageDestination( - rule.subsItem.id, - group.key - ) + SubsGlobalGroupListPageDestination( + rule.subsItem.id, + group.key ) + ) }) .itemPadding(), title = group.name, @@ -115,12 +113,12 @@ fun SlowGroupPage() { modifier = Modifier .clickable(onClick = throttle { mainVm.navigatePage( - SubsAppGroupListPageDestination( - rule.subsItem.id, - rule.app.id, - group.key - ) + SubsAppGroupListPageDestination( + rule.subsItem.id, + rule.app.id, + group.key ) + ) }) .itemPadding(), title = group.name, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt index 724dd9efac..0a0448a9ed 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt @@ -63,7 +63,6 @@ import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding @@ -85,7 +84,6 @@ import li.songe.gkd.util.toast fun SnapshotPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val colorScheme = MaterialTheme.colorScheme val vm = viewModel() @@ -100,7 +98,7 @@ fun SnapshotPage() { scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index 79d5a27f0a..33b1c93cb8 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -49,7 +49,6 @@ import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding @@ -71,7 +70,6 @@ fun SubsAppGroupListPage( @Suppress("unused") focusGroupKey: Int? = null, // 背景/边框高亮一下 ) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val subs = vm.subsRawFlow.collectAsState().value val subsConfigs by vm.subsConfigsFlow.collectAsState() @@ -113,7 +111,7 @@ fun SubsAppGroupListPage( if (isSelectedMode) { vm.isSelectedModeFlow.value = false } else { - navController.popBackStack() + mainVm.popBackStack() } }) { BackCloseIcon(backOrClose = !isSelectedMode) @@ -141,7 +139,7 @@ fun SubsAppGroupListPage( Row { IconButton(onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { val copyGroups = app.groups.filter { g -> - selectedDataSet.any { it.groupKey == g.key } + selectedDataSet.any { s -> s.groupKey == g.key } } val str = toJson5String(app.copy(groups = copyGroups)) copyText(str) @@ -160,7 +158,7 @@ fun SubsAppGroupListPage( text = "删除当前所选规则组?", error = true, ) - val keys = selectedDataSet.mapNotNull { it.groupKey } + val keys = selectedDataSet.mapNotNull { g -> g.groupKey } vm.isSelectedModeFlow.value = false if (keys.size == app.groups.size) { updateSubscription( @@ -174,7 +172,7 @@ fun SubsAppGroupListPage( subs.copy( apps = subs.apps.toMutableList().apply { set( - indexOfFirst { it.id == appId }, + indexOfFirst { a -> a.id == appId }, app.copy(groups = app.groups.filterNot { g -> keys.contains( g.key diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index 4541489699..c2b78661b7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -56,7 +56,6 @@ import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding @@ -78,7 +77,6 @@ fun SubsAppListPage( ) { val mainVm = LocalMainViewModel.current val context = LocalActivity.current!! - val navController = LocalNavController.current val vm = viewModel() val appAndConfigs by vm.filterAppAndConfigsFlow.collectAsState() @@ -108,7 +106,7 @@ fun SubsAppListPage( if (KeyboardUtils.isSoftInputVisible(context)) { softwareKeyboardController?.hide() } - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt index 0c07dc7af8..d2878db9fc 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt @@ -59,7 +59,6 @@ import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.ResetSettings import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding @@ -77,7 +76,6 @@ import li.songe.gkd.util.updateSubscription @Composable fun SubsCategoryPage(subsItemId: Long) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val subs = vm.subsRawFlow.collectAsState().value @@ -89,7 +87,7 @@ fun SubsCategoryPage(subsItemId: Long) { Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index ac44ab28f2..b238c8d5a2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -69,7 +69,7 @@ import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState -import li.songe.gkd.ui.share.LocalNavController +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemFlagPadding @@ -87,8 +87,8 @@ import li.songe.gkd.util.toast @Destination(style = ProfileTransitions::class) @Composable fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { + val mainVm = LocalMainViewModel.current val context = LocalActivity.current!! - val navController = LocalNavController.current val vm = viewModel() val rawSubs = vm.rawSubsFlow.collectAsState().value val group = vm.groupFlow.collectAsState().value @@ -122,7 +122,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { if (KeyboardUtils.isSoftInputVisible(context)) { softwareKeyboardController?.hide() } - navController.popBackStack() + mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index 85079dd976..b18298f112 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -48,7 +48,6 @@ import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding @@ -65,7 +64,6 @@ import li.songe.gkd.util.updateSubscription @Composable fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val vm = viewModel() val subs = vm.subsRawFlow.collectAsState().value val subsConfigs by vm.subsConfigsFlow.collectAsState() @@ -108,7 +106,7 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { if (isSelectedMode) { vm.isSelectedModeFlow.value = false } else { - navController.popBackStack() + mainVm.popBackStack() } }) { BackCloseIcon(backOrClose = !isSelectedMode) @@ -140,20 +138,19 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { vm.viewModelScope.launchAsFn( Dispatchers.Default ) { - subs mainVm.dialogFlow.waitResult( title = "删除规则组", text = "删除当前所选规则组?", error = true, ) - val keys = selectedDataSet.mapNotNull { it.groupKey } + val keys = selectedDataSet.mapNotNull { g -> + g.groupKey + } vm.isSelectedModeFlow.value = false updateSubscription( subs.copy( - globalGroups = globalGroups.filterNot { - keys.contains( - it.key - ) + globalGroups = globalGroups.filterNot { g -> + keys.contains(g.key) } ) ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt index 876ccbcd00..14c8a7f924 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt @@ -53,7 +53,6 @@ import li.songe.gkd.MainActivity import li.songe.gkd.data.Value import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.client @@ -68,7 +67,6 @@ fun WebViewPage( initUrl: String, ) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val webViewState = rememberWebViewState(url = initUrl) val webViewClient = remember { GkdWebViewClient() } val webView = remember { Value(null) } @@ -76,7 +74,7 @@ fun WebViewPage( TopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { - IconButton(onClick = throttle { navController.popBackStack() }) { + IconButton(onClick = { mainVm.popBackStack() }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt index dcc8416c09..2fa628fd9b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt @@ -153,7 +153,7 @@ fun RuleGroupDialog( if (currentDestination?.baseRoute != destination.baseRoute) { IconButton(onClick = throttle { onDismissRequest() - navController.navigate(direction.route) + mainVm.navigatePage(direction) }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowForward, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index bfde75c467..af16124b42 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.outlined.Block import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -65,6 +66,7 @@ import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.mapHashCode import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex @@ -132,6 +134,14 @@ fun useAppListPage(): ScaffoldExt { ) } }, actions = { + IconButton(onClick = throttle { + toast("应用白名单") + }) { + Icon( + imageVector = Icons.Outlined.Block, + contentDescription = Icons.Outlined.Block.name, + ) + } IconButton(onClick = throttle { if (showSearchBar) { if (vm.searchStrFlow.value.isEmpty()) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt index 310bc09b4f..53eab5340e 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.remember import java.text.SimpleDateFormat import java.util.Locale import java.util.concurrent.TimeUnit -import kotlin.collections.hashMapOf fun formatTimeAgo(timestamp: Long): String { val currentTime = System.currentTimeMillis() @@ -39,7 +38,7 @@ fun Long.format(formatStr: String): String { return df.format(this) } -private data class ThrottleTimer( +data class ThrottleTimer( private val interval: Long = 500L, private var value: Long = 0L ) { From dfa0aadea1ab3ad873621aa168f345e65c973120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 5 Sep 2025 18:27:21 +0800 Subject: [PATCH 041/245] perf: InnerDisable --- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 45 ++++++++++--------- .../songe/gkd/ui/SubsGlobalGroupExcludeVm.kt | 16 +++---- .../gkd/ui/component/InnerDisableSwitch.kt | 2 +- .../li/songe/gkd/ui/component/TowLineText.kt | 2 +- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index b238c8d5a2..0e2dc5ff31 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu @@ -99,7 +100,6 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { val showHiddenApp by vm.showHiddenAppFlow.collectAsState() val showDisabledApp by vm.showDisabledAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() - val disabledAppSet by vm.disabledAppSetFlow.collectAsState() var showEditDlg by remember { mutableStateOf(false) @@ -152,6 +152,14 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { ) } }, actions = { + IconButton(onClick = { + showEditDlg = true + }) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = null + ) + } IconButton(onClick = { if (showSearchBar) { if (vm.searchStrFlow.value.isEmpty()) { @@ -244,23 +252,21 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { storeFlow.update { it.copy(subsExcludeShowHiddenApp = !it.subsExcludeShowHiddenApp) } }, ) - if (disabledAppSet.isNotEmpty()) { - DropdownMenuItem( - text = { - Text("显示禁用应用") - }, - trailingIcon = { - Checkbox( - checked = showDisabledApp, - onCheckedChange = { - storeFlow.update { it.copy(subsExcludeShowDisabledApp = !it.subsExcludeShowDisabledApp) } - }) - }, - onClick = { - storeFlow.update { it.copy(subsExcludeShowDisabledApp = !it.subsExcludeShowDisabledApp) } - }, - ) - } + DropdownMenuItem( + text = { + Text("显示禁用应用") + }, + trailingIcon = { + Checkbox( + checked = showDisabledApp, + onCheckedChange = { + storeFlow.update { it.copy(subsExcludeShowDisabledApp = !it.subsExcludeShowDisabledApp) } + }) + }, + onClick = { + storeFlow.update { it.copy(subsExcludeShowDisabledApp = !it.subsExcludeShowDisabledApp) } + }, + ) } } @@ -337,7 +343,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { Spacer(modifier = Modifier.height(EmptyHeight)) if (showAppInfos.isEmpty() && searchStr.isNotEmpty()) { val hasShowAll = showSystemApp && showHiddenApp - EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") } QueryPkgAuthCard() } @@ -383,7 +389,6 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { confirmButton = { TextButton(onClick = throttle(vm.viewModelScope.launchAsFn { if (oldSource == source) { - toast("禁用项无变动") showEditDlg = false return@launchAsFn } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt index 095df68be9..ecd4a96a6b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt @@ -27,15 +27,12 @@ class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { val groupFlow = rawSubsFlow.mapState(viewModelScope) { r -> r?.globalGroups?.find { g -> g.key == args.groupKey } } - val disabledAppSetFlow = groupFlow.map { g -> - (g?.apps ?: emptyList()).filter { a -> a.enable == false }.map { a -> a.id }.toSet() - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) - val subsConfigFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId, args.groupKey) .stateIn(viewModelScope, SharingStarted.Eagerly, null) - val excludeDataFlow = subsConfigFlow.mapState(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } + val excludeDataFlow = + subsConfigFlow.mapState(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } val searchStrFlow = MutableStateFlow("") private val debounceSearchStrFlow = searchStrFlow.debounce(200) @@ -95,12 +92,13 @@ class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { combine( it, showDisabledAppFlow, - disabledAppSetFlow - ) { apps, showDisabledApp, disabledAppSet -> - if (showDisabledApp || disabledAppSet.isEmpty()) { + rawSubsFlow, + groupFlow, + ) { apps, showDisabledApp, rawSubs, group -> + if (showDisabledApp || rawSubs == null || group == null) { apps } else { - apps.filter { a -> !disabledAppSet.contains(a.id) } + apps.filter { a -> !rawSubs.getGlobalGroupInnerDisabled(group, a.id) } } } }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt index ccb0c9cbc4..12699e2134 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt @@ -22,7 +22,7 @@ fun InnerDisableSwitch( if (valid) { mainVm.dialogFlow.updateDialogOptions( title = "内置禁用", - text = "此规则组已经在内部配置对当前应用的禁用, 因此无法手动开启规则组\n\n提示: 这种情况一般在此全局规则无法适配/跳过适配/单独适配当前应用时出现", + text = "此规则组已经在内部配置对当前应用的禁用,就算强制开启规则组也是无意义或不生效的\n\n提示: 这种情况一般在此全局规则无法适配/跳过适配/单独适配当前应用时出现", ) } else { mainVm.dialogFlow.updateDialogOptions( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt index aad70d3ef9..b0be668884 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt @@ -28,7 +28,7 @@ fun TowLineText( Text( text = subtitle, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) } } From 16ec43ec4191a6e424993c7084f50628565466ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 6 Sep 2025 02:34:55 +0800 Subject: [PATCH 042/245] perf: ListPlaceholder --- app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt | 4 ++-- .../kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 6 +++--- .../main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt | 4 ++-- .../li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt | 4 ++-- .../kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt | 4 ++-- .../main/kotlin/li/songe/gkd/ui/home/AppListPage.kt | 10 +++++----- .../main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt | 4 ++-- .../kotlin/li/songe/gkd/ui/share/ListPlaceholder.kt | 9 +++++++++ app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt | 2 +- app/src/main/kotlin/li/songe/gkd/util/Constants.kt | 4 ---- 15 files changed, 38 insertions(+), 33 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/share/ListPlaceholder.kt diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index b61139bdc7..69db895dab 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -66,12 +66,12 @@ import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.mapState import li.songe.gkd.util.subsIdToRawFlow @@ -179,7 +179,7 @@ fun ActionLogPage( appId = appId, ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (actionDataItems.itemCount == 0 && actionDataItems.loadState.refresh !is LoadState.Loading) { EmptyText(text = "暂无记录") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt index 257173452d..7b05e21721 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt @@ -49,11 +49,11 @@ import li.songe.gkd.ui.component.LocalNumberCharWidth import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.copyText import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle @@ -122,7 +122,7 @@ fun ActivityLogPage() { ActivityLogCard(i = i, actionLog = actionLog, lastActionLog = lastActionLog) } } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (logCount == 0 && list.loadState.refresh !is LoadState.Loading) { EmptyText(text = "暂无记录") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index 14fa158dc4..6ece70d7fe 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -69,12 +69,12 @@ import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.icon.BackCloseIcon +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.RuleSortOption import li.songe.gkd.util.appInfoCacheFlow @@ -429,7 +429,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { ) } } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (groupSize == 0 && !firstLoading) { EmptyText(text = "暂无规则") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt index 24bf697b1d..32224272a0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt @@ -32,12 +32,12 @@ import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListP import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.updateDialogOptions +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle @@ -125,7 +125,7 @@ fun SlowGroupPage() { desc = "${rule.rawSubs.name}/应用规则/${appInfoCache[rule.app.id]?.name ?: rule.app.name ?: rule.app.id}" ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (ruleSummary.slowGroupCount == 0) { EmptyText(text = "暂无规则") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt index 0a0448a9ed..c216697ac9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt @@ -62,6 +62,7 @@ import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions @@ -69,7 +70,6 @@ import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.IMPORT_SHORT_URL -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.copyText @@ -144,7 +144,7 @@ fun SnapshotPage() { } ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (snapshots.isEmpty() && !firstLoading) { EmptyText(text = "暂无记录") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index 33b1c93cb8..f5deac34b2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -48,11 +48,11 @@ import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.copyText import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.launchAsFn @@ -311,7 +311,7 @@ fun SubsAppGroupListPage( } ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (app.groups.isEmpty()) { EmptyText(text = "暂无规则") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index c2b78661b7..abfdca15de 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -55,12 +55,12 @@ import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.LOCAL_SUBS_IDS import li.songe.gkd.util.SafeR import li.songe.gkd.util.SortTypeOption @@ -256,13 +256,13 @@ fun SubsAppListPage( }), ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) val firstLoading by vm.firstLoadingFlow.collectAsState() if (appAndConfigs.isEmpty() && !firstLoading) { EmptyText( text = if (searchStr.isNotEmpty()) { - if (showUninstallApp) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件" + if (showUninstallApp) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件" } else { "暂无规则" } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt index d2878db9fc..d52861b0d4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt @@ -58,12 +58,12 @@ import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.ResetSettings +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.EnableGroupOption -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.findOption import li.songe.gkd.util.getCategoryEnable import li.songe.gkd.util.launchAsFn @@ -135,7 +135,7 @@ fun SubsCategoryPage(subsItemId: Long) { showBottom = categories.last() != category ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (categories.isEmpty()) { EmptyText(text = "暂无类别") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index 0e2dc5ff31..57d3b8f0bc 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -70,13 +70,13 @@ import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemFlagPadding import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.SafeR import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.launchAsFn @@ -339,7 +339,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { CardFlagBar(visible = excludeData.appIds.containsKey(appInfo.id)) } } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (showAppInfos.isEmpty() && searchStr.isNotEmpty()) { val hasShowAll = showSystemApp && showHiddenApp diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index b18298f112..f318ebd329 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -47,11 +47,11 @@ import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.switchItem @@ -271,7 +271,7 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { } ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (globalGroups.isEmpty()) { EmptyText(text = "暂无规则") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index af16124b42..539a16109b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination +import com.ramcosta.composedestinations.generated.destinations.WhiteAppListPageDestination import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity import li.songe.gkd.store.storeFlow @@ -56,17 +57,16 @@ import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.appItemPadding import li.songe.gkd.ui.style.menuPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.SafeR import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.mapHashCode import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex @@ -135,7 +135,7 @@ fun useAppListPage(): ScaffoldExt { } }, actions = { IconButton(onClick = throttle { - toast("应用白名单") + mainVm.navigatePage(WhiteAppListPageDestination) }) { Icon( imageVector = Icons.Outlined.Block, @@ -313,11 +313,11 @@ fun useAppListPage(): ScaffoldExt { } } } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) { val hasShowAll = showSystemApp && showHiddenApp - EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") } QueryPkgAuthCard() } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 2f5181356c..bc03dacb69 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -74,10 +74,10 @@ import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.SubsItemCard import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemVerticalPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.SafeR import li.songe.gkd.util.ShortUrlSet @@ -520,7 +520,7 @@ fun useSubsManagePage(): ScaffoldExt { ) } } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/ListPlaceholder.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/ListPlaceholder.kt new file mode 100644 index 0000000000..359cc77381 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/ListPlaceholder.kt @@ -0,0 +1,9 @@ +package li.songe.gkd.ui.share + +import kotlin.math.E +import kotlin.math.PI + +object ListPlaceholder { + const val KEY = PI + const val TYPE = E +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt index 6430c5b860..d504db31e0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt @@ -35,6 +35,6 @@ fun Modifier.menuPadding() = this fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { return this.padding( top = values.calculateTopPadding(), - // 被 LazyColumn( 使用时, 移除 bottom padding, 否则 底部导航栏 无法实现透明背景 + // 被 LazyXXX 使用时, 移除 bottom padding, 否则 底部导航栏 无法实现透明背景 ) } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index c878936962..9fd045172c 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -1,7 +1,5 @@ package li.songe.gkd.util -import kotlin.math.PI - const val FILE_SHORT_URL = "https://f.gkd.li/" const val IMPORT_SHORT_URL = "https://i.gkd.li/i/" @@ -35,6 +33,4 @@ object ShortUrlSet { const val shizukuAppId = "moe.shizuku.privileged.api" -const val LIST_PLACEHOLDER_KEY = PI - const val PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=li.songe.gkd" From 1f14df041b62d53007886e58b17ceb098644cb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 18 Sep 2025 22:16:35 +0800 Subject: [PATCH 043/245] refactor: app list --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/Build-Release.yml | 10 + app/build.gradle.kts | 3 +- app/schemas/li.songe.gkd.db.AppDb/13.json | 374 +++++++++++++ app/src/main/AndroidManifest.xml | 21 +- app/src/main/kotlin/li/songe/gkd/App.kt | 39 +- .../main/kotlin/li/songe/gkd/MainActivity.kt | 62 ++- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 24 +- .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 10 +- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 26 +- .../li/songe/gkd/a11y/A11yRuleEngine.kt | 10 +- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 72 ++- .../kotlin/li/songe/gkd/data/ActionLog.kt | 7 - .../kotlin/li/songe/gkd/data/AppConfig.kt | 5 +- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 56 +- .../kotlin/li/songe/gkd/data/AppVisitLog.kt | 59 ++ .../li/songe/gkd/data/ComplexSnapshot.kt | 6 +- .../kotlin/li/songe/gkd/data/DeviceInfo.kt | 27 +- .../kotlin/li/songe/gkd/data/GkdAction.kt | 2 +- .../li/songe/gkd/data/RawSubscription.kt | 19 +- .../kotlin/li/songe/gkd/data/SubsConfig.kt | 44 +- .../kotlin/li/songe/gkd/data/TransferData.kt | 65 ++- app/src/main/kotlin/li/songe/gkd/db/AppDb.kt | 6 +- app/src/main/kotlin/li/songe/gkd/db/DbSet.kt | 2 + .../main/kotlin/li/songe/gkd/notif/Notif.kt | 8 +- .../songe/gkd/permission/PermissionState.kt | 16 +- .../li/songe/gkd/service/A11yService.kt | 5 + .../li/songe/gkd/service/ButtonService.kt | 17 +- .../li/songe/gkd/service/GkdTileService.kt | 182 ++++-- .../li/songe/gkd/service/HttpService.kt | 2 +- .../songe/gkd/service/OverlayWindowService.kt | 137 +++-- .../li/songe/gkd/service/RecordService.kt | 11 +- .../li/songe/gkd/service/StatusService.kt | 11 +- .../li/songe/gkd/shizuku/PackageManager.kt | 14 - .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 71 +-- .../li/songe/gkd/store/SettingsStore.kt | 22 +- .../kotlin/li/songe/gkd/store/StoreExt.kt | 24 +- .../main/kotlin/li/songe/gkd/ui/AboutPage.kt | 46 +- .../kotlin/li/songe/gkd/ui/ActionLogPage.kt | 132 ++--- .../kotlin/li/songe/gkd/ui/ActionLogVm.kt | 10 +- .../kotlin/li/songe/gkd/ui/ActivityLogPage.kt | 80 +-- .../kotlin/li/songe/gkd/ui/ActivityLogVm.kt | 8 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 70 ++- .../main/kotlin/li/songe/gkd/ui/AdvancedVm.kt | 6 +- .../kotlin/li/songe/gkd/ui/AppConfigPage.kt | 149 ++--- .../kotlin/li/songe/gkd/ui/AppConfigVm.kt | 36 +- .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 19 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 31 +- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 353 ++++++++++++ .../li/songe/gkd/ui/BlockA11yAppListVm.kt | 57 ++ .../li/songe/gkd/ui/EditBlockAppListPage.kt | 79 +++ .../li/songe/gkd/ui/EditBlockAppListVm.kt | 31 ++ .../li/songe/gkd/ui/ImagePreviewPage.kt | 52 +- .../kotlin/li/songe/gkd/ui/SlowGroupPage.kt | 36 +- .../kotlin/li/songe/gkd/ui/SnapshotPage.kt | 99 ++-- .../li/songe/gkd/ui/SubsAppGroupListPage.kt | 151 +++-- .../li/songe/gkd/ui/SubsAppGroupListVm.kt | 21 +- .../kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 121 ++-- .../kotlin/li/songe/gkd/ui/SubsAppListVm.kt | 188 ++++--- .../li/songe/gkd/ui/SubsCategoryPage.kt | 77 +-- .../kotlin/li/songe/gkd/ui/SubsCategoryVm.kt | 17 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 522 ++++++++---------- .../songe/gkd/ui/SubsGlobalGroupExcludeVm.kt | 158 +++--- .../songe/gkd/ui/SubsGlobalGroupListPage.kt | 59 +- .../li/songe/gkd/ui/SubsGlobalGroupListVm.kt | 13 +- .../li/songe/gkd/ui/UpsertRuleGroupPage.kt | 35 +- .../li/songe/gkd/ui/UpsertRuleGroupVm.kt | 6 +- .../kotlin/li/songe/gkd/ui/WebViewPage.kt | 41 +- .../ui/component/AnimatedBooleanContent.kt | 30 + .../AnimationFloatingActionButton.kt | 3 +- .../li/songe/gkd/ui/component/AppIcon.kt | 8 +- .../li/songe/gkd/ui/component/AppNameText.kt | 26 +- .../songe/gkd/ui/component/AuthButtonGroup.kt | 13 +- .../li/songe/gkd/ui/component/CardFlagBar.kt | 41 -- .../{UrlCopyText.kt => CopyTextCard.kt} | 12 +- .../gkd/ui/component/CustomIconButton.kt | 50 ++ .../ui/component/DropdownMenuCheckboxItem.kt | 26 + .../component/DropdownMenuRadioButtonItem.kt | 26 + .../ui/component/EditGroupExcludeDialog.kt | 89 --- .../li/songe/gkd/ui/component/EmptyText.kt | 4 - .../li/songe/gkd/ui/component/FlagCard.kt | 33 ++ .../gkd/ui/component/FullscreenDialog.kt | 58 ++ .../songe/gkd/ui/component/GroupNameText.kt | 4 +- .../kotlin/li/songe/gkd/ui/component/Hooks.kt | 37 +- .../gkd/ui/component/InputSubsLinkOption.kt | 19 +- .../gkd/ui/component/ManualAuthDialog.kt | 8 +- .../songe/gkd/ui/component/MultiTextField.kt | 82 +++ .../li/songe/gkd/ui/component/PerfCheckbox.kt | 27 + .../li/songe/gkd/ui/component/PerfIcon.kt | 160 ++++++ .../li/songe/gkd/ui/component/PerfSwitch.kt | 31 ++ .../songe/gkd/ui/component/PerfTopAppBar.kt | 40 ++ .../songe/gkd/ui/component/QueryPkgTipCard.kt | 27 +- .../gkd/ui/component/RotatingLoadingIcon.kt | 8 +- .../songe/gkd/ui/component/RuleGroupCard.kt | 202 ++++--- .../songe/gkd/ui/component/RuleGroupDialog.kt | 65 +-- .../songe/gkd/ui/component/RuleGroupState.kt | 163 ++++-- .../li/songe/gkd/ui/component/SettingItem.kt | 7 +- .../li/songe/gkd/ui/component/SubsAppCard.kt | 47 +- .../li/songe/gkd/ui/component/SubsItemCard.kt | 34 +- .../li/songe/gkd/ui/component/SubsSheet.kt | 91 ++- .../li/songe/gkd/ui/component/TextDialog.kt | 48 ++ .../li/songe/gkd/ui/component/TextMenu.kt | 25 +- .../li/songe/gkd/ui/component/TextSwitch.kt | 16 +- .../li/songe/gkd/ui/component/TowLineText.kt | 11 +- .../songe/gkd/ui/component/UploadOptions.kt | 2 +- .../songe/gkd/ui/component/UrlDetailDialog.kt | 39 -- .../li/songe/gkd/ui/home/AppListPage.kt | 297 ++++++---- .../li/songe/gkd/ui/home/ControlPage.kt | 67 +-- .../kotlin/li/songe/gkd/ui/home/HomePage.kt | 20 +- .../kotlin/li/songe/gkd/ui/home/HomeVm.kt | 118 ++-- .../li/songe/gkd/ui/home/ScaffoldExt.kt | 4 +- .../li/songe/gkd/ui/home/SettingsPage.kt | 193 +++++-- .../li/songe/gkd/ui/home/SubsManagePage.kt | 139 ++--- .../kotlin/li/songe/gkd/ui/share/AppFilter.kt | 93 ++++ .../li/songe/gkd/ui/share/BaseViewModel.kt | 34 +- .../songe/gkd/ui/share/FixedWindowInsets.kt | 16 + .../li/songe/gkd/ui/share/ModifierExt.kt | 23 + .../kotlin/li/songe/gkd/ui/share/StateExt.kt | 63 +++ .../kotlin/li/songe/gkd/ui/style/Padding.kt | 10 +- .../li/songe/gkd/ui/{theme => style}/Theme.kt | 9 +- .../kotlin/li/songe/gkd/util/AndroidTarget.kt | 30 + .../kotlin/li/songe/gkd/util/AppInfoState.kt | 128 +++-- .../main/kotlin/li/songe/gkd/util/BarUtils.kt | 23 + .../kotlin/li/songe/gkd/util/CollectionExt.kt | 2 +- .../kotlin/li/songe/gkd/util/Constants.kt | 2 + .../kotlin/li/songe/gkd/util/FolderExt.kt | 3 +- .../main/kotlin/li/songe/gkd/util/Github.kt | 21 +- .../kotlin/li/songe/gkd/util/ImageUtils.kt | 89 +++ .../kotlin/li/songe/gkd/util/IntentExt.kt | 19 +- .../kotlin/li/songe/gkd/util/KeyboardUtils.kt | 73 +++ .../li/songe/gkd/util/LifecycleCallbacks.kt | 9 +- .../kotlin/li/songe/gkd/util/MutexState.kt | 10 + .../kotlin/li/songe/gkd/util/NetworkUtils.kt | 11 + .../main/kotlin/li/songe/gkd/util/Option.kt | 105 ++-- .../main/kotlin/li/songe/gkd/util/Others.kt | 79 ++- .../kotlin/li/songe/gkd/util/ScreenUtils.kt | 25 + .../li/songe/gkd/util/ScreenshotUtil.kt | 1 - .../kotlin/li/songe/gkd/util/Singleton.kt | 3 +- .../kotlin/li/songe/gkd/util/SnapshotExt.kt | 5 +- .../kotlin/li/songe/gkd/util/SubsState.kt | 21 +- .../main/kotlin/li/songe/gkd/util/TimeExt.kt | 14 +- .../main/kotlin/li/songe/gkd/util/Toast.kt | 9 +- app/src/main/kotlin/li/songe/gkd/util/Unit.kt | 29 +- .../main/kotlin/li/songe/gkd/util/Upgrade.kt | 3 +- .../main/kotlin/li/songe/gkd/util/UriUtils.kt | 13 + .../main/kotlin/li/songe/gkd/util/ZipUtils.kt | 90 +++ build.gradle.kts | 1 - gradle/libs.versions.toml | 38 +- hidden_api/build.gradle.kts | 4 +- .../android/content/pm/PackageInfoHidden.java | 17 + 150 files changed, 4961 insertions(+), 2788 deletions(-) create mode 100644 app/schemas/li.songe.gkd.db.AppDb/13.json create mode 100644 app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedBooleanContent.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/CardFlagBar.kt rename app/src/main/kotlin/li/songe/gkd/ui/component/{UrlCopyText.kt => CopyTextCard.kt} (86%) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuCheckboxItem.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuRadioButtonItem.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/PerfCheckbox.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/TextDialog.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/UrlDetailDialog.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/share/FixedWindowInsets.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/share/ModifierExt.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt rename app/src/main/kotlin/li/songe/gkd/ui/{theme => style}/Theme.kt (93%) create mode 100644 app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/BarUtils.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/ImageUtils.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/KeyboardUtils.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/NetworkUtils.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/ScreenUtils.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/UriUtils.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/util/ZipUtils.kt create mode 100644 hidden_api/src/main/java/android/content/pm/PackageInfoHidden.java diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 335aed9974..7ff1a1d8a0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -20,7 +20,7 @@ body: label: | 日志文件 description: | - 主页-设置-关于-日志,上传日志文件或生成链接并粘贴到下面的输入框\ + 首页-设置-关于-日志,上传日志文件或生成链接并粘贴到下面的输入框\ 任何问题都需要提供日志文件. 否则将直接关闭,请不要纯发文字/截图/视频 validations: required: true diff --git a/.github/workflows/Build-Release.yml b/.github/workflows/Build-Release.yml index c62e63d799..1aa7a5e47e 100644 --- a/.github/workflows/Build-Release.yml +++ b/.github/workflows/Build-Release.yml @@ -93,3 +93,13 @@ jobs: asset_path: release/app-gkd-release.apk asset_name: gkd-${{ github.ref_name }}.apk asset_content_type: application/vnd.android.package-archive + + - run: zip -r outputs.zip outputs + - uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: outputs.zip + asset_name: outputs-${{ github.ref_name }}.zip + asset_content_type: application/zip diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc7b773307..71eece1dca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,11 +48,11 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.androidx.room) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlinx.atomicfu) alias(libs.plugins.google.ksp) + alias(libs.plugins.rikka.refine) } android { @@ -225,6 +225,7 @@ dependencies { compileOnly(project(":hidden_api")) implementation(libs.rikka.shizuku.api) implementation(libs.rikka.shizuku.provider) + implementation(libs.rikka.refine.runtime) implementation(libs.lsposed.hiddenapibypass) implementation(libs.androidx.room.runtime) diff --git a/app/schemas/li.songe.gkd.db.AppDb/13.json b/app/schemas/li.songe.gkd.db.AppDb/13.json new file mode 100644 index 0000000000..8275fe9ed5 --- /dev/null +++ b/app/schemas/li.songe.gkd.db.AppDb/13.json @@ -0,0 +1,374 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "f57629976fb6ff444f59487622f93814", + "entities": [ + { + "tableName": "subs_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ctime` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `enable_update` INTEGER NOT NULL, `order` INTEGER NOT NULL, `update_url` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableUpdate", + "columnName": "enable_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateUrl", + "columnName": "update_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "snapshot", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, `github_asset_id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "screenHeight", + "columnName": "screen_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenWidth", + "columnName": "screen_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLandscape", + "columnName": "is_landscape", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "githubAssetId", + "columnName": "github_asset_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subs_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, `exclude` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupKey", + "columnName": "group_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "category_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_id` INTEGER NOT NULL, `category_key` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryKey", + "columnName": "category_key", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "action_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ctime` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `subs_version` INTEGER NOT NULL DEFAULT 0, `group_key` INTEGER NOT NULL, `group_type` INTEGER NOT NULL DEFAULT 2, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subsVersion", + "columnName": "subs_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "groupKey", + "columnName": "group_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupType", + "columnName": "group_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2" + }, + { + "fieldPath": "ruleIndex", + "columnName": "rule_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleKey", + "columnName": "rule_key", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "activity_log_v2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ctime` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_visit_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `mtime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f57629976fb6ff444f59487622f93814')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a132c06722..5232e5d5f0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,31 +2,23 @@ + - + - - - - - - - + tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" /> - - - - @@ -124,7 +116,8 @@ android:name="com.google.android.accessibility.selecttospeak.SelectToSpeakService" android:exported="false" android:label="@string/app_name" - android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" + tools:ignore="AccessibilityPolicy"> diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 42a92d25f8..b8bf097e49 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -3,11 +3,15 @@ package li.songe.gkd import android.app.ActivityManager import android.app.AppOpsManager import android.app.Application +import android.app.KeyguardManager +import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager -import android.os.Build import android.provider.Settings +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils import kotlinx.coroutines.MainScope @@ -15,8 +19,10 @@ import kotlinx.serialization.Serializable import li.songe.gkd.data.selfAppInfo import li.songe.gkd.notif.initChannel import li.songe.gkd.service.clearHttpSubs +import li.songe.gkd.service.initA11yWhiteAppList import li.songe.gkd.shizuku.initShizuku import li.songe.gkd.store.initStore +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.SafeR import li.songe.gkd.util.initAppState import li.songe.gkd.util.initSubsState @@ -42,7 +48,7 @@ private fun getMetaString(key: String): String { return applicationInfo.metaData.getString(key) ?: error("Missing meta-data: $key") } -// https://github.com/aosp-mirror/platform_frameworks_base/blob/android16-release/packages/SettingsLib/src/com/android/settingslib/accessibility/AccessibilityUtils.java#L41 +// https://github.com/android-cs/16/blob/main/packages/SettingsLib/src/com/android/settingslib/accessibility/AccessibilityUtils.java#L41 private const val ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR = ':' @Serializable @@ -60,9 +66,9 @@ data class AppMeta( val commitUrl = "https://github.com/gkd-kit/gkd/".run { plus(if (tagName != null) "tree/$tagName" else "commit/$commitId") } - val isGkdChannel = channel == "gkd" - val updateEnabled: Boolean - get() = isGkdChannel + val isGkdChannel get() = channel == "gkd" + val updateEnabled get() = isGkdChannel + val isBeta get() = versionName.contains("beta") } val META by lazy { AppMeta() } @@ -74,7 +80,7 @@ class App : Application() { override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (AndroidTarget.P) { HiddenApiBypass.addHiddenApiExemptions("L") } } @@ -84,6 +90,10 @@ class App : Application() { return Settings.Secure.putString(contentResolver, name, value) } + fun putSecureInt(name: String, value: Int): Boolean { + return Settings.Secure.putInt(contentResolver, name, value) + } + fun getSecureA11yServices(): MutableSet { return (getSecureString(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) ?: "").split( ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR @@ -97,6 +107,18 @@ class App : Application() { ) } + fun resolveAppId(intent: Intent): String? { + return intent.resolveActivity(packageManager)?.packageName + } + + fun resolveAppId(action: String, category: String? = null): String? { + val intent = Intent(action) + if (category != null) { + intent.addCategory(category) + } + return resolveAppId(intent) + } + val startTime = System.currentTimeMillis() var justStarted: Boolean = true get() { @@ -108,6 +130,10 @@ class App : Application() { val activityManager by lazy { app.getSystemService(ACTIVITY_SERVICE) as ActivityManager } val appOpsManager by lazy { app.getSystemService(APP_OPS_SERVICE) as AppOpsManager } + val inputMethodManager by lazy { app.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager } + val windowManager by lazy { app.getSystemService(WINDOW_SERVICE) as WindowManager } + val keyguardManager by lazy { app.getSystemService(KEYGUARD_SERVICE) as KeyguardManager } + val clipboardManager by lazy { app.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } override fun onCreate() { super.onCreate() @@ -130,6 +156,7 @@ class App : Application() { initAppState() initShizuku() initSubsState() + initA11yWhiteAppList() clearHttpSubs() syncFixState() } diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 0230a6aa26..06366fc810 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.app.ActivityManager import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -16,6 +15,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -24,13 +24,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -47,7 +45,6 @@ import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController -import com.blankj.utilcode.util.KeyboardUtils import com.dylanc.activityresult.launcher.PickContentLauncher import com.dylanc.activityresult.launcher.StartActivityLauncher import com.ramcosta.composedestinations.DestinationsNavHost @@ -64,8 +61,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import li.songe.gkd.a11y.topActivityFlow -import li.songe.gkd.a11y.updateImeAppId -import li.songe.gkd.a11y.updateLauncherAppId +import li.songe.gkd.a11y.topAppIdFlow +import li.songe.gkd.a11y.updateSystemDefaultAppId import li.songe.gkd.a11y.updateTopActivity import li.songe.gkd.permission.AuthDialog import li.songe.gkd.permission.updatePermissionState @@ -77,16 +74,20 @@ import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.BuildDialog +import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.ShareDataDialog import li.songe.gkd.ui.component.SubsSheet import li.songe.gkd.ui.component.TermsAcceptDialog -import li.songe.gkd.ui.component.UrlDetailDialog +import li.songe.gkd.ui.component.TextDialog +import li.songe.gkd.ui.share.FixedWindowInsets import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.LocalNavController -import li.songe.gkd.ui.theme.AppTheme +import li.songe.gkd.ui.style.AppTheme +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.EditGithubCookieDlg +import li.songe.gkd.util.KeyboardUtils import li.songe.gkd.util.ShortUrlSet -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.componentName import li.songe.gkd.util.copyText import li.songe.gkd.util.fixSomeProblems @@ -108,14 +109,17 @@ class MainActivity : ComponentActivity() { val pickContentLauncher by lazy { PickContentLauncher(this) } val imeFullHiddenFlow = MutableStateFlow(true) - val imeShowingFlow = MutableStateFlow(false) + val imePlayingFlow = MutableStateFlow(false) private val imeVisible: Boolean get() = ViewCompat.getRootWindowInsets(window.decorView)!! .isVisible(WindowInsetsCompat.Type.ime()) + private var _topBarWindowInsets: WindowInsets? = null + val topBarWindowInsets get() = _topBarWindowInsets!! + private fun watchKeyboardVisible() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (AndroidTarget.R) { ViewCompat.setWindowInsetsAnimationCallback( window.decorView, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { @@ -123,7 +127,7 @@ class MainActivity : ComponentActivity() { animation: WindowInsetsAnimationCompat, bounds: WindowInsetsAnimationCompat.BoundsCompat ): WindowInsetsAnimationCompat.BoundsCompat { - imeShowingFlow.update { imeVisible } + imePlayingFlow.update { imeVisible } return super.onStart(animation, bounds) } @@ -136,7 +140,7 @@ class MainActivity : ComponentActivity() { override fun onEnd(animation: WindowInsetsAnimationCompat) { imeFullHiddenFlow.update { !imeVisible } - imeShowingFlow.update { false } + imePlayingFlow.update { false } super.onEnd(animation) } }) @@ -148,11 +152,21 @@ class MainActivity : ComponentActivity() { } } - suspend fun hideSoftInput() { + suspend fun hideSoftInput(): Boolean { if (!imeFullHiddenFlow.updateAndGet { !imeVisible }) { - KeyboardUtils.hideSoftInput(this) + KeyboardUtils.hideSoftInput(this@MainActivity) imeFullHiddenFlow.drop(1).first() + return true + } + return false + } + + fun justHideSoftInput(): Boolean { + if (!imeFullHiddenFlow.updateAndGet { !imeVisible }) { + KeyboardUtils.hideSoftInput(this@MainActivity) + return true } + return false } override fun onCreate(savedInstanceState: Bundle?) { @@ -176,7 +190,11 @@ class MainActivity : ComponentActivity() { } watchKeyboardVisible() StatusService.autoStart() + topAppIdFlow.value = META.appId setContent { + if (_topBarWindowInsets == null) { + _topBarWindowInsets = FixedWindowInsets(TopAppBarDefaults.windowInsets) + } val navController = rememberNavController() mainVm.updateNavController(navController) CompositionLocalProvider( @@ -202,7 +220,7 @@ class MainActivity : ComponentActivity() { ShareDataDialog(mainVm, mainVm.showShareDataIdsFlow) mainVm.inputSubsLinkOption.ContentDialog() mainVm.ruleGroupState.Render() - UrlDetailDialog(mainVm.urlFlow) + TextDialog(mainVm.textFlow) } } } @@ -292,8 +310,7 @@ private val syncStateMutex = Mutex() fun syncFixState() { appScope.launchTry(Dispatchers.IO) { syncStateMutex.withLock { - updateLauncherAppId() - updateImeAppId() + updateSystemDefaultAppId() updateServiceRunning() updatePermissionState() fixRestartService() @@ -306,7 +323,7 @@ private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { val state = stateFlow.collectAsState().value if (state != null) { val errorText = remember { state.stackTraceToString() } - val appInfoCache = appInfoCacheFlow.collectAsState().value + val appInfoCache = appInfoMapFlow.collectAsState().value val installed = appInfoCache.contains(shizukuAppId) AlertDialog( onDismissRequest = { stateFlow.value = null }, @@ -340,7 +357,7 @@ private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { style = MaterialTheme.typography.bodySmall, ) } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { @@ -348,8 +365,7 @@ private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { }) .padding(4.dp) .size(20.dp), - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, + imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 136b24a413..e151413f31 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -7,7 +7,6 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.webkit.URLUtil -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController import androidx.navigation.NavOptionsBuilder @@ -21,6 +20,8 @@ import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -40,6 +41,7 @@ import li.songe.gkd.ui.component.InputSubsLinkOption import li.songe.gkd.ui.component.RuleGroupState import li.songe.gkd.ui.component.UploadOptions import li.songe.gkd.ui.home.BottomNavItem +import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.ThrottleTimer @@ -61,7 +63,16 @@ import kotlin.reflect.jvm.jvmName private var tempTermsAccepted = false -class MainViewModel : ViewModel(), OnSimpleLife { +class MainViewModel : BaseViewModel(), OnSimpleLife { + companion object { + private var _instance: MainViewModel? = null + val instance get() = _instance!! + } + + init { + _instance = this + addCloseable { _instance = null } + } private lateinit var navController: NavHostController fun updateNavController(navController: NavHostController) { @@ -120,6 +131,11 @@ class MainViewModel : ViewModel(), OnSimpleLife { val showShareDataIdsFlow = MutableStateFlow?>(null) + val appOrderListFlow = DbSet.actionLogDao.queryLatestUniqueAppIds().stateInit(emptyList()) + val appVisitOrderMapFlow = DbSet.appVisitLogDao.query().map { + it.mapIndexed { i, appId -> appId to i }.toMap() + }.debounce(500).stateInit(emptyMap()) + fun addOrModifySubs( url: String, oldItem: SubsItem? = null, @@ -176,10 +192,10 @@ class MainViewModel : ViewModel(), OnSimpleLife { val ruleGroupState = RuleGroupState(this) - val urlFlow = MutableStateFlow(null) + val textFlow = MutableStateFlow(null) fun openUrl(url: String) { if (URLUtil.isNetworkUrl(url)) { - urlFlow.value = url + textFlow.value = url } else { openUri(url) } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index 3ecca01b07..8cccf0b8e4 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -6,7 +6,6 @@ import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback import android.content.ComponentName import android.database.ContentObserver import android.graphics.Bitmap -import android.os.Build import android.provider.Settings import android.view.Display import android.view.accessibility.AccessibilityEvent @@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.app import li.songe.gkd.service.A11yService +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.OnSimpleLife import li.songe.selector.initDefaultTypeInfo import kotlin.coroutines.resume @@ -95,7 +95,7 @@ fun AccessibilityNodeInfo.isExpired(expiryMillis: Long): Boolean { val typeInfo by lazy { initDefaultTypeInfo().globalType } val AccessibilityNodeInfo.compatChecked: Boolean? - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + get() = if (AndroidTarget.BAKLAVA) { when (checked) { AccessibilityNodeInfo.CHECKED_STATE_TRUE -> true AccessibilityNodeInfo.CHECKED_STATE_FALSE -> false @@ -114,9 +114,7 @@ val AccessibilityEvent.isUseful: Boolean suspend fun AccessibilityService.screenshot(): Bitmap? = suspendCoroutine { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - it.resume(null) - } else { + if (AndroidTarget.R) { val callback = object : TakeScreenshotCallback { override fun onSuccess(screenshot: ScreenshotResult) { try { @@ -137,6 +135,8 @@ suspend fun AccessibilityService.screenshot(): Bitmap? = suspendCoroutine { application.mainExecutor, callback ) + } else { + it.resume(null) } } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt index 99fe1898c4..9ad12c3215 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -11,22 +11,25 @@ import android.view.WindowManager import android.view.accessibility.AccessibilityEvent import androidx.core.content.ContextCompat import com.blankj.utilcode.util.LogUtils -import com.blankj.utilcode.util.ScreenUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import li.songe.gkd.META import li.songe.gkd.appScope +import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.service.A11yService import li.songe.gkd.service.StatusService import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.ScreenUtils import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.UpdateTimeOption import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.launchTry import li.songe.gkd.util.mapState +import li.songe.gkd.util.toast context(service: A11yService) @@ -48,9 +51,26 @@ fun onA11yFeatInit() = service.run { } private fun A11yService.useAttachState() { - useAliveToast("无障碍", onlyWhenVisible = true) + onCreated { + if (isActivityVisible() || META.debuggable) { + toast("无障碍已启动") + } + } + onDestroyed { + if (isActivityVisible() || META.debuggable) { + if (willDestroyByBlock) { + toast("无障碍已局部关闭") + } else { + toast("无障碍已停止") + } + } + } onCreated { storeFlow.update { it.copy(enableService = true) } } - onDestroyed { storeFlow.update { it.copy(enableService = false) } } + onDestroyed { + if (!willDestroyByBlock) { + storeFlow.update { it.copy(enableService = false) } + } + } } private fun onA11yFeatEvent(event: AccessibilityEvent) = event.run { diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index 5138ecba6b..85911a3186 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -15,10 +15,12 @@ import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus import li.songe.gkd.isActivityVisible import li.songe.gkd.service.A11yService +import li.songe.gkd.service.a11yPartDisabledFlow import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.store.storeFlow import li.songe.gkd.util.launchTry import li.songe.gkd.util.showActionToast +import li.songe.gkd.util.systemUiAppId import java.util.concurrent.Executors @@ -28,6 +30,11 @@ private val actionDispatcher = Executors.newSingleThreadExecutor().asCoroutineDi class A11yRuleEngine(val service: A11yService) { init { + service.onA11yConnected { + if (storeFlow.value.enableBlockA11yAppList && !a11yPartDisabledFlow.value) { + startQueryJob(byForced = true) + } + } service.onA11yEvent { onNewA11yEvent(it) } } @@ -37,7 +44,7 @@ class A11yRuleEngine(val service: A11yService) { var lastEventTime = 0L val eventDeque = ArrayDeque() fun onNewA11yEvent(event: AccessibilityEvent) { - if (event.eventType == CONTENT_CHANGED && event.packageName == "com.android.systemui") { + if (event.eventType == CONTENT_CHANGED && event.packageName == systemUiAppId) { if (!service.isInteractive) return // 屏幕关闭后仍然有无障碍事件 if (event.packageName != topActivityFlow.value.appId) return } @@ -137,6 +144,7 @@ class A11yRuleEngine(val service: A11yService) { byDelayRule: ResolvedRule? = null, ) { if (!storeFlow.value.enableMatch) return + if (activityRuleFlow.value.currentRules.isEmpty()) return if (queryJob?.isActive == true) return queryJob = scope.launchTry(queryDispatcher) { queryAction(byEvent, byForced, byDelayRule) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index 1901aa6c65..1e6d39d0b5 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -2,7 +2,6 @@ package li.songe.gkd.a11y import android.content.ComponentName import android.content.Intent -import android.content.pm.PackageManager import android.provider.Settings import android.util.LruCache import android.view.accessibility.AccessibilityNodeInfo @@ -17,14 +16,14 @@ import li.songe.gkd.appScope import li.songe.gkd.data.ActionLog import li.songe.gkd.data.ActionResult import li.songe.gkd.data.ActivityLog -import li.songe.gkd.data.AppRule import li.songe.gkd.data.AttrInfo -import li.songe.gkd.data.GlobalRule import li.songe.gkd.data.ResetMatchType import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus import li.songe.gkd.db.DbSet import li.songe.gkd.store.actionCountFlow +import li.songe.gkd.store.blockA11yAppListFlow +import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.RuleSummary @@ -88,12 +87,22 @@ fun isActivity( } class ActivityRule( - val appRules: List = emptyList(), - val globalRules: List = emptyList(), val topActivity: TopActivity = TopActivity(), val ruleSummary: RuleSummary = RuleSummary(), ) { - val currentRules = (appRules + globalRules).sortedBy { it.order } + val blockMatch = (blockMatchAppListFlow.value.contains(topActivity.appId) + || storeFlow.value.enableBlockA11yAppList && blockA11yAppListFlow.value.contains( + topActivity.appId + )) + val appRules = ruleSummary.appIdToRules[topActivity.appId] ?: emptyList() + val activityRules = if (blockMatch) emptyList() else appRules.filter { rule -> + rule.matchActivity(topActivity.appId, topActivity.activityId) + } + val globalRules = if (blockMatch) emptyList() else ruleSummary.globalRules.filter { r -> + r.matchActivity(topActivity.appId, topActivity.activityId) + } + + val currentRules = (activityRules + globalRules).sortedBy { it.order } val hasPriorityRule = currentRules.size > 1 && currentRules.any { it.priorityEnabled } val activePriority: Boolean get() = hasPriorityRule && currentRules.any { it.isPriority() } @@ -117,11 +126,21 @@ class ActivityRule( val activityRuleFlow = MutableStateFlow(ActivityRule()) +val topAppIdFlow by lazy { + MutableStateFlow(launcherAppId) +} + +private var appLogCount = 0 +private var lastAppId = "" + @Synchronized fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { + val t = System.currentTimeMillis() + if (type > 0 && storeFlow.value.enableBlockA11yAppList) { + topAppIdFlow.value = appId + } val oldActivity = topActivityFlow.value val forced = type > 0 - val t = System.currentTimeMillis() val isSame = oldActivity.sameAs(appId, activityId) if (forced) { lastActivityForceUpdateTime = t @@ -164,27 +183,28 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { } val topActivity = topActivityFlow.value val oldActivityRule = activityRuleFlow.value - val allRules = ruleSummaryFlow.value + val ruleSummary = ruleSummaryFlow.value val idChanged = topActivity.appId != oldActivityRule.topActivity.appId val topChanged = idChanged || oldActivityRule.topActivity != topActivity - val ruleChanged = oldActivityRule.ruleSummary !== allRules + val ruleChanged = oldActivityRule.ruleSummary !== ruleSummary if (topChanged || ruleChanged) { - val allAppRules = allRules.appIdToRules[topActivity.appId] ?: emptyList() val newActivityRule = ActivityRule( - ruleSummary = allRules, + ruleSummary = ruleSummary, topActivity = topActivity, - appRules = allAppRules.filter { rule -> - rule.matchActivity(topActivity.appId, topActivity.activityId) - }, - globalRules = ruleSummaryFlow.value.globalRules.filter { r -> - r.matchActivity(topActivity.appId, topActivity.activityId) - }, ) if (idChanged) { + lastAppId = appId appChangeTime = t - allRules.globalRules.forEach { it.resetState(t) } - allRules.appIdToRules[oldActivityRule.topActivity.appId]?.forEach { it.resetState(t) } - allAppRules.forEach { it.resetState(t) } + appScope.launchTry { + DbSet.appVisitLogDao.insert(lastAppId, appId, t) + appLogCount++ + if (appLogCount % 100 == 0) { + DbSet.appVisitLogDao.deleteKeepLatest() + } + } + ruleSummary.globalRules.forEach { it.resetState(t) } + ruleSummary.appIdToRules[oldActivityRule.topActivity.appId]?.forEach { it.resetState(t) } + newActivityRule.appRules.forEach { it.resetState(t) } } else { newActivityRule.currentRules.forEach { r -> when (r.resetMatchType) { @@ -220,17 +240,11 @@ var lastTriggerTime = 0L @Volatile var appChangeTime = 0L +var imeAppId = "" var launcherAppId = "" -fun updateLauncherAppId() { - launcherAppId = app.packageManager.resolveActivity( - Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), - PackageManager.MATCH_DEFAULT_ONLY - )?.activityInfo?.packageName ?: "" -} - -var imeAppId = "" -fun updateImeAppId() { +fun updateSystemDefaultAppId() { + launcherAppId = app.resolveAppId(Intent.ACTION_MAIN, Intent.CATEGORY_HOME) ?: "" imeAppId = app.getSecureString(Settings.Secure.DEFAULT_INPUT_METHOD) ?.let(ComponentName::unflattenFromString)?.packageName ?: "" } diff --git a/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt b/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt index 62202a3e1d..ab4fb0d366 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt @@ -3,13 +3,11 @@ package li.songe.gkd.data import androidx.paging.PagingSource import androidx.room.ColumnInfo import androidx.room.Dao -import androidx.room.Delete import androidx.room.DeleteTable import androidx.room.Entity import androidx.room.Insert import androidx.room.PrimaryKey import androidx.room.Query -import androidx.room.Update import androidx.room.migration.AutoMigrationSpec import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @@ -46,15 +44,10 @@ data class ActionLog( @Dao interface ActionLogDao { - @Update - suspend fun update(vararg objects: ActionLog): Int @Insert suspend fun insert(vararg objects: ActionLog): List - @Delete - suspend fun delete(vararg objects: ActionLog): Int - @Query("DELETE FROM action_log WHERE subs_id IN (:subsIds)") suspend fun deleteBySubsId(vararg subsIds: Long): Int diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/AppConfig.kt index e47c965681..d6215614e7 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppConfig.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppConfig.kt @@ -1,6 +1,5 @@ package li.songe.gkd.data -import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Entity @@ -10,20 +9,18 @@ import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable @Entity( tableName = "app_config", ) -@Parcelize data class AppConfig( @PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(), @ColumnInfo(name = "enable") val enable: Boolean, @ColumnInfo(name = "subs_id") val subsId: Long, @ColumnInfo(name = "app_id") val appId: String, -) : Parcelable { +) { @Dao interface AppConfigDao { @Update diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index 1d3cee1390..5c328be74d 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -1,12 +1,13 @@ package li.songe.gkd.data -import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build +import android.content.pm.PackageInfoHidden +import dev.rikka.tools.refine.Refine import kotlinx.serialization.Serializable import li.songe.gkd.app +import li.songe.gkd.shizuku.currentUserId +import li.songe.gkd.util.AndroidTarget @Serializable data class AppInfo( @@ -18,49 +19,52 @@ data class AppInfo( val mtime: Long, val hidden: Boolean, val enabled: Boolean, - val userId: Int? = null, + val userId: Int, ) { override fun equals(other: Any?): Boolean { if (other !is AppInfo) return false - return id == other.id && mtime == other.mtime + return id == other.id && mtime == other.mtime && userId == other.userId } override fun hashCode(): Int { var result = id.hashCode() result = 31 * result + mtime.hashCode() + result = 31 * result + userId return result } + + val visible get() = !(hidden && isSystem) } val selfAppInfo by lazy { app.packageManager.getPackageInfo(app.packageName, 0).toAppInfo() } -val PackageInfo.compatVersionCode: Int - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { +private val PackageInfo.compatVersionCode: Int + get() = if (AndroidTarget.P) { longVersionCode.toInt() } else { @Suppress("DEPRECATION") versionCode } +private val PackageInfo.isOverlay: Boolean + get() = try { + Refine.unsafeCast(this).overlayTarget != null + } catch (_: Throwable) { + false + } + fun PackageInfo.toAppInfo( - userId: Int? = null, - hidden: Boolean? = null, -): AppInfo { - return AppInfo( - id = packageName, - versionCode = compatVersionCode, - versionName = versionName, - mtime = lastUpdateTime, - isSystem = applicationInfo?.let { it.flags and ApplicationInfo.FLAG_SYSTEM != 0 } ?: false, - name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, - userId = userId, - hidden = hidden ?: app.packageManager.queryIntentActivities( - Intent(Intent.ACTION_MAIN).setPackage(packageName) - .addCategory(Intent.CATEGORY_LAUNCHER), - PackageManager.MATCH_DISABLED_COMPONENTS - ).isEmpty(), - enabled = applicationInfo?.enabled ?: true, - ) -} + userId: Int = currentUserId, +) = AppInfo( + userId = userId, + id = packageName, + versionCode = compatVersionCode, + versionName = versionName, + mtime = lastUpdateTime, + isSystem = applicationInfo?.let { it.flags and ApplicationInfo.FLAG_SYSTEM != 0 } ?: false, + name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, + hidden = activities?.isEmpty() != false || isOverlay, + enabled = applicationInfo?.enabled ?: true, +) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt new file mode 100644 index 0000000000..bf03482e29 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt @@ -0,0 +1,59 @@ +package li.songe.gkd.data + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import li.songe.gkd.META +import li.songe.gkd.a11y.launcherAppId +import li.songe.gkd.util.systemUiAppId + +@Entity( + tableName = "app_visit_log", +) +data class AppVisitLog( + @PrimaryKey() @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "mtime") val mtime: Long, +) { + @Dao + interface AppLogDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg objects: AppVisitLog): List + + @Transaction + suspend fun insert(oldAppId: String, newAppId: String, mtime: Long) { + insert(AppVisitLog(oldAppId, fixAppVisitTime(oldAppId, mtime - 1))) + insert(AppVisitLog(newAppId, fixAppVisitTime(newAppId, mtime))) + } + + @Query("SELECT DISTINCT id FROM app_visit_log ORDER BY mtime DESC") + fun query(): Flow> + + @Query( + """ + DELETE FROM app_visit_log + WHERE ( + SELECT COUNT(*) + FROM app_visit_log + ) > 1000 + AND mtime <= ( + SELECT mtime + FROM app_visit_log + ORDER BY mtime DESC + LIMIT 1 OFFSET 1000 + ) + """ + ) + suspend fun deleteKeepLatest(): Int + } +} + +private fun fixAppVisitTime(appId: String, t: Long): Long = when (appId) { + META.appId, launcherAppId, systemUiAppId -> t - 60_000 + else -> t +} diff --git a/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt b/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt index 36e8f00d7b..56cd682b5c 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt @@ -1,7 +1,7 @@ package li.songe.gkd.data import kotlinx.serialization.Serializable -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow @Serializable data class ComplexSnapshot( @@ -11,9 +11,9 @@ data class ComplexSnapshot( override val screenHeight: Int, override val screenWidth: Int, override val isLandscape: Boolean, - val appInfo: AppInfo? = appInfoCacheFlow.value[appId], + val appInfo: AppInfo? = appInfoMapFlow.value[appId], val gkdAppInfo: AppInfo? = selfAppInfo, - val device: DeviceInfo = DeviceInfo.instance, + val device: DeviceInfo = DeviceInfo(), val nodes: List, ) : BaseSnapshot { fun toSnapshot(): Snapshot { diff --git a/app/src/main/kotlin/li/songe/gkd/data/DeviceInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/DeviceInfo.kt index a7d31567c4..3ae14432c9 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/DeviceInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/DeviceInfo.kt @@ -5,23 +5,10 @@ import kotlinx.serialization.Serializable @Serializable data class DeviceInfo( - val device: String, - val model: String, - val manufacturer: String, - val brand: String, - val sdkInt: Int, - val release: String, -) { - companion object { - val instance by lazy { - DeviceInfo( - device = Build.DEVICE, - model = Build.MODEL, - manufacturer = Build.MANUFACTURER, - brand = Build.BRAND, - sdkInt = Build.VERSION.SDK_INT, - release = Build.VERSION.RELEASE, - ) - } - } -} + val device: String = Build.DEVICE, + val model: String = Build.MODEL, + val manufacturer: String = Build.MANUFACTURER, + val brand: String = Build.BRAND, + val sdkInt: Int = Build.VERSION.SDK_INT, + val release: String = Build.VERSION.RELEASE, +) diff --git a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt index 121031f9fb..65a6ee1c50 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt @@ -6,10 +6,10 @@ import android.graphics.Path import android.graphics.Rect import android.view.ViewConfiguration import android.view.accessibility.AccessibilityNodeInfo -import com.blankj.utilcode.util.ScreenUtils import kotlinx.serialization.Serializable import li.songe.gkd.service.A11yService import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.util.ScreenUtils @Serializable data class GkdAction( diff --git a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt index 62f6a34f99..1f041f8c4a 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt @@ -17,7 +17,7 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import li.songe.gkd.a11y.typeInfo import li.songe.gkd.util.LOCAL_SUBS_IDS -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.distinctByIfAny import li.songe.gkd.util.filterIfNotAll import li.songe.gkd.util.json @@ -52,9 +52,24 @@ data class RawSubscription( return Objects.hash(id, name, version) } + val isEmpty: Boolean + get() = globalGroups.isEmpty() && apps.all { it.groups.isEmpty() } && categories.isEmpty() + val isLocal: Boolean get() = LOCAL_SUBS_IDS.contains(id) + val hasRule get() = globalGroups.isNotEmpty() || apps.any { it.groups.isNotEmpty() } + + val usedApps by lazy { + apps.run { + if (any { it.groups.isEmpty() }) { + filterNot { it.groups.isEmpty() } + } else { + this + } + } + } + val categoryToGroupsMap by lazy { val allAppGroups = apps.flatMap { a -> a.groups.map { g -> g to a } } allAppGroups.groupBy { g -> @@ -86,7 +101,7 @@ data class RawSubscription( fun getApp(appId: String): RawApp { return apps.find { a -> a.id == appId } ?: RawApp( id = appId, - name = appInfoCacheFlow.value[appId]?.name, + name = appInfoMapFlow.value[appId]?.name, groups = emptyList() ) } diff --git a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt index a7f64d65c8..43cfce9f69 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt @@ -1,6 +1,5 @@ package li.songe.gkd.data -import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete @@ -12,8 +11,8 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import li.songe.gkd.util.isValidAppId private var lastId = 0L @@ -33,7 +32,6 @@ private fun buildUniqueTimeMillisId(): Long { @Entity( tableName = "subs_config", ) -@Parcelize data class SubsConfig( @PrimaryKey @ColumnInfo(name = "id") val id: Long = buildUniqueTimeMillisId(), @ColumnInfo(name = "type") val type: Int, @@ -42,7 +40,7 @@ data class SubsConfig( @ColumnInfo(name = "app_id") val appId: String = "", @ColumnInfo(name = "group_key") val groupKey: Int = -1, @ColumnInfo(name = "exclude", defaultValue = "") val exclude: String = "", -) : Parcelable { +) { @Suppress("ConstPropertyName") companion object { @@ -155,7 +153,7 @@ data class ExcludeData( fun stringify(appId: String? = null): String { return if (appId != null) { activityIds.filter { e -> e.first == appId }.map { e -> e.second }.sorted() - .joinToString("\n") + .joinToString("\n\n") } else { (appIds.entries.map { e -> if (e.value) { @@ -163,7 +161,7 @@ data class ExcludeData( } else { "!${e.key}" } - } + activityIds.map { e -> "${e.first}/${e.second}" }).sorted().joinToString("\n") + } + activityIds.map { e -> "${e.first}/${e.second}" }).sorted().joinToString("\n\n") } } @@ -200,24 +198,31 @@ data class ExcludeData( companion object { private val empty = ExcludeData(emptyMap(), emptySet()) + fun parse(exclude: String?): ExcludeData { - if (exclude == null || exclude.isBlank()) { + if (exclude.isNullOrBlank()) { return empty } - val appIds = mutableMapOf() - val activityIds = mutableSetOf>() - exclude.split('\n', ',').filter { s -> s.isNotBlank() }.map { s -> s.trim() } + val appIds = HashMap() + val activityIds = HashSet>() + exclude.split('\n') + .filter { it.isNotBlank() } .forEach { s -> if (s[0] == '!') { - appIds[s.substring(1)] = false + val appId = s.substring(1) + if (appId.isValidAppId()) { + appIds[appId] = false + } } else { - val a = s.split('/') + val a = s.split('/', limit = 2) val appId = a[0] - val activityId = a.getOrNull(1) - if (activityId != null) { - activityIds.add(appId to activityId) - } else { - appIds[appId] = true + if (appId.isValidAppId()) { + val activityId = a.getOrNull(1) + if (activityId != null) { + activityIds.add(appId to activityId) + } else { + appIds[appId] = true + } } } } @@ -227,8 +232,9 @@ data class ExcludeData( ) } - fun parse(appId: String, exclude: String?): ExcludeData { - return parse((exclude ?: "").split('\n', ',').joinToString("\n") { "$appId/$it" }) + fun parse(exclude: String?, appId: String): ExcludeData { + if (exclude.isNullOrBlank()) return empty + return parse(exclude.split('\n').joinToString("\n") { "$appId/$it" }) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt b/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt index b24f47215c..6a0dfe30d9 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt @@ -2,20 +2,20 @@ package li.songe.gkd.data import android.net.Uri import com.blankj.utilcode.util.LogUtils -import com.blankj.utilcode.util.UriUtils -import com.blankj.utilcode.util.ZipUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import li.songe.gkd.db.DbSet import li.songe.gkd.util.LOCAL_SUBS_IDS +import li.songe.gkd.util.UriUtils +import li.songe.gkd.util.ZipUtils import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.createTempDir import li.songe.gkd.util.json import li.songe.gkd.util.sharedDir -import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription import java.io.File @@ -34,8 +34,9 @@ private data class TransferData( } } +private const val subsDirName = "files" + private suspend fun importTransferData(transferData: TransferData): Boolean { - // TODO transaction val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1 val subsItems = transferData.subsItems.filter { s -> s.id >= 0 || LOCAL_SUBS_IDS.contains(s.id) } @@ -64,24 +65,34 @@ suspend fun exportData(subsIds: Collection): File { ) ) ) - val files = tempDir.resolve("files").apply { mkdir() } - subsIdToRawFlow.value.values.filter { it.id < 0 && subsIds.contains(it.id) }.forEach { - val file = files.resolve("${it.id}.json") - file.writeText(json.encodeToString(it)) + val localSubsList = subsMapFlow.value.values.filter { + it.id < 0 && subsIds.contains(it.id) && !it.isEmpty + } + val files = if (localSubsList.isNotEmpty()) { + val f = tempDir.resolve(subsDirName).apply { mkdir() } + localSubsList.forEach { + val file = f.resolve("${it.id}.json") + file.writeText(json.encodeToString(it)) + } + f + } else { + null } val file = sharedDir.resolve("backup-${System.currentTimeMillis()}.zip") - ZipUtils.zipFiles(listOf(dataFile, files), file) + ZipUtils.zipFiles(listOfNotNull(dataFile, files), file) tempDir.deleteRecursively() return file } suspend fun importData(uri: Uri) { val tempDir = createTempDir() - val zipFile = tempDir.resolve("import.zip") - zipFile.writeBytes(UriUtils.uri2Bytes(uri)) - val unZipImportFile = tempDir.resolve("unzipImport") - ZipUtils.unzipFile(zipFile, unZipImportFile) - val transferFile = unZipImportFile.resolve("${TransferData.TYPE}.json") + val zipFile = tempDir.resolve("file.zip").apply { + writeBytes(UriUtils.uri2Bytes(uri)) + } + val unzipDir = tempDir.resolve("unzip").apply { + ZipUtils.unzipFile(zipFile, this) + } + val transferFile = unzipDir.resolve("${TransferData.TYPE}.json") if (!transferFile.exists() || !transferFile.isFile) { toast("导入无数据") tempDir.deleteRecursively() @@ -91,19 +102,21 @@ suspend fun importData(uri: Uri) { json.decodeFromString(transferFile.readText()) } val hasNewSubsItem = importTransferData(data) - val files = unZipImportFile.resolve("files") - val subscriptions = (files.listFiles { f -> f.isFile && f.name.endsWith(".json") } - ?: emptyArray()).mapNotNull { f -> - try { - RawSubscription.parse(f.readText()) - } catch (e: Exception) { - LogUtils.d(e) - null + val files = unzipDir.resolve(subsDirName) + if (files.exists()) { + val subscriptions = (files.listFiles { f -> f.isFile && f.name.endsWith(".json") } + ?: emptyArray()).mapNotNull { f -> + try { + RawSubscription.parse(f.readText()) + } catch (e: Exception) { + LogUtils.d(e) + null + } } - } - subscriptions.forEach { subscription -> - if (LOCAL_SUBS_IDS.contains(subscription.id)) { - updateSubscription(subscription) + subscriptions.forEach { subscription -> + if (LOCAL_SUBS_IDS.contains(subscription.id)) { + updateSubscription(subscription) + } } } toast("导入成功") diff --git a/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt index 1cc48d5891..631f4ba289 100644 --- a/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt +++ b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt @@ -9,13 +9,14 @@ import androidx.room.migration.AutoMigrationSpec import li.songe.gkd.data.ActionLog import li.songe.gkd.data.ActivityLog import li.songe.gkd.data.AppConfig +import li.songe.gkd.data.AppVisitLog import li.songe.gkd.data.CategoryConfig import li.songe.gkd.data.Snapshot import li.songe.gkd.data.SubsConfig import li.songe.gkd.data.SubsItem @Database( - version = 12, + version = 13, entities = [ SubsItem::class, Snapshot::class, @@ -24,6 +25,7 @@ import li.songe.gkd.data.SubsItem ActionLog::class, ActivityLog::class, AppConfig::class, + AppVisitLog::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -37,6 +39,7 @@ import li.songe.gkd.data.SubsItem AutoMigration(from = 9, to = 10, spec = Migration9To10Spec::class), AutoMigration(from = 10, to = 11, spec = Migration10To11Spec::class), AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13), ] ) abstract class AppDb : RoomDatabase() { @@ -47,6 +50,7 @@ abstract class AppDb : RoomDatabase() { abstract fun categoryConfigDao(): CategoryConfig.CategoryConfigDao abstract fun actionLogDao(): ActionLog.ActionLogDao abstract fun activityLogDao(): ActivityLog.ActivityLogDao + abstract fun appVisitLogDao(): AppVisitLog.AppLogDao } @RenameColumn( diff --git a/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt b/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt index d4903cd92d..eed1daee25 100644 --- a/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt +++ b/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt @@ -29,4 +29,6 @@ object DbSet { get() = db.activityLogDao() val appConfigDao get() = db.appConfigDao() + val appVisitLogDao + get() = db.appVisitLogDao() } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 76942824fd..3875783ade 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -6,7 +6,6 @@ import android.app.PendingIntent import android.app.Service import android.content.Intent import android.content.pm.ServiceInfo -import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat @@ -20,6 +19,7 @@ import li.songe.gkd.service.ButtonService import li.songe.gkd.service.HttpService import li.songe.gkd.service.RecordService import li.songe.gkd.service.ScreenshotService +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.SafeR import li.songe.gkd.util.componentName import kotlin.reflect.KClass @@ -82,11 +82,7 @@ data class Notif( service, id, toNotification(), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST - } else { - -1 - } + if (AndroidTarget.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST else -1 ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 9113b01769..728a2c2d19 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -4,7 +4,6 @@ import android.Manifest import android.app.Activity import android.app.AppOpsManager import android.content.pm.PackageManager -import android.os.Build import android.provider.Settings import androidx.core.content.ContextCompat import com.hjq.permissions.XXPermissions @@ -19,6 +18,7 @@ import li.songe.gkd.app import li.songe.gkd.isActivityVisible import li.songe.gkd.shizuku.shizukuCheckGranted import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.mayQueryPkgNoAccessFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateAllAppInfo @@ -84,7 +84,7 @@ private suspend fun asyncRequestPermission( @Suppress("SameParameterValue") private fun checkOpNoThrow(op: String): Int { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (AndroidTarget.Q) { try { return app.appOpsManager.checkOpNoThrow( op, @@ -178,17 +178,17 @@ val canDrawOverlaysState by lazy { val canWriteExternalStorage by lazy { PermissionState( check = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { + if (AndroidTarget.Q) { true + } else { + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) } }, request = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - asyncRequestPermission(it, PermissionLists.getWriteExternalStoragePermission()) - } else { + if (AndroidTarget.Q) { PermissionResult.Granted + } else { + asyncRequestPermission(it, PermissionLists.getWriteExternalStoragePermission()) } }, reason = AuthReason( diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 1609b22b07..2e1f85c647 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -1,6 +1,7 @@ package li.songe.gkd.service import android.accessibilityservice.AccessibilityService +import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -28,6 +29,7 @@ import li.songe.gkd.util.componentName import li.songe.selector.MatchOption import li.songe.selector.Selector +@SuppressLint("AccessibilityPolicy") abstract class A11yService : AccessibilityService(), OnA11yLife { override fun onCreate() = onCreated() override fun onServiceConnected() = onA11yConnected() @@ -71,6 +73,9 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { } } + @Volatile + var willDestroyByBlock = false + init { useLogLifecycle() useAliveFlow(isRunning) diff --git a/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt index 74ae5df04c..aa056bad76 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt @@ -2,9 +2,6 @@ package li.songe.gkd.service import androidx.compose.foundation.background import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CenterFocusWeak -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -15,25 +12,25 @@ import li.songe.gkd.appScope import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.buttonNotif import li.songe.gkd.permission.canDrawOverlaysState -import li.songe.gkd.ui.theme.AppTheme +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.style.AppTheme import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.launchTry import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass -class ButtonService : OverlayWindowService() { +class ButtonService : OverlayWindowService( + positionKey = "button" +) { override fun onClickView() = appScope.launchTry { SnapshotExt.captureSnapshot() }.let { } - override val positionStoreKey = "overlay_xy_button" - @Composable override fun ComposeContent() = AppTheme(invertedTheme = true) { val alpha = 0.75f - Icon( - imageVector = Icons.Default.CenterFocusWeak, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.CenterFocusWeak, modifier = Modifier .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = alpha)) diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 37e0334d57..ce873522a7 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -3,15 +3,25 @@ package li.songe.gkd.service import android.provider.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import li.songe.gkd.META +import li.songe.gkd.a11y.launcherAppId +import li.songe.gkd.a11y.topAppIdFlow import li.songe.gkd.accessRestrictedSettingsShowFlow import li.songe.gkd.app import li.songe.gkd.appScope +import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.writeSecureSettingsState +import li.songe.gkd.shizuku.safeGetTopCpn +import li.songe.gkd.store.blockA11yAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.launchTry +import li.songe.gkd.util.mapState import li.songe.gkd.util.toast class GkdTileService : BaseTileService() { @@ -22,71 +32,137 @@ class GkdTileService : BaseTileService() { } } -private val modifyA11yMutex by lazy { Mutex() } +private val modifyA11yMutex = Mutex() private const val A11Y_AWAIT_START_TIME = 2000L private const val A11Y_AWAIT_FIX_TIME = 500L -fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { - if (modifyA11yMutex.isLocked) return@launchTry - modifyA11yMutex.withLock { - val newEnableService = !A11yService.isRunning.value - if (A11yService.isRunning.value) { - A11yService.instance?.disableSelf() - } else { - if (!writeSecureSettingsState.updateAndGet()) { - toast("请先授予「写入安全设置权限」") - return@launchTry +private fun modifyA11yRun(block: suspend () -> Unit) { + appScope.launchTry(Dispatchers.IO) { + if (modifyA11yMutex.isLocked) return@launchTry + modifyA11yMutex.withLock { block() } + } +} + +fun switchA11yService() = modifyA11yRun { + val newEnableService = !A11yService.isRunning.value + if (A11yService.isRunning.value) { + A11yService.instance?.disableSelf() + } else { + if (!writeSecureSettingsState.updateAndGet()) { + toast("请先授予「写入安全设置权限」") + return@modifyA11yRun + } + val names = app.getSecureA11yServices() + app.putSecureInt(Settings.Secure.ACCESSIBILITY_ENABLED, 1) + if (names.contains(A11yService.a11yClsName)) { // 当前无障碍异常, 重启服务 + names.remove(A11yService.a11yClsName) + app.putSecureA11yServices(names) + delay(A11Y_AWAIT_FIX_TIME) + } + names.add(A11yService.a11yClsName) + app.putSecureA11yServices(names) + delay(A11Y_AWAIT_START_TIME) + // https://github.com/orgs/gkd-kit/discussions/799 + if (!A11yService.isRunning.value) { + toast("开启无障碍失败") + accessRestrictedSettingsShowFlow.value = true + return@modifyA11yRun + } + } + storeFlow.update { it.copy(enableService = newEnableService) } +} + +fun fixRestartService() = modifyA11yRun { + if (!A11yService.isRunning.value && storeFlow.value.enableService && writeSecureSettingsState.updateAndGet()) { + if (storeFlow.value.enableBlockA11yAppList) { + val topAppId = if (isActivityVisible() || app.justStarted) { + META.appId + } else { + safeGetTopCpn()?.packageName } - val names = app.getSecureA11yServices() - Settings.Secure.putInt( - app.contentResolver, - Settings.Secure.ACCESSIBILITY_ENABLED, - 1 - ) - if (names.contains(A11yService.a11yClsName)) { // 当前无障碍异常, 重启服务 - names.remove(A11yService.a11yClsName) - app.putSecureA11yServices(names) - delay(A11Y_AWAIT_FIX_TIME) + if (topAppId != null && topAppId in blockA11yAppListFlow.value) { + return@modifyA11yRun } - names.add(A11yService.a11yClsName) + } + val names = app.getSecureA11yServices() + val a11yBroken = names.contains(A11yService.a11yClsName) + if (a11yBroken) { + // 无障碍出现故障, 重启服务 + names.remove(A11yService.a11yClsName) app.putSecureA11yServices(names) - delay(A11Y_AWAIT_START_TIME) - // https://github.com/orgs/gkd-kit/discussions/799 - if (!A11yService.isRunning.value) { - toast("开启无障碍失败") - accessRestrictedSettingsShowFlow.value = true - return@launchTry - } + // 必须等待一段时间, 否则概率不会触发系统重启无障碍 + delay(A11Y_AWAIT_FIX_TIME) + } + names.add(A11yService.a11yClsName) + app.putSecureA11yServices(names) + delay(A11Y_AWAIT_START_TIME) + if (!A11yService.isRunning.value) { + toast("重启无障碍失败") + accessRestrictedSettingsShowFlow.value = true + } + } +} + +private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { + if (!storeFlow.value.enableService) { + return@modifyA11yRun + } + if (!storeFlow.value.enableBlockA11yAppList) { + return@modifyA11yRun + } + if (!writeSecureSettingsState.updateAndGet()) { + return@modifyA11yRun + } + val names = app.getSecureA11yServices() + val hasA11y = names.contains(A11yService.a11yClsName) + if (disabled == !hasA11y) { + return@modifyA11yRun + } + if (disabled) { + A11yService.instance?.apply { + willDestroyByBlock = true + disableSelf() } - storeFlow.update { it.copy(enableService = newEnableService) } + } else { + names.add(A11yService.a11yClsName) + app.putSecureA11yServices(names) } } -fun fixRestartService() = appScope.launchTry(Dispatchers.IO) { - if (modifyA11yMutex.isLocked) return@launchTry - modifyA11yMutex.withLock { - // 1. 服务没有运行 - // 2. 用户配置开启了服务 - // 3. 有写入系统设置权限 - if (!A11yService.isRunning.value && storeFlow.value.enableService && writeSecureSettingsState.updateAndGet()) { - val names = app.getSecureA11yServices() - val a11yBroken = names.contains(A11yService.a11yClsName) - if (a11yBroken) { - // 无障碍出现故障, 重启服务 - names.remove(A11yService.a11yClsName) - app.putSecureA11yServices(names) - // 必须等待一段时间, 否则概率不会触发系统重启无障碍服务 - delay(A11Y_AWAIT_FIX_TIME) +private const val A11Y_WHITE_APP_AWAIT_TIME = 3000L + +val a11yPartDisabledFlow by lazy { + topAppIdFlow.mapState(appScope) { + blockA11yAppListFlow.value.contains(it) + } +} + + +fun initA11yWhiteAppList() { + val actualFlow = a11yPartDisabledFlow.drop(1) + appScope.launch(Dispatchers.Main) { + actualFlow.collect { disabled -> + if (!disabled) { + val appId = topAppIdFlow.value + if (appId == launcherAppId) { + // 开启或关闭无障碍会造成卡顿 + appScope.launch { + delay(A11Y_WHITE_APP_AWAIT_TIME) + if (appId == topAppIdFlow.value) { + forcedUpdateA11yService(false) + } + } + } else { + forcedUpdateA11yService(false) + } } - names.add(A11yService.a11yClsName) - app.putSecureA11yServices(names) - delay(A11Y_AWAIT_START_TIME) - if (!A11yService.isRunning.value) { - toast("重启无障碍失败") - accessRestrictedSettingsShowFlow.value = true - return@launchTry + } + } + appScope.launch(Dispatchers.Main) { + actualFlow.debounce(A11Y_WHITE_APP_AWAIT_TIME).collect { disabled -> + if (disabled) { + forcedUpdateA11yService(true) } } } } - diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index 1d36188a5a..6aaff2c4b5 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -154,7 +154,7 @@ data class ReqId( @Serializable data class ServerInfo( - val device: DeviceInfo = DeviceInfo.instance, + val device: DeviceInfo = DeviceInfo(), val gkdAppInfo: AppInfo = selfAppInfo ) diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index e3c166f46d..cc2e72dacf 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -19,27 +19,76 @@ import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.blankj.utilcode.util.BarUtils -import com.blankj.utilcode.util.ScreenUtils +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.permission.canDrawOverlaysState -import li.songe.gkd.store.createTextFlow +import li.songe.gkd.store.createAnyFlow +import li.songe.gkd.util.BarUtils import li.songe.gkd.util.OnSimpleLife +import li.songe.gkd.util.ScreenUtils import li.songe.gkd.util.mapState import li.songe.gkd.util.px import li.songe.gkd.util.toast -private var instanceFlags = mutableListOf() +private var tempShareContext: ShareContext? = null +private fun OverlayWindowService.useShareContext(): ShareContext { + val shareContext = tempShareContext ?: ShareContext().apply { tempShareContext = this } + shareContext.count++ + onDestroyed { + shareContext.count-- + if (shareContext.count == 0) { + shareContext.scope.cancel() + tempShareContext = null + } + } + return shareContext +} + +private class ShareContext { + var count = 0 + val scope = MainScope() + val positionMapFlow = createAnyFlow>>( + key = "overlay_position", + default = { emptyMap() }, + scope = scope, + ) + + init { + scope.launch { + var canDrawOverlays = canDrawOverlaysState.updateAndGet() + topActivityFlow + .mapState(scope) { it.appId to it.activityId } + .collectLatest { + var i = 0 + while (i < 6 && isActive) { + val oldV = canDrawOverlays + val newV = canDrawOverlaysState.updateAndGet() + canDrawOverlays = newV + if (!newV && oldV) { + toast("当前界面拒绝显示悬浮窗") + break + } + delay(500) + i++ + } + } + } + } +} -abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwner, - OnSimpleLife { +abstract class OverlayWindowService( + val positionKey: String, +) : LifecycleService(), SavedStateRegistryOwner, OnSimpleLife { override fun onCreate() { super.onCreate() onCreated() @@ -62,7 +111,7 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne } override val savedStateRegistry = registryController.savedStateRegistry - val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } + private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } @Composable abstract fun ComposeContent() @@ -77,56 +126,34 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne } } - abstract val positionStoreKey: String + private val minMargin get() = 10.dp.px.toInt() + private val defaultPosition get() = listOf(minMargin, BarUtils.getStatusBarHeight()) - private val minMargin: Int - get() = 10.dp.px.toInt() + private val shareContext = useShareContext() - val positionFlow by lazy { - createTextFlow( - key = positionStoreKey, - decode = { - (it ?: "").split(',', limit = 2).mapNotNull { v -> v.toIntOrNull() }.run { - if (size == 2) { - get(0) to get(1) - } else { - minMargin to BarUtils.getStatusBarHeight() - } - } - }, - encode = { - "${it.first},${it.second}" - }, - scope = lifecycleScope, - debounceMillis = 300, - ) - } + private val positionFlow = MutableStateFlow( + shareContext.positionMapFlow.value[positionKey].let { + if (it != null && it.size >= 2) { + it + } else { + defaultPosition + } + } + ) init { - val flag = System.currentTimeMillis() - onCreated { instanceFlags.add(flag) } - onDestroyed { instanceFlags.remove(flag) } - onCreated { - lifecycleScope.launch { - var canDrawOverlays = canDrawOverlaysState.updateAndGet() - topActivityFlow.mapState(lifecycleScope) { it.appId to it.activityId } - .filter { flag == instanceFlags.last() } - .collectLatest { - var i = 0 - while (i < 6 && isActive) { - val oldV = canDrawOverlays - val newV = canDrawOverlaysState.updateAndGet() - canDrawOverlays = newV - if (!newV && oldV) { - toast("当前界面拒绝显示悬浮窗") - break - } - delay(500) - i++ - } + lifecycleScope.launch { + positionFlow.drop(1).debounce(300).collect { pos -> + shareContext.positionMapFlow.update { + it.toMutableMap().apply { + set(positionKey, pos) } + } } } + } + + init { onCreated { val marginX = minMargin val marginY = minMargin @@ -141,8 +168,8 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne ).apply { windowAnimations = android.R.style.Animation_Dialog gravity = Gravity.START or Gravity.TOP - x = positionFlow.value.first - y = positionFlow.value.second + x = positionFlow.value.first() + y = positionFlow.value.last() } var screenWidth = ScreenUtils.getScreenWidth() var screenHeight = ScreenUtils.getScreenHeight() @@ -157,7 +184,7 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne screenHeight - view.height - marginY ) if (x != layoutParams.x || y != layoutParams.y) { - positionFlow.value = x to y + positionFlow.value = listOf(x, y) val startX = layoutParams.x val startY = layoutParams.y val newX = x @@ -210,7 +237,7 @@ abstract class OverlayWindowService : LifecycleService(), SavedStateRegistryOwne marginY, screenHeight - view.height - marginY ) - positionFlow.value = layoutParams.x to layoutParams.y + positionFlow.value = listOf(layoutParams.x, layoutParams.y) windowManager.updateViewLayout(view, layoutParams) } true diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt index 7a2f1a6529..5d645f454f 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -29,17 +29,18 @@ import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.shizuku.SafeTaskListener import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AppNameText -import li.songe.gkd.ui.theme.AppTheme -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.ui.style.AppTheme +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass -class RecordService : OverlayWindowService() { - override val positionStoreKey = "overlay_xy_record" +class RecordService : OverlayWindowService( + positionKey = "record" +) { val topAppInfoFlow by lazy { - appInfoCacheFlow.combine(topActivityFlow) { map, topActivity -> + appInfoMapFlow.combine(topActivityFlow) { map, topActivity -> map[topActivity.appId] }.stateIn(lifecycleScope, SharingStarted.Eagerly, null) } diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index 622d17d95c..87161075da 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -55,11 +55,15 @@ class StatusService : Service(), OnSimpleLife { } return if (!abRunning) { val text = if (a11yServiceEnabledFlow.value) { - "无障碍服务发生故障" + "无障碍发生故障" } else if (writeSecureSettingsState.updateAndGet()) { - "无障碍服务已关闭" + if (store.enableService && a11yPartDisabledFlow.value) { + "无障碍已局部关闭" + } else { + "无障碍已关闭" + } } else { - "无障碍服务未授权" + "无障碍未授权" } Triple(title, text, abNotif.uri) } else if (!store.enableMatch) { @@ -94,6 +98,7 @@ class StatusService : Service(), OnSimpleLife { shizukuWarnFlow, a11yServiceEnabledFlow, writeSecureSettingsState.stateFlow, + a11yPartDisabledFlow, actionCountFlow.debounce(1000L), ) { statusTriple() diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 9b5b43ccad..b0fa8d7eb9 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -1,7 +1,5 @@ package li.songe.gkd.shizuku -import android.content.Intent -import android.content.IntentFilter import android.content.pm.IPackageManager import android.content.pm.PackageInfo import li.songe.gkd.util.checkExistClass @@ -43,16 +41,4 @@ class SafePackageManager(private val value: IPackageManager) { fun getInstalledPackages(flags: Int, userId: Int): List { return safeInvokeMethod { value.compatGetInstalledPackages(flags, userId) } ?: emptyList() } - - fun getAllIntentFilters(packageName: String): List { - return safeInvokeMethod { value.getAllIntentFilters(packageName).list } ?: emptyList() - } - - fun checkAppHidden(appId: String): Boolean { - return !getAllIntentFilters(appId).any { f -> - f.hasAction(Intent.ACTION_MAIN) && f.hasCategory( - Intent.CATEGORY_LAUNCHER - ) - } - } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 4b40a5d7fe..ddda1f51af 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -3,32 +3,22 @@ package li.songe.gkd.shizuku import android.content.ComponentName import android.content.pm.PackageManager -import android.graphics.drawable.Drawable import android.os.IInterface import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.appScope -import li.songe.gkd.data.AppInfo -import li.songe.gkd.data.otherUserMapFlow -import li.songe.gkd.data.toAppInfo import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.store.storeFlow import li.songe.gkd.util.MutexState -import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.launchTry -import li.songe.gkd.util.otherUserAppIconMapFlow -import li.songe.gkd.util.otherUserAppInfoMapFlow -import li.songe.gkd.util.pkgIcon import li.songe.gkd.util.toast -import li.songe.gkd.util.userAppInfoMapFlow import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper @@ -92,7 +82,13 @@ class ShizukuContext( val userManager: SafeUserManager? = null, val activityManager: SafeActivityManager? = null, val activityTaskManager: SafeActivityTaskManager? = null, -) +) { + val ok get() = this !== defaultShizukuContext + fun destroy() { + serviceWrapper?.destroy() + activityTaskManager?.unregisterDefault() + } +} private val defaultShizukuContext = ShizukuContext() @@ -139,11 +135,8 @@ private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { toast("Shizuku 服务连接成功", delayMillis) } } - } else if (shizukuContextFlow.value != defaultShizukuContext) { - shizukuContextFlow.value.run { - serviceWrapper?.destroy() - activityTaskManager?.unregisterDefault() - } + } else if (shizukuContextFlow.value.ok) { + shizukuContextFlow.value.destroy() shizukuContextFlow.value = defaultShizukuContext if (isActivityVisible()) { toast("Shizuku 服务已断开") @@ -152,44 +145,6 @@ private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { } } -fun updateOtherUserAppInfo( - userAppInfoMap: Map = userAppInfoMapFlow.value, -) { - val pkgManager = shizukuContextFlow.value.packageManager - val userManager = shizukuContextFlow.value.userManager - if (pkgManager == null || userManager == null) { - otherUserMapFlow.value = emptyMap() - otherUserAppIconMapFlow.value = emptyMap() - otherUserAppInfoMapFlow.value = emptyMap() - return - } - val otherUsers = userManager.getUsers().filter { it.id != currentUserId }.sortedBy { it.id } - val userPackageInfoMap = otherUsers.associate { user -> - user.id to pkgManager.getInstalledPackages( - PKG_FLAGS, - user.id - ) - } - val newIconMap = HashMap() - val newAppMap = HashMap() - userPackageInfoMap.forEach { (userId, pkgInfoList) -> - val diffPkgList = pkgInfoList.filter { - !userAppInfoMap.contains(it.packageName) && !newAppMap.contains( - it.packageName - ) - } - diffPkgList.forEach { pkgInfo -> - newAppMap[pkgInfo.packageName] = pkgInfo.toAppInfo( - userId = userId, - hidden = pkgManager.checkAppHidden(pkgInfo.packageName), - ) - pkgInfo.pkgIcon?.let { newIconMap[pkgInfo.packageName] = it } - } - } - otherUserMapFlow.value = otherUsers.associateBy { it.id } - otherUserAppInfoMapFlow.value = newAppMap - otherUserAppIconMapFlow.value = newIconMap -} fun initShizuku() { Shizuku.addBinderReceivedListener { @@ -205,12 +160,4 @@ fun initShizuku() { appScope.launchTry { shizukuUsedFlow.collect { updateShizukuBinder() } } - appScope.launchTry(Dispatchers.IO) { - combine( - shizukuContextFlow, - userAppInfoMapFlow, - ) { a, b -> a to b } - .debounce(3000) - .collect { updateOtherUserAppInfo() } - } } diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index 8c48244e1e..ffc0ae517c 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -2,8 +2,8 @@ package li.songe.gkd.store import kotlinx.serialization.Serializable import li.songe.gkd.META +import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.RuleSortOption -import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.UpdateChannelOption import li.songe.gkd.util.UpdateTimeOption @@ -29,18 +29,20 @@ data class SettingsStore( val customNotifTitle: String = META.appName, val customNotifText: String = "\${i}全局/\${k}应用/\${u}规则组/\${n}触发", val enableActivityLog: Boolean = false, - val updateChannel: Int = if (META.versionName.contains("beta")) UpdateChannelOption.Beta.value else UpdateChannelOption.Stable.value, - val sortType: Int = SortTypeOption.SortByName.value, + val updateChannel: Int = if (META.isBeta) UpdateChannelOption.Beta.value else UpdateChannelOption.Stable.value, + val appSort: Int = AppSortOption.ByUsedTime.value, val showSystemApp: Boolean = true, - val showHiddenApp: Boolean = false, - val appRuleSortType: Int = RuleSortOption.Default.value, - val appShowInnerDisable: Boolean = false, - val subsAppSortType: Int = SortTypeOption.SortByName.value, + val showBlockApp: Boolean = false, + val appRuleSort: Int = RuleSortOption.ByDefault.value, + val subsAppSort: Int = AppSortOption.ByUsedTime.value, val subsAppShowUninstallApp: Boolean = false, - val subsExcludeSortType: Int = SortTypeOption.SortByName.value, + val subsExcludeSort: Int = AppSortOption.ByUsedTime.value, val subsExcludeShowSystemApp: Boolean = true, - val subsExcludeShowHiddenApp: Boolean = false, - val subsExcludeShowDisabledApp: Boolean = false, + val subsExcludeShowInnerDisabledApp: Boolean = false, + val subsExcludeShowBlockApp: Boolean = false, val subsPowerWarn: Boolean = true, val enableShizuku: Boolean = false, + val enableBlockA11yAppList: Boolean = false, + val a11yAppSort: Int = AppSortOption.ByUsedTime.value, + val a11yShowSystemApp: Boolean = true, ) diff --git a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt index e79eeecdd5..c5664a39e3 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt @@ -1,19 +1,21 @@ package li.songe.gkd.store import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import li.songe.gkd.appScope +import li.songe.gkd.util.AppListString import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast -val storeFlow by lazy { +val storeFlow: MutableStateFlow by lazy { createAnyFlow( key = "store", default = { SettingsStore() } ) } -val actionCountFlow by lazy { +val actionCountFlow: MutableStateFlow by lazy { createTextFlow( key = "action_count", decode = { it?.toLongOrNull() ?: 0L }, @@ -21,10 +23,28 @@ val actionCountFlow by lazy { ) } +val blockMatchAppListFlow: MutableStateFlow> by lazy { + createTextFlow( + key = "block_match_app_list", + decode = { it?.let(AppListString::decode) ?: AppListString.getDefaultBlockList() }, + encode = AppListString::encode, + ) +} + +val blockA11yAppListFlow: MutableStateFlow> by lazy { + createTextFlow( + key = "block_a11y_app_list", + decode = { it?.let(AppListString::decode) ?: emptySet() }, + encode = AppListString::encode, + ) +} + fun initStore() = appScope.launchTry(Dispatchers.IO) { // preload storeFlow.value actionCountFlow.value + blockMatchAppListFlow.value + blockA11yAppListFlow.value } fun switchStoreEnableMatch() { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 9c6fda31d3..464efaa47c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -17,19 +17,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -65,6 +60,9 @@ import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.RotatingLoadingIcon import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextMenu @@ -152,28 +150,24 @@ fun AboutPage() { Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { + mainVm.popBackStack() + }, + ) }, title = { Text(text = "关于") }, actions = { - IconButton(onClick = { - showShareAppDlg = true - }) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Share, + onClick = { + showShareAppDlg = true + }, + ) } ) } @@ -274,7 +268,7 @@ fun AboutPage() { append(",可点击下方继续反馈") }) }, - confirmText = "继续反馈", + confirmText = "继续", dismissRequest = true, ) mainVm.openUrl(ISSUES_URL) @@ -289,7 +283,7 @@ fun AboutPage() { } SettingItem( title = "导出日志", - imageVector = Icons.Default.Share, + imageVector = PerfIcon.Share, onClick = { showShareLogDlg = true } @@ -303,7 +297,7 @@ fun AboutPage() { ) TextMenu( title = "更新渠道", - option = UpdateChannelOption.allSubObject.findOption(store.updateChannel) + option = UpdateChannelOption.objects.findOption(store.updateChannel) ) { if (mainVm.updateStatus.checkUpdatingFlow.value) return@TextMenu if (it.value == UpdateChannelOption.Beta.value) { @@ -479,6 +473,7 @@ private fun AnimatedLogoIcon( modifier: Modifier = Modifier ) { val darkTheme = LocalDarkTheme.current + val colorRid = if (darkTheme) SafeR.better_white else SafeR.better_black var atEnd by remember { mutableStateOf(false) } val animation = AnimatedImageVector.animatedVectorResource(id = SafeR.ic_anim_logo) val painter = rememberAnimatedVectorPainter( @@ -491,7 +486,6 @@ private fun AnimatedLogoIcon( delay(animation.totalDuration.toLong()) } } - val colorRid = if (darkTheme) SafeR.better_white else SafeR.better_black Icon( modifier = modifier, painter = painter, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index 69db895dab..6691f86419 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -13,25 +13,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -60,6 +56,9 @@ import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.FixedTimeText import li.songe.gkd.ui.component.GroupNameText import li.songe.gkd.ui.component.LocalNumberCharWidth +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.measureNumberTextWidth @@ -68,14 +67,15 @@ import li.songe.gkd.ui.component.useSubs import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.mapState -import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -88,69 +88,75 @@ fun ActionLogPage( val mainVm = LocalMainViewModel.current val vm = viewModel() - val actionDataItems = vm.pagingDataFlow.collectAsLazyPagingItems() - val (scrollBehavior, listState) = useListScrollState(actionDataItems.itemCount > 0) + + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val list = vm.pagingDataFlow.collectAsLazyPagingItems() + val (scrollBehavior, listState) = useListScrollState(resetKey, list.itemCount > 0) val timeTextWidth = measureNumberTextWidth(MaterialTheme.typography.bodySmall) Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = throttle { - mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { + mainVm.popBackStack() + }, + ) }, title = { val title = "触发记录" + val titleModifier = Modifier.noRippleClickable { + resetKey.intValue++ + } if (subsId != null) { TowLineText( title = title, - subtitle = useSubs(subsId)?.name ?: subsId.toString() + subtitle = useSubs(subsId)?.name ?: subsId.toString(), + modifier = titleModifier, ) } else if (appId != null) { TowLineText( title = title, subtitle = appId, showApp = true, + modifier = titleModifier, ) } else { - Text(text = title) + Text( + text = title, + modifier = titleModifier, + ) } }, actions = { - if (actionDataItems.itemCount > 0) { - IconButton(onClick = throttle(fn = mainVm.viewModelScope.launchAsFn { - val text = if (subsId != null) { - "确定删除当前订阅所有触发记录?" - } else if (appId != null) { - "确定删除当前应用所有触发记录?" - } else { - "确定删除所有触发记录?" - } - mainVm.dialogFlow.waitResult( - title = "删除记录", - text = text, - error = true, - ) - if (subsId != null) { - DbSet.actionLogDao.deleteSubsAll(subsId) - } else if (appId != null) { - DbSet.actionLogDao.deleteAppAll(appId) - } else { - DbSet.actionLogDao.deleteAll() - } - toast("删除成功") - })) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + if (list.itemCount > 0) { + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(fn = mainVm.viewModelScope.launchAsFn { + val text = if (subsId != null) { + "确定删除当前订阅所有触发记录?" + } else if (appId != null) { + "确定删除当前应用所有触发记录?" + } else { + "确定删除所有触发记录?" + } + mainVm.dialogFlow.waitResult( + title = "删除记录", + text = text, + error = true, + ) + if (subsId != null) { + DbSet.actionLogDao.deleteSubsAll(subsId) + } else if (appId != null) { + DbSet.actionLogDao.deleteAppAll(appId) + } else { + DbSet.actionLogDao.deleteAll() + } + toast("删除成功") + }) + ) } }) }, content = { contentPadding -> @@ -162,11 +168,11 @@ fun ActionLogPage( state = listState, ) { items( - count = actionDataItems.itemCount, - key = actionDataItems.itemKey { c -> c.first.id } + count = list.itemCount, + key = list.itemKey { c -> c.first.id } ) { i -> - val item = actionDataItems[i] ?: return@items - val lastItem = if (i > 0) actionDataItems[i - 1] else null + val item = list[i] ?: return@items + val lastItem = if (i > 0) list[i - 1] else null ActionLogCard( modifier = Modifier.animateListItem(this), i = i, @@ -181,7 +187,7 @@ fun ActionLogPage( } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) - if (actionDataItems.itemCount == 0 && actionDataItems.loadState.refresh !is LoadState.Loading) { + if (list.itemCount == 0 && list.loadState.refresh !is LoadState.Loading) { EmptyText(text = "暂无记录") } } @@ -216,7 +222,7 @@ private fun ActionLogCard( val lastActionLog = lastItem?.first val isDiffApp = actionLog.appId != lastActionLog?.appId val verticalPadding = if (i == 0) 0.dp else if (isDiffApp) 12.dp else 8.dp - val subsIdToRaw by subsIdToRawFlow.collectAsState() + val subsIdToRaw by subsMapFlow.collectAsState() val subscription = subsIdToRaw[actionLog.subsId] Column( modifier = modifier @@ -349,16 +355,16 @@ private fun ActionLogDialog( onDismissRequest() if (actionLog.groupType == SubsConfig.AppGroupType) { mainVm.navigatePage( - SubsAppGroupListPageDestination( - actionLog.subsId, actionLog.appId, actionLog.groupKey - ) + SubsAppGroupListPageDestination( + actionLog.subsId, actionLog.appId, actionLog.groupKey ) + ) } else if (actionLog.groupType == SubsConfig.GlobalGroupType) { mainVm.navigatePage( - SubsGlobalGroupListPageDestination( - actionLog.subsId, actionLog.groupKey - ) + SubsGlobalGroupListPageDestination( + actionLog.subsId, actionLog.groupKey ) + ) } } ) @@ -366,7 +372,7 @@ private fun ActionLogDialog( if (actionLog.groupType == SubsConfig.GlobalGroupType) { val subs = remember(actionLog.subsId) { - subsIdToRawFlow.mapState(scope) { it[actionLog.subsId] } + subsMapFlow.mapState(scope) { it[actionLog.subsId] } }.collectAsState().value val group = subs?.globalGroups?.find { g -> g.key == actionLog.groupKey } val appChecked = if (group != null) { @@ -399,7 +405,7 @@ private fun ActionLogDialog( .stringify() ) DbSet.subsConfigDao.insert(newSubsConfig) - toast("更新禁用") + toast("更新成功") } ) HorizontalDivider() @@ -435,7 +441,7 @@ private fun ActionLogDialog( .stringify() ) DbSet.subsConfigDao.insert(newSubsConfig) - toast("更新禁用") + toast("更新成功") } ) HorizontalDivider() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt index 237ac5c629..74a000af26 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.combine import li.songe.gkd.data.ActionLog import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.subsMapFlow class ActionLogVm(stateHandle: SavedStateHandle) : ViewModel() { private val args = ActionLogPageDestination.argsFrom(stateHandle) @@ -28,13 +28,14 @@ class ActionLogVm(stateHandle: SavedStateHandle) : ViewModel() { } } .flow - .combine(subsIdToRawFlow) { pagingData, subsIdToRaw -> + .cachedIn(viewModelScope) + .combine(subsMapFlow) { pagingData, subsMap -> pagingData.map { c -> val group = if (c.groupType == SubsConfig.AppGroupType) { - val app = subsIdToRaw[c.subsId]?.apps?.find { a -> a.id == c.appId } + val app = subsMap[c.subsId]?.apps?.find { a -> a.id == c.appId } app?.groups?.find { g -> g.key == c.groupKey } } else { - subsIdToRaw[c.subsId]?.globalGroups?.find { g -> g.key == c.groupKey } + subsMap[c.subsId]?.globalGroups?.find { g -> g.key == c.groupKey } } val rule = group?.rules?.run { if (c.ruleKey != null) { @@ -46,7 +47,6 @@ class ActionLogVm(stateHandle: SavedStateHandle) : ViewModel() { Triple(c, group, rule) } } - .cachedIn(viewModelScope) val showActionLogFlow = MutableStateFlow(null) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt index 7b05e21721..84a1107ec9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt @@ -13,21 +13,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow @@ -46,15 +42,20 @@ import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.FixedTimeText import li.songe.gkd.ui.component.LocalNumberCharWidth +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.ListPlaceholder +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.copyText +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -68,43 +69,41 @@ fun ActivityLogPage() { val logCount by vm.logCountFlow.collectAsState() val list = vm.pagingDataFlow.collectAsLazyPagingItems() - val (scrollBehavior, listState) = useListScrollState(list.itemCount > 0) + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState(resetKey, list.itemCount > 0) val timeTextWidth = measureNumberTextWidth(MaterialTheme.typography.bodySmall) Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + }) }, title = { - Text(text = "界面记录") + Text( + text = "界面记录", + modifier = Modifier.noRippleClickable { resetKey.intValue++ }, + ) }, actions = { if (logCount > 0) { - IconButton(onClick = throttle(fn = vm.viewModelScope.launchAsFn { - mainVm.dialogFlow.waitResult( - title = "删除记录", - text = "确定删除所有界面记录?", - error = true, - ) - DbSet.activityLogDao.deleteAll() - toast("删除成功") - })) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(fn = vm.viewModelScope.launchAsFn { + mainVm.dialogFlow.waitResult( + title = "删除记录", + text = "确定删除所有界面记录?", + error = true, + ) + DbSet.activityLogDao.deleteAll() + toast("删除成功") + }) + ) } - }) + } + ) }) { contentPadding -> LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), @@ -138,8 +137,10 @@ private fun ActivityLogCard( actionLog: ActivityLog, lastActionLog: ActivityLog?, ) { + val mainVm = LocalMainViewModel.current val isDiffApp = actionLog.appId != lastActionLog?.appId val verticalPadding = if (i == 0) 0.dp else if (isDiffApp) 12.dp else 8.dp + val showActivityId = actionLog.showActivityId Column( modifier = Modifier .fillMaxWidth() @@ -154,6 +155,15 @@ private fun ActivityLogCard( } Row( modifier = Modifier + .clickable( + onClick = { + mainVm.textFlow.value = listOfNotNull( + appInfoMapFlow.value[actionLog.appId]?.name, + actionLog.appId, + actionLog.showActivityId, + ).joinToString("\n") + }, + ) .fillMaxWidth() .height(IntrinsicSize.Min) ) { @@ -174,15 +184,9 @@ private fun ActivityLogCard( color = MaterialTheme.colorScheme.secondary, ) CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { - val showActivityId = actionLog.showActivityId if (showActivityId != null) { Text( text = showActivityId, - modifier = Modifier - .clickable(onClick = throttle { - copyText(showActivityId) - }) - .height(LocalTextStyle.current.lineHeight.value.dp), softWrap = false, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogVm.kt index df056bb6a1..cac9c5eed8 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogVm.kt @@ -1,18 +1,16 @@ package li.songe.gkd.ui -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn import li.songe.gkd.db.DbSet +import li.songe.gkd.ui.share.BaseViewModel -class ActivityLogVm : ViewModel() { +class ActivityLogVm : BaseViewModel() { val pagingDataFlow = Pager(PagingConfig(pageSize = 100)) { DbSet.activityLogDao.pagingSource() } .flow.cachedIn(viewModelScope) val logCountFlow = - DbSet.activityLogDao.count().stateIn(viewModelScope, SharingStarted.Eagerly, 0) + DbSet.activityLogDao.count().stateInit(0) } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 188001b261..e2c5475ff9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -3,7 +3,6 @@ package li.songe.gkd.ui import android.app.Activity import android.content.Context import android.media.projection.MediaProjectionManager -import android.os.Build import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable @@ -20,13 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Api -import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -34,7 +27,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -74,15 +66,20 @@ import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthCard +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle @@ -96,9 +93,7 @@ fun AdvancedPage() { val vm = viewModel() val store by storeFlow.collectAsState() - var showEditPortDlg by remember { - mutableStateOf(false) - } + var showEditPortDlg by vm.showEditPortDlgFlow.asMutableState() if (showEditPortDlg) { val portRange = remember { 1000 to 65535 } val placeholderText = remember { "请输入 ${portRange.first}-${portRange.second} 的整数" } @@ -171,16 +166,15 @@ fun AdvancedPage() { Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, title = { Text(text = "高级设置") }) + PerfTopAppBar( + scrollBehavior = scrollBehavior, + navigationIcon = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popBackStack() + }) + }, + title = { Text(text = "高级设置") }, + ) } ) { contentPadding -> Column( @@ -204,7 +198,7 @@ fun AdvancedPage() { val lineHeightDp = LocalDensity.current.run { MaterialTheme.typography.titleSmall.lineHeight.toDp() } - Icon( + PerfIcon( modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { @@ -212,7 +206,7 @@ fun AdvancedPage() { mainVm.dialogFlow.updateDialogOptions( title = "授权状态", text = arrayOf( - "绑定服务" to c.serviceWrapper, + "IUserService" to c.serviceWrapper, "IUserManager" to c.userManager, "IPackageManager" to c.packageManager, "IActivityManager" to c.activityManager, @@ -223,9 +217,8 @@ fun AdvancedPage() { ) }) .size(lineHeightDp), - imageVector = Icons.Outlined.Api, + imageVector = PerfIcon.Api, tint = MaterialTheme.colorScheme.primary, - contentDescription = Icons.Outlined.Api.name, ) } val shizukuOk by shizukuOkState.stateFlow.collectAsState() @@ -261,7 +254,7 @@ fun AdvancedPage() { val localNetworkIps by HttpService.localNetworkIpsFlow.collectAsState() Text( - text = "HTTP服务", + text = "HTTP", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, @@ -326,7 +319,7 @@ fun AdvancedPage() { SettingItem( title = "服务端口", subtitle = store.httpServerPort.toString(), - imageVector = Icons.Outlined.Edit, + imageVector = PerfIcon.Edit, onClick = { showEditPortDlg = true } @@ -357,7 +350,7 @@ fun AdvancedPage() { } ) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (!AndroidTarget.R) { val screenshotRunning by ScreenshotService.isRunning.collectAsState() TextSwitch( title = "截屏服务", @@ -408,7 +401,7 @@ fun AdvancedPage() { TextSwitch( title = "截屏快照", - subtitle = "触发截屏时保存快照", + subtitle = "截屏时保存快照", suffix = "查看限制", onSuffixClick = { mainVm.dialogFlow.updateDialogOptions( @@ -435,7 +428,7 @@ fun AdvancedPage() { TextSwitch( title = "保存提示", - subtitle = "保存时提示\"正在保存快照\"", + subtitle = "提示「正在保存快照」", checked = store.showSaveSnapshotToast ) { storeFlow.value = store.copy( @@ -451,19 +444,24 @@ fun AdvancedPage() { onSuffixClick = { mainVm.navigateWebPage(ShortUrlSet.URL1) }, - imageVector = Icons.Outlined.Edit, + imageVector = PerfIcon.Edit, onClick = { mainVm.showEditCookieDlgFlow.value = true } ) Text( - text = "界面记录", + text = "界面", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) - + SettingItem( + title = "界面记录", + onClick = { + mainVm.navigatePage(ActivityLogPageDestination) + } + ) TextSwitch( title = "记录界面", subtitle = "记录打开的应用及界面", @@ -488,12 +486,6 @@ fun AdvancedPage() { } } ) - SettingItem( - title = "界面记录", - onClick = { - mainVm.navigatePage(ActivityLogPageDestination) - } - ) Spacer(modifier = Modifier.height(EmptyHeight)) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt index 3f5db66f2d..2c3a368153 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt @@ -1,5 +1,9 @@ package li.songe.gkd.ui import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow -class AdvancedVm : ViewModel() +class AdvancedVm : ViewModel() { + + val showEditPortDlgFlow = MutableStateFlow(false) +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index 6ece70d7fe..eace3fe44c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -1,8 +1,6 @@ package li.songe.gkd.ui import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -15,31 +13,22 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,10 +49,14 @@ import kotlinx.coroutines.flow.update import li.songe.gkd.data.ActionLog import li.songe.gkd.data.RawSubscription import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.component.AnimatedBooleanContent import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.BatchActionButtonGroup import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.RuleGroupCard import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.toGroupState @@ -71,15 +64,15 @@ import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.RuleSortOption -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.copyText -import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.switchItem import li.songe.gkd.util.throttle @@ -95,7 +88,12 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { val ruleSortType by vm.ruleSortTypeFlow.collectAsState() val groupSize by vm.groupSizeFlow.collectAsState() val firstLoading by vm.firstLoadingFlow.collectAsState() - val (scrollBehavior, listState) = useListScrollState(groupSize > 0, ruleSortType.value) + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState( + resetKey, + groupSize > 0, + ruleSortType.value + ) if (focusLog != null && groupSize > 0) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { @@ -136,7 +134,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { if (isSelectedMode) { vm.isSelectedModeFlow.value = false @@ -147,25 +145,29 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { BackCloseIcon(backOrClose = !isSelectedMode) } }, title = { + val titleModifier = Modifier.noRippleClickable { + resetKey.intValue++ + } if (isSelectedMode) { Text( + modifier = titleModifier, text = if (selectedDataSet.isNotEmpty()) selectedDataSet.size.toString() else "", ) } else { AppNameText( + modifier = titleModifier, appId = appId ) } }, actions = { var expanded by remember { mutableStateOf(false) } - AnimatedContent( + AnimatedBooleanContent( targetState = isSelectedMode, - transitionSpec = { getUpDownTransform() }, contentAlignment = Alignment.TopEnd, - ) { - Row { - if (it) { - IconButton( + contentTrue = { + Row { + PerfIconButton( + imageVector = PerfIcon.ContentCopy, enabled = selectedDataSet.any { a -> a.appId != null }, onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { val selectGroups = mutableListOf() @@ -178,50 +180,31 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { } val a = RawSubscription.RawApp( id = appId, - name = appInfoCacheFlow.value[appId]?.name, + name = appInfoMapFlow.value[appId]?.name, groups = selectGroups, ) copyText(toJson5String(a)) }) - ) { - Icon( - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, - tint = animateColorAsState(LocalContentColor.current).value, - ) - } + ) BatchActionButtonGroup(vm, selectedDataSet) - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { expanded = true - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, - ) - } - } else { - IconButton(onClick = throttle { + }) + } + }, + contentFalse = { + Row { + PerfIconButton(imageVector = PerfIcon.History, onClick = throttle { mainVm.navigatePage(ActionLogPageDestination(appId = appId)) - }) { - Icon( - imageVector = Icons.Default.History, - contentDescription = null, - ) - } - IconButton(onClick = { + }) + PerfIconButton(imageVector = PerfIcon.Sort, onClick = { expanded = true - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null, - ) - } + }) } - } - } + }, + ) Box( - modifier = Modifier - .wrapContentSize(Alignment.TopStart) + modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { key(isSelectedMode) { DropdownMenu( @@ -254,7 +237,10 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) - RuleSortOption.allSubObject.forEach { s -> + val handleItem: (RuleSortOption) -> Unit = throttle { v -> + storeFlow.update { s -> s.copy(appRuleSort = v.value) } + } + RuleSortOption.objects.forEach { s -> DropdownMenuItem( text = { Text(s.label) @@ -262,39 +248,16 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { trailingIcon = { RadioButton( selected = ruleSortType == s, - onClick = throttle { - storeFlow.update { it.copy(appRuleSortType = s.value) } + onClick = { + handleItem(s) } ) }, - onClick = throttle { - storeFlow.update { it.copy(appRuleSortType = s.value) } + onClick = { + handleItem(s) }, ) } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - DropdownMenuItem( - text = { - Text("显示禁用规则") - }, - trailingIcon = { - val appShowInnerDisable by vm.appShowInnerDisableFlow.collectAsState() - Checkbox( - checked = appShowInnerDisable, - onCheckedChange = throttle { - storeFlow.update { s -> s.copy(appShowInnerDisable = !s.appShowInnerDisable) } - } - ) - }, - onClick = throttle { - storeFlow.update { s -> s.copy(appShowInnerDisable = !s.appShowInnerDisable) } - }, - ) } } } @@ -304,7 +267,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { floatingActionButton = { AnimationFloatingActionButton( visible = !isSelectedMode, - onClick = throttle { + onClick = { mainVm.navigatePage( UpsertRuleGroupPageDestination( subsId = LOCAL_SUBS_ID, @@ -314,10 +277,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { ) }, content = { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - ) + PerfIcon(imageVector = PerfIcon.Add) } ) }, @@ -364,9 +324,8 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { val lineHeightDp = LocalDensity.current.run { MaterialTheme.typography.titleSmall.lineHeight.toDp() } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, tint = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(start = 4.dp) @@ -420,7 +379,6 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { subsConfig = subsConfig, category = category, categoryConfig = categoryConfig, - showBottom = true, onLongClick = onLongClick, isSelectedMode = isSelectedMode, isSelected = isSelected, @@ -433,9 +391,6 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { Spacer(modifier = Modifier.height(EmptyHeight)) if (groupSize == 0 && !firstLoading) { EmptyText(text = "暂无规则") - } else { - // 避免被 floatingActionButton 遮挡 - Spacer(modifier = Modifier.height(EmptyHeight)) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt index 7c42c3132b..0786c59d1e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt @@ -19,7 +19,6 @@ import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.util.RuleSortOption import li.songe.gkd.util.collator import li.songe.gkd.util.findOption -import li.songe.gkd.util.mapState import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.usedSubsEntriesFlow @@ -27,15 +26,11 @@ import li.songe.gkd.util.usedSubsEntriesFlow class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = AppConfigPageDestination.argsFrom(stateHandle) - val ruleSortTypeFlow = storeFlow.mapState(viewModelScope) { - RuleSortOption.allSubObject.findOption(it.appRuleSortType) + val ruleSortTypeFlow = storeFlow.mapNew { + RuleSortOption.objects.findOption(it.appRuleSort) } - val appShowInnerDisableFlow = storeFlow.mapState(viewModelScope) { - it.appShowInnerDisable - } - - private val usedSubsIdsFlow = subsItemsFlow.mapState(viewModelScope) { list -> + private val usedSubsIdsFlow = subsItemsFlow.mapNew { list -> list.filter { it.enable }.map { it.id }.sorted() } @@ -48,7 +43,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { }.stateInit(usedSubsIdsFlow.value) private val latestLogsFlow = ruleSortTypeFlow.map { - if (it == RuleSortOption.ByTime) { + if (it == RuleSortOption.ByActionTime) { DbSet.actionLogDao.queryLatestByAppId(args.appId) } else { flowOf(emptyList()) @@ -71,24 +66,11 @@ class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val temp1ListFlow = combine( appUsedSubsIdsFlow, usedSubsEntriesFlow, - appShowInnerDisableFlow, globalSubsConfigsFlow, - ) { usedSubsIds, list, show, configs -> + ) { usedSubsIds, list, configs -> list.map { e -> val globalGroups = e.subscription.globalGroups .filter { g -> configs.find { it.subsId == e.subsItem.id && it.groupKey == g.key }?.enable != false } - .let { - if (show) { - it - } else { - it.filter { g -> - !e.subscription.getGlobalGroupInnerDisabled( - g, - args.appId - ) - } - } - } val appGroups = if (usedSubsIds.contains(e.subsItem.id)) { e.subscription.getAppGroups(args.appId) } else { @@ -104,8 +86,8 @@ class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { ruleSortTypeFlow ) { list, logs, sortType -> when (sortType) { - RuleSortOption.Default -> list - RuleSortOption.ByName -> list.map { e -> + RuleSortOption.ByDefault -> list + RuleSortOption.ByRuleName -> list.map { e -> e.first to e.second.sortedWith { a, b -> collator.compare( a.name, @@ -114,7 +96,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { } } - RuleSortOption.ByTime -> list.map { e -> + RuleSortOption.ByActionTime -> list.map { e -> e.first to e.second.sortedBy { a -> -(logs.find { c -> c.subsId == e.first.subsItem.id && c.groupType == a.groupType && c.groupKey == a.key @@ -130,7 +112,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : BaseViewModel() { } }.stateInit(emptyList()) - val groupSizeFlow = subsPairsFlow.mapState(viewModelScope) { list -> + val groupSizeFlow = subsPairsFlow.mapNew { list -> list.sumOf { it.second.size } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index 759be048cb..d2a5f48842 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -8,15 +8,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -35,6 +30,9 @@ import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.ManualAuthDialog +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions @@ -54,15 +52,10 @@ fun AppOpsAllowPage() { val foregroundServiceSpecialUse by foregroundServiceSpecialUseState.stateFlow.collectAsStateWithLifecycle() val restrictedCount = arrayOf(foregroundServiceSpecialUse).count { !it } Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + }) }, title = { Text(text = "解除限制") }) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index fc5d292563..e224768f56 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -1,6 +1,5 @@ package li.songe.gkd.ui -import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,16 +9,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -41,6 +35,9 @@ import li.songe.gkd.service.fixRestartService import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.ManualAuthDialog +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight @@ -48,6 +45,7 @@ import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.cardHorizontalPadding import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.surfaceCardColors +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.openA11ySettings @@ -67,15 +65,10 @@ fun AuthA11yPage() { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + }) }, title = { Text(text = "授权状态") }) @@ -106,7 +99,7 @@ fun AuthA11yPage() { Text( modifier = Modifier.padding(cardHorizontalPadding, 0.dp), style = MaterialTheme.typography.bodyMedium, - text = "1. 授予「无障碍权限」\n2. 无障碍服务关闭后需重新授权" + text = "1. 授予「无障碍权限」\n2. 无障碍关闭后需重新授权" ) if (writeSecureSettings || a11yRunning) { Spacer(modifier = Modifier.height(12.dp)) @@ -158,7 +151,7 @@ fun AuthA11yPage() { Text( modifier = Modifier.padding(cardHorizontalPadding, 0.dp), style = MaterialTheme.typography.bodyMedium, - text = "1. 授予「写入安全设置权限」\n2. 授权永久有效, 包含「无障碍权限」\n3. 应用可自行控制开关无障碍服务\n4. 在通知栏快捷开关可快捷重启, 无感保活" + text = "1. 授予「写入安全设置权限」\n2. 授权永久有效, 包含「无障碍权限」\n3. 应用可自行控制开关无障碍\n4. 在通知栏快捷开关可快捷重启, 无感保活" ) if (!writeSecureSettings) { A11yAuthButtonGroup() @@ -245,14 +238,14 @@ fun AuthA11yPage() { } private val a11yCommandText by lazy { - arrayOf( + listOfNotNull( "pm grant ${META.appId} android.permission.WRITE_SECURE_SETTINGS", - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (AndroidTarget.TIRAMISU) { "appops set ${META.appId} ACCESS_RESTRICTED_SETTINGS allow" } else { null }, - ).filterNotNull().joinToString("; ").trimEnd() + ).joinToString("; ") } private fun successAuthExec() { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt new file mode 100644 index 0000000000..e569cc8bb8 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -0,0 +1,353 @@ +package li.songe.gkd.ui + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import kotlinx.coroutines.flow.update +import li.songe.gkd.MainActivity +import li.songe.gkd.data.AppInfo +import li.songe.gkd.store.blockA11yAppListFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.component.AnimatedBooleanContent +import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AppBarTextField +import li.songe.gkd.ui.component.AppIcon +import li.songe.gkd.ui.component.AppNameText +import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.MultiTextField +import li.songe.gkd.ui.component.PerfCheckbox +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar +import li.songe.gkd.ui.component.QueryPkgAuthCard +import li.songe.gkd.ui.component.autoFocus +import li.songe.gkd.ui.component.useListScrollState +import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.icon.BackCloseIcon +import li.songe.gkd.ui.share.ListPlaceholder +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable +import li.songe.gkd.ui.style.EmptyHeight +import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.appItemPadding +import li.songe.gkd.ui.style.menuPadding +import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.AppListString +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.SafeR +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.mapState +import li.songe.gkd.util.switchItem +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast + +@Destination(style = ProfileTransitions::class) +@Composable +fun BlockA11yAppListPage() { + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + val vm = viewModel() + val showSystemApp by vm.showSystemAppFlow.collectAsState() + val sortType by vm.sortTypeFlow.collectAsState() + val appInfos by vm.appInfosFlow.collectAsState() + val searchStr by vm.searchStrFlow.collectAsState() + val showSearchBar by vm.showSearchBarFlow.collectAsState() + val (scrollBehavior, listState) = useListScrollState(vm.resetKey) + val editable by vm.editableFlow.collectAsState() + BackHandler(editable, vm.viewModelScope.launchAsFn { + context.justHideSoftInput() + if (vm.textChanged) { + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + vm.editableFlow.value = false + }) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + PerfTopAppBar( + scrollBehavior = if (editable) { + remember(scrollBehavior) { + object : TopAppBarScrollBehavior by scrollBehavior { + override val isPinned: Boolean + get() = true + } + } + } else { + scrollBehavior + }, + navigationIcon = { + IconButton( + onClick = throttle(vm.viewModelScope.launchAsFn { + if (vm.editableFlow.value) { + if (vm.textChanged) { + context.justHideSoftInput() + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + vm.editableFlow.update { !it } + } else { + context.hideSoftInput() + mainVm.popBackStack() + } + }) + ) { + BackCloseIcon(backOrClose = !editable) + } + }, + title = { + val firstShowSearchBar = remember { showSearchBar } + if (showSearchBar) { + BackHandler { + if (!context.justHideSoftInput()) { + vm.showSearchBarFlow.value = false + } + } + AppBarTextField( + value = searchStr, + onValueChange = { newValue -> + vm.searchStrFlow.value = newValue.trim() + }, + hint = "请输入应用名称/ID", + modifier = if (firstShowSearchBar) Modifier else Modifier.autoFocus(), + ) + } else { + val titleModifier = Modifier + .noRippleClickable( + onClick = throttle { + vm.resetKey.intValue++ + } + ) + Text( + modifier = titleModifier, + text = "无障碍白名单", + ) + } + }, + actions = { + AnimatedBooleanContent( + targetState = editable, + contentAlignment = Alignment.TopEnd, + contentTrue = { + PerfIconButton( + imageVector = PerfIcon.Save, + onClick = throttle { + if (vm.textChanged) { + blockA11yAppListFlow.value = + AppListString.decode(vm.textFlow.value) + toast("更新成功") + } else { + toast("未修改") + } + context.justHideSoftInput() + vm.editableFlow.value = false + }, + ) + }, + contentFalse = { + Row { + PerfIconButton( + imageVector = PerfIcon.Edit, + onClick = vm.viewModelScope.launchAsFn { + if (vm.editableFlow.value && vm.textChanged) { + context.justHideSoftInput() + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + vm.editableFlow.update { !it } + }) + IconButton(onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + vm.showSearchBarFlow.value = false + } else { + vm.searchStrFlow.value = "" + } + } else { + vm.showSearchBarFlow.value = true + } + }) { + AnimatedIcon( + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) + } + var expanded by remember { mutableStateOf(false) } + PerfIconButton(imageVector = PerfIcon.Sort, onClick = { + expanded = true + }) + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Text( + text = "排序", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + val handleItem: (AppSortOption) -> Unit = throttle { v -> + storeFlow.update { s -> s.copy(a11yAppSort = v.value) } + } + AppSortOption.objects.forEach { sortOption -> + DropdownMenuItem( + text = { + Text(sortOption.label) + }, + trailingIcon = { + RadioButton( + selected = sortType == sortOption, + onClick = { + handleItem(sortOption) + } + ) + }, + onClick = { + handleItem(sortOption) + }, + ) + } + Text( + text = "筛选", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + val handle1 = { + storeFlow.update { s -> s.copy(a11yShowSystemApp = !showSystemApp) } + } + DropdownMenuItem( + text = { + Text("显示系统应用") + }, + trailingIcon = { + Checkbox( + checked = showSystemApp, + onCheckedChange = { handle1() } + ) + }, + onClick = handle1, + ) + } + } + } + }, + ) + }) + }, + floatingActionButton = {}, + ) { contentPadding -> + if (editable) { + MultiTextField( + modifier = Modifier.scaffoldPadding(contentPadding), + textFlow = vm.textFlow, + immediateFocus = true, + placeholderText = "请输入应用ID列表\n示例:\ncom.android.systemui\ncom.android.settings", + indicatorText = vm.indicatorTextFlow.collectAsState().value, + ) + } else { + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + ) { + items(appInfos, { it.id }) { appInfo -> + AppItemCard(appInfo) + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (appInfos.isEmpty() && searchStr.isNotEmpty()) { + val hasShowAll = showSystemApp + EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) + } + QueryPkgAuthCard() + } + } + } + } +} + +@Composable +private fun AppItemCard( + appInfo: AppInfo, +) { + val scope = rememberCoroutineScope() + Row( + modifier = Modifier + .clickable(onClick = throttle { + blockA11yAppListFlow.update { it.switchItem(appInfo.id) } + }) + .appItemPadding(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(appId = appInfo.id) + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + AppNameText(appInfo = appInfo) + Text( + text = appInfo.id, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + PerfCheckbox( + key = appInfo.id, + checked = remember(appInfo.id) { + blockA11yAppListFlow.mapState(scope) { + it.contains(appInfo.id) + } + }.collectAsState().value, + ) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt new file mode 100644 index 0000000000..5023ea34ab --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt @@ -0,0 +1,57 @@ +package li.songe.gkd.ui + +import androidx.compose.runtime.mutableIntStateOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import li.songe.gkd.store.blockA11yAppListFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.ui.share.useAppFilter +import li.songe.gkd.util.AppListString +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.findOption + +class BlockA11yAppListVm : BaseViewModel() { + + val sortTypeFlow = storeFlow.mapNew { + AppSortOption.objects.findOption(it.a11yAppSort) + } + val showSystemAppFlow = storeFlow.mapNew { s -> s.a11yShowSystemApp } + + val appFilter = useAppFilter( + sortTypeFlow = sortTypeFlow, + showSystemAppFlow = showSystemAppFlow, + ) + val searchStrFlow = appFilter.searchStrFlow + + val showSearchBarFlow = MutableStateFlow(false) + val appInfosFlow = appFilter.appListFlow + + val resetKey = mutableIntStateOf(0) + val editableFlow = MutableStateFlow(false) + + val textFlow = MutableStateFlow("") + val textChanged get() = blockA11yAppListFlow.value != AppListString.decode(textFlow.value) + + val indicatorTextFlow = textFlow.debounce(500).map { + AppListString.decode(it).size.toString() + }.stateInit("") + + init { + showSearchBarFlow.launchCollect { + if (!it) { + searchStrFlow.value = "" + } + } + editableFlow.launchOnChange { + if (it) { + showSearchBarFlow.value = false + textFlow.value = AppListString.encode(blockA11yAppListFlow.value, append = true) + } + } + appInfosFlow.launchOnChange { + resetKey.intValue++ + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt new file mode 100644 index 0000000000..0546c8c4f1 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt @@ -0,0 +1,79 @@ +package li.songe.gkd.ui + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import li.songe.gkd.MainActivity +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.ui.component.MultiTextField +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar +import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast + +@Destination(style = ProfileTransitions::class) +@Composable +fun EditBlockAppListPage() { + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + val vm = viewModel() + Scaffold(modifier = Modifier, topBar = { + PerfTopAppBar( + modifier = Modifier.fillMaxWidth(), + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = throttle(vm.viewModelScope.launchAsFn { + if (vm.getChangedSet() != null) { + context.justHideSoftInput() + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } else { + context.hideSoftInput() + } + mainVm.popBackStack() + }) + ) + }, + title = { Text(text = "应用白名单") }, + actions = { + PerfIconButton( + imageVector = PerfIcon.Save, + onClick = throttle(vm.viewModelScope.launchAsFn { + val newSet = vm.getChangedSet() + if (newSet != null) { + blockMatchAppListFlow.value = newSet + toast("更新成功") + } else { + toast("未修改") + } + context.hideSoftInput() + mainVm.popBackStack() + }) + ) + } + ) + }) { contentPadding -> + MultiTextField( + modifier = Modifier.scaffoldPadding(contentPadding), + textFlow = vm.textFlow, + indicatorText = vm.indicatorTextFlow.collectAsState().value + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt new file mode 100644 index 0000000000..4fda442ce2 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt @@ -0,0 +1,31 @@ +package li.songe.gkd.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.util.AppListString + +class EditBlockAppListVm : BaseViewModel() { + + val textFlow = MutableStateFlow( + AppListString.encode( + blockMatchAppListFlow.value, + append = true, + ) + ) + + val indicatorTextFlow = textFlow.debounce(500).map { + AppListString.decode(it).size.toString() + }.stateInit("") + + fun getChangedSet(): Set? { + val newSet = AppListString.decode(textFlow.value) + if (blockMatchAppListFlow.value != newSet) { + return newSet + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt index 2975868f63..dc1108a80e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt @@ -1,6 +1,7 @@ package li.songe.gkd.ui import android.webkit.URLUtil +import androidx.activity.compose.LocalActivity import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -16,17 +17,12 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -34,14 +30,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import li.songe.gkd.MainActivity +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.util.imageLoader @@ -55,6 +58,14 @@ fun ImagePreviewPage( uris: Array = emptyArray(), ) { val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + DisposableEffect(null) { + val controller = WindowCompat.getInsetsController(context.window, context.window.decorView) + controller.hide(WindowInsetsCompat.Type.statusBars()) + onDispose { + controller.show(WindowInsetsCompat.Type.statusBars()) + } + } Box( modifier = Modifier .background(MaterialTheme.colorScheme.background) @@ -62,40 +73,35 @@ fun ImagePreviewPage( ) { val showUri = uri ?: if (uris.size == 1) uris.first() else null val state = rememberPagerState { uris.size } - TopAppBar( + PerfTopAppBar( modifier = Modifier .zIndex(1f) .fillMaxWidth(), navigationIcon = { - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + }) }, title = { if (title != null) { - Text(text = title) + Text( + text = title, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + ) } }, actions = { val currentUri = showUri ?: uris.getOrNull(state.currentPage) if (currentUri != null && URLUtil.isNetworkUrl(currentUri)) { - IconButton(onClick = throttle(fn = { + PerfIconButton(imageVector = PerfIcon.OpenInNew, onClick = throttle(fn = { mainVm.openUrl(currentUri) - })) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = null, - ) - } + })) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.3f) + containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.1f) ) ) if (showUri != null) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt index 32224272a0..ca97379fcd 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt @@ -8,16 +8,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -31,6 +24,9 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel @@ -38,7 +34,7 @@ import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle @@ -47,27 +43,22 @@ import li.songe.gkd.util.throttle fun SlowGroupPage() { val mainVm = LocalMainViewModel.current val ruleSummary by ruleSummaryFlow.collectAsState() - val appInfoCache by appInfoCacheFlow.collectAsState() + val appInfoCache by appInfoMapFlow.collectAsState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + }) }, title = { Text(text = "缓慢查询") }, actions = { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Info, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "缓慢查询", text = arrayOf( @@ -76,9 +67,7 @@ fun SlowGroupPage() { "缓慢查询可能导致触发缓慢或更多耗电, 一些可能优化的建议操作\n1. 降低选择器获取新节点次数\n2. 降低或限制规则查询时间或次数" ).joinToString("\n\n"), ) - }) { - Icon(Icons.Outlined.Info, contentDescription = null) - } + }) } ) } @@ -159,9 +148,8 @@ fun SlowGroupCard(title: String, desc: String, modifier: Modifier = Modifier) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt index c216697ac9..aff6ad17ed 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt @@ -1,6 +1,6 @@ package li.songe.gkd.ui -import android.graphics.Bitmap +import android.graphics.BitmapFactory import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -17,23 +17,19 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,8 +39,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.ImageUtils -import com.blankj.utilcode.util.UriUtils import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.ImagePreviewPageDestination @@ -58,20 +52,26 @@ import li.songe.gkd.permission.requiredPermission import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.FixedTimeText import li.songe.gkd.ui.component.LocalNumberCharWidth +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.animateListItem import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.IMPORT_SHORT_URL +import li.songe.gkd.util.ImageUtils import li.songe.gkd.util.SnapshotExt -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.UriUtils +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.copyText import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.saveFileToDownloads @@ -90,41 +90,44 @@ fun SnapshotPage() { val firstLoading by vm.firstLoadingFlow.collectAsState() val snapshots by vm.snapshotsState.collectAsState() var selectedSnapshot by remember { mutableStateOf(null) } - val (scrollBehavior, listState) = useListScrollState(snapshots.isNotEmpty(), firstLoading) + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState( + resetKey, + snapshots.isEmpty(), + firstLoading, + ) val timeTextWidth = measureNumberTextWidth(MaterialTheme.typography.bodySmall) Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + }) + }, + title = { + Text( + text = "快照记录", + modifier = Modifier.noRippleClickable { resetKey.intValue++ }, + ) }, - title = { Text(text = "快照记录") }, actions = { if (snapshots.isNotEmpty()) { - IconButton(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.dialogFlow.waitResult( - title = "删除快照", - text = "确定删除所有快照记录?", - error = true, - ) - snapshots.forEach { s -> - SnapshotExt.removeSnapshot(s.id) - } - DbSet.snapshotDao.deleteAll() - })) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.dialogFlow.waitResult( + title = "删除快照", + text = "确定删除所有快照记录?", + error = true, + ) + snapshots.forEach { s -> + SnapshotExt.removeSnapshot(s.id) + } + DbSet.snapshotDao.deleteAll() + }) + ) } }) }, content = { contentPadding -> @@ -168,14 +171,14 @@ fun SnapshotPage() { Text( text = "查看", modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn { + selectedSnapshot = null mainVm.navigatePage( ImagePreviewPageDestination( - title = appInfoCacheFlow.value[snapshotVal.appId]?.name + title = appInfoMapFlow.value[snapshotVal.appId]?.name ?: snapshotVal.appId, uri = snapshotVal.screenshotFile.absolutePath, ) ) - selectedSnapshot = null })) .then(modifier) ) @@ -198,8 +201,9 @@ fun SnapshotPage() { Text( text = "保存到下载", modifier = Modifier - .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn { + .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { selectedSnapshot = null + toast("正在保存...") val zipFile = SnapshotExt.snapshotZipFile( snapshotVal.id, snapshotVal.appId, @@ -240,14 +244,11 @@ fun SnapshotPage() { Text( text = "保存截图到相册", modifier = Modifier - .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn { + .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { + toast("正在保存...") selectedSnapshot = null requiredPermission(context, canWriteExternalStorage) - ImageUtils.save2Album( - ImageUtils.getBitmap(snapshotVal.screenshotFile), - Bitmap.CompressFormat.PNG, - true - ) + ImageUtils.save2Album(BitmapFactory.decodeFile(snapshotVal.screenshotFile.absolutePath)) toast("保存成功") })) .then(modifier) @@ -258,9 +259,11 @@ fun SnapshotPage() { modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { val uri = context.pickContentLauncher.launchForImageResult() - val oldBitmap = ImageUtils.getBitmap(snapshotVal.screenshotFile) + val oldBitmap = + BitmapFactory.decodeFile(snapshotVal.screenshotFile.absolutePath) val newBytes = UriUtils.uri2Bytes(uri) - val newBitmap = ImageUtils.getBitmap(newBytes, 0) + val newBitmap = + BitmapFactory.decodeByteArray(newBytes, 0, newBytes.size) if (oldBitmap.width == newBitmap.width && oldBitmap.height == newBitmap.height) { snapshotVal.screenshotFile.writeBytes(newBytes) if (snapshotVal.githubAssetId != null) { @@ -326,7 +329,7 @@ private fun SnapshotCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - val appInfo = appInfoCacheFlow.collectAsState().value[snapshot.appId] + val appInfo = appInfoMapFlow.collectAsState().value[snapshot.appId] val showAppName = appInfo?.name ?: snapshot.appId Text( text = showAppName, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index f5deac34b2..fbcba6df89 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -9,24 +9,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,6 +36,9 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.BatchActionButtonGroup import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.RuleGroupCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.animateListItem @@ -50,6 +48,7 @@ import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding @@ -71,12 +70,12 @@ fun SubsAppGroupListPage( ) { val mainVm = LocalMainViewModel.current val vm = viewModel() - val subs = vm.subsRawFlow.collectAsState().value + val subs = vm.subsFlow.collectAsState().value val subsConfigs by vm.subsConfigsFlow.collectAsState() val categoryConfigs by vm.categoryConfigsFlow.collectAsState() val app by vm.subsAppFlow.collectAsState() - val groupToCategoryMap = subs?.groupToCategoryMap ?: emptyMap() + val groupToCategoryMap = subs.groupToCategoryMap val editable = subsItemId < 0 val isSelectedMode = vm.isSelectedModeFlow.collectAsState().value @@ -94,7 +93,8 @@ fun SubsAppGroupListPage( BackHandler(isSelectedMode) { vm.isSelectedModeFlow.value = false } - val (scrollBehavior, listState) = useListScrollState(app.groups.isEmpty()) + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState(resetKey, app.groups.isEmpty()) if (focusGroupKey != null) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { @@ -106,7 +106,7 @@ fun SubsAppGroupListPage( } } Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { if (isSelectedMode) { vm.isSelectedModeFlow.value = false @@ -117,13 +117,16 @@ fun SubsAppGroupListPage( BackCloseIcon(backOrClose = !isSelectedMode) } }, title = { + val titleModifier = Modifier.noRippleClickable { resetKey.intValue++ } if (isSelectedMode) { Text( + modifier = titleModifier, text = if (selectedDataSet.isNotEmpty()) selectedDataSet.size.toString() else "", ) } else { TowLineText( - title = subs?.name ?: subsItemId.toString(), + modifier = titleModifier, + title = subs.name, subtitle = appId, showApp = true, ) @@ -137,73 +140,63 @@ fun SubsAppGroupListPage( ) { if (it) { Row { - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { - val copyGroups = app.groups.filter { g -> - selectedDataSet.any { s -> s.groupKey == g.key } - } - val str = toJson5String(app.copy(groups = copyGroups)) - copyText(str) - })) { - Icon( - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ContentCopy, + onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { + val copyGroups = app.groups.filter { g -> + selectedDataSet.any { s -> s.groupKey == g.key } + } + val str = toJson5String(app.copy(groups = copyGroups)) + copyText(str) + }) + ) BatchActionButtonGroup(vm, selectedDataSet) if (editable) { - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { - subs!! - mainVm.dialogFlow.waitResult( - title = "删除规则组", - text = "删除当前所选规则组?", - error = true, - ) - val keys = selectedDataSet.mapNotNull { g -> g.groupKey } - vm.isSelectedModeFlow.value = false - if (keys.size == app.groups.size) { - updateSubscription( - subs.copy( - apps = subs.apps.filter { a -> a.id != appId } - ) + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(vm.viewModelScope.launchAsFn { + mainVm.dialogFlow.waitResult( + title = "删除规则组", + text = "删除当前所选规则组?", + error = true, ) - DbSet.subsConfigDao.deleteAppConfig(subsItemId, appId) - } else { - updateSubscription( - subs.copy( - apps = subs.apps.toMutableList().apply { - set( - indexOfFirst { a -> a.id == appId }, - app.copy(groups = app.groups.filterNot { g -> - keys.contains( - g.key - ) - }) - ) - } + val keys = selectedDataSet.mapNotNull { g -> g.groupKey } + vm.isSelectedModeFlow.value = false + if (keys.size == app.groups.size) { + updateSubscription( + subs.copy( + apps = subs.apps.filter { a -> a.id != appId } + ) ) - ) - DbSet.subsConfigDao.batchDeleteAppGroupConfig( - subsItemId, - appId, - keys - ) - } - toast("删除成功") - })) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } - } - IconButton(onClick = { - expanded = true - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, + DbSet.subsConfigDao.deleteAppConfig(subsItemId, appId) + } else { + updateSubscription( + subs.copy( + apps = subs.apps.toMutableList().apply { + set( + indexOfFirst { a -> a.id == appId }, + app.copy(groups = app.groups.filterNot { g -> + keys.contains( + g.key + ) + }) + ) + } + ) + ) + DbSet.subsConfigDao.batchDeleteAppGroupConfig( + subsItemId, + appId, + keys + ) + } + toast("删除成功") + }) ) } + PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { + expanded = true + }) } } } @@ -253,7 +246,7 @@ fun SubsAppGroupListPage( if (editable) { AnimationFloatingActionButton( visible = !isSelectedMode, - onClick = throttle { + onClick = { mainVm.navigatePage( UpsertRuleGroupPageDestination( subsId = subsItemId, @@ -263,9 +256,8 @@ fun SubsAppGroupListPage( ) }, content = { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Add, ) } ) @@ -283,13 +275,12 @@ fun SubsAppGroupListPage( } RuleGroupCard( modifier = Modifier.animateListItem(this), - subs = subs!!, + subs = subs, appId = appId, group = group, category = category, subsConfig = subsConfig, categoryConfig = categoryConfig, - showBottom = group !== app.groups.last(), focusGroupFlow = vm.focusGroupFlow, isSelectedMode = isSelectedMode, isSelected = selectedDataSet.any { it.groupKey == group.key }, @@ -315,8 +306,6 @@ fun SubsAppGroupListPage( Spacer(modifier = Modifier.height(EmptyHeight)) if (app.groups.isEmpty()) { EmptyText(text = "暂无规则") - } else if (editable) { - Spacer(modifier = Modifier.height(EmptyHeight)) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt index 83ae7c9875..39f4b8f335 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt @@ -1,33 +1,24 @@ package li.songe.gkd.ui import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.ShowGroupState -import li.songe.gkd.util.mapState -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.ui.share.BaseViewModel -class SubsAppGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { +class SubsAppGroupListVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = SubsAppGroupListPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { s -> s[args.subsItemId] } + val subsFlow = mapSafeSubs(args.subsItemId) + val subsAppFlow = subsFlow.mapNew { it.getApp(args.appId) } val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(args.subsItemId, args.appId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + .stateInit(emptyList()) val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + .stateInit(emptyList()) - val subsAppFlow = subsIdToRawFlow.mapState(viewModelScope) { subsIdToRaw -> - subsIdToRaw[args.subsItemId]?.apps?.find { it.id == args.appId } - ?: RawSubscription.RawApp(id = args.appId, name = null) - } val isSelectedModeFlow = MutableStateFlow(false) val selectedDataSetFlow = MutableStateFlow(emptySet()) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index abfdca15de..5b3e2503d1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -8,21 +8,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -34,21 +28,23 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination import kotlinx.coroutines.flow.update +import li.songe.gkd.MainActivity import li.songe.gkd.data.AppConfig import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AnimatedIcon import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.SubsAppCard import li.songe.gkd.ui.component.TowLineText @@ -57,16 +53,15 @@ import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.useSubs import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.LOCAL_SUBS_IDS import li.songe.gkd.util.SafeR -import li.songe.gkd.util.SortTypeOption -import li.songe.gkd.util.appInfoCacheFlow import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.mapHashCode import li.songe.gkd.util.throttle @@ -76,50 +71,41 @@ fun SubsAppListPage( subsItemId: Long, ) { val mainVm = LocalMainViewModel.current - val context = LocalActivity.current!! - + val context = LocalActivity.current as MainActivity val vm = viewModel() - val appAndConfigs by vm.filterAppAndConfigsFlow.collectAsState() + + val appTripleList by vm.appItemListFlow.collectAsState() val searchStr by vm.searchStrFlow.collectAsState() - val appInfoCache by appInfoCacheFlow.collectAsState() - var showSearchBar by rememberSaveable { - mutableStateOf(false) - } + var showSearchBar by rememberSaveable { mutableStateOf(false) } LaunchedEffect(key1 = showSearchBar, block = { if (!showSearchBar) { vm.searchStrFlow.value = "" } }) - val resetKey = appAndConfigs.mapHashCode { it.first.id } - val (scrollBehavior, listState) = useListScrollState(resetKey) + val (scrollBehavior, listState) = useListScrollState( + vm.resetKey, + ) var expanded by remember { mutableStateOf(false) } val showUninstallApp by vm.showUninstallAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() - val softwareKeyboardController = LocalSoftwareKeyboardController.current Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } - mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = throttle(vm.viewModelScope.launchAsFn { + context.hideSoftInput() + mainVm.popBackStack() + }), + ) }, title = { val firstShowSearchBar = remember { showSearchBar } if (showSearchBar) { BackHandler { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } else { + if (!context.justHideSoftInput()) { showSearchBar = false } } @@ -133,6 +119,9 @@ fun SubsAppListPage( TowLineText( title = useSubs(subsItemId)?.name ?: subsItemId.toString(), subtitle = "应用规则", + modifier = Modifier.noRippleClickable { + vm.resetKey.intValue++ + } ) } }, actions = { @@ -152,11 +141,12 @@ fun SubsAppListPage( atEnd = showSearchBar, ) } - IconButton(onClick = { expanded = true }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = null - ) - } + PerfIconButton( + imageVector = PerfIcon.Sort, + onClick = { + expanded = true + }, + ) Box( modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { @@ -167,7 +157,10 @@ fun SubsAppListPage( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) - SortTypeOption.allSubObject.forEach { sortOption -> + val handleItem: (AppSortOption) -> Unit = throttle { v -> + storeFlow.update { s -> s.copy(subsAppSort = v.value) } + } + AppSortOption.objects.forEach { sortOption -> DropdownMenuItem( text = { Text(sortOption.label) @@ -176,11 +169,11 @@ fun SubsAppListPage( RadioButton( selected = sortType == sortOption, onClick = { - storeFlow.update { s -> s.copy(subsAppSortType = sortOption.value) } + handleItem(sortOption) }) }, onClick = { - storeFlow.update { s -> s.copy(subsAppSortType = sortOption.value) } + handleItem(sortOption) }, ) } @@ -190,18 +183,20 @@ fun SubsAppListPage( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) + val handle1 = { + storeFlow.update { s -> s.copy(subsAppShowUninstallApp = !s.subsAppShowUninstallApp) } + } DropdownMenuItem( text = { Text("显示未安装应用") }, trailingIcon = { - Checkbox(checked = showUninstallApp, onCheckedChange = { - storeFlow.update { s -> s.copy(subsAppShowUninstallApp = it) } - }) - }, - onClick = { - storeFlow.update { s -> s.copy(subsAppShowUninstallApp = !showUninstallApp) } + Checkbox( + checked = showUninstallApp, + onCheckedChange = { handle1() } + ) }, + onClick = handle1, ) } } @@ -219,9 +214,8 @@ fun SubsAppListPage( ) ) }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Add, ) } } @@ -231,26 +225,20 @@ fun SubsAppListPage( modifier = Modifier.scaffoldPadding(contentPadding), state = listState ) { - items(appAndConfigs, { a -> a.first.id }) { a -> - val (appRaw, appConfig, enableSize) = a + items(appTripleList, { it.id }) { a -> SubsAppCard( - rawApp = appRaw, - appInfo = appInfoCache[appRaw.id], - appConfig = appConfig, - enableSize = enableSize, + data = a, onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } - mainVm.navigatePage(SubsAppGroupListPageDestination(subsItemId, appRaw.id)) + context.justHideSoftInput() + mainVm.navigatePage(SubsAppGroupListPageDestination(subsItemId, a.id)) }, onValueChange = throttle(fn = vm.viewModelScope.launchAsFn { enable -> - val newItem = appConfig?.copy( + val newItem = a.appConfig?.copy( enable = enable ) ?: AppConfig( enable = enable, subsId = subsItemId, - appId = appRaw.id, + appId = a.id, ) DbSet.appConfigDao.insert(newItem) }), @@ -259,7 +247,7 @@ fun SubsAppListPage( item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) val firstLoading by vm.firstLoadingFlow.collectAsState() - if (appAndConfigs.isEmpty() && !firstLoading) { + if (appTripleList.isEmpty() && !firstLoading) { EmptyText( text = if (searchStr.isNotEmpty()) { if (showUninstallApp) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件" @@ -267,6 +255,7 @@ fun SubsAppListPage( "暂无规则" } ) + Spacer(modifier = Modifier.height(EmptyHeight / 2)) } QueryPkgAuthCard() } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt index 5acb75cd3f..7b640c0681 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt @@ -1,29 +1,29 @@ package li.songe.gkd.ui +import androidx.compose.runtime.mutableIntStateOf import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.SubsAppListPageDestination import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map +import li.songe.gkd.MainViewModel import li.songe.gkd.data.AppConfig +import li.songe.gkd.data.AppInfo import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.share.BaseViewModel -import li.songe.gkd.util.SortTypeOption -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.collator import li.songe.gkd.util.findOption import li.songe.gkd.util.getGroupEnable -import li.songe.gkd.util.mapState -import li.songe.gkd.util.subsIdToRawFlow class SubsAppListVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = SubsAppListPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { s -> s[args.subsItemId] } + val subsFlow = mapSafeSubs(args.subsItemId) private val appConfigsFlow = DbSet.appConfigDao.queryAppTypeConfig(args.subsItemId) .attachLoad().stateInit(emptyList()) @@ -34,128 +34,142 @@ class SubsAppListVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) .attachLoad().stateInit(emptyList()) - private val appIdToOrderFlow = - DbSet.actionLogDao.queryLatestUniqueAppIds(args.subsItemId).attachLoad() - .map { appIds -> - appIds.mapIndexed { index, appId -> appId to index }.toMap() - } - val sortTypeFlow = - storeFlow.mapState(viewModelScope) { SortTypeOption.allSubObject.findOption(it.subsAppSortType) } - - val showUninstallAppFlow = storeFlow.mapState(viewModelScope) { it.subsAppShowUninstallApp } - private val rawAppsFlow = subsRawFlow.mapState(viewModelScope) { subs -> - (subs?.apps ?: emptyList()).run { - if (any { it.groups.isEmpty() }) { - filterNot { it.groups.isEmpty() } - } else { - this - } - } - } - private val temp0ListFlow = combine(rawAppsFlow, appInfoCacheFlow) { rawApps, appInfoCache -> - rawApps.sortedWith { a, b -> + private val temp0ListFlow = combine(subsFlow, appInfoMapFlow) { subs, appInfoCache -> + subs.usedApps.map { + it to appInfoCache[it.id] + }.sortedWith { a, b -> // 顺序: 已安装(有名字->无名字)->未安装(有名字(来自订阅)->无名字) - collator.compare(appInfoCache[a.id]?.name ?: a.name?.let { "\uFFFF" + it } - ?: ("\uFFFF\uFFFF" + a.id), - appInfoCache[b.id]?.name ?: b.name?.let { "\uFFFF" + it } - ?: ("\uFFFF\uFFFF" + b.id)) + val x = a.second?.name ?: a.first.name?.let { "\uFFFF" + it } + ?: ("\uFFFF\uFFFF" + a.first.id) + val y = b.second?.name ?: b.first.name?.let { "\uFFFF" + it } + ?: ("\uFFFF\uFFFF" + b.first.id) + collator.compare(x, y) } } + + val showUninstallAppFlow = storeFlow.mapNew { it.subsAppShowUninstallApp } private val temp1ListFlow = combine( temp0ListFlow, - appInfoCacheFlow, - showUninstallAppFlow - ) { apps, appInfoCache, showUninstallApp -> + showUninstallAppFlow, + ) { apps, showUninstallApp -> if (showUninstallApp) { apps } else { - apps.filter { a -> appInfoCache.containsKey(a.id) } + apps.filter { it.second != null } } } - private val sortAppsFlow = combine( + + val sortTypeFlow = storeFlow.mapNew { + AppSortOption.objects.findOption(it.subsAppSort) + } + private val appActionOrderMapFlow = DbSet.actionLogDao + .queryLatestUniqueAppIds(args.subsItemId) + .map { + it.mapIndexed { i, appId -> appId to i }.toMap() + } + private val temp2ListFlow = combine( temp1ListFlow, - appInfoCacheFlow, - appIdToOrderFlow, - sortTypeFlow - ) { apps, appInfoCache, appIdToOrder, sortType -> + appActionOrderMapFlow, + sortTypeFlow, + MainViewModel.instance.appVisitOrderMapFlow, + ) { apps, appIdToOrder, sortType, appVisitOrderMap -> when (sortType) { - SortTypeOption.SortByAppMtime -> { - apps.sortedBy { a -> -(appInfoCache[a.id]?.mtime ?: 0) } - } - SortTypeOption.SortByTriggerTime -> { - apps.sortedBy { a -> appIdToOrder[a.id] ?: Int.MAX_VALUE } + AppSortOption.ByActionTime -> { + apps.sortedBy { a -> appIdToOrder[a.first.id] ?: Int.MAX_VALUE } } - SortTypeOption.SortByName -> { + AppSortOption.ByAppName -> { apps } - } - }.stateInit(emptyList()) - val searchStrFlow = MutableStateFlow("") - - private val debounceSearchStr = searchStrFlow.debounce(200).stateInit(searchStrFlow.value) - - - private val appAndConfigsFlow = combine( - subsRawFlow, - sortAppsFlow, - categoryConfigsFlow, - appConfigsFlow, - groupSubsConfigsFlow, - ) { subsRaw, apps, categoryConfigs, appSubsConfigs, groupSubsConfigs -> - val groupToCategoryMap = subsRaw?.groupToCategoryMap ?: emptyMap() - apps.map { app -> - val appGroupSubsConfigs = groupSubsConfigs.filter { s -> s.appId == app.id } - val enableSize = app.groups.count { g -> - getGroupEnable( - g, - appGroupSubsConfigs.find { c -> c.groupKey == g.key }, - groupToCategoryMap[g], - categoryConfigs.find { c -> c.categoryKey == groupToCategoryMap[g]?.key } - ) + AppSortOption.ByUsedTime -> { + apps.sortedBy { a -> appVisitOrderMap[a.first.id] ?: Int.MAX_VALUE } } - Triple(app, appSubsConfigs.find { s -> s.appId == app.id }, enableSize) } - }.stateInit(emptyList()) + } - val filterAppAndConfigsFlow = combine( - appAndConfigsFlow, debounceSearchStr, appInfoCacheFlow - ) { appAndConfigs, searchStr, appInfoCache -> + val searchStrFlow = MutableStateFlow("") + private val debounceSearchStr = searchStrFlow.debounce(200).stateInit(searchStrFlow.value) + val temp3ListFlow = combine( + temp2ListFlow, + debounceSearchStr, + ) { apps, searchStr -> if (searchStr.isBlank()) { - appAndConfigs + apps } else { - val results = mutableListOf>() - val remnantList = appAndConfigs.toMutableList() + val results = mutableListOf>() + val tempList = apps.toMutableList() //1. 搜索已安装应用名称 - remnantList.toList().apply { remnantList.clear() }.forEach { a -> - val name = appInfoCache[a.first.id]?.name - if (name?.contains(searchStr, true) == true) { + tempList.toList().apply { tempList.clear() }.forEach { a -> + if (a.second?.name?.contains(searchStr, true) == true) { results.add(a) } else { - remnantList.add(a) + tempList.add(a) } } //2. 搜索未安装应用名称 - remnantList.toList().apply { remnantList.clear() }.forEach { a -> + tempList.toList().apply { tempList.clear() }.forEach { a -> val name = a.first.name - if (appInfoCache[a.first.id] == null && name?.contains(searchStr, true) == true) { + if (a.second == null && name?.contains(searchStr, true) == true) { results.add(a) } else { - remnantList.add(a) + tempList.add(a) } } //3. 搜索应用 id - remnantList.toList().apply { remnantList.clear() }.forEach { a -> + tempList.toList().apply { tempList.clear() }.forEach { a -> if (a.first.id.contains(searchStr, true)) { results.add(a) } else { - remnantList.add(a) + tempList.add(a) } } results } }.stateInit(emptyList()) -} \ No newline at end of file + val appItemListFlow = combine( + subsFlow, + temp3ListFlow, + categoryConfigsFlow, + appConfigsFlow, + groupSubsConfigsFlow, + ) { subsRaw, apps, categoryConfigs, appConfigs, groupSubsConfigs -> + val groupToCategoryMap = subsRaw.groupToCategoryMap + apps.map { + val appGroupSubsConfigs = groupSubsConfigs.filter { s -> s.appId == it.first.id } + val enableSize = it.first.groups.count { g -> + getGroupEnable( + g, + appGroupSubsConfigs.find { c -> c.groupKey == g.key }, + groupToCategoryMap[g], + categoryConfigs.find { c -> c.categoryKey == groupToCategoryMap[g]?.key } + ) + } + SubsAppInfoItem( + rawApp = it.first, + appInfo = it.second, + appConfig = appConfigs.find { s -> s.appId == it.first.id }, + configSize = enableSize, + ) + } + }.stateInit(emptyList()) + + val resetKey = mutableIntStateOf(0) + + init { + appItemListFlow.mapNew { it.map { a -> a.id } }.launchOnChange { + resetKey.intValue++ + } + } +} + +data class SubsAppInfoItem( + val rawApp: RawSubscription.RawApp, + val appInfo: AppInfo?, + val appConfig: AppConfig?, + val configSize: Int, +) { + val id get() = rawApp.id +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt index d52861b0d4..0932751fb7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt @@ -11,26 +11,17 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable @@ -53,6 +44,9 @@ import li.songe.gkd.data.CategoryConfig import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions @@ -74,33 +68,31 @@ import li.songe.gkd.util.updateSubscription @Destination(style = ProfileTransitions::class) @Composable -fun SubsCategoryPage(subsItemId: Long) { +fun SubsCategoryPage(@Suppress("unused") subsItemId: Long) { val mainVm = LocalMainViewModel.current val vm = viewModel() val subs = vm.subsRawFlow.collectAsState().value val categoryConfigMap = vm.categoryConfigMapFlow.collectAsState().value - val categories = subs?.categories ?: emptyList() + val categories = subs.categories val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { + mainVm.popBackStack() + }, + ) }, title = { TowLineText( - title = subs?.name ?: subsItemId.toString(), + title = subs.name, subtitle = "规则类别" ) }, actions = { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Info, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "类别说明", text = arrayOf( @@ -109,16 +101,13 @@ fun SubsCategoryPage(subsItemId: Long) { "因此如果手动开关了规则组(规则手动配置), 则该规则组不会被批量开关, 可通过点击类别-重置规则组开关, 来移除类别下所有规则手动配置", ).joinToString("\n\n"), ) - }) { - Icon(Icons.Outlined.Info, contentDescription = null) - } + }) }) }, floatingActionButton = { - if (subs != null && subs.isLocal) { + if (subs.isLocal) { FloatingActionButton(onClick = { vm.showAddCategoryFlow.value = true }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "add", + PerfIcon( + imageVector = PerfIcon.Add, ) } } @@ -129,25 +118,22 @@ fun SubsCategoryPage(subsItemId: Long) { items(categories, { it.key }) { category -> CategoryItemCard( vm = vm, - subs = subs!!, + subs = subs, category = category, categoryConfig = categoryConfigMap[category.key], - showBottom = categories.last() != category ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (categories.isEmpty()) { EmptyText(text = "暂无类别") - } else if (subs != null && subs.isLocal) { - Spacer(modifier = Modifier.height(EmptyHeight)) } } } } val editCategory by vm.editCategoryFlow.collectAsState() - if (subs != null && editCategory != null) { + if (editCategory != null) { AddOrEditCategoryDialog( subs = subs, category = editCategory, @@ -156,7 +142,7 @@ fun SubsCategoryPage(subsItemId: Long) { } } val showAddCategory by vm.showAddCategoryFlow.collectAsState() - if (subs != null && showAddCategory) { + if (showAddCategory) { AddOrEditCategoryDialog( subs = subs, category = null, @@ -172,7 +158,6 @@ private fun CategoryItemCard( subs: RawSubscription, category: RawSubscription.RawCategory, categoryConfig: CategoryConfig?, - showBottom: Boolean, ) { val groups = subs.categoryToGroupsMap[category] ?: emptyList() var expanded by remember { mutableStateOf(false) } @@ -185,9 +170,8 @@ private fun CategoryItemCard( onClick = onClick, shape = MaterialTheme.shapes.extraSmall, modifier = Modifier.padding( - start = 8.dp, - end = 8.dp, - bottom = if (showBottom) 4.dp else 0.dp + horizontal = 8.dp, + vertical = 2.dp, ), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { @@ -227,7 +211,7 @@ private fun CategoryItemCard( Spacer(modifier = Modifier.width(8.dp)) val enable = getCategoryEnable(category, categoryConfig) TriStateCheckbox( - state = EnableGroupOption.allSubObject.findOption(enable).toToggleableState(), + state = EnableGroupOption.objects.findOption(enable).toToggleableState(), onClick = throttle(appScope.launchAsFn { val option = when (enable) { false -> EnableGroupOption.FollowSubs @@ -268,9 +252,8 @@ private fun CategoryMenu( if (groups.isNotEmpty()) { DropdownMenuItem( leadingIcon = { - Icon( + PerfIcon( imageVector = ResetSettings, - contentDescription = null ) }, text = { Text(text = "重置规则组开关") }, @@ -291,9 +274,8 @@ private fun CategoryMenu( if (subs.isLocal) { DropdownMenuItem( leadingIcon = { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.Edit, ) }, text = { Text(text = "编辑") }, @@ -305,9 +287,8 @@ private fun CategoryMenu( DropdownMenuItem( text = { Text(text = "删除", color = MaterialTheme.colorScheme.error) }, leadingIcon = { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Delete, ) }, onClick = throttle(vm.viewModelScope.launchAsFn { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt index adc1ee647c..c6cafa4d3b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt @@ -1,28 +1,23 @@ package li.songe.gkd.ui import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.SubsCategoryPageDestination import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet -import li.songe.gkd.util.mapState -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.ui.share.BaseViewModel -class SubsCategoryVm(stateHandle: SavedStateHandle) : ViewModel() { +class SubsCategoryVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = SubsCategoryPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { m -> m[args.subsItemId] } + val subsRawFlow = mapSafeSubs(args.subsItemId) val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + .stateInit(emptyList()) - val categoryConfigMapFlow = categoryConfigsFlow.map { it.associateBy { it.categoryKey } } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + val categoryConfigMapFlow = categoryConfigsFlow.map { it.associateBy { c -> c.categoryKey } } + .stateInit(emptyMap()) val editCategoryFlow = MutableStateFlow(null) val showAddCategoryFlow = MutableStateFlow(false) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index 57d3b8f0bc..80c6da3e39 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -9,33 +9,20 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -43,44 +30,52 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import kotlinx.coroutines.flow.update +import li.songe.gkd.MainActivity import li.songe.gkd.a11y.launcherAppId import li.songe.gkd.data.ExcludeData import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet -import li.songe.gkd.store.storeFlow +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.ui.component.AnimatedBooleanContent import li.songe.gkd.ui.component.AnimatedIcon import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon import li.songe.gkd.ui.component.AppNameText -import li.songe.gkd.ui.component.CardFlagBar +import li.songe.gkd.ui.component.DropdownMenuCheckboxItem +import li.songe.gkd.ui.component.DropdownMenuRadioButtonItem import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.FlagCard import li.songe.gkd.ui.component.InnerDisableSwitch +import li.songe.gkd.ui.component.MultiTextField +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfSwitch +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState +import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions -import li.songe.gkd.ui.style.itemFlagPadding +import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.SafeR -import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.mapHashCode import li.songe.gkd.util.systemAppsFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -89,232 +84,253 @@ import li.songe.gkd.util.toast @Composable fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { val mainVm = LocalMainViewModel.current - val context = LocalActivity.current!! + val context = LocalActivity.current as MainActivity val vm = viewModel() - val rawSubs = vm.rawSubsFlow.collectAsState().value - val group = vm.groupFlow.collectAsState().value + val subs = vm.subsFlow.collectAsState().value + val group = vm.groupFlow.collectAsState().value ?: return val excludeData = vm.excludeDataFlow.collectAsState().value val showAppInfos = vm.showAppInfosFlow.collectAsState().value - val searchStr by vm.searchStrFlow.collectAsState() + + var searchStr by vm.searchStrFlow.asMutableState() val showSystemApp by vm.showSystemAppFlow.collectAsState() - val showHiddenApp by vm.showHiddenAppFlow.collectAsState() - val showDisabledApp by vm.showDisabledAppFlow.collectAsState() + val showBlockApp by vm.showBlockAppFlow.collectAsState() + val showInnerDisabledApp by vm.showInnerDisabledAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() + var editable by vm.editableFlow.asMutableState() - var showEditDlg by remember { - mutableStateOf(false) - } var showSearchBar by rememberSaveable { mutableStateOf(false) } LaunchedEffect(key1 = showSearchBar, block = { if (!showSearchBar) { - vm.searchStrFlow.value = "" + searchStr = "" } }) - val resetKey = showAppInfos.mapHashCode { it.id } - val (scrollBehavior, listState) = useListScrollState(resetKey) - var expanded by remember { mutableStateOf(false) } - val softwareKeyboardController = LocalSoftwareKeyboardController.current + val (scrollBehavior, listState) = useListScrollState( + vm.resetKey, + ) + + BackHandler(editable, onBack = throttle(vm.viewModelScope.launchAsFn { + context.justHideSoftInput() + if (vm.changedValue != null) { + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + editable = false + })) + Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { + if (vm.editableFlow.value) { + editable = false + context.justHideSoftInput() + } else { + context.hideSoftInput() + mainVm.popBackStack() } - mainVm.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) + })) { + BackCloseIcon(backOrClose = !editable) } }, title = { - val firstShowSearchBar = remember { showSearchBar } if (showSearchBar) { BackHandler { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } else { + if (!context.justHideSoftInput()) { showSearchBar = false } } AppBarTextField( value = searchStr, - onValueChange = { newValue -> vm.searchStrFlow.value = newValue.trim() }, + onValueChange = { newValue -> + searchStr = newValue.trim() + }, hint = "请输入应用名称/ID", - modifier = if (firstShowSearchBar) Modifier else Modifier.autoFocus(), + modifier = Modifier.autoFocus(), ) } else { TowLineText( - title = "编辑禁用", - subtitle = "${rawSubs?.name ?: subsItemId}/${group?.name ?: groupKey}" + title = group.name, + subtitle = "编辑禁用", + modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ } ) } }, actions = { - IconButton(onClick = { - showEditDlg = true - }) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = null - ) - } - IconButton(onClick = { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - showSearchBar = false - } else { - vm.searchStrFlow.value = "" - } - } else { - showSearchBar = true - } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - ) - } - IconButton(onClick = { - expanded = true - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null - ) - } - Box( - modifier = Modifier - .wrapContentSize(Alignment.TopStart) - ) { - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - Text( - text = "排序", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - SortTypeOption.allSubObject.forEach { sortOption -> - DropdownMenuItem( - text = { - Text(sortOption.label) - }, - trailingIcon = { - RadioButton( - selected = sortType == sortOption, - onClick = { - storeFlow.update { it.copy(subsExcludeSortType = sortOption.value) } - } + AnimatedBooleanContent( + targetState = editable, + contentAlignment = Alignment.TopEnd, + contentTrue = { + PerfIconButton( + imageVector = PerfIcon.Save, + onClick = throttle(vm.viewModelScope.launchAsFn { + val newExclude = vm.changedValue + if (newExclude != null) { + val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( + type = SubsConfig.GlobalGroupType, + subsId = subsItemId, + groupKey = groupKey, + )).copy( + exclude = newExclude.stringify() ) + DbSet.subsConfigDao.insert(subsConfig) + toast("更新成功") + } else { + toast("未修改") + } + context.justHideSoftInput() + editable = false + }), + ) + }, + contentFalse = { + Row { + PerfIconButton( + imageVector = PerfIcon.Edit, + onClick = { + editable = true + showSearchBar = false }, + ) + IconButton(onClick = { + if (showSearchBar) { + if (searchStr.isEmpty()) { + showSearchBar = false + } else { + searchStr = "" + } + } else { + showSearchBar = true + } + }) { + AnimatedIcon( + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) + } + var expanded by remember { mutableStateOf(false) } + PerfIconButton( + imageVector = PerfIcon.Sort, onClick = { - storeFlow.update { it.copy(subsExcludeSortType = sortOption.value) } + expanded = true }, ) + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Text( + text = "排序", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + AppSortOption.objects.forEach { sortOption -> + DropdownMenuRadioButtonItem( + text = sortOption.label, + selected = sortType == sortOption, + onClick = { + vm.sortTypeFlow.value = sortOption + } + ) + } + Text( + text = "筛选", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + DropdownMenuCheckboxItem( + text = "显示系统应用", + checked = showSystemApp, + onCheckedChange = { + vm.showSystemAppFlow.value = it + } + ) + DropdownMenuCheckboxItem( + text = "显示内置禁用", + checked = showInnerDisabledApp, + onCheckedChange = { + vm.showInnerDisabledAppFlow.value = it + } + ) + DropdownMenuCheckboxItem( + text = "显示白名单", + checked = showBlockApp, + onCheckedChange = { + vm.showBlockAppFlow.value = it + } + ) + } + } } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - DropdownMenuItem( - text = { - Text("显示系统应用") - }, - trailingIcon = { - Checkbox( - checked = showSystemApp, - onCheckedChange = { - storeFlow.update { it.copy(subsExcludeShowSystemApp = !it.subsExcludeShowSystemApp) } - }) - }, - onClick = { - storeFlow.update { it.copy(subsExcludeShowSystemApp = !it.subsExcludeShowSystemApp) } - }, - ) - DropdownMenuItem( - text = { - Text("显示隐藏应用") - }, - trailingIcon = { - Checkbox( - checked = showHiddenApp, - onCheckedChange = { - storeFlow.update { it.copy(subsExcludeShowHiddenApp = !it.subsExcludeShowHiddenApp) } - }) - }, - onClick = { - storeFlow.update { it.copy(subsExcludeShowHiddenApp = !it.subsExcludeShowHiddenApp) } - }, - ) - DropdownMenuItem( - text = { - Text("显示禁用应用") - }, - trailingIcon = { - Checkbox( - checked = showDisabledApp, - onCheckedChange = { - storeFlow.update { it.copy(subsExcludeShowDisabledApp = !it.subsExcludeShowDisabledApp) } - }) - }, - onClick = { - storeFlow.update { it.copy(subsExcludeShowDisabledApp = !it.subsExcludeShowDisabledApp) } - }, - ) - } - } - + }, + ) }) - }, content = { contentPadding -> - LazyColumn( - modifier = Modifier.scaffoldPadding(contentPadding), - state = listState - ) { - items(showAppInfos, { it.id }) { appInfo -> - Row( - modifier = Modifier - .itemFlagPadding(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - AppIcon(appId = appInfo.id) - Spacer(modifier = Modifier.width(12.dp)) - - Column( + }) { contentPadding -> + if (editable) { + MultiTextField( + modifier = Modifier.scaffoldPadding(contentPadding), + textFlow = vm.excludeTextFlow, + immediateFocus = true, + placeholderText = tipText, + ) + } else { + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + ) { + items(showAppInfos, { it.id }) { appInfo -> + FlagCard( + visible = excludeData.appIds.contains(appInfo.id), modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.SpaceBetween + .itemPadding() + .fillMaxWidth(), ) { - AppNameText(appInfo = appInfo) - Text( - text = appInfo.id, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, + Row( modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - if (group != null) { - val checked = getGlobalGroupChecked( - rawSubs!!, - excludeData, - group, - appInfo.id - ) - if (checked != null) { - key(appInfo.id) { - Switch( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AppIcon(appId = appInfo.id) + Column( + modifier = Modifier.weight(1f), + ) { + AppNameText(appInfo = appInfo) + Text( + text = appInfo.id, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val blockMatch = + blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) + if (blockMatch) { + PerfIcon( + modifier = Modifier + .padding(2.dp) + .size(20.dp), + imageVector = PerfIcon.Block, + tint = MaterialTheme.colorScheme.secondary, + ) + } + val checked = getGlobalGroupChecked( + subs, + excludeData, + group, + appInfo.id + ) + if (checked != null) { + PerfSwitch( + key = appInfo.id, checked = checked, onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newChecked -> val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( @@ -323,92 +339,34 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { groupKey = groupKey, )).copy( exclude = excludeData.copy( - appIds = excludeData.appIds.toMutableMap().apply { - set(appInfo.id, !newChecked) - }) + appIds = excludeData.appIds.toMutableMap() + .apply { + set(appInfo.id, !newChecked) + }) .stringify() ) DbSet.subsConfigDao.insert(subsConfig) }), ) + } else { + InnerDisableSwitch() } - } else { - InnerDisableSwitch() } } - CardFlagBar(visible = excludeData.appIds.containsKey(appInfo.id)) } - } - item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { - Spacer(modifier = Modifier.height(EmptyHeight)) - if (showAppInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = showSystemApp && showHiddenApp - EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (showAppInfos.isEmpty() && searchStr.isNotEmpty()) { + val hasShowAll = + showSystemApp && showBlockApp && showInnerDisabledApp + EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) + } + QueryPkgAuthCard() } - QueryPkgAuthCard() } } - }) - - if (group != null && showEditDlg) { - var source by remember { - mutableStateOf( - excludeData.stringify() - ) - } - val oldSource = remember { source } - AlertDialog( - properties = DialogProperties(dismissOnClickOutside = false), - title = { Text(text = "编辑禁用") }, - text = { - OutlinedTextField( - value = source, - onValueChange = { source = it }, - modifier = Modifier - .fillMaxWidth() - .autoFocus(), - placeholder = { - Text( - text = tipText, - style = MaterialTheme.typography.bodySmall, - ) - }, - minLines = 8, - maxLines = 12, - textStyle = MaterialTheme.typography.bodySmall - ) - }, - onDismissRequest = { - showEditDlg = false - }, - dismissButton = { - TextButton(onClick = { showEditDlg = false }) { - Text(text = "取消") - } - }, - confirmButton = { - TextButton(onClick = throttle(vm.viewModelScope.launchAsFn { - if (oldSource == source) { - showEditDlg = false - return@launchAsFn - } - showEditDlg = false - val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( - type = SubsConfig.GlobalGroupType, - subsId = subsItemId, - groupKey = groupKey, - )).copy( - exclude = ExcludeData.parse(source).stringify() - ) - DbSet.subsConfigDao.insert(subsConfig) - toast("更新成功") - })) { - Text(text = "更新") - } - }, - ) } - } // null - 内置禁用 diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt index ecd4a96a6b..a955c37466 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt @@ -1,106 +1,100 @@ package li.songe.gkd.ui +import androidx.compose.runtime.mutableIntStateOf import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupExcludePageDestination import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import li.songe.gkd.data.ExcludeData import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow -import li.songe.gkd.util.SortTypeOption +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.ui.share.asMutableStateFlow +import li.songe.gkd.ui.share.useAppFilter +import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.findOption -import li.songe.gkd.util.mapState -import li.songe.gkd.util.orderedAppInfosFlow -import li.songe.gkd.util.subsIdToRawFlow -class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { +class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = SubsGlobalGroupExcludePageDestination.argsFrom(stateHandle) - val rawSubsFlow = subsIdToRawFlow.mapState(viewModelScope) { it[args.subsItemId] } + val subsFlow = mapSafeSubs(args.subsItemId) + val groupFlow = subsFlow.mapNew { r -> r.globalGroups.find { g -> g.key == args.groupKey } } + val subsConfigFlow = DbSet.subsConfigDao + .queryGlobalGroupTypeConfig(args.subsItemId, args.groupKey) + .stateInit(null) + val excludeDataFlow = subsConfigFlow.mapNew { s -> ExcludeData.parse(s?.exclude) } - val groupFlow = - rawSubsFlow.mapState(viewModelScope) { r -> r?.globalGroups?.find { g -> g.key == args.groupKey } } - - val subsConfigFlow = - DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId, args.groupKey) - .stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val excludeDataFlow = - subsConfigFlow.mapState(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } + val sortTypeFlow = storeFlow.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.subsExcludeSort) }, + setter = { + storeFlow.value.copy(subsExcludeSort = it.value) + } + ) + val showSystemAppFlow = storeFlow.asMutableStateFlow( + getter = { it.subsExcludeShowSystemApp }, + setter = { + storeFlow.value.copy(subsExcludeShowSystemApp = it) + } + ) + val showInnerDisabledAppFlow = storeFlow.asMutableStateFlow( + getter = { it.subsExcludeShowInnerDisabledApp }, + setter = { + storeFlow.value.copy(subsExcludeShowInnerDisabledApp = it) + } + ) + val showBlockAppFlow = storeFlow.asMutableStateFlow( + getter = { it.subsExcludeShowBlockApp }, + setter = { + storeFlow.value.copy(subsExcludeShowBlockApp = it) + } + ) - val searchStrFlow = MutableStateFlow("") - private val debounceSearchStrFlow = searchStrFlow.debounce(200) - .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) + val appFilter = useAppFilter( + appOrderListFlow = DbSet.actionLogDao.queryLatestUniqueAppIds( + args.subsItemId, + args.groupKey + ).stateInit(emptyList()), + sortTypeFlow = sortTypeFlow, + showSystemAppFlow = showSystemAppFlow, + showBlockAppFlow = showBlockAppFlow, + ) + val searchStrFlow = appFilter.searchStrFlow - private val appIdToOrderFlow = - DbSet.actionLogDao.queryLatestUniqueAppIds(args.subsItemId, args.groupKey).map { appIds -> - appIds.mapIndexed { index, appId -> appId to index }.toMap() + val showAppInfosFlow = combine( + appFilter.appListFlow, + showInnerDisabledAppFlow, + subsFlow, + groupFlow, + ) { apps, showDisabledApp, rawSubs, group -> + if (showDisabledApp || group == null) { + apps + } else { + apps.filter { a -> !rawSubs.getGlobalGroupInnerDisabled(group, a.id) } + } + }.stateInit(emptyList()).apply { + launchOnChange { + resetKey.intValue++ } - val sortTypeFlow = storeFlow.mapState(viewModelScope) { - SortTypeOption.allSubObject.findOption(it.subsExcludeSortType) } - val showSystemAppFlow = storeFlow.mapState(viewModelScope) { it.subsExcludeShowSystemApp } - val showHiddenAppFlow = storeFlow.mapState(viewModelScope) { it.subsExcludeShowHiddenApp } - val showDisabledAppFlow = storeFlow.mapState(viewModelScope) { it.subsExcludeShowDisabledApp } - val showAppInfosFlow = - orderedAppInfosFlow.combine(showHiddenAppFlow) { appInfos, showHiddenApp -> - if (showHiddenApp) { - appInfos - } else { - appInfos.filter { a -> !a.hidden } - } - }.combine(showSystemAppFlow) { apps, showSystemApp -> - if (showSystemApp) { - apps - } else { - apps.filter { a -> !a.isSystem } - } - }.let { - combine(it, sortTypeFlow, appIdToOrderFlow) { apps, sortType, appIdToOrder -> - when (sortType) { - SortTypeOption.SortByAppMtime -> { - apps.sortedBy { a -> -a.mtime } - } - SortTypeOption.SortByTriggerTime -> { - apps.sortedBy { a -> appIdToOrder[a.id] ?: Int.MAX_VALUE } - } - - SortTypeOption.SortByName -> { - apps - } - } + val resetKey = mutableIntStateOf(0) + val excludeTextFlow = MutableStateFlow("") + val editableFlow = MutableStateFlow(false).apply { + launchOnChange { + if (it) { + excludeTextFlow.value = excludeDataFlow.value.stringify() } - }.combine(debounceSearchStrFlow) { apps, str -> - if (str.isBlank()) { - apps + } + } + + val changedValue: ExcludeData? + get() { + val newExclude = ExcludeData.parse(excludeTextFlow.value) + return if (newExclude != excludeDataFlow.value) { + newExclude } else { - (apps.filter { a -> a.name.contains(str, true) } + apps.filter { a -> - a.id.contains( - str, - true - ) - }).distinct() + null } - }.let { - combine( - it, - showDisabledAppFlow, - rawSubsFlow, - groupFlow, - ) { apps, showDisabledApp, rawSubs, group -> - if (showDisabledApp || rawSubs == null || group == null) { - apps - } else { - apps.filter { a -> !rawSubs.getGlobalGroupInnerDisabled(group, a.id) } - } - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index f318ebd329..dc27b4b1b2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -9,23 +9,19 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,6 +36,9 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.BatchActionButtonGroup import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.RuleGroupCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.animateListItem @@ -49,6 +48,7 @@ import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding @@ -68,8 +68,8 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { val subs = vm.subsRawFlow.collectAsState().value val subsConfigs by vm.subsConfigsFlow.collectAsState() - val editable = subsItemId < 0 && subs != null - val globalGroups = subs?.globalGroups ?: emptyList() + val editable = subsItemId < 0 + val globalGroups = subs.globalGroups val isSelectedMode = vm.isSelectedModeFlow.collectAsState().value val selectedDataSet = vm.selectedDataSetFlow.collectAsState().value @@ -87,7 +87,8 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { vm.isSelectedModeFlow.value = false } - val (scrollBehavior, listState) = useListScrollState(globalGroups.isEmpty()) + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState(resetKey, globalGroups.isEmpty()) if (focusGroupKey != null) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { @@ -101,7 +102,7 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { if (isSelectedMode) { vm.isSelectedModeFlow.value = false @@ -112,13 +113,16 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { BackCloseIcon(backOrClose = !isSelectedMode) } }, title = { + val titleModifier = Modifier.noRippleClickable { resetKey.intValue++ } if (isSelectedMode) { Text( + modifier = titleModifier, text = selectedDataSet.size.toString(), ) } else { TowLineText( - title = subs?.name ?: subsItemId.toString(), + modifier = titleModifier, + title = subs.name, subtitle = "全局规则" ) } @@ -133,7 +137,8 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { Row { BatchActionButtonGroup(vm, selectedDataSet) if (editable) { - IconButton( + PerfIconButton( + imageVector = PerfIcon.Delete, onClick = throttle( vm.viewModelScope.launchAsFn( Dispatchers.Default @@ -160,21 +165,13 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { ) toast("删除成功") }) - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } - } - IconButton(onClick = { - expanded = true - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, ) } + PerfIconButton( + imageVector = PerfIcon.MoreVert, + onClick = { + expanded = true + }) } } } @@ -221,7 +218,7 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { }, floatingActionButton = { if (editable) { - AnimationFloatingActionButton(visible = !isSelectedMode, onClick = throttle { + AnimationFloatingActionButton(visible = !isSelectedMode, onClick = { mainVm.navigatePage( UpsertRuleGroupPageDestination( subsId = subsItemId, @@ -230,9 +227,8 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { ) ) }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "add", + PerfIcon( + imageVector = PerfIcon.Add, ) } } @@ -246,14 +242,13 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { val subsConfig = subsConfigs.find { it.groupKey == group.key } RuleGroupCard( modifier = Modifier.animateListItem(this), - subs = subs!!, + subs = subs, appId = null, group = group, focusGroupFlow = vm.focusGroupFlow, subsConfig = subsConfig, category = null, categoryConfig = null, - showBottom = group !== globalGroups.last(), isSelectedMode = isSelectedMode, isSelected = selectedDataSet.any { it.groupKey == group.key }, onLongClick = { @@ -275,8 +270,6 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { Spacer(modifier = Modifier.height(EmptyHeight)) if (globalGroups.isEmpty()) { EmptyText(text = "暂无规则") - } else if (editable) { - Spacer(modifier = Modifier.height(EmptyHeight)) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt index 14e225a96c..7bc8fe4c03 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt @@ -1,23 +1,18 @@ package li.songe.gkd.ui import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.ShowGroupState -import li.songe.gkd.util.mapState -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.ui.share.BaseViewModel -class SubsGlobalGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { +class SubsGlobalGroupListVm(stateHandle: SavedStateHandle) : BaseViewModel() { private val args = SubsGlobalGroupListPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.mapState(viewModelScope) { s -> s[args.subsItemId] } + val subsRawFlow = mapSafeSubs(args.subsItemId) val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + .stateInit(emptyList()) val isSelectedModeFlow = MutableStateFlow(false) val selectedDataSetFlow = MutableStateFlow(emptySet()) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt index ea48970449..8c3a03bb2f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt @@ -8,18 +8,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState @@ -38,9 +32,11 @@ import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListP import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import li.songe.gkd.MainActivity +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalDarkTheme @@ -67,11 +63,9 @@ fun UpsertRuleGroupPage( val checkIfSaveText = throttle(mainVm.viewModelScope.launchAsFn(Dispatchers.Default) { if (vm.hasTextChanged()) { - vm.viewModelScope.launch { - context.hideSoftInput() - } + context.justHideSoftInput() mainVm.dialogFlow.waitResult( - title = "放弃编辑", + title = "提示", text = "当前内容未保存,是否放弃编辑?", ) } else { @@ -108,23 +102,20 @@ fun UpsertRuleGroupPage( }) BackHandler(true, checkIfSaveText) Scaffold(modifier = Modifier, topBar = { - TopAppBar( + PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { - IconButton(onClick = checkIfSaveText) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = checkIfSaveText) }, title = { Text(text = if (vm.isEdit) "编辑规则组" else "添加规则组") }, actions = { - IconButton(onClick = onClickSave, enabled = text.isNotBlank()) { - Icon(imageVector = Icons.Default.Check, contentDescription = null) - } + PerfIconButton( + imageVector = PerfIcon.Save, + onClick = onClickSave, + enabled = text.isNotBlank() + ) } ) }) { paddingValues -> @@ -140,7 +131,7 @@ fun UpsertRuleGroupPage( .fillMaxSize(), ) { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { - val imeShowing by context.imeShowingFlow.collectAsState() + val imeShowing by context.imePlayingFlow.collectAsState() val modifier = Modifier .autoFocus() .fillMaxSize() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt index 4a04771fee..b58ad0a8d2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import li.songe.gkd.data.RawSubscription import li.songe.gkd.ui.style.clearJson5TransformationCache -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription import li.songe.json5.Json5 @@ -25,7 +25,7 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { val isAddAnyApp = appId == "" private val initialGroup: RawSubscription.RawGroupProps? = run { - val subs = subsIdToRawFlow.value[args.subsId] + val subs = subsMapFlow.value[args.subsId] subs ?: return@run null if (groupKey != null) { if (appId != null) { @@ -52,7 +52,7 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { var addAppId: String? = null fun saveRule() { - val subs = subsIdToRawFlow.value[args.subsId] ?: error("订阅不存在") + val subs = subsMapFlow.value[args.subsId] ?: error("订阅不存在") val text = textFlow.value if (text.isBlank()) { error("规则不能为空") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt index 14c8a7f924..6e48370403 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt @@ -2,7 +2,6 @@ package li.songe.gkd.ui import android.annotation.SuppressLint import android.graphics.Bitmap -import android.os.Build import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -13,20 +12,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,10 +42,14 @@ import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.data.Value +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.client import li.songe.gkd.util.copyText import li.songe.gkd.util.openUri @@ -71,15 +66,13 @@ fun WebViewPage( val webViewClient = remember { GkdWebViewClient() } val webView = remember { Value(null) } Scaffold(modifier = Modifier, topBar = { - TopAppBar( + PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { - IconButton(onClick = { mainVm.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { mainVm.popBackStack() }, + ) }, title = { val loadingState = webViewState.loadingState @@ -107,23 +100,15 @@ fun WebViewPage( }, actions = { if (chromeVersion > 0 && chromeVersion < MINI_CHROME_VERSION) { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.WarningAmber, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "兼容性提示", text = "检测到您的系统内置浏览器版本($chromeVersion)过低, 可能无法正常浏览网页文档\n\n建议自行升级版本后重启 GKD 再查看文档, 或点击右上角后在外部浏览器打开查阅\n\n若能正常浏览文档请忽略此项提示" ) - }) { - Icon( - imageVector = Icons.Default.WarningAmber, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - } + }) } var expanded by remember { mutableStateOf(false) } - IconButton(onClick = { expanded = true }) { - Icon(imageVector = Icons.Default.MoreVert, contentDescription = null) - } + PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { expanded = true }) Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) @@ -179,7 +164,7 @@ fun WebViewPage( @SuppressLint("SetJavaScriptEnabled") javaScriptEnabled = true domStorageEnabled = true - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (AndroidTarget.TIRAMISU) { setAlgorithmicDarkeningAllowed(false) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedBooleanContent.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedBooleanContent.kt new file mode 100644 index 0000000000..83053fa3ee --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedBooleanContent.kt @@ -0,0 +1,30 @@ +package li.songe.gkd.ui.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import li.songe.gkd.util.getUpDownTransform + +@Composable +fun AnimatedBooleanContent( + targetState: Boolean, + modifier: Modifier = Modifier, + transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { getUpDownTransform() }, + contentAlignment: Alignment = Alignment.TopStart, + contentTrue: @Composable () -> Unit, + contentFalse: @Composable () -> Unit, +) = AnimatedContent( + targetState = targetState, + modifier = modifier, + transitionSpec = transitionSpec, + contentAlignment = contentAlignment, +) { + if (it) { + contentTrue() + } else { + contentFalse() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt index 958079c736..081161082e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import li.songe.gkd.util.throttle private const val elevationDurationMillis = 50 @@ -63,7 +64,7 @@ fun AnimationFloatingActionButton( translationX = (1f - percent.value) * maxTranslationX ), elevation = FloatingActionButtonDefaults.elevation(defaultElevation = (defaultElevation.value * 6f).dp), - onClick = onClick, + onClick = throttle(onClick), content = content, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt index d89f9bc142..21c2b2ca5a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt @@ -2,9 +2,6 @@ package li.songe.gkd.ui.component import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Android -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier @@ -26,9 +23,8 @@ fun AppIcon( modifier = iconModifier ) } else { - Icon( - imageVector = Icons.Default.Android, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Android, modifier = iconModifier ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt index 39a49af4a2..577ce6ef0b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt @@ -4,9 +4,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.VerifiedUser -import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -27,26 +24,33 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.shizuku.currentUserId +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Composable fun AppNameText( + modifier: Modifier = Modifier, appId: String? = null, appInfo: AppInfo? = null, fallbackName: String? = null, ) { - val info = appInfo ?: appInfoCacheFlow.collectAsState().value[appId] + val info = appInfo ?: appInfoMapFlow.collectAsState().value[appId] val showSystemIcon = info?.isSystem == true val appName = (info?.name ?: fallbackName ?: appId ?: error("appId is required")) - val userName = info?.userId?.let { - val userInfo = otherUserMapFlow.collectAsState().value[info.userId] - "「${userInfo?.name ?: info.userId}」" + val userName = info?.userId?.let { userId -> + if (userId == currentUserId) { + null + } else { + val userInfo = otherUserMapFlow.collectAsState().value[userId] + "「${userInfo?.name ?: userId}」" + } } val textDecoration = if (info?.enabled == false) TextDecoration.LineThrough else null if (!showSystemIcon && userName == null) { Text( + modifier = modifier, text = appName, maxLines = 1, softWrap = false, @@ -86,13 +90,12 @@ fun AppNameText( placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { - Icon( - imageVector = Icons.Outlined.VerifiedUser, + PerfIcon( + imageVector = PerfIcon.VerifiedUser, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { toast("当前是系统应用") }) .fillMaxSize(), - contentDescription = null, tint = contentColor ) } @@ -102,6 +105,7 @@ fun AppNameText( emptyMap() } Text( + modifier = modifier, text = annotatedString, inlineContent = inlineContent, maxLines = 1, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt index cfe40545d6..7d70edca0d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt @@ -1,6 +1,6 @@ package li.songe.gkd.ui.component -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -13,12 +13,11 @@ import li.songe.gkd.util.throttle @Composable fun AuthButtonGroup( - onClickShizuku: ()-> Unit, - onClickManual: ()-> Unit, - onClickRoot: ()-> Unit, - -){ - Row( + onClickShizuku: () -> Unit, + onClickManual: () -> Unit, + onClickRoot: () -> Unit, +) { + FlowRow( modifier = Modifier .padding(4.dp, 0.dp) .fillMaxWidth(), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/CardFlagBar.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/CardFlagBar.kt deleted file mode 100644 index cac276d169..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/CardFlagBar.kt +++ /dev/null @@ -1,41 +0,0 @@ -package li.songe.gkd.ui.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import li.songe.gkd.ui.style.itemHorizontalPadding - - -@Composable -fun CardFlagBar( - visible: Boolean, - width: Dp = itemHorizontalPadding -) { - Row( - modifier = Modifier - .width(width) - .height(16.dp), - horizontalArrangement = Arrangement.End, - ) { - AnimatedVisibility( - visible = visible, - ) { - Spacer( - modifier = Modifier - .background(MaterialTheme.colorScheme.tertiary) - .fillMaxHeight() - .width(2.dp) - ) - } - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/UrlCopyText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/CopyTextCard.kt similarity index 86% rename from app/src/main/kotlin/li/songe/gkd/ui/component/UrlCopyText.kt rename to app/src/main/kotlin/li/songe/gkd/ui/component/CopyTextCard.kt index 0e16b5dd55..6851571025 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/UrlCopyText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/CopyTextCard.kt @@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,7 +19,7 @@ import li.songe.gkd.util.throttle @Composable -fun UrlCopyText( +fun CopyTextCard( text: String, modifier: Modifier = Modifier, ) { @@ -44,7 +41,7 @@ fun UrlCopyText( style = MaterialTheme.typography.bodyLarge, ) } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { @@ -52,9 +49,8 @@ fun UrlCopyText( }) .padding(4.dp) .size(24.dp), - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, + imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt new file mode 100644 index 0000000000..47ebba497e --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt @@ -0,0 +1,50 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun CustomIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 40.dp, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource? = null, + content: @Composable () -> Unit +) { + Box( + modifier = + modifier + .size(size) + .clip(CircleShape) + .background(color = colors.run { if (enabled) containerColor else disabledContainerColor }) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple(bounded = false, radius = size / 2) + ), + contentAlignment = Alignment.Center + ) { + val contentColor = colors.run { if (enabled) contentColor else disabledContentColor } + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuCheckboxItem.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuCheckboxItem.kt new file mode 100644 index 0000000000..8a279e4d03 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuCheckboxItem.kt @@ -0,0 +1,26 @@ +package li.songe.gkd.ui.component + +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun DropdownMenuCheckboxItem( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + DropdownMenuItem( + text = { + Text(text = text) + }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + }, + onClick = { onCheckedChange(!checked) }, + ) +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuRadioButtonItem.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuRadioButtonItem.kt new file mode 100644 index 0000000000..6fdd1c82e6 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/DropdownMenuRadioButtonItem.kt @@ -0,0 +1,26 @@ +package li.songe.gkd.ui.component + +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun DropdownMenuRadioButtonItem( + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text(text = text) + }, + trailingIcon = { + RadioButton( + selected = selected, + onClick = onClick + ) + }, + onClick = onClick, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt deleted file mode 100644 index 25f8a68d40..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/EditGroupExcludeDialog.kt +++ /dev/null @@ -1,89 +0,0 @@ -package li.songe.gkd.ui.component - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewModelScope -import li.songe.gkd.data.ExcludeData -import li.songe.gkd.data.RawSubscription -import li.songe.gkd.data.SubsConfig -import li.songe.gkd.db.DbSet -import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast - -@Composable -fun EditGroupExcludeDialog( - subs: RawSubscription, - groupKey: Int, - appId: String? = null, - subsConfig: SubsConfig?, - onDismissRequest: () -> Unit, -) { - val mainVm = LocalMainViewModel.current - var value by remember { - mutableStateOf( - ExcludeData.parse(subsConfig?.exclude).stringify(appId) - ) - } - val oldValue = remember { value } - AlertDialog( - properties = DialogProperties(dismissOnClickOutside = false), - title = { Text(text = "编辑禁用") }, - text = { - OutlinedTextField( - value = value, - onValueChange = { value = it }, - modifier = Modifier.fillMaxWidth().autoFocus(), - placeholder = { - Text( - text = "请填入需要禁用的 activityId\n以换行或英文逗号分割", - style = MaterialTheme.typography.bodySmall - ) - }, - minLines = 8, - maxLines = 12, - textStyle = MaterialTheme.typography.bodySmall - ) - }, - onDismissRequest = onDismissRequest, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(text = "取消") - } - }, - confirmButton = { - TextButton(onClick = throttle { - if (oldValue == value) { - toast("禁用项无变动") - onDismissRequest() - } else { - onDismissRequest() - val newSubsConfig = (subsConfig ?: SubsConfig( - type = SubsConfig.AppGroupType, - subsId = subs.id, - appId = appId!!, - groupKey = groupKey, - )).copy(exclude = ExcludeData.parse(appId!!, value).stringify()) - mainVm.viewModelScope.launchTry { - DbSet.subsConfigDao.insert(newSubsConfig) - toast("更新成功") - } - } - }) { - Text(text = "更新") - } - }, - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/EmptyText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/EmptyText.kt index 3dc02a5896..2623c2ca08 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/EmptyText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/EmptyText.kt @@ -1,14 +1,11 @@ package li.songe.gkd.ui.component -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp @Composable fun EmptyText(text: String = "暂无数据") { @@ -19,5 +16,4 @@ fun EmptyText(text: String = "暂无数据") { textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) - Spacer(modifier = Modifier.height(16.dp)) } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt new file mode 100644 index 0000000000..4b1780275c --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt @@ -0,0 +1,33 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun FlagCard( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable (() -> Unit), +) = Box( + modifier = modifier, +) { + content() + if (visible) { + Spacer( + modifier = Modifier + .align(Alignment.TopEnd) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + .size(4.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt new file mode 100644 index 0000000000..c2a67c3923 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt @@ -0,0 +1,58 @@ +package li.songe.gkd.ui.component + +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowInsetsControllerCompat +import li.songe.gkd.ui.share.LocalDarkTheme + +@Composable +fun FullscreenDialog( + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) = Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ) +) { + val activity = LocalActivity.current!! + val parentView = LocalView.current.parent as View + val dialogWindow = (parentView as DialogWindowProvider).window + SideEffect { + dialogWindow.setDimAmount(0f) + dialogWindow.attributes = WindowManager.LayoutParams().apply { + copyFrom(activity.window.attributes) + type = dialogWindow.attributes.type + windowAnimations = android.R.style.Animation_Dialog + } + parentView.layoutParams = FrameLayout.LayoutParams( + activity.window.decorView.width, + activity.window.decorView.height + ) + parentView.setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + val darkTheme = LocalDarkTheme.current + val controller = remember(dialogWindow) { + WindowInsetsControllerCompat( + dialogWindow, + dialogWindow.decorView + ) + } + LaunchedEffect(darkTheme) { + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } + content() +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt index 3266a86cb0..1016fa8103 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text @@ -55,14 +54,13 @@ fun GroupNameText( placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { - Icon( + PerfIcon( imageVector = SportsBasketball, modifier = Modifier .runIf(!clickDisabled) { clickable(onClick = throttle { toast("当前是全局规则组") }) } .fillMaxSize(), - contentDescription = null, tint = textColor ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt index e2f6c23049..12a299b099 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt @@ -21,14 +21,15 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Density import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.data.RawSubscription import li.songe.gkd.util.mapState -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.subsMapFlow @Composable fun useSubs(subsId: Long?): RawSubscription? { val scope = rememberCoroutineScope() - return remember(subsId) { subsIdToRawFlow.mapState(scope) { it[subsId] } }.collectAsState().value + return remember(subsId) { subsMapFlow.mapState(scope) { it[subsId] } }.collectAsState().value } @Composable @@ -51,24 +52,38 @@ fun useSubsGroup( } @Composable -private fun useAutoFocus(): FocusRequester { +fun Modifier.autoFocus(immediateFocus: Boolean = false): Modifier { val focusRequester = remember { FocusRequester() } LaunchedEffect(null) { - delay(DefaultDurationMillis.toLong()) + if (!immediateFocus) { + delay(DefaultDurationMillis.toLong()) + } focusRequester.requestFocus() } - return focusRequester + return focusRequester(focusRequester) } @Composable -fun Modifier.autoFocus() = focusRequester(useAutoFocus()) +private fun getCompatStateValue(v: Any?): Any? = when (v) { + is StateFlow<*> -> v.collectAsState().value + is androidx.compose.runtime.State<*> -> v.value + else -> v +} +// key 函数的依赖变化时, compose 将重置 key 函数那行代码之后所有代码的状态, 因此需要需要将 key 作用域限定在 Composable fun 内 @Composable -fun useListScrollState(k1: Any?, k2: Any? = null): Pair { - // key 函数的依赖变化时, compose 将重置 key 函数那行代码之后所有代码的状态, 因此需要需要将 key 作用域限定在 Composable fun 内 - val scrollBehavior = key(k1, k2) { TopAppBarDefaults.enterAlwaysScrollBehavior() } - val listState = key(k1, k2) { rememberLazyListState() } - return scrollBehavior to listState +fun useListScrollState( + v1: Any?, + v2: Any? = null, + v3: Any? = null, +): Pair { + return key( + getCompatStateValue(v1), + getCompatStateValue(v2), + getCompatStateValue(v3) + ) { + TopAppBarDefaults.enterAlwaysScrollBehavior() to rememberLazyListState() + } } @Composable diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt index ae4710c91c..453e9b27f4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt @@ -4,11 +4,7 @@ import android.webkit.URLUtil import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -90,15 +86,12 @@ class InputSubsLinkOption { modifier = Modifier.fillMaxWidth(), ) { Text(text = if (initValue.isNotEmpty()) "修改订阅" else "添加订阅") - IconButton(onClick = throttle { - cancel() - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL5)) - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + onClick = throttle { + cancel() + mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL5)) + }) } }, text = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt index e7dcd5581a..a0f1a35cd2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt @@ -10,10 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -64,7 +61,7 @@ fun ManualAuthDialog( style = MaterialTheme.typography.bodySmall, ) } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { @@ -72,8 +69,7 @@ fun ManualAuthDialog( }) .padding(4.dp) .size(20.dp), - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, + imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt new file mode 100644 index 0000000000..8901c38105 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt @@ -0,0 +1,82 @@ +package li.songe.gkd.ui.component + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import li.songe.gkd.MainActivity + +@Composable +fun MultiTextField( + modifier: Modifier = Modifier, + textFlow: MutableStateFlow, + immediateFocus: Boolean = false, + indicatorText: String? = null, + placeholderText: String? = null, +) { + val text by textFlow.collectAsState() + Box(modifier = modifier) { + val textColors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { + val modifier = Modifier + .autoFocus(immediateFocus = immediateFocus) + .fillMaxSize() + .optimizedImePadding() + TextField( + value = text, + onValueChange = { textFlow.value = it }, + placeholder = if (placeholderText != null) ({ Text(text = placeholderText) }) else null, + modifier = modifier, + shape = RectangleShape, + colors = textColors, + ) + } + if (text.isNotEmpty()) { + Text( + text = indicatorText ?: text.length.toString(), + modifier = Modifier + .padding(8.dp) + .align(Alignment.TopEnd) + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 2.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + + +private fun Modifier.optimizedImePadding() = composed { + val context = LocalActivity.current as MainActivity + if (context.imePlayingFlow.collectAsState().value) { + this + } else { + imePadding() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfCheckbox.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfCheckbox.kt new file mode 100644 index 0000000000..7dcb33a9a2 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfCheckbox.kt @@ -0,0 +1,27 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun PerfCheckbox( + checked: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: ((Boolean) -> Unit)? = null, + key: Any? = null, + enabled: Boolean = true, + colors: CheckboxColors = CheckboxDefaults.colors(), + interactionSource: MutableInteractionSource? = null +) = androidx.compose.runtime.key(key) { + androidx.compose.material3.Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt new file mode 100644 index 0000000000..8c5027657d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -0,0 +1,160 @@ +package li.songe.gkd.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CenterFocusWeak +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Api +import androidx.compose.material.icons.outlined.AppRegistration +import androidx.compose.material.icons.outlined.AutoMode +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Eco +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Equalizer +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.ToggleOff +import androidx.compose.material.icons.outlined.ToggleOn +import androidx.compose.material.icons.outlined.VerifiedUser +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource + +@Composable +fun PerfIcon( + imageVector: ImageVector, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current +) = Icon( + imageVector = imageVector, + modifier = modifier, + contentDescription = imageVector.name, + tint = tint +) + +@Composable +fun PerfIconButton( + imageVector: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), +) = IconButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + colors = colors, +) { + PerfIcon( + imageVector = imageVector, + ) +} + +@Composable +fun PerfIcon( + @DrawableRes id: Int, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current +) = Icon( + painter = painterResource(id), + modifier = modifier, + contentDescription = null, + tint = tint +) + +@Composable +fun PerfIconButton( + @DrawableRes id: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), +) = IconButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + colors = colors, +) { + PerfIcon( + id = id, + ) +} + +object PerfIcon { + val Block get() = Icons.Default.Block + val History get() = Icons.Default.History + val Sort get() = Icons.AutoMirrored.Filled.Sort + val Add get() = Icons.Outlined.Add + val KeyboardArrowRight get() = Icons.AutoMirrored.Filled.KeyboardArrowRight + val ContentCopy get() = Icons.Outlined.ContentCopy + val MoreVert get() = Icons.Default.MoreVert + val ArrowBack get() = Icons.AutoMirrored.Filled.ArrowBack + val Android get() = Icons.Default.Android + val Edit get() = Icons.Outlined.Edit + val Save get() = Icons.Outlined.Save + val Share get() = Icons.Default.Share + val Delete get() = Icons.Outlined.Delete + val Eco get() = Icons.Outlined.Eco + val Close get() = Icons.Default.Close + val OpenInNew get() = Icons.AutoMirrored.Outlined.OpenInNew + val Settings get() = Icons.Outlined.Settings + val Home get() = Icons.Outlined.Home + val FormatListBulleted get() = Icons.AutoMirrored.Filled.FormatListBulleted + val Apps get() = Icons.Default.Apps + val Info get() = Icons.Outlined.Info + val ToggleOff get() = Icons.Outlined.ToggleOff + val ToggleOn get() = Icons.Outlined.ToggleOn + val HelpOutline get() = Icons.AutoMirrored.Outlined.HelpOutline + val ArrowForward get() = Icons.AutoMirrored.Filled.ArrowForward + val Image get() = Icons.Outlined.Image + val WarningAmber get() = Icons.Default.WarningAmber + val AppRegistration get() = Icons.Outlined.AppRegistration + val RocketLaunch get() = Icons.Outlined.RocketLaunch + val CenterFocusWeak get() = Icons.Default.CenterFocusWeak + val AutoMode get() = Icons.Outlined.AutoMode + val LightMode get() = Icons.Outlined.LightMode + val DarkMode get() = Icons.Outlined.DarkMode + val VerifiedUser get() = Icons.Outlined.VerifiedUser + val Api get() = Icons.Outlined.Api + val Autorenew get() = Icons.Default.Autorenew + val UnfoldMore get() = Icons.Default.UnfoldMore + val Memory get() = Icons.Default.Memory + val Notifications get() = Icons.Outlined.Notifications + val Layers get() = Icons.Outlined.Layers + val Equalizer get() = Icons.Outlined.Equalizer + +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt new file mode 100644 index 0000000000..a98d97d106 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt @@ -0,0 +1,31 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import li.songe.gkd.util.throttle + +@Composable +fun PerfSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + key: Any? = null, + thumbContent: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + colors: SwitchColors = SwitchDefaults.colors(), + interactionSource: MutableInteractionSource? = null, +) = androidx.compose.runtime.key(key) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange?.let { throttle(it) }, + modifier = modifier, + thumbContent = thumbContent, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt new file mode 100644 index 0000000000..2c0bac9e26 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt @@ -0,0 +1,40 @@ +package li.songe.gkd.ui.component + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import li.songe.gkd.MainActivity + +@Composable +fun PerfTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + // SingleRowTopAppBar 内部 containerColor+scrolledContainerColor 合成了一个动画 + // 应用主题颜色更新时形成叠加动画,导致和周围正常组件视觉变换效果表现割裂 + key(MaterialTheme.colorScheme.surface) { + TopAppBar( + title = title, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + expandedHeight = expandedHeight, + windowInsets = (LocalActivity.current as MainActivity).topBarWindowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt index 9bd1801c5d..e51a32694f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt @@ -6,10 +6,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -32,17 +29,19 @@ import li.songe.gkd.util.throttle import li.songe.gkd.util.updateAppMutex @Composable -fun QueryPkgAuthCard() { +fun QueryPkgAuthCard(hideLoading: Boolean = false) { val canQueryPkg by canQueryPkgState.stateFlow.collectAsState() val mayQueryPkgNoAccess by mayQueryPkgNoAccessFlow.collectAsState() val appRefreshing by updateAppMutex.state.collectAsState() if (appRefreshing) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(EmptyHeight / 2)) - CircularProgressIndicator() + if (!hideLoading) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(EmptyHeight)) + } } } else if (!canQueryPkg || mayQueryPkgNoAccess) { val mainVm = LocalMainViewModel.current @@ -51,9 +50,8 @@ fun QueryPkgAuthCard() { modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( - imageVector = Icons.Default.WarningAmber, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.WarningAmber, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -73,8 +71,7 @@ fun QueryPkgAuthCard() { })) { Text(text = "申请权限") } - Spacer(modifier = Modifier.height(EmptyHeight / 2)) - + Spacer(modifier = Modifier.height(EmptyHeight)) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RotatingLoadingIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RotatingLoadingIcon.kt index 3c3b2520c2..a7e03eb7c2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RotatingLoadingIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RotatingLoadingIcon.kt @@ -5,9 +5,6 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -20,7 +17,7 @@ import kotlin.math.sin fun RotatingLoadingIcon( modifier: Modifier = Modifier, loading: Boolean, - imageVector: ImageVector = Icons.Default.Autorenew, + imageVector: ImageVector = PerfIcon.Autorenew, ) { val rotation = remember { Animatable(0f) } LaunchedEffect(loading) { @@ -49,9 +46,8 @@ fun RotatingLoadingIcon( ) } } - Icon( + PerfIcon( imageVector = imageVector, - contentDescription = null, modifier = modifier.graphicsLayer(rotationZ = rotation.value) ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt index 81fcc86d38..3343a4554c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt @@ -6,27 +6,18 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ToggleOff -import androidx.compose.material.icons.outlined.ToggleOn import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,6 +45,7 @@ import li.songe.gkd.util.getGroupEnable import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast +import java.util.Objects @Composable @@ -65,7 +57,6 @@ fun RuleGroupCard( subsConfig: SubsConfig?, category: RawSubscription.RawCategory?, categoryConfig: CategoryConfig?, - showBottom: Boolean, focusGroupFlow: MutableStateFlow?>? = null, isSelectedMode: Boolean = false, isSelected: Boolean = false, @@ -162,8 +153,6 @@ fun RuleGroupCard( pageAppId = appId, ) }) - val horizontal = 8.dp - val vertical = 8.dp val containerColor = animateColorAsState( if (isSelected || highlighted) { MaterialTheme.colorScheme.primaryContainer @@ -175,9 +164,8 @@ fun RuleGroupCard( Card( modifier = modifier .padding( - start = 8.dp, - end = 8.dp, - bottom = if (showBottom) 4.dp else 0.dp + vertical = 2.dp, + horizontal = 8.dp ) .combinedClickable(onClick = onClick, onLongClick = onLongClick), shape = MaterialTheme.shapes.extraSmall, @@ -185,52 +173,59 @@ fun RuleGroupCard( containerColor = containerColor.value ), ) { - Row( + val visible = if (inGlobalAppPage) { + excludeData != null && excludeData.appIds.contains(appId) + } else { + subsConfig?.enable != null + } + FlagCard( + visible = visible, modifier = Modifier .fillMaxWidth() - .padding(start = horizontal, top = vertical, bottom = vertical), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + .padding(8.dp), ) { - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - GroupNameText( - modifier = Modifier.fillMaxWidth(), - text = group.name, - style = MaterialTheme.typography.bodyLarge, - isGlobal = group is RawSubscription.RawGlobalGroup, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - clickDisabled = isSelectedMode, - ) - if (group.valid) { - if (!group.desc.isNullOrBlank()) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + GroupNameText( + modifier = Modifier.fillMaxWidth(), + text = group.name, + style = MaterialTheme.typography.bodyLarge, + isGlobal = group is RawSubscription.RawGlobalGroup, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + clickDisabled = isSelectedMode, + ) + if (group.valid) { + if (!group.desc.isNullOrBlank()) { + Text( + text = group.desc!!, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { Text( - text = group.desc!!, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, + text = group.errorDesc ?: "未知错误", modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.error ) } - } else { - Text( - text = group.errorDesc ?: "未知错误", - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error - ) } - } - Spacer(modifier = Modifier.width(8.dp)) - key(subs.id, appId, group.key) { val percent = usePercentAnimatable(!isSelectedMode) val switchModifier = Modifier.graphicsLayer( alpha = 0.5f + (1 - 0.5f) * percent.value, @@ -242,7 +237,8 @@ fun RuleGroupCard( isSelectedMode = isSelectedMode, ) } else if (checked != null) { - Switch( + PerfSwitch( + key = Objects.hash(subs.id, appId, group.key), modifier = switchModifier.minimumInteractiveComponentSize(), checked = checked, onCheckedChange = if (isSelectedMode) null else throttle(onCheckedChange) @@ -253,12 +249,6 @@ fun RuleGroupCard( isSelectedMode = isSelectedMode, ) } - val visible = if (inGlobalAppPage) { - excludeData != null && excludeData.appIds.contains(appId) - } else { - subsConfig?.enable != null - } - CardFlagBar(visible = visible, width = horizontal) } } } @@ -268,55 +258,49 @@ fun RuleGroupCard( @Composable fun BatchActionButtonGroup(vm: ViewModel, selectedDataSet: Set) { val mainVm = LocalMainViewModel.current - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { - mainVm.dialogFlow.waitResult( - title = "操作提示", - text = "是否将所选规则全部关闭?\n\n注: 也可在「订阅-规则类别」操作" - ) - val list = batchUpdateGroupEnable(selectedDataSet, false) - if (list.isNotEmpty()) { - toast("已关闭 ${list.size} 条规则") - } else { - toast("无规则被改变") - } - })) { - Icon( - imageVector = Icons.Outlined.ToggleOff, - contentDescription = null, - ) - } - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { - mainVm.dialogFlow.waitResult( - title = "操作提示", - text = "是否将所选规则全部启用?\n\n注: 也可在「订阅-规则类别」操作" - ) - val list = batchUpdateGroupEnable(selectedDataSet, true) - if (list.isNotEmpty()) { - toast("已启用 ${list.size} 条规则") - } else { - toast("无规则被改变") - } - })) { - Icon( - imageVector = Icons.Outlined.ToggleOn, - contentDescription = null, - ) - } - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { - mainVm.dialogFlow.waitResult( - title = "操作提示", - text = "是否将所选规则重置开关至初始状态?\n\n注: 也可在「订阅-规则类别」操作" - ) - val list = batchUpdateGroupEnable(selectedDataSet, null) - if (list.isNotEmpty()) { - toast("已重置 ${list.size} 条规则开关至初始状态") - } else { - toast("无规则被改变") - } - })) { - Icon( - imageVector = ResetSettings, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ToggleOff, + onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { + mainVm.dialogFlow.waitResult( + title = "操作提示", + text = "是否将所选规则全部关闭?\n\n注: 也可在「订阅-规则类别」操作" + ) + val list = batchUpdateGroupEnable(selectedDataSet, false) + if (list.isNotEmpty()) { + toast("已关闭 ${list.size} 条规则") + } else { + toast("无规则被改变") + } + }) + ) + PerfIconButton( + imageVector = PerfIcon.ToggleOn, + onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { + mainVm.dialogFlow.waitResult( + title = "操作提示", + text = "是否将所选规则全部启用?\n\n注: 也可在「订阅-规则类别」操作" + ) + val list = batchUpdateGroupEnable(selectedDataSet, true) + if (list.isNotEmpty()) { + toast("已启用 ${list.size} 条规则") + } else { + toast("无规则被改变") + } + }) + ) + PerfIconButton( + imageVector = ResetSettings, + onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { + mainVm.dialogFlow.waitResult( + title = "操作提示", + text = "是否将所选规则重置开关至初始状态?\n\n注: 也可在「订阅-规则类别」操作" + ) + val list = batchUpdateGroupEnable(selectedDataSet, null) + if (list.isNotEmpty()) { + toast("已重置 ${list.size} 条规则开关至初始状态") + } else { + toast("无规则被改变") + } + }) + ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt index 2fa628fd9b..58521aee35 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt @@ -13,16 +13,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.outlined.AppRegistration -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Image import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -59,7 +50,7 @@ fun RuleGroupDialog( onClickResetSwitch: (() -> Unit)?, onClickDelete: () -> Unit = {} ) { - val mainVm = LocalMainViewModel.current + val mainVm = LocalMainViewModel.current val navController = LocalNavController.current AlertDialog( onDismissRequest = onDismissRequest, @@ -108,7 +99,7 @@ fun RuleGroupDialog( ) } } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { @@ -116,8 +107,7 @@ fun RuleGroupDialog( }) .padding(4.dp) .size(24.dp), - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, + imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) Text( @@ -151,18 +141,13 @@ fun RuleGroupDialog( } } if (currentDestination?.baseRoute != destination.baseRoute) { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.ArrowForward, onClick = throttle { onDismissRequest() mainVm.navigatePage(direction) - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null - ) - } + }) } if (group.allExampleUrls.isNotEmpty()) { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Image, onClick = throttle { onDismissRequest() mainVm.navigatePage( ImagePreviewPageDestination( @@ -170,38 +155,28 @@ fun RuleGroupDialog( uris = group.allExampleUrls.toTypedArray() ) ) - }) { - Icon(imageVector = Icons.Outlined.Image, contentDescription = null) - } + }) } if (subs.isLocal) { - IconButton(onClick = throttle(onClickEdit)) { - Icon(imageVector = Icons.Outlined.Edit, contentDescription = null) - } - } - IconButton(onClick = throttle(onClickEditExclude)) { - Icon( - imageVector = Icons.Outlined.AppRegistration, - contentDescription = null - ) + PerfIconButton(imageVector = PerfIcon.Edit, onClick = throttle(onClickEdit)) } + PerfIconButton( + imageVector = PerfIcon.AppRegistration, + onClick = throttle(onClickEditExclude), + ) AnimatedVisibility( visible = onClickResetSwitch != null, ) { - IconButton(onClick = throttle(onClickResetSwitch ?: {})) { - Icon( - imageVector = ResetSettings, - contentDescription = null - ) - } + PerfIconButton( + imageVector = ResetSettings, + onClick = throttle(onClickResetSwitch ?: {}), + ) } if (subs.isLocal) { - IconButton(onClick = throttle(onClickDelete)) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(onClickDelete), + ) } } }, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt index 25fae28c1c..dd2f7fa2ff 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt @@ -1,13 +1,19 @@ package li.songe.gkd.ui.component +import androidx.activity.compose.BackHandler +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.lifecycle.viewModelScope import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupExcludePageDestination import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow @@ -20,10 +26,12 @@ import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet import li.songe.gkd.ui.getGlobalGroupChecked -import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.getGroupEnable import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.subsMapFlow +import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription @@ -53,7 +61,7 @@ data class ShowGroupState( suspend fun queryCategoryConfig(): CategoryConfig? { groupKey ?: error("require groupKey") - val subs = subsIdToRawFlow.value[subsId] ?: error("require subs") + val subs = subsMapFlow.value[subsId] ?: error("require subs") val group = if (groupType == SubsConfig.AppGroupType) { subs.apps.find { it.id == appId }?.groups } else { @@ -88,7 +96,7 @@ suspend fun batchUpdateGroupEnable( ): List> { val diffDataList = groups.map { g -> if (g.groupKey == null) return@map null - val subscription = subsIdToRawFlow.value[g.subsId] ?: return@map null + val subscription = subsMapFlow.value[g.subsId] ?: return@map null val targetGroup = subscription.run { if (g.appId != null) { apps.find { a -> a.id == g.appId }?.groups?.find { it.key == g.groupKey } @@ -198,29 +206,57 @@ suspend fun batchUpdateGroupEnable( } class RuleGroupState( - private val vm: MainViewModel, + private val mainVm: MainViewModel, ) { - val showGroupFlow = MutableStateFlow(null) - val dismissShow = { showGroupFlow.value = null } - private val showSubsConfigFlow = showGroupFlow.map { - if (it?.groupKey != null) { - if (it.appId != null) { - DbSet.subsConfigDao.queryAppGroupTypeConfig(it.subsId, it.appId, it.groupKey) + fun getSubsConfigFlow(state: MutableStateFlow): StateFlow { + return state.map { + if (it?.groupKey != null) { + if (it.appId != null) { + DbSet.subsConfigDao.queryAppGroupTypeConfig(it.subsId, it.appId, it.groupKey) + } else { + DbSet.subsConfigDao.queryGlobalGroupTypeConfig(it.subsId, it.groupKey) + } } else { - DbSet.subsConfigDao.queryGlobalGroupTypeConfig(it.subsId, it.groupKey) + flow { emit(null) } } - } else { - flow { emit(null) } - } - }.flatMapLatest { it }.stateIn(vm.viewModelScope, SharingStarted.Eagerly, null) + }.flatMapLatest { it }.stateIn(mainVm.viewModelScope, SharingStarted.Eagerly, null) + } + + val showGroupFlow = MutableStateFlow(null) + private val showSubsConfigFlow = getSubsConfigFlow(showGroupFlow) + private val dismissGroupShow = { showGroupFlow.value = null } val editExcludeGroupFlow = MutableStateFlow(null) - val dismissExcludeGroup = { editExcludeGroupFlow.value = null } + private val excludeTextFlow = MutableStateFlow("") + private val dismissExcludeGroupShow = { + editExcludeGroupFlow.value = null + excludeTextFlow.value = "" + } + private val excludeSubsConfigFlow = getSubsConfigFlow(editExcludeGroupFlow).apply { + mainVm.run { + launchOnChange { + excludeTextFlow.value = value?.let { config -> + ExcludeData.parse(config.exclude).stringify(config.appId) + } ?: "" + } + } + } + private val changedExcludeData: ExcludeData? + get() { + val oldValue = + ExcludeData.parse(excludeSubsConfigFlow.value?.exclude) + val newValue = ExcludeData.parse( + excludeTextFlow.value, + editExcludeGroupFlow.value?.appId!! + ) + if (oldValue != newValue) { + return newValue + } + return null + } @Composable fun Render() { - val mainVm = LocalMainViewModel.current - val showGroupState = showGroupFlow.collectAsState().value val showSubs = useSubs(showGroupState?.subsId) val showGroup = useSubsGroup(showSubs, showGroupState?.groupKey, showGroupState?.appId) @@ -233,10 +269,10 @@ class RuleGroupState( subs = showSubs, group = showGroup, appId = showGroupState.appId, - onDismissRequest = dismissShow, + onDismissRequest = dismissGroupShow, onClickEdit = { - dismissShow() - vm.navigatePage( + dismissGroupShow() + mainVm.navigatePage( UpsertRuleGroupPageDestination( subsId = showGroupState.subsId, groupKey = showGroupState.groupKey, @@ -245,7 +281,7 @@ class RuleGroupState( ) }, onClickEditExclude = { - dismissShow() + dismissGroupShow() if (showGroupState.appId == null) { mainVm.navigatePage( SubsGlobalGroupExcludePageDestination( @@ -261,7 +297,7 @@ class RuleGroupState( if (showGroup is RawSubscription.RawGlobalGroup) { if (showGroupState.pageAppId != null) { if (excludeData.appIds.contains(showGroupState.pageAppId)) { - vm.viewModelScope.launchAsFn { + mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update( subsConfig.copy( exclude = excludeData.clear( @@ -276,7 +312,7 @@ class RuleGroupState( } } else { subsConfig.enable?.let { - vm.viewModelScope.launchAsFn { + mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update(subsConfig.copy(enable = null)) toast("已重置开关至初始状态") } @@ -284,16 +320,16 @@ class RuleGroupState( } } else { subsConfig.enable?.let { - vm.viewModelScope.launchAsFn { + mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update(subsConfig.copy(enable = null)) toast("已重置开关至初始状态") } } } }, - onClickDelete = vm.viewModelScope.launchAsFn { - dismissShow() - val r = vm.dialogFlow.getResult( + onClickDelete = mainVm.viewModelScope.launchAsFn { + dismissGroupShow() + val r = mainVm.dialogFlow.getResult( title = "删除规则组", text = "确定删除 ${showGroup.name} ?", error = true, @@ -337,14 +373,67 @@ class RuleGroupState( val excludeGroupState = editExcludeGroupFlow.collectAsState().value val excludeSubs = useSubs(excludeGroupState?.subsId) - if (excludeGroupState?.groupKey != null && excludeSubs != null) { - EditGroupExcludeDialog( - subs = excludeSubs, - groupKey = excludeGroupState.groupKey, - appId = excludeGroupState.appId, - subsConfig = null, - onDismissRequest = dismissExcludeGroup - ) + if (excludeGroupState?.groupKey != null && excludeGroupState.appId != null && excludeSubs != null) { + FullscreenDialog(onDismissRequest = dismissExcludeGroupShow) { + val keyboardController = LocalSoftwareKeyboardController.current + val onBack = mainVm.viewModelScope.launchAsFn { + keyboardController?.hide() + val newValue = changedExcludeData + if (newValue != null) { + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + dismissExcludeGroupShow() + } + BackHandler(onBack = onBack) + Scaffold( + topBar = { + PerfTopAppBar( + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.Close, + onClick = onBack + ) + }, + title = { + Text(text = "编辑禁用") + }, + actions = { + PerfIconButton(imageVector = PerfIcon.Save, onClick = throttle { + val newValue = changedExcludeData + if (newValue == null) { + toast("无修改") + dismissExcludeGroupShow() + } else { + val newSubsConfig = + (excludeSubsConfigFlow.value ?: SubsConfig( + type = SubsConfig.AppGroupType, + subsId = excludeSubs.id, + appId = excludeGroupState.appId, + groupKey = excludeGroupState.groupKey, + )).copy( + exclude = newValue.stringify() + ) + dismissExcludeGroupShow() + mainVm.viewModelScope.launchTry { + DbSet.subsConfigDao.insert(newSubsConfig) + toast("更新成功") + } + } + }) + } + ) + }, + ) { contentPadding -> + MultiTextField( + modifier = Modifier.scaffoldPadding(contentPadding), + textFlow = excludeTextFlow, + placeholderText = "请填入需要禁用的 activityId 列表\n每行一个", + ) + } + } } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt index 5d84b298e1..0dfff37051 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt @@ -6,9 +6,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -27,7 +24,7 @@ fun SettingItem( suffix: String? = null, suffixUnderline: Boolean = false, onSuffixClick: (() -> Unit)? = null, - imageVector: ImageVector? = Icons.AutoMirrored.Filled.KeyboardArrowRight, + imageVector: ImageVector? = PerfIcon.KeyboardArrowRight, onClick: (() -> Unit)? = null, ) { Row( @@ -82,7 +79,7 @@ fun SettingItem( } } if (imageVector != null) { - Icon(imageVector = imageVector, contentDescription = title) + PerfIcon(imageVector = imageVector) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt index 83833052c1..21b32316c9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt @@ -4,54 +4,46 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.key +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import li.songe.gkd.data.AppConfig -import li.songe.gkd.data.AppInfo -import li.songe.gkd.data.RawSubscription +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.ui.SubsAppInfoItem import li.songe.gkd.ui.style.appItemPadding @Composable fun SubsAppCard( - rawApp: RawSubscription.RawApp, - appInfo: AppInfo? = null, - appConfig: AppConfig? = null, - enableSize: Int = rawApp.groups.count { g -> g.enable ?: true }, + data: SubsAppInfoItem, + enableSize: Int = data.rawApp.groups.count { g -> g.enable ?: true }, onClick: (() -> Unit)? = null, onValueChange: ((Boolean) -> Unit)? = null, ) { + val rawApp = data.rawApp Row( modifier = Modifier .clickable { onClick?.invoke() } .appItemPadding(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - AppIcon(appId = rawApp.id) - Spacer(modifier = Modifier.width(12.dp)) + AppIcon(appId = data.id) Column( modifier = Modifier .weight(1f), verticalArrangement = Arrangement.Center ) { - AppNameText( - appId = rawApp.id, - appInfo = appInfo, - fallbackName = rawApp.name, - ) + AppNameText(appInfo = data.appInfo, fallbackName = data.rawApp.name) if (rawApp.groups.isNotEmpty()) { val enableDesc = when (enableSize) { 0 -> "${rawApp.groups.size}组规则/${rawApp.groups.size}关闭" @@ -69,13 +61,20 @@ fun SubsAppCard( ) } } - Spacer(modifier = Modifier.width(8.dp)) - key(rawApp.id) { - Switch( - checked = appConfig?.enable ?: (appInfo != null), - onCheckedChange = onValueChange, + if (blockMatchAppListFlow.collectAsState().value.contains(data.id)) { + PerfIcon( + modifier = Modifier + .padding(2.dp) + .size(20.dp), + imageVector = PerfIcon.Block, + tint = MaterialTheme.colorScheme.secondary, ) } + PerfSwitch( + key = data.id, + checked = data.appConfig?.enable ?: (data.appInfo != null), + onCheckedChange = onValueChange, + ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt index 6f7c1c9398..8f8b2c1d90 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt @@ -14,13 +14,11 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +27,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import li.songe.gkd.META import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem @@ -49,13 +48,13 @@ fun SubsItemCard( subsItem: SubsItem, subscription: RawSubscription?, index: Int, - vm: HomeVm, isSelectedMode: Boolean, isSelected: Boolean, onCheckedChange: ((Boolean) -> Unit), onSelectedChange: (() -> Unit)? = null, ) { val mainVm = LocalMainViewModel.current + val vm = viewModel() val subsLoadError by remember(subsItem.id) { subsLoadErrorsFlow.mapState(vm.viewModelScope) { it[subsItem.id] } }.collectAsState() @@ -170,23 +169,22 @@ fun SubsItemCard( } } Spacer(modifier = Modifier.width(4.dp)) - key(subsItem.id) { - val percent = usePercentAnimatable(!isSelectedMode) - val switchModifier = Modifier.graphicsLayer( - alpha = 0.5f + (1 - 0.5f) * percent.value, - ).run { - if (isSelectedMode) { - minimumInteractiveComponentSize() - } else { - this - } + val percent = usePercentAnimatable(!isSelectedMode) + val switchModifier = Modifier.graphicsLayer( + alpha = 0.5f + (1 - 0.5f) * percent.value, + ).run { + if (isSelectedMode) { + minimumInteractiveComponentSize() + } else { + this } - Switch( - modifier = switchModifier, - checked = subsItem.enable, - onCheckedChange = if (isSelectedMode) null else throttle(fn = onCheckedChange), - ) } + PerfSwitch( + key = subsItem.id, + modifier = switchModifier, + checked = subsItem.enable, + onCheckedChange = if (isSelectedMode) null else throttle(fn = onCheckedChange), + ) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt index dedccec0a9..16f7ea9b5c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt @@ -12,16 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetValue @@ -57,8 +48,8 @@ import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.deleteSubscription import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry -import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex @@ -79,7 +70,7 @@ fun SubsSheet( } } else { val mainVm = LocalMainViewModel.current - val subsIdToRaw by subsIdToRawFlow.collectAsState() + val subsIdToRaw by subsMapFlow.collectAsState() var swipeEnabled by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, @@ -218,9 +209,8 @@ fun SubsSheet( }, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } } @@ -255,9 +245,8 @@ fun SubsSheet( }, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } @@ -293,9 +282,8 @@ fun SubsSheet( }, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } } @@ -332,14 +320,13 @@ fun SubsSheet( overflow = TextOverflow.MiddleEllipsis, modifier = Modifier .clickable(onClick = { - mainVm.urlFlow.value = subsItem.updateUrl + mainVm.textFlow.value = subsItem.updateUrl }) ) } Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Edit, ) } } @@ -372,45 +359,39 @@ fun SubsSheet( horizontalArrangement = Arrangement.End ) { if (!subsItem.isLocal && subscription?.supportUri != null) { - IconButton(onClick = throttle { - mainVm.urlFlow.value = subscription.supportUri - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, - contentDescription = null - ) - } + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + onClick = throttle { + mainVm.textFlow.value = subscription.supportUri + }, + ) } - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.History, onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(ActionLogPageDestination(subsId = subsItem.id)) - }) { - Icon(imageVector = Icons.Default.History, contentDescription = null) - } + }) if (subscription != null || !subsItem.isLocal) { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Share, onClick = throttle { mainVm.showShareDataIdsFlow.value = setOf(subsItem.id) - }) { - Icon(imageVector = Icons.Default.Share, contentDescription = null) - } + }) } if (subsItem.id != LOCAL_SUBS_ID) { - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { - mainVm.dialogFlow.waitResult( - title = "删除订阅", - text = "确定删除 ${subscription?.name ?: subsItem.id} ?", - error = true, - ) - sheetSubsIdFlow.value = null - setSubsId(null) - deleteSubscription(subsItem.id) - })) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle( + vm.viewModelScope.launchAsFn { + mainVm.dialogFlow.waitResult( + title = "删除订阅", + text = "确定删除 ${subscription?.name ?: subsItem.id} ?", + error = true, + ) + sheetSubsIdFlow.value = null + setSubsId(null) + deleteSubscription(subsItem.id) + } + ), + ) } } Spacer(modifier = Modifier.height(EmptyHeight / 2)) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TextDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextDialog.kt new file mode 100644 index 0000000000..31c47613e6 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextDialog.kt @@ -0,0 +1,48 @@ +package li.songe.gkd.ui.component + +import android.webkit.URLUtil +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.MutableStateFlow +import li.songe.gkd.util.openUri +import li.songe.gkd.util.throttle + +@Composable +fun TextDialog( + textFlow: MutableStateFlow +) { + val text = textFlow.collectAsState().value + if (text != null) { + val isUri = remember(text) { URLUtil.isNetworkUrl(text) } + val onDismissRequest = { + textFlow.value = null + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = if (isUri) "查看链接" else "查看文本") + }, + text = { + CopyTextCard(text = text) + }, + confirmButton = { + if (isUri) { + TextButton(onClick = throttle { + onDismissRequest() + openUri(text) + }) { + Text(text = "打开") + } + } else { + TextButton(onClick = onDismissRequest) { + Text(text = "关闭") + } + } + }, + ) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TextMenu.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextMenu.kt index 9611e4411b..a9156bf750 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TextMenu.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextMenu.kt @@ -1,14 +1,12 @@ package li.songe.gkd.ui.component +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,7 +19,7 @@ import androidx.compose.ui.Modifier import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.util.Option import li.songe.gkd.util.OptionIcon -import li.songe.gkd.util.allSubObject +import li.songe.gkd.util.OptionMenuLabel @Composable fun TextMenu( @@ -57,24 +55,29 @@ fun TextMenu( text = option.label, style = MaterialTheme.typography.bodyMedium, ) - Icon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.UnfoldMore, ) DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { - option.allSubObject.forEach { otherOption -> + option.options.forEach { otherOption -> + val selected = remember { otherOption.value == option.value } DropdownMenuItem( + modifier = if (selected) Modifier.background(MaterialTheme.colorScheme.onSecondary) else Modifier, leadingIcon = if (otherOption is OptionIcon) ({ - Icon( + PerfIcon( imageVector = otherOption.icon, - contentDescription = null ) }) else null, text = { - Text(text = otherOption.label) + val text = if (otherOption is OptionMenuLabel) { + otherOption.menuLabel + } else { + otherOption.label + } + Text(text = text) }, onClick = { expanded = false diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt index d7f1a13046..fdec5854b9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt @@ -1,9 +1,12 @@ package li.songe.gkd.ui.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -20,17 +23,20 @@ import li.songe.gkd.util.throttle fun TextSwitch( modifier: Modifier = Modifier, title: String, + paddingDisabled: Boolean = false, subtitle: String? = null, suffix: String? = null, suffixUnderline: Boolean = false, onSuffixClick: (() -> Unit)? = null, + suffixIcon: (@Composable () -> Unit)? = null, checked: Boolean = true, enabled: Boolean = true, onCheckedChange: ((Boolean) -> Unit)? = null, ) { Row( - modifier = modifier.itemPadding(), - verticalAlignment = Alignment.CenterVertically + modifier = if (paddingDisabled) modifier else modifier.itemPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Column(modifier = Modifier.weight(1f)) { Text( @@ -39,7 +45,9 @@ fun TextSwitch( ) if (subtitle != null) { if (suffix != null) { - Row { + FlowRow( + modifier = Modifier.fillMaxWidth(), + ) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, @@ -69,7 +77,7 @@ fun TextSwitch( } } } - Spacer(modifier = Modifier.width(8.dp)) + suffixIcon?.invoke() Switch( checked = checked, enabled = enabled, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt index b0be668884..4e0e1b1372 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt @@ -6,20 +6,25 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @Composable fun TowLineText( title: String, subtitle: String, + modifier: Modifier = Modifier, showApp: Boolean = false, ) { - Column { + Column( + modifier = modifier, + ) { Text( text = title, maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + style = MaterialTheme.typography.titleMedium, ) CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleSmall) { if (showApp) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/UploadOptions.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/UploadOptions.kt index 3be2cf7d17..173091a6ee 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/UploadOptions.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/UploadOptions.kt @@ -111,7 +111,7 @@ class UploadOptions( val href = showHref(status.result) AlertDialog( title = { Text(text = "上传完成") }, - text = { UrlCopyText(text = href) }, + text = { CopyTextCard(text = href) }, onDismissRequest = {}, confirmButton = { TextButton(onClick = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/UrlDetailDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/UrlDetailDialog.kt deleted file mode 100644 index 1f8bb57d43..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/UrlDetailDialog.kt +++ /dev/null @@ -1,39 +0,0 @@ -package li.songe.gkd.ui.component - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import kotlinx.coroutines.flow.MutableStateFlow -import li.songe.gkd.util.openUri -import li.songe.gkd.util.throttle - -@Composable -fun UrlDetailDialog( - urlFlow: MutableStateFlow -) { - val url = urlFlow.collectAsState().value - if (url != null) { - val onDismissRequest = { - urlFlow.value = null - } - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Text(text = "链接详情") - }, - text = { - UrlCopyText(text = url) - }, - confirmButton = { - TextButton(onClick = throttle { - onDismissRequest() - openUri(url) - }) { - Text(text = "打开") - } - }, - ) - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 539a16109b..992e8f91c7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui.home import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,22 +12,20 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Sort -import androidx.compose.material.icons.outlined.Block import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable @@ -39,48 +38,54 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination -import com.ramcosta.composedestinations.generated.destinations.WhiteAppListPageDestination +import com.ramcosta.composedestinations.generated.destinations.EditBlockAppListPageDestination import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity +import li.songe.gkd.data.AppInfo +import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.PerfCheckbox +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.appItemPadding import li.songe.gkd.ui.style.menuPadding +import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.SafeR -import li.songe.gkd.util.SortTypeOption -import li.songe.gkd.util.mapHashCode +import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.ruleSummaryFlow +import li.songe.gkd.util.switchItem import li.songe.gkd.util.throttle import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex @Composable fun useAppListPage(): ScaffoldExt { - val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current - val softwareKeyboardController = LocalSoftwareKeyboardController.current + val context = LocalActivity.current as MainActivity val vm = viewModel() val showSystemApp by vm.showSystemAppFlow.collectAsState() - val showHiddenApp by vm.showHiddenAppFlow.collectAsState() + val showBlockApp by vm.showBlockAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() - val orderedAppInfos by vm.appInfosFlow.collectAsState() + val appInfos by vm.appInfosFlow.collectAsState() val searchStr by vm.searchStrFlow.collectAsState() val ruleSummary by ruleSummaryFlow.collectAsState() @@ -91,10 +96,10 @@ fun useAppListPage(): ScaffoldExt { } val appListKey by mainVm.appListKeyFlow.collectAsState() val showSearchBar by vm.showSearchBarFlow.collectAsState() - val resetKey = orderedAppInfos.mapHashCode { it.id } - val (scrollBehavior, listState) = useListScrollState(resetKey, appListKey) + val (scrollBehavior, listState) = useListScrollState(appListKey) val refreshing by updateAppMutex.state.collectAsState() val pullToRefreshState = rememberPullToRefreshState() + val editWhiteListMode by vm.editWhiteListModeFlow.collectAsState() return ScaffoldExt( navItem = BottomNavItem.AppList, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -104,15 +109,14 @@ fun useAppListPage(): ScaffoldExt { if (vm.searchStrFlow.value.isEmpty()) { vm.showSearchBarFlow.value = false } + vm.editWhiteListModeFlow.value = false } } - TopAppBar(scrollBehavior = scrollBehavior, title = { + PerfTopAppBar(scrollBehavior = scrollBehavior, title = { val firstShowSearchBar = remember { showSearchBar } if (showSearchBar) { BackHandler { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } else { + if (!context.justHideSoftInput()) { vm.showSearchBarFlow.value = false } } @@ -123,25 +127,48 @@ fun useAppListPage(): ScaffoldExt { modifier = if (firstShowSearchBar) Modifier else Modifier.autoFocus(), ) } else { - Text( - modifier = Modifier.clickable( - enabled = orderedAppInfos.isNotEmpty(), + val titleModifier = Modifier + .noRippleClickable( onClick = throttle { mainVm.appListKeyFlow.update { it + 1 } } - ), - text = BottomNavItem.AppList.label, - ) + ) + if (editWhiteListMode) { + BackHandler { + vm.editWhiteListModeFlow.value = false + } + } + AnimatedContent( + targetState = editWhiteListMode, + transitionSpec = { getUpDownTransform() }, + ) { localEditWhiteListMode -> + if (localEditWhiteListMode) { + Text( + modifier = titleModifier, + text = "应用白名单", + ) + } else { + Text( + modifier = titleModifier, + text = BottomNavItem.AppList.label, + ) + } + } } }, actions = { - IconButton(onClick = throttle { - mainVm.navigatePage(WhiteAppListPageDestination) - }) { - Icon( - imageVector = Icons.Outlined.Block, - contentDescription = Icons.Outlined.Block.name, - ) - } + PerfIconButton( + imageVector = PerfIcon.Block, + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (editWhiteListMode) { + CheckboxDefaults.colors().checkedBoxColor + } else { + LocalContentColor.current + } + ), + onClick = throttle { + vm.editWhiteListModeFlow.update { !it } + }, + ) IconButton(onClick = throttle { if (showSearchBar) { if (vm.searchStrFlow.value.isEmpty()) { @@ -159,14 +186,9 @@ fun useAppListPage(): ScaffoldExt { ) } var expanded by remember { mutableStateOf(false) } - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.Sort, onClick = { expanded = true - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null - ) - } + }) Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) @@ -181,7 +203,10 @@ fun useAppListPage(): ScaffoldExt { style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) - SortTypeOption.allSubObject.forEach { sortOption -> + val handleItem: (AppSortOption) -> Unit = throttle { v -> + storeFlow.update { s -> s.copy(appSort = v.value) } + } + AppSortOption.objects.forEach { sortOption -> DropdownMenuItem( text = { Text(sortOption.label) @@ -190,12 +215,12 @@ fun useAppListPage(): ScaffoldExt { RadioButton( selected = sortType == sortOption, onClick = { - storeFlow.update { s -> s.copy(sortType = sortOption.value) } + handleItem(sortOption) } ) }, onClick = { - storeFlow.update { s -> s.copy(sortType = sortOption.value) } + handleItem(sortOption) }, ) } @@ -205,6 +230,9 @@ fun useAppListPage(): ScaffoldExt { style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) + val handle1 = { + storeFlow.update { s -> s.copy(showSystemApp = !showSystemApp) } + } DropdownMenuItem( text = { Text("显示系统应用") @@ -212,34 +240,40 @@ fun useAppListPage(): ScaffoldExt { trailingIcon = { Checkbox( checked = showSystemApp, - onCheckedChange = { - storeFlow.update { s -> s.copy(showSystemApp = !showSystemApp) } - } + onCheckedChange = { handle1() } ) }, - onClick = { - storeFlow.update { s -> s.copy(showSystemApp = !showSystemApp) } - }, + onClick = handle1, ) + val handle3 = { + storeFlow.update { s -> s.copy(showBlockApp = !s.showBlockApp) } + } DropdownMenuItem( text = { - Text("显示隐藏应用") + Text("显示白名单") }, trailingIcon = { Checkbox( - checked = showHiddenApp, - onCheckedChange = { - storeFlow.update { s -> s.copy(showHiddenApp = !s.showHiddenApp) } - }) - }, - onClick = { - storeFlow.update { s -> s.copy(showHiddenApp = !showHiddenApp) } + checked = showBlockApp, + onCheckedChange = { handle3() } + ) }, + onClick = handle3, ) } } - }) + }, + floatingActionButton = { + AnimationFloatingActionButton( + visible = editWhiteListMode, + onClick = { + mainVm.navigatePage(EditBlockAppListPageDestination) + }, + content = { + PerfIcon(imageVector = PerfIcon.Edit) + } + ) } ) { contentPadding -> PullToRefreshBox( @@ -252,76 +286,103 @@ fun useAppListPage(): ScaffoldExt { modifier = Modifier.fillMaxSize(), state = listState ) { - items(orderedAppInfos, { it.id }) { appInfo -> - Row( - modifier = Modifier - .clickable(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() + items(appInfos, { it.id }) { appInfo -> + val desc = run { + if (editWhiteListMode) return@run null + val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList() + val appDesc = if (appGroups.isNotEmpty()) { + when (val disabledCount = appGroups.count { g -> !g.enable }) { + 0 -> "${appGroups.size}组规则" + appGroups.size -> "${appGroups.size}组规则/${disabledCount}关闭" + else -> { + "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" } - mainVm.navigatePage(AppConfigPageDestination(appInfo.id)) - }) - .appItemPadding(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - AppIcon(appId = appInfo.id) - Spacer(modifier = Modifier.width(12.dp)) - Column( - modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - AppNameText(appInfo = appInfo) - val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList() - val appDesc = if (appGroups.isNotEmpty()) { - when (val disabledCount = appGroups.count { g -> !g.enable }) { - 0 -> { - "${appGroups.size}组规则" - } - - appGroups.size -> { - "${appGroups.size}组规则/${disabledCount}关闭" - } - - else -> { - "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" - } - } - } else { - null } - val desc = if (globalDesc != null) { - if (appDesc != null) { - "$globalDesc/$appDesc" - } else { - globalDesc - } + } else { + null + } + if (globalDesc != null) { + if (appDesc != null) { + "$globalDesc/$appDesc" } else { - appDesc - } - if (desc != null) { - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - softWrap = false - ) + globalDesc } + } else { + appDesc } } + AppItemCard( + appInfo = appInfo, + desc = desc, + ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) - if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = showSystemApp && showHiddenApp + if (appInfos.isEmpty() && searchStr.isNotEmpty()) { + val hasShowAll = showSystemApp && showBlockApp EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) } - QueryPkgAuthCard() + QueryPkgAuthCard(hideLoading = true) } } } } -} \ No newline at end of file +} + +@Composable +private fun AppItemCard( + appInfo: AppInfo, + desc: String?, +) { + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + val vm = viewModel() + Row( + modifier = Modifier + .clickable(onClick = throttle { + if (vm.editWhiteListModeFlow.value) { + blockMatchAppListFlow.update { it.switchItem(appInfo.id) } + } else { + context.justHideSoftInput() + mainVm.navigatePage(AppConfigPageDestination(appInfo.id)) + } + }) + .appItemPadding(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(appId = appInfo.id) + Column( + modifier = Modifier + .weight(1f), + verticalArrangement = Arrangement.Center + ) { + AppNameText(appInfo = appInfo) + Text( + text = desc ?: appInfo.id, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + val editWhiteListMode = vm.editWhiteListModeFlow.collectAsState().value + val inWhiteList = blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) + if (editWhiteListMode) { + PerfCheckbox( + key = appInfo.id, + checked = inWhiteList, + ) + } else if (inWhiteList) { + PerfIcon( + modifier = Modifier + .padding(2.dp) + .size(20.dp), + imageVector = PerfIcon.Block, + tint = MaterialTheme.colorScheme.secondary, + ) + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 77c3978f07..960d0d7c75 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -16,22 +16,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.outlined.Equalizer -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -57,9 +45,13 @@ import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService import li.songe.gkd.service.StatusService +import li.songe.gkd.service.a11yPartDisabledFlow import li.songe.gkd.service.switchA11yService import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.GroupNameText +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.textSize import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight @@ -82,19 +74,17 @@ fun useControlPage(): ScaffoldExt { navItem = BottomNavItem.Control, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, title = { + PerfTopAppBar(scrollBehavior = scrollBehavior, title = { Text( text = stringResource(SafeR.app_name), ) }, actions = { - IconButton(onClick = throttle { - mainVm.navigatePage(AuthA11YPageDestination) - }) { - Icon( - imageVector = Icons.Outlined.RocketLaunch, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.RocketLaunch, + onClick = throttle { + mainVm.navigatePage(AuthA11YPageDestination) + }, + ) }) } ) { contentPadding -> @@ -110,16 +100,20 @@ fun useControlPage(): ScaffoldExt { .padding(contentPadding) ) { PageItemCard( - imageVector = Icons.Default.Memory, + imageVector = PerfIcon.Memory, title = "服务状态", subtitle = if (a11yRunning) { - "无障碍服务正在运行" + "无障碍正在运行" } else if (mainVm.a11yServiceEnabledFlow.collectAsState().value) { - "无障碍服务发生故障" + "无障碍发生故障" } else if (writeSecureSettings) { - "无障碍服务已关闭" + if (store.enableService && a11yPartDisabledFlow.collectAsState().value) { + "无障碍已局部关闭" + } else { + "无障碍已关闭" + } } else { - "无障碍服务未授权" + "无障碍未授权" }, rightContent = { Switch( @@ -136,7 +130,7 @@ fun useControlPage(): ScaffoldExt { ) PageItemCard( - imageVector = Icons.Outlined.Notifications, + imageVector = PerfIcon.Notifications, title = "常驻通知", subtitle = "显示运行状态及统计数据", rightContent = { @@ -163,7 +157,7 @@ fun useControlPage(): ScaffoldExt { PageItemCard( title = "触发记录", subtitle = "规则误触可定位关闭", - imageVector = Icons.Default.History, + imageVector = PerfIcon.History, onClick = { mainVm.navigatePage(ActionLogPageDestination()) } @@ -173,7 +167,7 @@ fun useControlPage(): ScaffoldExt { PageItemCard( title = "界面记录", subtitle = "记录打开的应用及界面", - imageVector = Icons.Outlined.Layers, + imageVector = PerfIcon.Layers, onClick = { mainVm.navigatePage(ActivityLogPageDestination) } @@ -183,7 +177,7 @@ fun useControlPage(): ScaffoldExt { PageItemCard( title = "了解 GKD", subtitle = "查阅规则文档和常见问题", - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + imageVector = PerfIcon.HelpOutline, onClick = { mainVm.navigatePage(WebViewPageDestination(initUrl = HOME_PAGE_URL)) } @@ -245,9 +239,8 @@ private fun IconTextCard( .padding(itemVerticalPadding), verticalAlignment = Alignment.CenterVertically ) { - Icon( + PerfIcon( imageVector = imageVector, - contentDescription = null, modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer) @@ -282,9 +275,8 @@ private fun ServerStatusCard(vm: HomeVm) { ), verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Outlined.Equalizer, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Equalizer, modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer) @@ -356,9 +348,8 @@ private fun ServerStatusCard(vm: HomeVm) { color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, modifier = Modifier.textSize(style = MaterialTheme.typography.bodyMedium), tint = MaterialTheme.colorScheme.primary, ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt index 2a816ef4d0..8d63739ced 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt @@ -1,11 +1,5 @@ package li.songe.gkd.ui.home -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.FormatListBulleted -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -18,6 +12,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions @@ -28,26 +23,26 @@ sealed class BottomNavItem( ) { object Control : BottomNavItem( key = 0, - label = "主页", - icon = Icons.Outlined.Home, + label = "首页", + icon = PerfIcon.Home, ) object SubsManage : BottomNavItem( key = 1, label = "订阅", - icon = Icons.AutoMirrored.Filled.FormatListBulleted, + icon = PerfIcon.FormatListBulleted, ) object AppList : BottomNavItem( key = 2, label = "应用", - icon = Icons.Default.Apps, + icon = PerfIcon.Apps, ) object Settings : BottomNavItem( key = 3, label = "设置", - icon = Icons.Outlined.Settings, + icon = PerfIcon.Settings, ) companion object { @@ -78,9 +73,8 @@ fun HomePage() { mainVm.updateTab(page.navItem) }, icon = { - Icon( + PerfIcon( imageVector = page.navItem.icon, - contentDescription = page.navItem.label ) }, label = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt index eca080d68c..9f2fb15116 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt @@ -1,37 +1,31 @@ package li.songe.gkd.ui.home -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import li.songe.gkd.appScope +import li.songe.gkd.MainViewModel import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet import li.songe.gkd.store.actionCountFlow +import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.ui.share.useAppFilter +import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.EMPTY_RULE_TIP -import li.songe.gkd.util.SortTypeOption -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow +import li.songe.gkd.util.findOption import li.songe.gkd.util.getSubsStatus -import li.songe.gkd.util.mapState -import li.songe.gkd.util.orderedAppInfosFlow import li.songe.gkd.util.ruleSummaryFlow -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.usedSubsEntriesFlow -class HomeVm : ViewModel() { - - val latestRecordFlow = DbSet.actionLogDao.queryLatest().stateIn(viewModelScope, SharingStarted.Eagerly, null) +class HomeVm : BaseViewModel() { + val latestRecordFlow = DbSet.actionLogDao.queryLatest().stateInit(null) val latestRecordIsGlobalFlow = - latestRecordFlow.mapState(viewModelScope) { it?.groupType == SubsConfig.GlobalGroupType } + latestRecordFlow.mapNew { it?.groupType == SubsConfig.GlobalGroupType } val latestRecordDescFlow = combine( - latestRecordFlow, subsIdToRawFlow, appInfoCacheFlow + latestRecordFlow, subsMapFlow, appInfoMapFlow ) { latestRecord, subsIdToRaw, appInfoCache -> if (latestRecord == null) return@combine null val isAppRule = latestRecord.groupType == SubsConfig.AppGroupType @@ -55,78 +49,50 @@ class HomeVm : ViewModel() { } else { appShowName } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + }.stateInit(null) val subsStatusFlow by lazy { combine(ruleSummaryFlow, actionCountFlow) { ruleSummary, count -> getSubsStatus(ruleSummary, count) - }.stateIn(appScope, SharingStarted.Eagerly, EMPTY_RULE_TIP) + }.stateInit(EMPTY_RULE_TIP) } - val usedSubsItemCountFlow = usedSubsEntriesFlow.mapState(viewModelScope) { it.size } + val usedSubsItemCountFlow = usedSubsEntriesFlow.mapNew { it.size } - private val appIdToOrderFlow = DbSet.actionLogDao.queryLatestUniqueAppIds().map { appIds -> - appIds.mapIndexed { index, appId -> appId to index }.toMap() + val sortTypeFlow = storeFlow.mapNew { + AppSortOption.objects.findOption(it.appSort) } + val showSystemAppFlow = storeFlow.mapNew { s -> s.showSystemApp } + val showBlockAppFlow = storeFlow.mapNew { s -> s.showBlockApp } - val sortTypeFlow = storeFlow.mapState(viewModelScope) { s -> - SortTypeOption.allSubObject.find { o -> o.value == s.sortType } - ?: SortTypeOption.SortByName - } - val showSystemAppFlow = storeFlow.mapState(viewModelScope) { s -> s.showSystemApp } - val showHiddenAppFlow = storeFlow.mapState(viewModelScope) { s -> s.showHiddenApp } - val showSearchBarFlow = MutableStateFlow(false) - val searchStrFlow = MutableStateFlow("") - private val debounceSearchStrFlow = searchStrFlow.debounce(200) - .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) - val appInfosFlow = - combine(orderedAppInfosFlow.combine(showHiddenAppFlow) { appInfos, showHiddenApp -> - if (showHiddenApp) { - appInfos - } else { - appInfos.filter { a -> !a.hidden } + val editWhiteListModeFlow = MutableStateFlow(false) + val blockAppListFlow = MutableStateFlow(blockMatchAppListFlow.value).also { stateFlow -> + combine(blockMatchAppListFlow, editWhiteListModeFlow) { it }.launchCollect { + if (!editWhiteListModeFlow.value) { + stateFlow.value = blockMatchAppListFlow.value } - }.combine(showSystemAppFlow) { appInfos, showSystemApp -> - if (showSystemApp) { - appInfos - } else { - appInfos.filter { a -> !a.isSystem } - } - }, sortTypeFlow, appIdToOrderFlow) { appInfos, sortType, appIdToOrder -> - when (sortType) { - SortTypeOption.SortByAppMtime -> { - appInfos.sortedBy { a -> -a.mtime } - } + } + } - SortTypeOption.SortByTriggerTime -> { - appInfos.sortedBy { a -> appIdToOrder[a.id] ?: Int.MAX_VALUE } - } + val appFilter = useAppFilter( + sortTypeFlow = sortTypeFlow, + showSystemAppFlow = showSystemAppFlow, + showBlockAppFlow = showBlockAppFlow, + blockAppListFlow = blockAppListFlow, + ) + val searchStrFlow = appFilter.searchStrFlow - SortTypeOption.SortByName -> { - appInfos - } - } - }.combine(debounceSearchStrFlow) { appInfos, str -> - if (str.isBlank()) { - appInfos - } else { - (appInfos.filter { a -> a.name.contains(str, true) } + appInfos.filter { a -> - a.id.contains( - str, - true - ) - }).distinct() - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val showSearchBarFlow = MutableStateFlow(false) + val appInfosFlow = appFilter.appListFlow init { - viewModelScope.launch { - showSearchBarFlow.collect { - if (!it) { - searchStrFlow.value = "" - } + showSearchBarFlow.launchCollect { + if (!it) { + searchStrFlow.value = "" } } + appInfosFlow.launchOnChange { + MainViewModel.instance.appListKeyFlow.value++ + } } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt index 6c64197e73..0376a982b6 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt @@ -2,15 +2,15 @@ package li.songe.gkd.ui.home import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import li.songe.gkd.ui.component.PerfTopAppBar data class ScaffoldExt( val navItem: BottomNavItem, val modifier: Modifier = Modifier, val topBar: @Composable () -> Unit = { - TopAppBar(title = { + PerfTopAppBar(title = { Text( text = navItem.label, ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 8cf9f6701f..83ce478e7a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -11,18 +11,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -36,31 +32,44 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.blankj.utilcode.util.KeyboardUtils import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination +import com.ramcosta.composedestinations.generated.destinations.BlockA11YAppListPageDestination import kotlinx.coroutines.flow.update +import li.songe.gkd.META +import li.songe.gkd.MainActivity +import li.songe.gkd.permission.writeSecureSettingsState +import li.songe.gkd.service.fixRestartService +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.component.CustomIconButton import li.songe.gkd.ui.component.CustomOutlinedTextField +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions +import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.titleItemPadding -import li.songe.gkd.ui.theme.supportDynamicColor +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.DarkThemeOption +import li.songe.gkd.util.SafeR import li.songe.gkd.util.findOption +import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Composable fun useSettingsPage(): ScaffoldExt { val mainVm = LocalMainViewModel.current - val activity = LocalActivity.current + val context = LocalActivity.current as MainActivity val store by storeFlow.collectAsState() val vm = viewModel() @@ -136,25 +145,22 @@ fun useSettingsPage(): ScaffoldExt { modifier = Modifier.fillMaxWidth(), ) { Text(text = "通知文案") - IconButton(onClick = throttle { - KeyboardUtils.hideSoftInput(activity) - showNotifTextInputDlg = false - val confirmAction = { - mainVm.dialogFlow.value = null - showNotifTextInputDlg = true - } - mainVm.dialogFlow.updateDialogOptions( - title = "文案规则", - text = "通知文案支持变量替换,规则如下\n\${i} 全局规则数\n\${k} 应用数\n\${u} 应用规则组数\n\${n} 触发次数\n\n示例模板\n\${i}全局/\${k}应用/\${u}规则组/\${n}触发\n\n替换结果\n0全局/1应用/2规则组/3触发", - confirmAction = confirmAction, - onDismissRequest = confirmAction, - ) - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + onClick = throttle { + showNotifTextInputDlg = false + val confirmAction = { + mainVm.dialogFlow.value = null + showNotifTextInputDlg = true + } + mainVm.dialogFlow.updateDialogOptions( + title = "文案规则", + text = "通知文案支持变量替换,规则如下\n\${i} 全局规则数\n\${k} 应用数\n\${u} 应用规则组数\n\${n} 触发次数\n\n示例模板\n\${i}全局/\${k}应用/\${u}规则组/\${n}触发\n\n替换结果\n0全局/1应用/2规则组/3触发", + confirmAction = confirmAction, + onDismissRequest = confirmAction, + ) + }, + ) } }, text = { @@ -210,7 +216,7 @@ fun useSettingsPage(): ScaffoldExt { }, confirmButton = { TextButton(onClick = { - KeyboardUtils.hideSoftInput(activity) + context.justHideSoftInput() if (store.customNotifTitle != textValue || store.customNotifText != textValue) { storeFlow.update { it.copy( @@ -236,17 +242,59 @@ fun useSettingsPage(): ScaffoldExt { }) } + var showToastSettingsDlg by remember { mutableStateOf(false) } + if (showToastSettingsDlg) { + AlertDialog( + onDismissRequest = { showToastSettingsDlg = false }, + title = { Text("提示设置") }, + text = { + TextSwitch( + paddingDisabled = true, + title = "系统提示", + subtitle = "系统样式触发提示", + suffix = "查看限制", + onSuffixClick = { + showToastSettingsDlg = false + val confirmAction = { + mainVm.dialogFlow.value = null + showToastSettingsDlg = true + } + mainVm.dialogFlow.updateDialogOptions( + title = "限制说明", + text = "系统 Toast 存在频率限制, 触发过于频繁会被系统强制不显示\n\n如果只使用开屏一类低频率规则可使用系统提示, 否则建议关闭此项使用自定义样式提示", + confirmAction = confirmAction, + onDismissRequest = confirmAction, + ) + }, + checked = store.useSystemToast, + onCheckedChange = { + storeFlow.value = store.copy( + useSystemToast = it + ) + }) + }, + confirmButton = { + TextButton(onClick = { showToastSettingsDlg = false }) { + Text("关闭") + } + } + ) + } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollState = rememberScrollState() return ScaffoldExt( navItem = BottomNavItem.Settings, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, title = { - Text( - text = BottomNavItem.Settings.label, - ) - }) + PerfTopAppBar( + scrollBehavior = scrollBehavior, + title = { + Text( + text = BottomNavItem.Settings.label, + ) + }, + ) }, ) { contentPadding -> Column( @@ -269,31 +317,23 @@ fun useSettingsPage(): ScaffoldExt { modifier = Modifier.clickable { showToastInputDlg = true }, + suffixIcon = { + CustomIconButton( + size = 32.dp, + onClick = throttle { showToastSettingsDlg = true }, + ) { + PerfIcon( + modifier = Modifier.size(20.dp), + id = SafeR.ic_page_info, + ) + } + }, onCheckedChange = { storeFlow.value = store.copy( toastWhenClick = it ) }) - AnimatedVisibility(visible = store.toastWhenClick) { - TextSwitch( - title = "系统提示", - subtitle = "系统样式触发提示", - suffix = "查看限制", - onSuffixClick = { - mainVm.dialogFlow.updateDialogOptions( - title = "限制说明", - text = "系统 Toast 存在频率限制, 触发过于频繁会被系统强制不显示\n\n如果只使用开屏一类低频率规则可使用系统提示, 否则建议关闭此项使用自定义样式提示", - ) - }, - checked = store.useSystemToast, - onCheckedChange = { - storeFlow.value = store.copy( - useSystemToast = it - ) - }) - } - val subsStatus by vm.subsStatusFlow.collectAsState() TextSwitch( title = "通知文案", @@ -322,6 +362,50 @@ fun useSettingsPage(): ScaffoldExt { ) }) + if (store.enableShizuku && writeSecureSettingsState.stateFlow.collectAsState().value || META.debuggable) { + AnimatedVisibility(visible = store.enableBlockA11yAppList) { + Text( + text = "无障碍", + modifier = Modifier.titleItemPadding(), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + TextSwitch( + title = "局部关闭", + subtitle = "白名单应用内关闭无障碍", + checked = store.enableBlockA11yAppList, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + mainVm.dialogFlow.waitResult( + title = "使用说明", + text = "开启或关闭无障碍时会造成短暂触摸卡顿,请自行考虑后再编辑无障碍白名单\n\n如果你还使用其它无障碍软件,此功能无效\n\n此外需确保无障碍关闭后后台运行\n1. 开启「常驻通知」\n2. 在「最近任务界面」锁定\n3. 允许自启动\n4. 设置省电策略为无限制\n不设置会被系统暂停或结束运行,导致无法恢复无障碍", + confirmText = "继续", + dismissRequest = true, + ) + } + storeFlow.value = store.copy( + enableBlockA11yAppList = it + ) + if (!it) { + fixRestartService() + } + if (it) { + if (!shizukuContextFlow.value.ok) { + toast("请先连接 Shizuku") + } else { + !writeSecureSettingsState.updateAndGet() + } + } + }, + ) + AnimatedVisibility(visible = store.enableBlockA11yAppList) { + SettingItem(title = "白名单", onClick = { + mainVm.navigatePage(BlockA11YAppListPageDestination) + }) + } + } + Text( text = "主题", modifier = Modifier.titleItemPadding(), @@ -331,16 +415,15 @@ fun useSettingsPage(): ScaffoldExt { TextMenu( title = "深色模式", - option = DarkThemeOption.allSubObject.findOption(store.enableDarkTheme), + option = DarkThemeOption.objects.findOption(store.enableDarkTheme), onOptionChange = { storeFlow.update { s -> s.copy(enableDarkTheme = it.value) } } ) - if (supportDynamicColor) { + if (AndroidTarget.S) { TextSwitch( title = "动态配色", - subtitle = "配色跟随系统主题", checked = store.enableDynamicColor, onCheckedChange = { storeFlow.update { s -> s.copy(enableDynamicColor = it) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index bc03dacb69..80cd08e6ae 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -21,23 +21,16 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Eco import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -53,7 +46,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope @@ -71,6 +63,9 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow import li.songe.gkd.store.switchStoreEnableMatch import li.songe.gkd.ui.component.AnimationFloatingActionButton +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.SubsItemCard import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult @@ -90,8 +85,8 @@ import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry import li.songe.gkd.util.mapState import li.songe.gkd.util.ruleSummaryFlow -import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex @@ -106,7 +101,7 @@ fun useSubsManagePage(): ScaffoldExt { val vm = viewModel() val subItems by subsItemsFlow.collectAsState() - val subsIdToRaw by subsIdToRawFlow.collectAsState() + val subsIdToRaw by subsMapFlow.collectAsState() var orderSubItems by remember { mutableStateOf(subItems) @@ -145,18 +140,16 @@ fun useSubsManagePage(): ScaffoldExt { TextMenu( modifier = Modifier.padding(0.dp, itemVerticalPadding), title = "更新订阅", - option = UpdateTimeOption.allSubObject.findOption(store.updateSubsInterval) + option = UpdateTimeOption.objects.findOption(store.updateSubsInterval) ) { storeFlow.update { s -> s.copy(updateSubsInterval = it.value) } } - val updateValue = throttle { storeFlow.update { it.copy(subsPowerWarn = !it.subsPowerWarn) } } Row( modifier = Modifier - .padding(0.dp, itemVerticalPadding) - .clickable(onClick = updateValue), + .padding(0.dp, itemVerticalPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -192,14 +185,12 @@ fun useSubsManagePage(): ScaffoldExt { navItem = BottomNavItem.SubsManage, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { if (isSelectedMode) { - IconButton(onClick = { isSelectedMode = false }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Close, + onClick = { isSelectedMode = false }, + ) } }, title = { if (isSelectedMode) { @@ -220,14 +211,9 @@ fun useSubsManagePage(): ScaffoldExt { ) { Row { if (it) { - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.Share, onClick = { mainVm.showShareDataIdsFlow.value = selectedIds - }) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = null, - ) - } + }) val canDeleteIds = if (selectedIds.contains(LOCAL_SUBS_ID)) { selectedIds - LOCAL_SUBS_ID } else { @@ -237,23 +223,21 @@ fun useSubsManagePage(): ScaffoldExt { val text = "确定删除所选 ${canDeleteIds.size} 个订阅?".let { s -> if (selectedIds.contains(LOCAL_SUBS_ID)) "$s\n\n注: 不包含本地订阅" else s } - IconButton(onClick = vm.viewModelScope.launchAsFn { - mainVm.dialogFlow.waitResult( - title = "删除订阅", - text = text, - error = true, - ) - deleteSubscription(*canDeleteIds.toLongArray()) - selectedIds = selectedIds - canDeleteIds - if (selectedIds.size == canDeleteIds.size) { - isSelectedMode = false - } - }) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = vm.viewModelScope.launchAsFn { + mainVm.dialogFlow.waitResult( + title = "删除订阅", + text = text, + error = true, + ) + deleteSubscription(*canDeleteIds.toLongArray()) + selectedIds = selectedIds - canDeleteIds + if (selectedIds.size == canDeleteIds.size) { + isSelectedMode = false + } + }, + ) } } else { val ruleSummary by ruleSummaryFlow.collectAsState() @@ -262,49 +246,38 @@ fun useSubsManagePage(): ScaffoldExt { enter = scaleIn(), exit = scaleOut(), ) { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Eco, onClick = throttle { mainVm.navigatePage(SlowGroupPageDestination) - }) { - Icon( - imageVector = Icons.Outlined.Eco, - contentDescription = null, - ) - } - } - IconButton(onClick = throttle { switchStoreEnableMatch() }) { - val scope = rememberCoroutineScope() - val enableMatch by remember { - storeFlow.mapState(scope) { s -> s.enableMatch } - }.collectAsState() - val id = if (enableMatch) SafeR.ic_flash_on else SafeR.ic_flash_off - Icon( - painter = painterResource(id = id), - contentDescription = null, - ) + }) } - IconButton(onClick = { + val scope = rememberCoroutineScope() + val enableMatch by remember { + storeFlow.mapState(scope) { s -> s.enableMatch } + }.collectAsState() + PerfIconButton( + id = if (enableMatch) SafeR.ic_flash_on else SafeR.ic_flash_off, + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (!enableMatch) { + CheckboxDefaults.colors().checkedBoxColor + } else { + LocalContentColor.current + } + ), + onClick = throttle { switchStoreEnableMatch() }, + ) + PerfIconButton(id = SafeR.ic_page_info, onClick = { showSettingsDlg = true - }) { - Icon( - painter = painterResource(id = SafeR.ic_page_info), - contentDescription = null, - ) - } + }) } } } - IconButton(onClick = { + PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { if (updateSubsMutex.mutex.isLocked) { toast("正在刷新订阅,请稍后操作") } else { expanded = true } - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, - ) - } + }) Box( modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { @@ -405,9 +378,8 @@ fun useSubsManagePage(): ScaffoldExt { } } ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Add, ) } }, @@ -473,7 +445,6 @@ fun useSubsManagePage(): ScaffoldExt { subsItem = subItem, subscription = subsIdToRaw[subItem.id], index = index + 1, - vm = vm, isSelectedMode = isSelectedMode, isSelected = selectedIds.contains(subItem.id), onCheckedChange = mainVm.viewModelScope.launchAsFn { checked -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt new file mode 100644 index 0000000000..e4db551486 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt @@ -0,0 +1,93 @@ +package li.songe.gkd.ui.share + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import li.songe.gkd.MainViewModel +import li.songe.gkd.data.AppInfo +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.visibleAppInfosFlow + +fun BaseViewModel.useAppFilter( + sortTypeFlow: StateFlow, + showSystemAppFlow: StateFlow, + appOrderListFlow: StateFlow> = MainViewModel.instance.appOrderListFlow, + showBlockAppFlow: StateFlow? = null, + blockAppListFlow: StateFlow> = blockMatchAppListFlow, +): AppFilter { + + val searchStrFlow = MutableStateFlow("") + val debounceSearchStrFlow = searchStrFlow.debounce(200) + .stateInit(searchStrFlow.value) + val appActionOrderMapFlow = appOrderListFlow.map { + it.mapIndexed { i, appId -> appId to i }.toMap() + } + + var tempListFlow = visibleAppInfosFlow.combine(showSystemAppFlow) { appInfos, showSystemApp -> + if (showSystemApp) { + appInfos + } else { + appInfos.filterNot { it.isSystem } + } + } + + if (showBlockAppFlow != null) { + tempListFlow = combine( + tempListFlow, + showBlockAppFlow, + blockAppListFlow, + ) { appInfos, showBlockApp, blockAppList -> + if (showBlockApp) { + appInfos + } else { + appInfos.filterNot { it.id in blockAppList } + } + } + } + + tempListFlow = combine( + tempListFlow, + sortTypeFlow, + appActionOrderMapFlow, + MainViewModel.instance.appVisitOrderMapFlow, + ) { apps, sortType, appActionOrderMap, appVisitOrderMap -> + when (sortType) { + AppSortOption.ByActionTime -> { + apps.sortedBy { a -> appActionOrderMap[a.id] ?: Int.MAX_VALUE } + } + + AppSortOption.ByAppName -> { + apps + } + + AppSortOption.ByUsedTime -> { + apps.sortedBy { a -> appVisitOrderMap[a.id] ?: Int.MAX_VALUE } + } + } + } + tempListFlow = tempListFlow.combine(debounceSearchStrFlow) { apps, str -> + if (str.isBlank()) { + apps + } else { + (apps.filter { a -> a.name.contains(str, true) } + apps.filter { a -> + a.id.contains( + str, + true + ) + }).distinct() + } + }.stateInit(emptyList()) + return AppFilter( + searchStrFlow = searchStrFlow, + appListFlow = tempListFlow + ) +} + + +class AppFilter( + val searchStrFlow: MutableStateFlow, + val appListFlow: StateFlow>, +) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt index 4f96fabbf3..93e2a3b5b2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt @@ -3,18 +3,23 @@ package li.songe.gkd.ui.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import li.songe.gkd.util.mapState +import kotlinx.coroutines.launch +import li.songe.gkd.data.RawSubscription +import li.songe.gkd.util.subsMapFlow abstract class BaseViewModel : ViewModel() { private val countFlow by lazy { MutableStateFlow(0) } - val firstLoadingFlow by lazy { countFlow.mapState(viewModelScope) { it > 0 } } + val firstLoadingFlow by lazy { countFlow.mapNew { it > 0 } } fun Flow.attachLoad(): Flow { countFlow.update { it + 1 } var currentUsed = false @@ -35,4 +40,29 @@ abstract class BaseViewModel : ViewModel() { fun Flow.stateInit(initialValue: T): StateFlow { return stateIn(viewModelScope, SharingStarted.Eagerly, initialValue) } + + fun Flow.launchCollect(collector: FlowCollector) { + viewModelScope.launch { collect(collector) } + } + + fun StateFlow.launchOnChange(collector: FlowCollector) { + viewModelScope.launch { drop(1).collect(collector) } + } + + fun StateFlow.mapNew( + mapper: (value: T) -> M, + ): StateFlow = map { mapper(it) }.stateIn( + viewModelScope, SharingStarted.Eagerly, mapper(value) + ) + + fun mapSafeSubs(id: Long): StateFlow { + return subsMapFlow.mapNew { + it[id] ?: RawSubscription( + id = id, + version = 0, + name = id.toString() + ) + } + } + } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/FixedWindowInsets.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/FixedWindowInsets.kt new file mode 100644 index 0000000000..9b0166047a --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/FixedWindowInsets.kt @@ -0,0 +1,16 @@ +package li.songe.gkd.ui.share + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.unit.Density + +// 解决 val obj = TopAppBarDefaults.windowInsets 在不同时机返回不一致的问题 +class FixedWindowInsets( + val insets: WindowInsets +) : WindowInsets by insets { + var top: Int? = null + override fun getTop(density: Density) = top ?: insets.getTop(density).also { top = it } + + var bottom: Int? = null + override fun getBottom(density: Density) = + bottom ?: insets.getBottom(density).also { bottom = it } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/ModifierExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/ModifierExt.kt new file mode 100644 index 0000000000..995442eb39 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/ModifierExt.kt @@ -0,0 +1,23 @@ +package li.songe.gkd.ui.share + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role + +@Composable +fun Modifier.noRippleClickable( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit, +): Modifier = clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, +) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt new file mode 100644 index 0000000000..9719c3babd --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt @@ -0,0 +1,63 @@ +package li.songe.gkd.ui.share + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + + +@Composable +fun MutableStateFlow.asMutableState(): MutableState { + val state = collectAsState() + return remember(this) { + val stateFlow = this + object : MutableState { + override var value: T + get() = state.value + set(newValue) { + stateFlow.value = newValue + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} + +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +fun MutableStateFlow.asMutableStateFlow( + getter: (T) -> S, + setter: (S) -> T +) = object : MutableStateFlow { + val source = this@asMutableStateFlow + override var value: S + get() = getter(source.value) + set(newValue) = source.update { setter(newValue) } + + override fun compareAndSet(expect: S, update: S) = source.compareAndSet( + setter(expect), + setter(update), + ) + + override suspend fun collect(collector: FlowCollector): Nothing { + var oldValue = value + collector.emit(oldValue) + source.collect { + val newValue = getter(it) + if (oldValue != newValue) { + oldValue = newValue + collector.emit(oldValue) + } + } + } + + override val replayCache get() = source.replayCache.map(getter) + override val subscriptionCount get() = source.subscriptionCount + override suspend fun emit(value: S) = source.emit(setter(value)) + override fun tryEmit(value: S) = source.tryEmit(setter(value)) + override fun resetReplayCache() = source.resetReplayCache() +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt index d504db31e0..5ef2ad0c42 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt @@ -8,17 +8,11 @@ import androidx.compose.ui.unit.dp val itemHorizontalPadding = 16.dp val itemVerticalPadding = 12.dp -val EmptyHeight = 40.dp +val EmptyHeight = 80.dp val cardHorizontalPadding = 12.dp fun Modifier.itemPadding() = this.padding(itemHorizontalPadding, itemVerticalPadding) -fun Modifier.itemFlagPadding() = this.padding( - start = itemHorizontalPadding, - top = itemVerticalPadding, - bottom = itemVerticalPadding -) - fun Modifier.titleItemPadding(showTop: Boolean = true) = this.padding( itemHorizontalPadding, if (showTop) itemVerticalPadding + itemVerticalPadding / 2 else 0.dp, @@ -33,7 +27,7 @@ fun Modifier.menuPadding() = this .padding(vertical = 8.dp) fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { - return this.padding( + return padding( top = values.calculateTopPadding(), // 被 LazyXXX 使用时, 移除 bottom padding, 否则 底部导航栏 无法实现透明背景 ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt similarity index 93% rename from app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt rename to app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt index 63b02d8fdb..8370568b3e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt @@ -1,6 +1,5 @@ -package li.songe.gkd.ui.theme +package li.songe.gkd.ui.style -import android.os.Build import androidx.activity.compose.LocalActivity import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -27,10 +26,10 @@ import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.share.LocalDarkTheme +import li.songe.gkd.util.AndroidTarget private val LightColorScheme = lightColorScheme() private val DarkColorScheme = darkColorScheme() -val supportDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @Composable fun AppTheme( @@ -55,8 +54,8 @@ fun AppTheme( if (invertedTheme) !it else it } val colorScheme = when { - supportDynamicColor && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app) - supportDynamicColor && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(app) + AndroidTarget.S && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app) + AndroidTarget.S && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(app) darkTheme -> DarkColorScheme else -> LightColorScheme } diff --git a/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt b/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt new file mode 100644 index 0000000000..856b11ced9 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt @@ -0,0 +1,30 @@ +package li.songe.gkd.util + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast + +object AndroidTarget { + /** Android 9+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P) + val P = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + + /** Android 10+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) + val Q = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + /** Android 11+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) + val R = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + /** Android 12+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) + val S = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + /** Android 13+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) + val TIRAMISU = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + /** Android 16+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) + val BAKLAVA = Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index 6923438f71..bc605e8c9b 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -20,36 +20,38 @@ import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo +import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.data.toAppInfo import li.songe.gkd.permission.canQueryPkgState -import li.songe.gkd.shizuku.updateOtherUserAppInfo +import li.songe.gkd.shizuku.currentUserId +import li.songe.gkd.shizuku.shizukuContextFlow val userAppInfoMapFlow = MutableStateFlow(emptyMap()) val userAppIconMapFlow = MutableStateFlow(emptyMap()) val otherUserAppInfoMapFlow = MutableStateFlow(emptyMap()) val otherUserAppIconMapFlow = MutableStateFlow(emptyMap()) -val appInfoCacheFlow by lazy { +val appInfoMapFlow by lazy { combine(otherUserAppInfoMapFlow, userAppInfoMapFlow) { a, b -> a + b } .stateIn(appScope, SharingStarted.Eagerly, emptyMap()) } val appIconMapFlow by lazy { - combine(userAppIconMapFlow, otherUserAppIconMapFlow) { a, b -> a + b } + combine(otherUserAppIconMapFlow, userAppIconMapFlow) { a, b -> a + b } .stateIn(appScope, SharingStarted.Eagerly, emptyMap()) } val systemAppInfoCacheFlow by lazy { - appInfoCacheFlow.mapState(appScope) { c -> + appInfoMapFlow.mapState(appScope) { c -> c.filter { a -> a.value.isSystem } } } val systemAppsFlow by lazy { systemAppInfoCacheFlow.mapState(appScope) { c -> c.keys } } -val orderedAppInfosFlow by lazy { - appInfoCacheFlow.mapState(appScope) { c -> - c.values.sortedWith { a, b -> +val visibleAppInfosFlow by lazy { + appInfoMapFlow.mapState(appScope) { c -> + c.values.filter { it.visible }.sortedWith { a, b -> collator.compare(a.name, b.name) } } @@ -95,7 +97,7 @@ private val packageReceiver by lazy { } } -const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES +const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES private fun getPkgInfo(appId: String): PackageInfo? = try { app.packageManager.getPackageInfo(appId, PKG_FLAGS) @@ -105,7 +107,41 @@ private fun getPkgInfo(appId: String): PackageInfo? = try { val updateAppMutex = MutexState() -private suspend fun updateAppInfo(appIds: Set) = updateAppMutex.withStateLock { +private fun updateOtherUserAppInfo(userAppInfoMap: Map? = null) { + val pkgManager = shizukuContextFlow.value.packageManager + val userManager = shizukuContextFlow.value.userManager + if (pkgManager == null || userManager == null) { + otherUserMapFlow.value = emptyMap() + otherUserAppIconMapFlow.value = emptyMap() + otherUserAppInfoMapFlow.value = emptyMap() + return + } + val actualUserAppInfoMap = userAppInfoMap ?: userAppInfoMapFlow.value + val otherUsers = userManager.getUsers().filter { it.id != currentUserId }.sortedBy { it.id } + val userPackageInfoMap = otherUsers.associate { user -> + user.id to pkgManager.getInstalledPackages( + PKG_FLAGS, + user.id + ).filterNot { actualUserAppInfoMap.contains(it.packageName) } + } + val newIconMap = HashMap() + val newAppMap = HashMap() + userPackageInfoMap.forEach { (userId, pkgInfoList) -> + pkgInfoList.forEach { pkgInfo -> + if (!newAppMap.contains(pkgInfo.packageName)) { + newAppMap[pkgInfo.packageName] = pkgInfo.toAppInfo(userId = userId) + pkgInfo.pkgIcon?.let { newIconMap[pkgInfo.packageName] = it } + } + } + } + otherUserMapFlow.value = otherUsers.associateBy { it.id } + otherUserAppInfoMapFlow.value = newAppMap + otherUserAppIconMapFlow.value = newIconMap +} + +private fun updatePartAppInfo( + appIds: Set, +) = updateAppMutex.launchTry(appScope, Dispatchers.IO) { willUpdateAppIds.update { it - appIds } val newAppMap = HashMap(userAppInfoMapFlow.value) val newIconMap = HashMap(userAppIconMapFlow.value) @@ -123,56 +159,60 @@ private suspend fun updateAppInfo(appIds: Set) = updateAppMutex.withStat newIconMap.remove(appId) } } + updateOtherUserAppInfo(newAppMap) userAppInfoMapFlow.value = newAppMap userAppIconMapFlow.value = newIconMap } -fun updateAllAppInfo(showToast: Boolean = false) = appScope.launchTry(Dispatchers.IO) { - updateAppMutex.withStateLock { - val newAppMap = HashMap() - val newIconMap = HashMap() - val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) - pkgList.forEach { packageInfo -> +fun updateAllAppInfo( + showToast: Boolean = false, +) = updateAppMutex.launchTry(appScope, Dispatchers.IO) { + val newAppMap = HashMap() + val newIconMap = HashMap() + val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) + pkgList.forEach { packageInfo -> + newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() + packageInfo.pkgIcon?.let { icon -> + newIconMap[packageInfo.packageName] = icon + } + } + if (!canQueryPkgState.updateAndGet() || newAppMap.getMayQueryPkgNoAccess()) { + val visiblePkgList = arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> + app.packageManager.queryIntentActivities( + Intent(action), + PackageManager.MATCH_DISABLED_COMPONENTS + ) + }.flatten() + .map { it.activityInfo.packageName }.toSet() + .filter { !newAppMap.contains(it) }.mapNotNull { getPkgInfo(it) } + visiblePkgList.forEach { packageInfo -> newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() packageInfo.pkgIcon?.let { icon -> newIconMap[packageInfo.packageName] = icon } } - if (!canQueryPkgState.updateAndGet() || newAppMap.getMayQueryPkgNoAccess()) { - val visiblePkgList = arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> - app.packageManager.queryIntentActivities( - Intent(action), - PackageManager.MATCH_DISABLED_COMPONENTS - ) - }.flatten() - .map { it.activityInfo.packageName }.toSet() - .filter { !newAppMap.contains(it) }.mapNotNull { getPkgInfo(it) } - visiblePkgList.forEach { packageInfo -> - newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() - packageInfo.pkgIcon?.let { icon -> - newIconMap[packageInfo.packageName] = icon - } - } - } - val oldAppMap = userAppInfoMapFlow.value - if (oldAppMap == newAppMap) { - updateOtherUserAppInfo(newAppMap) - } else { - userAppInfoMapFlow.value = newAppMap - } - userAppIconMapFlow.value = newIconMap - if (showToast) { - toast("应用列表更新成功") - } } -}.let { } + updateOtherUserAppInfo(newAppMap) + userAppInfoMapFlow.value = newAppMap + userAppIconMapFlow.value = newIconMap + if (showToast) { + toast("应用列表更新成功") + } +} fun initAppState() { packageReceiver updateAllAppInfo() - appScope.launchTry(Dispatchers.IO) { + appScope.launchTry { + shizukuContextFlow.collect { + updateAppMutex.launchTry(appScope, Dispatchers.IO) { + updateOtherUserAppInfo() + } + } + } + appScope.launchTry { willUpdateAppIds.debounce(3000) .filter { it.isNotEmpty() } - .collect { updateAppInfo(it) } + .collect { updatePartAppInfo(it) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/BarUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/BarUtils.kt new file mode 100644 index 0000000000..cca3fa475b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/BarUtils.kt @@ -0,0 +1,23 @@ +package li.songe.gkd.util + +import android.annotation.SuppressLint +import android.content.res.Resources + +@SuppressLint("DiscouragedApi", "InternalInsetResource") +object BarUtils { + fun getNavBarHeight(): Int { + val res = Resources.getSystem() + val resourceId = res.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId != 0) { + res.getDimensionPixelSize(resourceId) + } else { + 0 + } + } + + fun getStatusBarHeight(): Int { + val resources = Resources.getSystem() + val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") + return resources.getDimensionPixelSize(resourceId) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/CollectionExt.kt b/app/src/main/kotlin/li/songe/gkd/util/CollectionExt.kt index f58f5daa61..cd211d5a9f 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/CollectionExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/CollectionExt.kt @@ -9,7 +9,7 @@ fun Set.switchItem(t: T): Set { } inline fun Iterable.filterIfNotAll(predicate: (T) -> Boolean): List { - return if (count() > 1 && !all(predicate)) { + return if (count() > 0 && !all(predicate)) { filter(predicate) } else { this as? List ?: toList() diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index 9fd045172c..231aaaa3e3 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -34,3 +34,5 @@ object ShortUrlSet { const val shizukuAppId = "moe.shizuku.privileged.api" const val PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=li.songe.gkd" + +const val systemUiAppId = "com.android.systemui" diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index 20d853f37d..0ed9516523 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -2,7 +2,6 @@ package li.songe.gkd.util import android.text.format.DateUtils import com.blankj.utilcode.util.LogUtils -import com.blankj.utilcode.util.ZipUtils import li.songe.gkd.app import java.io.File @@ -65,7 +64,7 @@ fun buildLogFile(): File { val files = mutableListOf(dbFolder, storeFolder, subsFolder) LogUtils.getLogFiles().firstOrNull()?.parentFile?.let { files.add(it) } tempDir.resolve("appList.json").also { - it.writeText(json.encodeToString(appInfoCacheFlow.value.values.toList())) + it.writeText(json.encodeToString(appInfoMapFlow.value.values.toList())) files.add(it) } val logZipFile = sharedDir.resolve("log-${System.currentTimeMillis()}.zip") diff --git a/app/src/main/kotlin/li/songe/gkd/util/Github.kt b/app/src/main/kotlin/li/songe/gkd/util/Github.kt index 3fa30ab66f..f93264a659 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Github.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Github.kt @@ -3,11 +3,7 @@ package li.songe.gkd.util import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -40,6 +36,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject import li.songe.gkd.data.GithubPoliciesAsset +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.json5.Json5 @@ -211,15 +209,12 @@ fun EditGithubCookieDlg() { modifier = Modifier.fillMaxWidth(), ) { Text(text = "Github Cookie") - IconButton(onClick = throttle { - mainVm.showEditCookieDlgFlow.value = false - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL1)) - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + onClick = throttle { + mainVm.showEditCookieDlgFlow.value = false + mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL1)) + }) } }, text = { diff --git a/app/src/main/kotlin/li/songe/gkd/util/ImageUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/ImageUtils.kt new file mode 100644 index 0000000000..e79177a932 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/ImageUtils.kt @@ -0,0 +1,89 @@ +package li.songe.gkd.util + +import android.content.ContentValues +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.net.toUri +import li.songe.gkd.app +import li.songe.gkd.permission.canWriteExternalStorage +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream + + +object ImageUtils { + fun save2Album( + src: Bitmap, + quality: Int = 100, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG, + recycle: Boolean = true, + ): Boolean { + val safeDirName = app.packageName + val suffix: String? = if (Bitmap.CompressFormat.JPEG == format) "JPG" else format.name + val fileName = System.currentTimeMillis().toString() + "_" + quality + "." + suffix + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (!canWriteExternalStorage.updateAndGet()) { + return false + } + val picDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) + val destFile = File(picDir, "$safeDirName/$fileName") + BufferedOutputStream(FileOutputStream(destFile)).use { + val ret = src.compress(format, quality, it) + if (!ret) return false + } + if (recycle && !src.isRecycled) { + src.recycle() + } + @Suppress("DEPRECATION") + val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) + intent.setData(("file://" + destFile.absolutePath).toUri()) + app.sendBroadcast(intent) + return true + } else { + val contentValues = ContentValues() + contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/*") + val contentUri: Uri + if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else { + contentUri = MediaStore.Images.Media.INTERNAL_CONTENT_URI + } + contentValues.put( + MediaStore.Images.Media.RELATIVE_PATH, + Environment.DIRECTORY_DCIM + "/" + safeDirName + ) + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1) + val uri: Uri? = app.contentResolver.insert(contentUri, contentValues) + if (uri == null) { + return false + } + var os: OutputStream? = null + try { + os = app.contentResolver.openOutputStream(uri) + src.compress(format, quality, os!!) + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + app.contentResolver.update(uri, contentValues, null, null) + return true + } catch (e: Exception) { + app.contentResolver.delete(uri, null, null) + e.printStackTrace() + return false + } finally { + try { + os?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt index 718cc23fdf..ecfaa9e771 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt @@ -6,7 +6,6 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Environment import android.provider.MediaStore import android.provider.Settings @@ -46,14 +45,7 @@ fun MainActivity.shareFile(file: File, title: String) { } suspend fun MainActivity.saveFileToDownloads(file: File) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - requiredPermission(this, canWriteExternalStorage) - val targetFile = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - file.name - ) - targetFile.writeBytes(file.readBytes()) - } else { + if (AndroidTarget.Q) { val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) @@ -66,6 +58,13 @@ suspend fun MainActivity.saveFileToDownloads(file: File) { outputStream.flush() } } + } else { + requiredPermission(this, canWriteExternalStorage) + val targetFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + file.name + ) + targetFile.writeBytes(file.readBytes()) } toast("已保存 ${file.name} 到下载") } @@ -143,7 +142,7 @@ fun startForegroundServiceByClass(clazz: KClass) { } val Intent.extraCptName: ComponentName? - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + get() = if (AndroidTarget.TIRAMISU) { getParcelableExtra(Intent.EXTRA_COMPONENT_NAME, ComponentName::class.java) } else { @Suppress("DEPRECATION") diff --git a/app/src/main/kotlin/li/songe/gkd/util/KeyboardUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/KeyboardUtils.kt new file mode 100644 index 0000000000..3915537ed3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/KeyboardUtils.kt @@ -0,0 +1,73 @@ +package li.songe.gkd.util + +import android.app.Activity +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.Window +import android.view.WindowManager +import android.widget.EditText +import li.songe.gkd.app +import kotlin.math.abs + +object KeyboardUtils { + private const val TAG_ON_GLOBAL_LAYOUT_LISTENER = -8 + private var sDecorViewDelta = 0 + private fun getDecorViewInvisibleHeight(window: Window): Int { + val decorView = window.decorView + val outRect = Rect() + decorView.getWindowVisibleDisplayFrame(outRect) + val delta = abs(decorView.bottom - outRect.bottom) + if (delta <= BarUtils.getNavBarHeight() + BarUtils.getStatusBarHeight()) { + sDecorViewDelta = delta + return 0 + } + return delta - sDecorViewDelta + } + + fun registerSoftInputChangedListener(window: Window, onSoftInputChanged: (Int) -> Unit) { + val flags = window.attributes.flags + if ((flags and WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0) { + window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) + } + val contentView = window.findViewById(android.R.id.content) + val decorViewInvisibleHeightPre = intArrayOf(getDecorViewInvisibleHeight(window)) + val onGlobalLayoutListener = OnGlobalLayoutListener { + val height = getDecorViewInvisibleHeight(window) + if (decorViewInvisibleHeightPre[0] != height) { + onSoftInputChanged(height) + decorViewInvisibleHeightPre[0] = height + } + } + contentView.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener) + contentView.setTag(TAG_ON_GLOBAL_LAYOUT_LISTENER, onGlobalLayoutListener) + } + + + fun hideSoftInput(activity: Activity) { + hideSoftInput(activity.window) + } + + fun hideSoftInput(window: Window) { + val tempTag = "keyboardTagView" + var view = window.currentFocus + if (view == null) { + val decorView = window.decorView + val focusView = decorView.findViewWithTag(tempTag) + if (focusView == null) { + view = EditText(window.context) + view.tag = tempTag + (decorView as ViewGroup).addView(view, 0, 0) + } else { + view = focusView + } + view.requestFocus() + } + hideSoftInput(view) + } + + fun hideSoftInput(view: View) { + app.inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt index f502e37663..7a90aca891 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.isActivityVisible import java.util.WeakHashMap -import kotlin.reflect.jvm.jvmName private val cbMap = WeakHashMap>>() @@ -29,13 +28,13 @@ interface OnSimpleLife { fun onDestroyed() = cbs(2).forEach { it() } fun useLogLifecycle() { - onCreated { LogUtils.d("onCreated:" + this::class.jvmName) } - onDestroyed { LogUtils.d("onDestroyed:" + this::class.jvmName) } + onCreated { LogUtils.d("onCreated -> " + this::class.simpleName) } + onDestroyed { LogUtils.d("onDestroyed -> " + this::class.simpleName) } if (this is OnA11yLife) { - onA11yConnected { LogUtils.d("onA11yConnected:" + this::class.jvmName) } + onA11yConnected { LogUtils.d("onA11yConnected -> " + this::class.simpleName) } } if (this is OnTileLife) { - onTileClicked { LogUtils.d("onTileClicked:" + this::class.jvmName) } + onTileClicked { LogUtils.d("onTileClicked -> " + this::class.simpleName) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt b/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt index 5e71a1338a..0a17b65e51 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt @@ -1,5 +1,6 @@ package li.songe.gkd.util +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow @@ -7,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext class MutexState() { val mutex: Mutex = Mutex() @@ -45,4 +47,12 @@ class MutexState() { if (mutex.isLocked) return withStateLock(block) } + + fun launchTry( + scope: CoroutineScope, + context: CoroutineContext, + block: () -> Unit, + ) = scope.launchTry(context = context) { + withStateLock(block) + }.let { } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/NetworkUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/NetworkUtils.kt new file mode 100644 index 0000000000..1213bcfa40 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/NetworkUtils.kt @@ -0,0 +1,11 @@ +package li.songe.gkd.util + +import java.net.InetAddress + +object NetworkUtils { + fun isAvailable(): Boolean = try { + InetAddress.getByName("www.baidu.com") != null + } catch (_: Throwable) { + false + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/Option.kt b/app/src/main/kotlin/li/songe/gkd/util/Option.kt index dce08ef8e2..458705bee0 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Option.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Option.kt @@ -1,44 +1,42 @@ package li.songe.gkd.util -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AutoMode -import androidx.compose.material.icons.outlined.DarkMode -import androidx.compose.material.icons.outlined.LightMode import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.state.ToggleableState +import li.songe.gkd.ui.component.PerfIcon sealed interface Option { val value: T val label: String + val options: List> } -interface OptionIcon { +sealed interface OptionIcon { val icon: ImageVector } -fun > Array.findOption(value: V): T { +sealed interface OptionMenuLabel { + val menuLabel: String +} + +fun > Iterable.findOption(value: V): T { return find { it.value == value } ?: first() } -@Suppress("UNCHECKED_CAST") -val Option.allSubObject: Array> - get() = when (this) { - is SortTypeOption -> SortTypeOption.allSubObject - is UpdateTimeOption -> UpdateTimeOption.allSubObject - is DarkThemeOption -> DarkThemeOption.allSubObject - is EnableGroupOption -> EnableGroupOption.allSubObject - is RuleSortOption -> RuleSortOption.allSubObject - is UpdateChannelOption -> UpdateChannelOption.allSubObject - } as Array> - -sealed class SortTypeOption(override val value: Int, override val label: String) : Option { - data object SortByName : SortTypeOption(0, "按名称") - data object SortByAppMtime : SortTypeOption(1, "按更新时间") - data object SortByTriggerTime : SortTypeOption(2, "按触发时间") +fun Option.toToggleableState() = when (value) { + true -> ToggleableState.On + false -> ToggleableState.Off + null -> ToggleableState.Indeterminate +} + +sealed class AppSortOption(override val value: Int, override val label: String) : Option { + override val options get() = objects + + data object ByAppName : AppSortOption(0, "按应用名称") + data object ByActionTime : AppSortOption(2, "按最近触发") + data object ByUsedTime : AppSortOption(3, "按最近使用") companion object { - // https://stackoverflow.com/questions/47648689 - val allSubObject by lazy { arrayOf(SortByName, SortByAppMtime, SortByTriggerTime) } + val objects by lazy { listOf(ByAppName, ByUsedTime, ByActionTime) } } } @@ -46,27 +44,32 @@ sealed class UpdateTimeOption( override val value: Long, override val label: String ) : Option { + override val options get() = objects + data object Pause : UpdateTimeOption(-1, "暂停") data object Everyday : UpdateTimeOption(24 * 60 * 60_000, "每天") data object Every3Days : UpdateTimeOption(24 * 60 * 60_000 * 3, "每3天") data object Every7Days : UpdateTimeOption(24 * 60 * 60_000 * 7, "每7天") companion object { - val allSubObject by lazy { arrayOf(Pause, Everyday, Every3Days, Every7Days) } + val objects by lazy { listOf(Pause, Everyday, Every3Days, Every7Days) } } } sealed class DarkThemeOption( override val value: Boolean?, override val label: String, + override val menuLabel: String, override val icon: ImageVector -) : Option, OptionIcon { - data object FollowSystem : DarkThemeOption(null, "自动", Icons.Outlined.AutoMode) - data object AlwaysEnable : DarkThemeOption(true, "启用", Icons.Outlined.DarkMode) - data object AlwaysDisable : DarkThemeOption(false, "关闭", Icons.Outlined.LightMode) +) : Option, OptionIcon, OptionMenuLabel { + override val options get() = objects + + data object FollowSystem : DarkThemeOption(null, "自动", "自动", PerfIcon.AutoMode) + data object AlwaysEnable : DarkThemeOption(true, "启用", "深色", PerfIcon.DarkMode) + data object AlwaysDisable : DarkThemeOption(false, "关闭", "浅色", PerfIcon.LightMode) companion object { - val allSubObject by lazy { arrayOf(FollowSystem, AlwaysEnable, AlwaysDisable) } + val objects by lazy { listOf(FollowSystem, AlwaysEnable, AlwaysDisable) } } } @@ -74,47 +77,49 @@ sealed class EnableGroupOption( override val value: Boolean?, override val label: String ) : Option { + override val options get() = objects + data object FollowSubs : EnableGroupOption(null, "跟随订阅") data object AllEnable : EnableGroupOption(true, "全部启用") data object AllDisable : EnableGroupOption(false, "全部关闭") companion object { - val allSubObject by lazy { arrayOf(FollowSubs, AllEnable, AllDisable) } + val objects by lazy { listOf(FollowSubs, AllEnable, AllDisable) } } } -fun Option.toToggleableState() = when (value) { - true -> ToggleableState.On - false -> ToggleableState.Off - null -> ToggleableState.Indeterminate -} - sealed class RuleSortOption(override val value: Int, override val label: String) : Option { - data object Default : RuleSortOption(0, "按默认顺序") - data object ByTime : RuleSortOption(1, "按触发时间") - data object ByName : RuleSortOption(2, "按名称") + override val options get() = objects + + data object ByDefault : RuleSortOption(0, "按默认顺序") + data object ByActionTime : RuleSortOption(1, "按最近触发") + data object ByRuleName : RuleSortOption(2, "按规则名称") companion object { - val allSubObject by lazy { arrayOf(Default, ByTime, ByName) } + val objects by lazy { listOf(ByDefault, ByActionTime, ByRuleName) } } } sealed class UpdateChannelOption( override val value: Int, - override val label: String + override val label: String, + val url: String ) : Option { - abstract val url: String + override val options get() = objects - data object Stable : UpdateChannelOption(0, "稳定版") { - override val url = "https://registry.npmmirror.com/@gkd-kit/app/latest/files/index.json" - } + data object Stable : UpdateChannelOption( + 0, + "稳定版", + "https://registry.npmmirror.com/@gkd-kit/app/latest/files/index.json" + ) - data object Beta : UpdateChannelOption(1, "测试版") { - override val url = - "https://registry.npmmirror.com/@gkd-kit/app-beta/latest/files/index.json" - } + data object Beta : UpdateChannelOption( + 1, + "测试版", + "https://registry.npmmirror.com/@gkd-kit/app-beta/latest/files/index.json" + ) companion object { - val allSubObject by lazy { arrayOf(Stable, Beta) } + val objects by lazy { listOf(Stable, Beta) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Others.kt b/app/src/main/kotlin/li/songe/gkd/util/Others.kt index 1a118273c6..a8113b54ee 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -2,13 +2,16 @@ package li.songe.gkd.util import android.app.Activity import android.content.ComponentName +import android.content.Intent import android.content.pm.PackageInfo import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.drawable.Drawable -import android.os.Build +import android.provider.AlarmClock +import android.provider.MediaStore +import android.provider.Settings import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.SizeTransform @@ -29,10 +32,6 @@ import java.io.DataOutputStream import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName -inline fun Iterable.mapHashCode(transform: (T) -> R): Int { - return fold(0) { acc, t -> 31 * acc + transform(t).hashCode() } -} - private val componentNameCache by lazy { HashMap() } val KClass<*>.componentName @@ -69,7 +68,7 @@ fun MainActivity.fixSomeProblems() { private fun Activity.fixTransparentNavigationBar() { // 修复在浅色主题下导航栏背景不透明的问题 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (AndroidTarget.Q) { window.isNavigationBarContrastEnforced = false } else { @Suppress("DEPRECATION") @@ -156,3 +155,71 @@ private val Drawable.safeDrawable: Drawable? val PackageInfo.pkgIcon: Drawable? get() = applicationInfo?.loadIcon(app.packageManager)?.safeDrawable + +private fun Char.isAsciiLetter(): Boolean { + return this in 'a'..'z' || this in 'A'..'Z' +} + +private fun Char.isAsciiVar(): Boolean { + return this.isAsciiLetter() || this in '0'..'9' || this == '_' +} + +// https://developer.android.com/build/configure-app-module?hl=zh-cn +fun String.isValidAppId(): Boolean { + if (!contains('.')) return false + if (!first().isAsciiLetter()) return false + var i = 0 + while (i < length) { + val c = get(i) + if (c == '.') { + i++ + if (getOrNull(i)?.isAsciiLetter() != true) { + return false + } + } else if (!c.isAsciiVar()) { + return false + } + i++ + } + return true +} + +object AppListString { + fun decode(text: String): Set { + return text.split('\n').filter { a -> a.isValidAppId() }.toHashSet() + } + + fun encode(set: Set, append: Boolean = false): String { + val list = set.sorted() + if (append) { + return list.joinToString(separator = "\n\n", postfix = "\n\n") { + val name = appInfoMapFlow.value[it]?.name + if (name != null) { + "$it\n# $name" + } else { + it + } + } + } + return list.joinToString("\n") + } + + fun getDefaultBlockList(): Set { + val set = hashSetOf(META.appId, systemUiAppId) + listOf( + Intent.ACTION_MAIN to Intent.CATEGORY_HOME, + Intent.ACTION_MAIN to Intent.CATEGORY_APP_GALLERY, + Intent.ACTION_MAIN to Intent.CATEGORY_APP_CONTACTS, + Intent.ACTION_MAIN to Intent.CATEGORY_APP_CALENDAR, + Intent.ACTION_MAIN to Intent.CATEGORY_APP_MESSAGING, + Intent.ACTION_MAIN to Intent.CATEGORY_APP_CALCULATOR, + Intent.ACTION_OPEN_DOCUMENT to Intent.CATEGORY_OPENABLE, + AlarmClock.ACTION_SHOW_ALARMS to null, + MediaStore.ACTION_IMAGE_CAPTURE to null, + Settings.ACTION_SETTINGS to null, + ).forEach { + app.resolveAppId(it.first, it.second)?.let(set::add) + } + return set + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/util/ScreenUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/ScreenUtils.kt new file mode 100644 index 0000000000..06fa6e227e --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/ScreenUtils.kt @@ -0,0 +1,25 @@ +package li.songe.gkd.util + +import android.content.res.Configuration +import android.content.res.Resources +import android.graphics.Point +import li.songe.gkd.app + +@Suppress("DEPRECATION") +object ScreenUtils { + fun getScreenWidth(): Int = Point().apply { + app.windowManager.defaultDisplay.getRealSize(this) + }.x + + fun getScreenHeight(): Int = Point().apply { + app.windowManager.defaultDisplay.getRealSize(this) + }.y + + fun getScreenDensityDpi(): Int = Resources.getSystem().displayMetrics.densityDpi + + fun isLandscape(): Boolean { + return app.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + } + + fun isScreenLock(): Boolean = app.keyguardManager.inKeyguardRestrictedInputMode() +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt b/app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt index 90ec97bf68..f79b3d1f58 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt @@ -15,7 +15,6 @@ import android.media.projection.MediaProjectionManager import android.os.Handler import android.os.Looper import androidx.core.graphics.createBitmap -import com.blankj.utilcode.util.ScreenUtils import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine diff --git a/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt b/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt index 58f54e426f..f659c62a86 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt @@ -1,6 +1,5 @@ package li.songe.gkd.util -import android.os.Build import coil3.ImageLoader import coil3.disk.DiskCache import coil3.gif.AnimatedImageDecoder @@ -56,7 +55,7 @@ val imageLoader by lazy { } .components { // https://coil-kt.github.io/coil/gifs/ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (AndroidTarget.P) { add(AnimatedImageDecoder.Factory()) } else { add(GifDecoder.Factory()) diff --git a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt index 6d547a3f00..67f18f7256 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -3,9 +3,6 @@ package li.songe.gkd.util import android.graphics.Bitmap import androidx.core.graphics.createBitmap import androidx.core.graphics.set -import com.blankj.utilcode.util.BarUtils -import com.blankj.utilcode.util.ScreenUtils -import com.blankj.utilcode.util.ZipUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -40,7 +37,7 @@ object SnapshotExt { ): File { val filename = if (appId != null) { val name = - appInfoCacheFlow.value[appId]?.name?.filterNot { c -> c in "\\/:*?\"<>|" || c <= ' ' } + appInfoMapFlow.value[appId]?.name?.filterNot { c -> c in "\\/:*?\"<>|" || c <= ' ' } if (activityId != null) { "${(name ?: appId).take(20)}_${ activityId.split('.').last().take(40) diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index 972b53570b..ab0359a6bd 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -1,7 +1,6 @@ package li.songe.gkd.util import com.blankj.utilcode.util.LogUtils -import com.blankj.utilcode.util.NetworkUtils import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers @@ -65,12 +64,12 @@ data class UsedSubsEntry( val subsLoadErrorsFlow = MutableStateFlow>(emptyMap()) val subsRefreshErrorsFlow = MutableStateFlow>(emptyMap()) -val subsIdToRawFlow = MutableStateFlow>(emptyMap()) +val subsMapFlow = MutableStateFlow>(emptyMap()) val subsEntriesFlow by lazy { combine( subsItemsFlow, - subsIdToRawFlow, + subsMapFlow, ) { subsItems, subsIdToRaw -> subsItems.map { s -> SubsEntry( @@ -83,7 +82,7 @@ val subsEntriesFlow by lazy { val usedSubsEntriesFlow by lazy { subsEntriesFlow.map { list -> - list.filter { s -> s.subsItem.enable && s.subscription != null } + list.filter { s -> s.subsItem.enable && s.subscription?.hasRule == true } .map { UsedSubsEntry(it.subsItem, it.subscription!!) } }.stateIn(appScope, SharingStarted.Eagerly, emptyList()) } @@ -93,7 +92,7 @@ fun updateSubscription(subscription: RawSubscription) { updateSubsMutex.withStateLock { val subsId = subscription.id val subsName = subscription.name - val newMap = subsIdToRawFlow.value.toMutableMap() + val newMap = subsMapFlow.value.toMutableMap() if (subsId < 0 && newMap[subsId]?.version == subscription.version) { newMap[subsId] = subscription.run { copy( @@ -105,7 +104,7 @@ fun updateSubscription(subscription: RawSubscription) { } else { newMap[subsId] = subscription } - subsIdToRawFlow.value = newMap + subsMapFlow.value = newMap if (subsLoadErrorsFlow.value.contains(subsId)) { subsLoadErrorsFlow.update { it.toMutableMap().apply { @@ -131,7 +130,7 @@ fun deleteSubscription(vararg subsIds: Long) { DbSet.subsConfigDao.deleteBySubsId(*subsIds) DbSet.actionLogDao.deleteBySubsId(*subsIds) DbSet.categoryConfigDao.deleteBySubsId(*subsIds) - val newMap = subsIdToRawFlow.value.toMutableMap() + val newMap = subsMapFlow.value.toMutableMap() subsIds.forEach { id -> newMap.remove(id) subsFolder.resolve("$id.json").apply { @@ -140,7 +139,7 @@ fun deleteSubscription(vararg subsIds: Long) { } } } - subsIdToRawFlow.value = newMap + subsMapFlow.value = newMap toast("删除成功") LogUtils.d("deleteSubscription", subsIds) } @@ -215,7 +214,7 @@ data class RuleSummary( val ruleSummaryFlow by lazy { combine( usedSubsEntriesFlow, - appInfoCacheFlow, + appInfoMapFlow, DbSet.appConfigDao.queryUsedList(), DbSet.subsConfigDao.queryUsedList(), DbSet.categoryConfigDao.queryUsedList(), @@ -374,7 +373,7 @@ private fun loadSubs(id: Long): RawSubscription { private fun refreshRawSubsList(items: List): Boolean { if (items.isEmpty()) return false - val subscriptions = subsIdToRawFlow.value.toMutableMap() + val subscriptions = subsMapFlow.value.toMutableMap() val errors = subsLoadErrorsFlow.value.toMutableMap() var changed = false items.forEach { s -> @@ -386,7 +385,7 @@ private fun refreshRawSubsList(items: List): Boolean { errors[s.id] = e } } - subsIdToRawFlow.value = subscriptions + subsMapFlow.value = subscriptions subsLoadErrorsFlow.value = errors return changed } diff --git a/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt index 53eab5340e..58f2c863cd 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt @@ -40,27 +40,26 @@ fun Long.format(formatStr: String): String { data class ThrottleTimer( private val interval: Long = 500L, - private var value: Long = 0L ) { + private var lastAccessTime: Long = 0L fun expired(): Boolean { val t = System.currentTimeMillis() - if (t - value > interval) { - value = t + if (t - lastAccessTime > interval) { + lastAccessTime = t return true } return false } } -private val defaultThrottleTimer by lazy { ThrottleTimer() } - @Composable fun throttle( fn: (() -> Unit), ): (() -> Unit) { + val timer = remember { ThrottleTimer() } return remember(fn) { { - if (defaultThrottleTimer.expired()) { + if (timer.expired()) { fn.invoke() } } @@ -71,9 +70,10 @@ fun throttle( fun throttle( fn: ((T) -> Unit), ): ((T) -> Unit) { + val timer = remember { ThrottleTimer() } return remember(fn) { { - if (defaultThrottleTimer.expired()) { + if (timer.expired()) { fn.invoke(it) } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 9dca6e74f3..6aa566f269 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -1,11 +1,13 @@ package li.songe.gkd.util +import android.content.ClipData import android.content.Context import android.content.res.Configuration import android.graphics.Color import android.graphics.Outline import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable +import android.graphics.text.LineBreaker import android.os.Handler import android.os.Looper import android.util.TypedValue @@ -19,8 +21,6 @@ import android.widget.Toast import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt -import com.blankj.utilcode.util.ClipboardUtils -import com.blankj.utilcode.util.ScreenUtils import com.hjq.toast.Toaster import com.hjq.toast.style.WhiteToastStyle import kotlinx.coroutines.Dispatchers @@ -76,6 +76,9 @@ private fun View.updateToastView() { if (this is TextView) { setTextSize(TypedValue.COMPLEX_UNIT_PX, 14.sp.px) setTextColor(if (darkTheme) Color.WHITE else Color.BLACK) + if (AndroidTarget.Q) { + breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE + } } background = GradientDrawable().apply { setColor((if (darkTheme) "#303030" else "#fafafa").toColorInt()) @@ -167,7 +170,7 @@ private fun showA11yToast(message: CharSequence) { } fun copyText(text: String) { - ClipboardUtils.copyText(text) + app.clipboardManager.setPrimaryClip(ClipData.newPlainText(app.packageName, text)) toast("复制成功") } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Unit.kt b/app/src/main/kotlin/li/songe/gkd/util/Unit.kt index 0d428f78ac..9bafb1e625 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Unit.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Unit.kt @@ -5,19 +5,24 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import li.songe.gkd.app +/** + * px -> dp + */ val Dp.px: Float - get() { - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - value, app.resources.displayMetrics - ) - } + get() = value * app.resources.displayMetrics.density +/** + * sp -> px + */ val TextUnit.px: Float - get() { - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, - value, app.resources.displayMetrics - ) - } + get() = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + value, app.resources.displayMetrics + ) + +///** +// * px -> dp +// */ +//val Int.calcDp: Float +// get() = this / app.resources.displayMetrics.density diff --git a/app/src/main/kotlin/li/songe/gkd/util/Upgrade.kt b/app/src/main/kotlin/li/songe/gkd/util/Upgrade.kt index 1ab8fe876f..12a604bf28 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Upgrade.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Upgrade.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider -import com.blankj.utilcode.util.NetworkUtils import io.ktor.client.call.body import io.ktor.client.plugins.onDownload import io.ktor.client.request.get @@ -38,7 +37,7 @@ import java.net.URI private val UPDATE_URL: String - get() = UpdateChannelOption.allSubObject.findOption(storeFlow.value.updateChannel).url + get() = UpdateChannelOption.objects.findOption(storeFlow.value.updateChannel).url @Serializable data class NewVersion( diff --git a/app/src/main/kotlin/li/songe/gkd/util/UriUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/UriUtils.kt new file mode 100644 index 0000000000..ffac727398 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/UriUtils.kt @@ -0,0 +1,13 @@ +package li.songe.gkd.util + +import android.net.Uri +import li.songe.gkd.app + +object UriUtils { + fun uri2Bytes(uri: Uri): ByteArray { + app.contentResolver.openInputStream(uri)?.use { + return it.readBytes() + } + return ByteArray(0) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/ZipUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/ZipUtils.kt new file mode 100644 index 0000000000..849f9c4af3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/ZipUtils.kt @@ -0,0 +1,90 @@ +package li.songe.gkd.util + +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +object ZipUtils { + private const val BUFFER_LEN = 8192 + private fun zipFile( + srcFile: File, + rawRootPath: String, + zos: ZipOutputStream, + comment: String?, + ): Boolean { + val rootPath = + rawRootPath + (if (rawRootPath.isBlank()) "" else File.separator) + srcFile.getName() + if (srcFile.isDirectory()) { + val fileList = srcFile.listFiles() + if (fileList == null || fileList.size <= 0) { + val entry = ZipEntry("$rootPath/") + entry.setComment(comment) + zos.putNextEntry(entry) + zos.closeEntry() + } else { + for (file in fileList) { + if (!zipFile(file, rootPath, zos, comment)) return false + } + } + } else { + var stream: InputStream? = null + try { + stream = BufferedInputStream(FileInputStream(srcFile)) + val entry = ZipEntry(rootPath) + entry.setComment(comment) + zos.putNextEntry(entry) + val buffer: ByteArray? = ByteArray(BUFFER_LEN) + var len: Int + while ((stream.read(buffer, 0, BUFFER_LEN).also { len = it }) != -1) { + zos.write(buffer, 0, len) + } + zos.closeEntry() + } finally { + stream?.close() + } + } + return true + } + + fun zipFiles(srcFiles: Collection, zipFile: File): Boolean { + var zos: ZipOutputStream? = null + try { + zos = ZipOutputStream(FileOutputStream(zipFile)) + for (srcFile in srcFiles) { + if (!zipFile(srcFile, "", zos, null)) return false + } + return true + } finally { + if (zos != null) { + zos.finish() + zos.close() + } + } + } + + fun unzipFile( + zipFile: File, + destDir: File, + ) { + ZipFile(zipFile).use { zip -> + zip.entries().asSequence().forEach { entry -> + val outFile = destDir.resolve(entry.name) + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> + FileOutputStream(outFile).use { output -> + input.copyTo(output) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 60c9c6c3b3..278ced1efa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,6 @@ plugins { alias(libs.plugins.androidx.room) apply false alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.android) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e01a67e868..8d2ba17aa8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,16 @@ [versions] -kotlin = "2.2.10" -ksp = "2.2.10-2.0.2" -agp = "8.12.2" -compose = "1.9.0" -rikka = "4.4.0" -room = "2.7.2" +kotlin = "2.2.20" +ksp = "2.2.20-2.0.2" +agp = "8.13.0" +compose = "1.9.1" +room = "2.8.0" paging = "3.3.6" -ktor = "3.2.3" +ktor = "3.3.0" +atomicfu = "0.29.0" destinations = "2.2.0" coil = "3.3.0" -shizuku = "13.1.5" -atomicfu = "0.29.0" +rikka_refine = "4.4.0" +rikka_shizuku = "13.1.5" [libraries] kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -33,8 +33,8 @@ compose_tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "co compose_junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose_icons = "androidx.compose.material:material-icons-extended:1.7.8" compose_material3 = "androidx.compose.material3:material3:1.3.2" -compose_activity = "androidx.activity:activity-compose:1.10.1" -compose_navigation = "androidx.navigation:navigation-compose:2.9.3" +compose_activity = "androidx.activity:activity-compose:1.11.0" +compose_navigation = "androidx.navigation:navigation-compose:2.9.4" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.17.0" androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.3" @@ -51,10 +51,11 @@ androidx_paging_runtime = { module = "androidx.paging:paging-runtime", version.r androidx_paging_compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } google_accompanist_drawablepainter = "com.google.accompanist:accompanist-drawablepainter:0.37.3" junit = "junit:junit:4.13.2" -rikka_processor = { module = "dev.rikka.tools.refine:annotation-processor", version.ref = "rikka" } -rikka_annotation = { module = "dev.rikka.tools.refine:annotation", version.ref = "rikka" } -rikka_shizuku_api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } -rikka_shizuku_provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } +rikka_refine_processor = { module = "dev.rikka.tools.refine:annotation-processor", version.ref = "rikka_refine" } +rikka_refine_annotation = { module = "dev.rikka.tools.refine:annotation", version.ref = "rikka_refine" } +rikka_refine_runtime = { module = "dev.rikka.tools.refine:runtime", version.ref = "rikka_refine" } +rikka_shizuku_api = { module = "dev.rikka.shizuku:api", version.ref = "rikka_shizuku" } +rikka_shizuku_provider = { module = "dev.rikka.shizuku:provider", version.ref = "rikka_shizuku" } lsposed_hiddenapibypass = "org.lsposed.hiddenapibypass:hiddenapibypass:6.1" destinations_core = { module = "io.github.raamcosta.compose-destinations:core", version.ref = "destinations" } destinations_ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "destinations" } @@ -63,9 +64,9 @@ coil_network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" -toaster = "com.github.getActivity:Toaster:13.2" +toaster = "com.github.getActivity:Toaster:13.5" permissions = "com.github.getActivity:XXPermissions:26.5" -json5 = "li.songe:json5:0.3.5" +json5 = "li.songe:json5:0.3.6" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" kevinnzouWebview = "io.github.kevinnzou:compose-webview:0.33.6" @@ -74,13 +75,12 @@ kevinnzouWebview = "io.github.kevinnzou:compose-webview:0.33.6" kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin_multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin_parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin_compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinx_atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } android_library = { id = "com.android.library", version.ref = "agp" } android_application = { id = "com.android.application", version.ref = "agp" } androidx_room = { id = "androidx.room", version.ref = "room" } google_ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -rikka_refine = { id = "dev.rikka.tools.refine", version.ref = "rikka" } +rikka_refine = { id = "dev.rikka.tools.refine", version.ref = "rikka_refine" } benmanes_version = "com.github.ben-manes.versions:0.52.0" littlerobots_version = "nl.littlerobots.version-catalog-update:1.0.0" diff --git a/hidden_api/build.gradle.kts b/hidden_api/build.gradle.kts index 9d368bd044..1b3b72b4e9 100644 --- a/hidden_api/build.gradle.kts +++ b/hidden_api/build.gradle.kts @@ -28,6 +28,6 @@ android { dependencies { compileOnly(libs.androidx.annotation) - compileOnly(libs.rikka.annotation) - annotationProcessor(libs.rikka.processor) + compileOnly(libs.rikka.refine.annotation) + annotationProcessor(libs.rikka.refine.processor) } \ No newline at end of file diff --git a/hidden_api/src/main/java/android/content/pm/PackageInfoHidden.java b/hidden_api/src/main/java/android/content/pm/PackageInfoHidden.java new file mode 100644 index 0000000000..12ac4000f3 --- /dev/null +++ b/hidden_api/src/main/java/android/content/pm/PackageInfoHidden.java @@ -0,0 +1,17 @@ +package android.content.pm; + +import dev.rikka.tools.refine.RefineAs; + +/** + * @noinspection unused + */ +@RefineAs(PackageInfo.class) +public class PackageInfoHidden { + + public String overlayTarget; + + // android9+ + public boolean isOverlayPackage() { + throw new RuntimeException("Stub"); + } +} \ No newline at end of file From f635f30ea50bc48831bbf05291304ad7a248b91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 19 Sep 2025 20:25:23 +0800 Subject: [PATCH 044/245] perf: add lastScreenOnTime --- app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt | 3 ++- app/src/main/kotlin/li/songe/gkd/service/A11yService.kt | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index 85911a3186..043d6c5f84 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -30,6 +30,7 @@ private val actionDispatcher = Executors.newSingleThreadExecutor().asCoroutineDi class A11yRuleEngine(val service: A11yService) { init { + service.outStartQueryJob = { startQueryJob(byForced = true) } service.onA11yConnected { if (storeFlow.value.enableBlockA11yAppList && !a11yPartDisabledFlow.value) { startQueryJob(byForced = true) @@ -153,7 +154,7 @@ class A11yRuleEngine(val service: A11yService) { fun checkFutureStartJob() { val t = System.currentTimeMillis() - if (t - lastTriggerTime < 3000L || t - appChangeTime < 5000L) { + if (t - lastTriggerTime < 3000L || t - appChangeTime < 5000L || t - service.lastScreenOnTime < 3000L) { scope.launch(actionDispatcher) { delay(300) startQueryJob() diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 2e1f85c647..b64c0e5f18 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -58,6 +58,10 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { val scope = useScope() val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } var isInteractive = true + private set + var outStartQueryJob = {} + var lastScreenOnTime = 0L + private set private val screenStateReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context?, @@ -70,6 +74,10 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { Intent.ACTION_SCREEN_OFF -> false else -> isInteractive } + if (isInteractive) { + lastScreenOnTime = System.currentTimeMillis() + outStartQueryJob() + } } } From 53987a7045ce24eee649eb7d6eacd98fcfa23952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 19 Sep 2025 20:58:59 +0800 Subject: [PATCH 045/245] perf: shizuku appops --- app/src/main/AndroidManifest.xml | 4 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 9 +- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 5 +- .../songe/gkd/permission/PermissionDialog.kt | 32 +++---- .../songe/gkd/permission/PermissionState.kt | 56 +++++++----- .../li/songe/gkd/service/GkdTileService.kt | 7 +- .../li/songe/gkd/service/StatusService.kt | 8 +- .../li/songe/gkd/shizuku/AppOpsManager.kt | 51 +++++++++++ .../li/songe/gkd/shizuku/PackageManager.kt | 18 ++++ .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 91 ++++++++++++------- .../li/songe/gkd/shizuku/UserService.kt | 14 ++- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 19 ++-- .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 10 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 19 ++-- .../li/songe/gkd/ui/home/ControlPage.kt | 13 +-- .../kotlin/li/songe/gkd/util/AndroidTarget.kt | 4 + .../kotlin/li/songe/gkd/util/MutexState.kt | 6 +- .../main/res/xml/network_security_config.xml | 6 ++ gradle/libs.versions.toml | 8 +- .../java/android/app/AppOpsManagerHidden.java | 27 ++++++ .../android/content/pm/IPackageManager.java | 4 +- .../android/internal/app/IAppOpsService.java | 18 ++++ 22 files changed, 294 insertions(+), 135 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 hidden_api/src/main/java/android/app/AppOpsManagerHidden.java create mode 100644 hidden_api/src/main/java/com/android/internal/app/IAppOpsService.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5232e5d5f0..e85e8f3822 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,10 +28,10 @@ android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@drawable/ic_launcher" android:supportsRtl="false" - android:theme="@style/AppTheme" - android:usesCleartextTraffic="true"> + android:theme="@style/AppTheme"> Boolean, + val grantSelf: (() -> Unit)? = null, val request: (suspend (context: MainActivity) -> PermissionResult)? = null, /** * show it when user doNotAskAgain @@ -33,19 +34,21 @@ class PermissionState( val reason: AuthReason? = null, ) { val stateFlow = MutableStateFlow(false) - val value: Boolean - get() = stateFlow.value + val value get() = stateFlow.value fun updateAndGet(): Boolean { return stateFlow.updateAndGet { check() } } - fun checkOrToast(): Boolean { + fun checkOrToast(): Boolean = if (!updateAndGet()) { + grantSelf?.invoke() val r = updateAndGet() if (!r) { reason?.text?.let { toast(it()) } } - return r + r + } else { + true } } @@ -83,19 +86,12 @@ private suspend fun asyncRequestPermission( } @Suppress("SameParameterValue") -private fun checkOpNoThrow(op: String): Int { - if (AndroidTarget.Q) { - try { - return app.appOpsManager.checkOpNoThrow( - op, - android.os.Process.myUid(), - app.packageName - ) - } catch (e: Throwable) { - e.printStackTrace() - } - } - return AppOpsManager.MODE_ALLOWED +private fun checkAllowedOp(op: String): Boolean = app.appOpsManager.checkOpNoThrow( + op, + android.os.Process.myUid(), + app.packageName +).let { + it != AppOpsManager.MODE_IGNORED && it != AppOpsManager.MODE_ERRORED } // https://github.com/gkd-kit/gkd/issues/954 @@ -103,7 +99,14 @@ private fun checkOpNoThrow(op: String): Int { val foregroundServiceSpecialUseState by lazy { PermissionState( check = { - checkOpNoThrow("android:foreground_service_special_use") != AppOpsManager.MODE_IGNORED + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + checkAllowedOp("android:foreground_service_special_use") + } else { + true + } + }, + grantSelf = { + shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() }, reason = AuthReason( text = { "当前操作权限「特殊用途的前台服务」已被限制, 请先解除限制" }, @@ -123,6 +126,9 @@ val notificationState by lazy { check = { XXPermissions.isGrantedPermission(app, permission) }, + grantSelf = { + shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + }, request = { asyncRequestPermission(it, permission) }, reason = AuthReason( text = { "当前操作需要「通知权限」\n请先前往权限页面授权" }, @@ -157,13 +163,12 @@ val canDrawOverlaysState by lazy { // https://developer.android.com/security/fraud-prevention/activities?hl=zh-cn#hide_overlay_windows Settings.canDrawOverlays(app) }, + grantSelf = { + shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + }, reason = AuthReason( text = { - if (isActivityVisible()) { - "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" - } else { - "缺少「悬浮窗权限」请先授权\n或当前应用拒绝显示悬浮窗" - } + "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" }, confirm = { XXPermissions.startPermissionActivity( @@ -206,6 +211,9 @@ val canWriteExternalStorage by lazy { val writeSecureSettingsState by lazy { PermissionState( check = { checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, + grantSelf = { + shizukuContextFlow.value.packageManager?.grantSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) + }, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index ce873522a7..b89d3eb31d 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -49,8 +49,11 @@ fun switchA11yService() = modifyA11yRun { A11yService.instance?.disableSelf() } else { if (!writeSecureSettingsState.updateAndGet()) { - toast("请先授予「写入安全设置权限」") - return@modifyA11yRun + writeSecureSettingsState.grantSelf?.invoke() + if (!writeSecureSettingsState.updateAndGet()) { + toast("请先授予「写入安全设置权限」") + return@modifyA11yRun + } } val names = app.getSecureA11yServices() app.putSecureInt(Settings.Secure.ACCESSIBILITY_ENABLED, 1) diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index 87161075da..b49748463b 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -53,11 +53,13 @@ class StatusService : Service(), OnSimpleLife { } else { META.appName } - return if (!abRunning) { + return if (shizukuWarn) { + Triple(title, "Shizuku 未连接,请授权或关闭优化", "gkd://page/1") + } else if (!abRunning) { val text = if (a11yServiceEnabledFlow.value) { "无障碍发生故障" } else if (writeSecureSettingsState.updateAndGet()) { - if (store.enableService && a11yPartDisabledFlow.value) { + if (store.enableService && store.enableBlockA11yAppList && a11yPartDisabledFlow.value) { "无障碍已局部关闭" } else { "无障碍已关闭" @@ -68,8 +70,6 @@ class StatusService : Service(), OnSimpleLife { Triple(title, text, abNotif.uri) } else if (!store.enableMatch) { Triple(title, "暂停规则匹配", "gkd://page?tab=1") - } else if (shizukuWarn) { - Triple(title, "Shizuku 未连接,请授权或关闭优化", "gkd://page/1") } else if (store.useCustomNotifText) { Triple( title, diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt new file mode 100644 index 0000000000..dc74466791 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt @@ -0,0 +1,51 @@ +package li.songe.gkd.shizuku + +import android.app.AppOpsManager +import android.app.AppOpsManagerHidden +import android.content.Context +import com.android.internal.app.IAppOpsService +import li.songe.gkd.META +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.checkExistClass + +class SafeAppOpsManager( + private val value: IAppOpsService +) { + companion object { + val isAvailable: Boolean + get() = checkExistClass("com.android.internal.app.IAppOpsService") + + fun newBinder() = getStubService( + Context.APP_OPS_SERVICE, + isAvailable, + )?.let { + SafeAppOpsManager(IAppOpsService.Stub.asInterface(it)) + } + } + + fun setMode( + code: Int, + uid: Int = currentUserId, + packageName: String, + mode: Int + ) = safeInvokeMethod { + value.setMode(code, uid, packageName, mode) + } + + private fun setAllowSelfMode(code: Int) = setMode( + code = code, + packageName = META.appId, + mode = AppOpsManager.MODE_ALLOWED, + ) + + fun allowAllSelfMode() { + setAllowSelfMode(AppOpsManagerHidden.OP_POST_NOTIFICATION) + setAllowSelfMode(AppOpsManagerHidden.OP_SYSTEM_ALERT_WINDOW) + if (AndroidTarget.TIRAMISU) { + setAllowSelfMode(AppOpsManagerHidden.OP_ACCESS_RESTRICTED_SETTINGS) + } + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + setAllowSelfMode(AppOpsManagerHidden.OP_FOREGROUND_SERVICE_SPECIAL_USE) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index b0fa8d7eb9..ceda50db21 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -2,6 +2,7 @@ package li.songe.gkd.shizuku import android.content.pm.IPackageManager import android.content.pm.PackageInfo +import li.songe.gkd.META import li.songe.gkd.util.checkExistClass import kotlin.reflect.typeOf @@ -41,4 +42,21 @@ class SafePackageManager(private val value: IPackageManager) { fun getInstalledPackages(flags: Int, userId: Int): List { return safeInvokeMethod { value.compatGetInstalledPackages(flags, userId) } ?: emptyList() } + + fun grantRuntimePermission( + packageName: String, + permissionName: String, + userId: Int = currentUserId, + ) = safeInvokeMethod { + value.grantRuntimePermission( + packageName, + permissionName, + userId + ) + } + + fun grantSelfPermission(permissionName: String) = grantRuntimePermission( + packageName = META.appId, + permissionName = permissionName, + ) } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index ddda1f51af..01d7140cb7 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -57,7 +57,8 @@ inline fun safeInvokeMethod( block: () -> T ): T? = try { block() -} catch (_: Throwable) { +} catch (e: Throwable) { + e.message null } @@ -77,20 +78,41 @@ private val shizukuUsedFlow by lazy { } class ShizukuContext( - val serviceWrapper: UserServiceWrapper? = null, - val packageManager: SafePackageManager? = null, - val userManager: SafeUserManager? = null, - val activityManager: SafeActivityManager? = null, - val activityTaskManager: SafeActivityTaskManager? = null, + val serviceWrapper: UserServiceWrapper?, + val packageManager: SafePackageManager?, + val userManager: SafeUserManager?, + val activityManager: SafeActivityManager?, + val activityTaskManager: SafeActivityTaskManager?, + val appOpsManager: SafeAppOpsManager?, ) { + init { + activityTaskManager?.registerDefault() + } + val ok get() = this !== defaultShizukuContext fun destroy() { serviceWrapper?.destroy() activityTaskManager?.unregisterDefault() } + + val states = listOf( + "IUserService" to serviceWrapper, + "IUserManager" to userManager, + "IPackageManager" to packageManager, + "IActivityManager" to activityManager, + "IActivityTaskManager" to activityTaskManager, + "IAppOpsService" to appOpsManager, + ) } -private val defaultShizukuContext = ShizukuContext() +private val defaultShizukuContext = ShizukuContext( + serviceWrapper = null, + packageManager = null, + userManager = null, + activityManager = null, + activityTaskManager = null, + appOpsManager = null, +) val currentUserId by lazy { android.os.Process.myUserHandle().hashCode() } @@ -112,40 +134,41 @@ fun shizukuCheckGranted(): Boolean { } val updateBinderMutex = MutexState() -private fun updateShizukuBinder() = appScope.launchTry(Dispatchers.IO) { - updateBinderMutex.withStateLock { - if (shizukuUsedFlow.value) { - if (!app.justStarted && isActivityVisible()) { - toast("正在连接 Shizuku 服务...") - } - shizukuContextFlow.value = ShizukuContext( - serviceWrapper = buildServiceWrapper(), - packageManager = SafePackageManager.newBinder(), - userManager = SafeUserManager.newBinder(), - activityManager = SafeActivityManager.newBinder(), - activityTaskManager = SafeActivityTaskManager.newBinder()?.apply { - registerDefault() - }, - ) - if (isActivityVisible()) { - val delayMillis = if (app.justStarted) 1200L else 0L - if (shizukuContextFlow.value.serviceWrapper == null) { - toast("Shizuku 服务连接失败", delayMillis) +private fun updateShizukuBinder() = updateBinderMutex.launchTry(appScope, Dispatchers.IO) { + if (shizukuUsedFlow.value) { + if (!app.justStarted && isActivityVisible()) { + toast("正在连接 Shizuku 服务...") + } + shizukuContextFlow.value = ShizukuContext( + serviceWrapper = buildServiceWrapper(), + packageManager = SafePackageManager.newBinder(), + userManager = SafeUserManager.newBinder(), + activityManager = SafeActivityManager.newBinder(), + activityTaskManager = SafeActivityTaskManager.newBinder(), + appOpsManager = SafeAppOpsManager.newBinder(), + ) + if (isActivityVisible()) { + val delayMillis = if (app.justStarted) 1200L else 0L + val newValue = shizukuContextFlow.value + if (newValue.serviceWrapper == null) { + if (newValue.packageManager != null) { + toast("Shizuku 服务连接部分失败", delayMillis) } else { - toast("Shizuku 服务连接成功", delayMillis) + toast("Shizuku 服务连接失败", delayMillis) } - } - } else if (shizukuContextFlow.value.ok) { - shizukuContextFlow.value.destroy() - shizukuContextFlow.value = defaultShizukuContext - if (isActivityVisible()) { - toast("Shizuku 服务已断开") + } else { + toast("Shizuku 服务连接成功", delayMillis) } } + } else if (shizukuContextFlow.value.ok) { + shizukuContextFlow.value.destroy() + shizukuContextFlow.value = defaultShizukuContext + if (isActivityVisible()) { + toast("Shizuku 服务已断开") + } } } - fun initShizuku() { Shizuku.addBinderReceivedListener { LogUtils.d("Shizuku.addBinderReceivedListener") diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index da1d42941a..0cd63c08ac 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -7,6 +7,7 @@ import android.os.IBinder import android.util.Log import androidx.annotation.Keep import com.blankj.utilcode.util.LogUtils +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import li.songe.gkd.META @@ -16,7 +17,6 @@ import li.songe.gkd.util.json import rikka.shizuku.Shizuku import java.io.DataOutputStream import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlin.system.exitProcess @@ -86,9 +86,13 @@ class UserService : IUserService.Stub { } } -private fun unbindUserService(serviceArgs: Shizuku.UserServiceArgs, connection: ServiceConnection) { +private fun unbindUserService( + serviceArgs: Shizuku.UserServiceArgs, + connection: ServiceConnection, + reason: String? = null, +) { if (!shizukuOkState.stateFlow.value) return - LogUtils.d("unbindUserService", serviceArgs) + LogUtils.d("unbindUserService", serviceArgs, reason) // https://github.com/RikkaApps/Shizuku-API/blob/master/server-shared/src/main/java/rikka/shizuku/server/UserServiceManager.java#L62 try { Shizuku.unbindUserService(serviceArgs, connection, false) @@ -167,7 +171,7 @@ suspend fun buildServiceWrapper(): UserServiceWrapper? { } } return withTimeoutOrNull(3000) { - suspendCoroutine { continuation -> + suspendCancellableCoroutine { continuation -> resumeCallback = { continuation.resume(it) } try { Shizuku.bindUserService(serviceArgs, connection) @@ -178,7 +182,7 @@ suspend fun buildServiceWrapper(): UserServiceWrapper? { } }.apply { if (this == null) { - unbindUserService(serviceArgs, connection) + unbindUserService(serviceArgs, connection, "connect timeout") } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index e2c5475ff9..5f82b237aa 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -205,14 +206,8 @@ fun AdvancedPage() { val c = shizukuContextFlow.value mainVm.dialogFlow.updateDialogOptions( title = "授权状态", - text = arrayOf( - "IUserService" to c.serviceWrapper, - "IUserManager" to c.userManager, - "IPackageManager" to c.packageManager, - "IActivityManager" to c.activityManager, - "IActivityTaskManager" to c.activityTaskManager, - ).joinToString("\n") { (name, state) -> - name + " " + if (state != null) "✅" else "❎" + text = c.states.joinToString("\n") { (name, state) -> + if (state != null) "$name ✅" else "$name ❎" } ) }) @@ -238,6 +233,14 @@ fun AdvancedPage() { suffixUnderline = true, onSuffixClick = { mainVm.navigateWebPage(ShortUrlSet.URL14) }, checked = store.enableShizuku, + suffixIcon = { + if (updateBinderMutex.state.collectAsState().value) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp), + ) + } + }, ) { if (updateBinderMutex.mutex.isLocked) { toast("正在连接中,请稍后") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index d2a5f48842..ab4d7521b4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -25,8 +25,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import kotlinx.coroutines.Dispatchers -import li.songe.gkd.META import li.songe.gkd.permission.foregroundServiceSpecialUseState +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.ManualAuthDialog @@ -88,7 +88,8 @@ fun AppOpsAllowPage() { ) AuthButtonGroup( onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.grantPermissionByShizuku(appOpsCommand) + mainVm.guardShizukuContext() + shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() toast("授权成功") }, onClickManual = { @@ -120,8 +121,3 @@ fun AppOpsAllowPage() { } ) } - -private val appOpsCommand by lazy { - "appops set ${META.appId} FOREGROUND_SERVICE_SPECIAL_USE allow" -} - diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index e224768f56..c6c89c0f86 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -1,5 +1,6 @@ package li.songe.gkd.ui +import android.Manifest import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -32,6 +33,7 @@ import li.songe.gkd.META import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService import li.songe.gkd.service.fixRestartService +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.ManualAuthDialog @@ -237,14 +239,15 @@ fun AuthA11yPage() { ) } +private val String.appopsAllowCommand: String + get() = "appops set ${META.appId} $this allow" + +val appOpsCommand by lazy { "FOREGROUND_SERVICE_SPECIAL_USE".appopsAllowCommand } + private val a11yCommandText by lazy { listOfNotNull( - "pm grant ${META.appId} android.permission.WRITE_SECURE_SETTINGS", - if (AndroidTarget.TIRAMISU) { - "appops set ${META.appId} ACCESS_RESTRICTED_SETTINGS allow" - } else { - null - }, + "pm grant ${META.appId} ${Manifest.permission.WRITE_SECURE_SETTINGS}", + if (AndroidTarget.TIRAMISU) "ACCESS_RESTRICTED_SETTINGS".appopsAllowCommand else null, ).joinToString("; ") } @@ -262,7 +265,9 @@ private fun A11yAuthButtonGroup() { val vm = viewModel() AuthButtonGroup( onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.grantPermissionByShizuku(a11yCommandText) + mainVm.guardShizukuContext() + writeSecureSettingsState.grantSelf?.invoke() + shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() successAuthExec() }, onClickManual = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 960d0d7c75..d467e00aa7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -75,9 +75,7 @@ fun useControlPage(): ScaffoldExt { modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, title = { - Text( - text = stringResource(SafeR.app_name), - ) + Text(text = stringResource(SafeR.app_name)) }, actions = { PerfIconButton( imageVector = PerfIcon.RocketLaunch, @@ -119,10 +117,13 @@ fun useControlPage(): ScaffoldExt { Switch( checked = a11yRunning, onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newEnabled -> - if (writeSecureSettings || !newEnabled) { - switchA11yService() - } else { + if (newEnabled && !writeSecureSettingsState.updateAndGet()) { + writeSecureSettingsState.grantSelf?.invoke() + } + if (newEnabled && !writeSecureSettingsState.updateAndGet()) { mainVm.navigatePage(AuthA11YPageDestination) + } else { + switchA11yService() } }), ) diff --git a/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt b/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt index 856b11ced9..edb25554e9 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt @@ -24,6 +24,10 @@ object AndroidTarget { @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) val TIRAMISU = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + /** Android 14+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + val UPSIDE_DOWN_CAKE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE + /** Android 16+ */ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) val BAKLAVA = Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA diff --git a/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt b/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt index 0a17b65e51..86fa5b9c0c 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/MutexState.kt @@ -51,8 +51,10 @@ class MutexState() { fun launchTry( scope: CoroutineScope, context: CoroutineContext, - block: () -> Unit, + block: suspend () -> Unit, ) = scope.launchTry(context = context) { - withStateLock(block) + withStateLock { + block() + } }.let { } } diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..28d358bca1 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d2ba17aa8..71fe620be3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] kotlin = "2.2.20" -ksp = "2.2.20-2.0.2" +ksp = "2.2.20-2.0.3" agp = "8.13.0" compose = "1.9.1" room = "2.8.0" @@ -37,8 +37,8 @@ compose_activity = "androidx.activity:activity-compose:1.11.0" compose_navigation = "androidx.navigation:navigation-compose:2.9.4" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.17.0" -androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.3" -androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.3" +androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.4" +androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.4" androidx_junit = "androidx.test.ext:junit:1.3.0" androidx_annotation = "androidx.annotation:annotation:1.9.1" androidx_espresso = "androidx.test.espresso:espresso-core:3.7.0" @@ -64,7 +64,7 @@ coil_network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" -toaster = "com.github.getActivity:Toaster:13.5" +toaster = "com.github.getActivity:Toaster:13.6" permissions = "com.github.getActivity:XXPermissions:26.5" json5 = "li.songe:json5:0.3.6" utilcodex = "com.blankj:utilcodex:1.31.1" diff --git a/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java b/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java new file mode 100644 index 0000000000..cabfd3732f --- /dev/null +++ b/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java @@ -0,0 +1,27 @@ +package android.app; + + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import dev.rikka.tools.refine.RefineAs; + +/** + * @noinspection unused + */ +@RefineAs(AppOpsManager.class) +public class AppOpsManagerHidden { + public static int OP_POST_NOTIFICATION; + public static int OP_SYSTEM_ALERT_WINDOW; + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + public static int OP_ACCESS_RESTRICTED_SETTINGS; + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + public static int OP_FOREGROUND_SERVICE_SPECIAL_USE; + + public static String opToPublicName(int op) { + throw new RuntimeException("Stub"); + } +} diff --git a/hidden_api/src/main/java/android/content/pm/IPackageManager.java b/hidden_api/src/main/java/android/content/pm/IPackageManager.java index d7643b58cf..aaae8033db 100644 --- a/hidden_api/src/main/java/android/content/pm/IPackageManager.java +++ b/hidden_api/src/main/java/android/content/pm/IPackageManager.java @@ -12,7 +12,7 @@ public static IPackageManager asInterface(IBinder binder) { throw new IllegalArgumentException("Stub!"); } } - // android8 - android12 -> int flags + // android8 - android12.1 -> int flags // android13+ -> long flags // android8 - android12 @@ -23,4 +23,6 @@ public static IPackageManager asInterface(IBinder binder) { ParceledListSlice getAllIntentFilters(String packageName); + void grantRuntimePermission(String packageName, String permissionName, int userId); + } diff --git a/hidden_api/src/main/java/com/android/internal/app/IAppOpsService.java b/hidden_api/src/main/java/com/android/internal/app/IAppOpsService.java new file mode 100644 index 0000000000..52a695c77d --- /dev/null +++ b/hidden_api/src/main/java/com/android/internal/app/IAppOpsService.java @@ -0,0 +1,18 @@ +package com.android.internal.app; + +import android.os.Binder; +import android.os.IBinder; +import android.os.IInterface; + +/** + * @noinspection unused + */ +public interface IAppOpsService extends IInterface { + abstract class Stub extends Binder implements IAppOpsService { + public static IAppOpsService asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } + + void setMode(int code, int uid, String packageName, int mode); +} From 446bac0b8092ddd73c2351c6e080603bfb2f730a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 20 Sep 2025 02:30:59 +0800 Subject: [PATCH 046/245] perf: RecordService copy text --- .../li/songe/gkd/service/RecordService.kt | 35 ++++++++++++++++--- .../kotlin/li/songe/gkd/ui/style/Padding.kt | 13 +++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt index 5d645f454f..004f38dffa 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -1,9 +1,13 @@ package li.songe.gkd.service import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,8 +33,11 @@ import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.shizuku.SafeTaskListener import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AppNameText +import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.style.AppTheme +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.util.appInfoMapFlow +import li.songe.gkd.util.copyText import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass @@ -40,7 +47,7 @@ class RecordService : OverlayWindowService( ) { val topAppInfoFlow by lazy { - appInfoMapFlow.combine(topActivityFlow) { map, topActivity -> + combine(appInfoMapFlow, topActivityFlow) { map, topActivity -> map[topActivity.appId] }.stateIn(lifecycleScope, SharingStarted.Eagerly, null) } @@ -58,7 +65,7 @@ class RecordService : OverlayWindowService( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(bgColor.copy(alpha = 0.9f)) - .padding(horizontal = 4.dp, vertical = 2.dp) + .padding(4.dp) ) { CompositionLocalProvider(LocalContentColor provides contentColorFor(bgColor)) { if (activityOkFlow.collectAsState().value) { @@ -77,7 +84,10 @@ class RecordService : OverlayWindowService( topAppInfoFlow.collectAsState().value?.let { AppNameText(appInfo = it) } - Text(text = "${topActivity.appId}\n${topActivity.shortActivityId}") + RowText(text = topActivity.appId) + topActivity.shortActivityId?.let { + RowText(text = it) + } } } else { Column { @@ -113,4 +123,21 @@ class RecordService : OverlayWindowService( fun stop() = stopServiceByClass(RecordService::class) } -} \ No newline at end of file +} + +@Composable +private fun RowText(text: String) { + Row { + Text(text = text, modifier = Modifier.weight(1f, false)) + Spacer(modifier = Modifier.width(4.dp)) + PerfIcon( + imageVector = PerfIcon.ContentCopy, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = { + copyText(text) + }) + .iconTextSize(), + ) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt index 5ef2ad0c42..ad23db639b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt @@ -2,8 +2,12 @@ package li.songe.gkd.ui.style import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MenuDefaults +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp val itemHorizontalPadding = 16.dp @@ -32,3 +36,12 @@ fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { // 被 LazyXXX 使用时, 移除 bottom padding, 否则 底部导航栏 无法实现透明背景 ) } + +@Composable +fun Modifier.iconTextSize(): Modifier { + val density = LocalDensity.current + val textStyle = LocalTextStyle.current + val lineHeightDp = density.run { textStyle.lineHeight.toDp() } + val fontSizeDp = density.run { textStyle.fontSize.toDp() } + return padding((lineHeightDp - fontSizeDp) / 2).size(fontSizeDp) +} From 1bbfec135069ab23231030b90be9613db479138a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 05:17:44 +0800 Subject: [PATCH 047/245] perf: topBarWindowInsets --- app/src/main/kotlin/li/songe/gkd/MainActivity.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 06366fc810..3659d8b6a3 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -34,10 +34,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.ViewCompat @@ -115,8 +118,7 @@ class MainActivity : ComponentActivity() { get() = ViewCompat.getRootWindowInsets(window.decorView)!! .isVisible(WindowInsetsCompat.Type.ime()) - private var _topBarWindowInsets: WindowInsets? = null - val topBarWindowInsets get() = _topBarWindowInsets!! + var topBarWindowInsets by mutableStateOf(WindowInsets()) private fun watchKeyboardVisible() { if (AndroidTarget.R) { @@ -192,8 +194,10 @@ class MainActivity : ComponentActivity() { StatusService.autoStart() topAppIdFlow.value = META.appId setContent { - if (_topBarWindowInsets == null) { - _topBarWindowInsets = FixedWindowInsets(TopAppBarDefaults.windowInsets) + val latestInsets = TopAppBarDefaults.windowInsets + val density = LocalDensity.current + if (latestInsets.getTop(density) > topBarWindowInsets.getTop(density)) { + topBarWindowInsets = FixedWindowInsets(latestInsets) } val navController = rememberNavController() mainVm.updateNavController(navController) From e9c7d18300dcf51ebe072625a15a45434a7e8b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 05:18:52 +0800 Subject: [PATCH 048/245] refactor: IInputManager --- .../kotlin/li/songe/gkd/data/GkdAction.kt | 26 +- .../li/songe/gkd/service/A11yService.kt | 12 +- .../li/songe/gkd/service/GkdTileService.kt | 5 +- .../li/songe/gkd/service/RecordService.kt | 2 +- .../li/songe/gkd/shizuku/ActivityManager.kt | 27 +- .../songe/gkd/shizuku/ActivityTaskManager.kt | 31 +-- .../li/songe/gkd/shizuku/AppOpsManager.kt | 3 + .../li/songe/gkd/shizuku/InputManager.kt | 47 ++++ .../li/songe/gkd/shizuku/InputShellCommand.kt | 238 ++++++++++++++++++ .../li/songe/gkd/shizuku/PackageManager.kt | 34 +-- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 54 ++-- .../li/songe/gkd/shizuku/UserManager.kt | 34 +-- .../li/songe/gkd/shizuku/UserService.kt | 5 +- .../java/android/app/AppOpsManagerHidden.java | 3 + .../java/android/app/IActivityManager.java | 16 +- .../android/app/IActivityTaskManager.java | 17 +- .../java/android/app/ITaskStackListener.java | 4 +- .../android/content/pm/IPackageManager.java | 16 +- .../java/android/content/pm/UserInfo.java | 4 +- .../android/hardware/input/IInputManager.java | 25 ++ .../hardware/input/InputManagerHidden.java | 14 ++ .../main/java/android/os/IUserManager.java | 11 +- .../java/android/view/MotionEventHidden.java | 23 ++ 23 files changed, 508 insertions(+), 143 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt create mode 100644 hidden_api/src/main/java/android/hardware/input/IInputManager.java create mode 100644 hidden_api/src/main/java/android/hardware/input/InputManagerHidden.java create mode 100644 hidden_api/src/main/java/android/view/MotionEventHidden.java diff --git a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt index 65a6ee1c50..2125decc5c 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt @@ -61,9 +61,13 @@ sealed class ActionPerformer(val action: String) { return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - val result = shizukuContextFlow.value.serviceWrapper?.safeTap(x, y) - if (result != null) { - return ActionResult(action, result, true, position = x to y) + if (shizukuContextFlow.value.inputManager?.tap(x, y) != null) { + return ActionResult( + action = action, + result = true, + shizuku = true, + position = x to y + ) } val gestureDescription = GestureDescription.Builder() val path = Path() @@ -125,10 +129,18 @@ sealed class ActionPerformer(val action: String) { return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - val result = - shizukuContextFlow.value.serviceWrapper?.safeTap(x, y, longClickDuration) - if (result != null) { - return ActionResult(action, result, true, position = x to y) + if (shizukuContextFlow.value.inputManager?.tap( + x, + y, + longClickDuration + ) != null + ) { + return ActionResult( + action = action, + result = true, + shizuku = true, + position = x to y + ) } val gestureDescription = GestureDescription.Builder() val path = Path() diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index b64c0e5f18..6b8b685e32 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -12,7 +12,9 @@ import android.view.accessibility.AccessibilityNodeInfo import androidx.core.content.ContextCompat import com.blankj.utilcode.util.LogUtils import com.google.android.accessibility.selecttospeak.SelectToSpeakService +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext import li.songe.gkd.a11y.A11yContext import li.songe.gkd.a11y.A11yRuleEngine import li.songe.gkd.a11y.a11yContext @@ -115,7 +117,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { val instance: A11yService? get() = a11yRef - fun execAction(gkdAction: GkdAction): ActionResult { + suspend fun execAction(gkdAction: GkdAction): ActionResult { val service = instance ?: throw RpcError("无障碍没有运行") val selector = Selector.parseOrNull(gkdAction.selector) ?: throw RpcError("非法选择器") runCatching { selector.checkType(typeInfo) }.exceptionOrNull()?.let { @@ -129,9 +131,11 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { matchOption ) } ?: throw RpcError("没有查询到节点") - return ActionPerformer - .getAction(gkdAction.action ?: ActionPerformer.None.action) - .perform(targetNode, gkdAction.position) + return withContext(Dispatchers.IO) { + ActionPerformer + .getAction(gkdAction.action ?: ActionPerformer.None.action) + .perform(targetNode, gkdAction.position) + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index b89d3eb31d..844908ea60 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -22,6 +22,7 @@ import li.songe.gkd.store.blockA11yAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.launchTry import li.songe.gkd.util.mapState +import li.songe.gkd.util.systemUiAppId import li.songe.gkd.util.toast class GkdTileService : BaseTileService() { @@ -147,8 +148,8 @@ fun initA11yWhiteAppList() { actualFlow.collect { disabled -> if (!disabled) { val appId = topAppIdFlow.value - if (appId == launcherAppId) { - // 开启或关闭无障碍会造成卡顿 + if (appId == launcherAppId || appId == systemUiAppId) { + // 检测最近任务界面,开启或关闭无障碍会造成卡顿 appScope.launch { delay(A11Y_WHITE_APP_AWAIT_TIME) if (appId == topAppIdFlow.value) { diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt index 004f38dffa..cb0d9a8d09 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt @@ -54,7 +54,7 @@ class RecordService : OverlayWindowService( val activityOkFlow by lazy { combine(A11yService.isRunning, shizukuContextFlow) { a, b -> - a || (b.activityTaskManager != null && SafeTaskListener.isAvailable) + a || SafeTaskListener.isAvailable }.stateIn(scope = lifecycleScope, started = SharingStarted.Eagerly, initialValue = false) } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt index f3d84c5b8c..fdce83d190 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt @@ -1,7 +1,9 @@ package li.songe.gkd.shizuku +import android.app.ActivityManager import android.app.IActivityManager -import android.content.ComponentName +import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass class SafeActivityManager(private val value: IActivityManager) { @@ -17,7 +19,26 @@ class SafeActivityManager(private val value: IActivityManager) { } } - fun getTopCpn(): ComponentName? = safeInvokeMethod { - value.getTasks(1).firstOrNull()?.topActivity + fun getTasks(maxNum: Int): List = safeInvokeMethod { + if (AndroidTarget.P) { + value.getTasks(maxNum) + } else { + value.getTasks(maxNum, 0) + } + } ?: emptyList() + + fun registerDefault() { + if (!SafeTaskListener.isAvailable) return + safeInvokeMethod { + value.registerTaskStackListener(SafeTaskListener.instance) + } + } + + fun unregisterDefault() { + if (!shizukuOkState.stateFlow.value) return + if (!SafeTaskListener.isAvailable) return + safeInvokeMethod { + value.unregisterTaskStackListener(SafeTaskListener.instance) + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt index e767321821..47fae15b5b 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt @@ -2,29 +2,10 @@ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.IActivityTaskManager -import android.content.ComponentName import android.view.Display import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass -import kotlin.reflect.typeOf - -private var tasksFcType: Int? = null -private fun IActivityTaskManager.compatGetTasks(maxNum: Int): List { - tasksFcType = tasksFcType ?: findCompatMethod( - "getTasks", - listOf( - 1 to listOf(typeOf()), - 3 to listOf(typeOf(), typeOf(), typeOf()), - 4 to listOf(typeOf(), typeOf(), typeOf(), typeOf()), - ) - ) - return when (tasksFcType) { - 1 -> getTasks(maxNum) - 3 -> getTasks(maxNum, false, false) - 4 -> getTasks(maxNum, false, false, Display.INVALID_DISPLAY) - else -> emptyList() - } -} object SafeTaskListener { val isAvailable: Boolean @@ -45,8 +26,14 @@ class SafeActivityTaskManager(private val value: IActivityTaskManager) { } } - fun getTopCpn(): ComponentName? = safeInvokeMethod { - value.compatGetTasks(1).firstOrNull()?.topActivity + fun getTasks(maxNum: Int): List? = safeInvokeMethod { + if (AndroidTarget.TIRAMISU) { + value.getTasks(maxNum, false, false, Display.INVALID_DISPLAY) + } else if (AndroidTarget.S) { + value.getTasks(maxNum, false, false) + } else { + value.getTasks(maxNum) + } } fun registerDefault() { diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt index dc74466791..3b8d1db995 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt @@ -41,6 +41,9 @@ class SafeAppOpsManager( fun allowAllSelfMode() { setAllowSelfMode(AppOpsManagerHidden.OP_POST_NOTIFICATION) setAllowSelfMode(AppOpsManagerHidden.OP_SYSTEM_ALERT_WINDOW) + if (AndroidTarget.Q) { + setAllowSelfMode(AppOpsManagerHidden.OP_ACCESS_ACCESSIBILITY) + } if (AndroidTarget.TIRAMISU) { setAllowSelfMode(AppOpsManagerHidden.OP_ACCESS_RESTRICTED_SETTINGS) } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt new file mode 100644 index 0000000000..37c8e681df --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt @@ -0,0 +1,47 @@ +package li.songe.gkd.shizuku + +import android.content.Context +import android.hardware.input.IInputManager +import android.view.InputEvent +import androidx.annotation.WorkerThread +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.checkExistClass + + +class SafeInputManager(private val value: IInputManager) { + companion object { + val isAvailable: Boolean + get() = checkExistClass("android.hardware.input.IInputManager") + + fun newBinder() = getStubService( + Context.INPUT_SERVICE, + isAvailable, + )?.let { + SafeInputManager(IInputManager.Stub.asInterface(it)) + } + } + + private val command = InputShellCommand(this) + + fun compatInjectInputEvent( + ev: InputEvent, + mode: Int, + ) = safeInvokeMethod { + if (AndroidTarget.TIRAMISU) { + // https://github.com/android-cs/16/blob/main/core/java/android/hardware/input/InputManagerGlobal.java#L1707 + value.injectInputEventToTarget(ev, mode, android.os.Process.INVALID_UID) + } else { + value.injectInputEvent(ev, mode) + } + } + + @WorkerThread + fun tap(x: Float, y: Float, duration: Long = 0) { + if (duration > 0) { + command.runSwipe(x, y, x, y, duration) + } else { + command.runTap(x, y) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt new file mode 100644 index 0000000000..0c90e059cc --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt @@ -0,0 +1,238 @@ +package li.songe.gkd.shizuku + +import android.hardware.input.InputManagerHidden +import android.os.Build +import android.os.SystemClock +import android.view.Display +import android.view.InputDevice +import android.view.MotionEvent +import android.view.MotionEvent.PointerCoords +import android.view.MotionEvent.PointerProperties +import android.view.MotionEventHidden +import android.view.ViewConfiguration +import androidx.annotation.RequiresApi +import dev.rikka.tools.refine.Refine +import li.songe.gkd.util.AndroidTarget +import java.util.Map +import kotlin.math.floor + + +// https://github.com/android-cs/16/blob/main/services/core/java/com/android/server/input/InputShellCommand.java +class InputShellCommand(val safeInputManager: SafeInputManager) { + companion object { + private const val DEFAULT_DEVICE_ID = 0 + private const val DEFAULT_SIZE = 1.0f + private const val DEFAULT_META_STATE = 0 + private const val DEFAULT_PRECISION_X = 1.0f + private const val DEFAULT_PRECISION_Y = 1.0f + private const val DEFAULT_EDGE_FLAGS = 0 + private const val DEFAULT_BUTTON_STATE = 0 + private const val DEFAULT_FLAGS = 0 + private const val SECOND_IN_MILLISECONDS = 1000L + private const val SWIPE_EVENT_HZ_DEFAULT = 120 + } + + fun runTap(x: Float, y: Float) { + sendTap(InputDevice.SOURCE_TOUCHSCREEN, x, y, Display.INVALID_DISPLAY) + } + + fun runSwipe(x1: Float, y1: Float, x2: Float, y2: Float, duration: Long) { + sendSwipe( + InputDevice.SOURCE_TOUCHSCREEN, + x1, + y1, + x2, + y2, + duration, + Display.INVALID_DISPLAY, + false, + ) + } + + @Suppress("SameParameterValue") + private fun sendSwipe( + inputSource: Int, + x1: Float, + y1: Float, + x2: Float, + y2: Float, + duration: Long, + displayId: Int, + isDragDrop: Boolean, + ) { + val down = SystemClock.uptimeMillis() + injectMotionEvent( + inputSource, MotionEvent.ACTION_DOWN, down, down, x1, y1, 1.0f, + displayId + ) + if (isDragDrop) { + // long press until drag start. + sleep(ViewConfiguration.getLongPressTimeout().toLong()) + } + var now = SystemClock.uptimeMillis() + val endTime = down + duration + val swipeEventPeriodMillis: Float = + SECOND_IN_MILLISECONDS.toFloat() / SWIPE_EVENT_HZ_DEFAULT + var injected = 1 + while (now < endTime) { + // Ensure that we inject at most at the frequency of SWIPE_EVENT_HZ_DEFAULT + // by waiting an additional delta between the actual time and expected time. + var elapsedTime = now - down + val errorMillis = + floor((injected * swipeEventPeriodMillis - elapsedTime).toDouble()).toLong() + if (errorMillis > 0) { + // Make sure not to exceed the duration and inject an extra event. + if (errorMillis > endTime - now) { + sleep(endTime - now) + break + } + sleep(errorMillis) + } + now = SystemClock.uptimeMillis() + elapsedTime = now - down + val alpha = elapsedTime.toFloat() / duration + injectMotionEvent( + inputSource, MotionEvent.ACTION_MOVE, down, now, + lerp(x1, x2, alpha), lerp(y1, y2, alpha), 1.0f, displayId + ) + injected++ + now = SystemClock.uptimeMillis() + } + injectMotionEvent( + inputSource, MotionEvent.ACTION_UP, down, now, x2, y2, 0.0f, + displayId + ) + } + + @Suppress("SameParameterValue") + private fun sendTap( + inputSource: Int, + x: Float, + y: Float, + displayId: Int, + ) { + val now = SystemClock.uptimeMillis() + injectMotionEvent(inputSource, MotionEvent.ACTION_DOWN, now, now, x, y, 1.0f, displayId) + injectMotionEvent(inputSource, MotionEvent.ACTION_UP, now, now, x, y, 0.0f, displayId) + } + + private fun injectMotionEvent( + inputSource: Int, + action: Int, + downTime: Long, + mWhen: Long, + x: Float, + y: Float, + pressure: Float, + displayId: Int + ) { + if (AndroidTarget.S) { + val axisValues = Map.of( + MotionEvent.AXIS_X, x, MotionEvent.AXIS_Y, y, MotionEvent.AXIS_PRESSURE, pressure + ) + injectMotionEvent(inputSource, action, downTime, mWhen, axisValues, displayId) + } else { + // https://github.com/android-cs/11/blob/main/cmds/input/src/com/android/commands/input/Input.java#L382 + val event = MotionEvent.obtain( + downTime, mWhen, action, x, y, pressure, DEFAULT_SIZE, + DEFAULT_META_STATE, DEFAULT_PRECISION_X, DEFAULT_PRECISION_Y, + getInputDeviceId(inputSource), DEFAULT_EDGE_FLAGS + ) + event.setSource(inputSource) + // https://github.com/android-cs/9/blob/main/cmds/input/src/com/android/commands/input/Input.java#L298 + if (AndroidTarget.Q) { + var mDisplayId = displayId + if (mDisplayId == Display.INVALID_DISPLAY && (inputSource and InputDevice.SOURCE_CLASS_POINTER) != 0) { + mDisplayId = Display.DEFAULT_DISPLAY + } + Refine.unsafeCast(event).setDisplayId(mDisplayId) + } + safeInputManager.compatInjectInputEvent( + event, InputManagerHidden.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH + ) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + @Suppress("KotlinConstantConditions") + private fun injectMotionEvent( + inputSource: Int, + action: Int, + downTime: Long, + mWhen: Long, + axisValues: MutableMap, + displayId: Int + ) { + val pointerCount = 1 + val pointerProperties = arrayOfNulls(pointerCount) + for (i in 0..(pointerCount) + for (i in 0.. MotionEvent.TOOL_TYPE_MOUSE + InputDevice.SOURCE_STYLUS, InputDevice.SOURCE_BLUETOOTH_STYLUS -> MotionEvent.TOOL_TYPE_STYLUS + InputDevice.SOURCE_TOUCHPAD, InputDevice.SOURCE_TOUCHSCREEN, InputDevice.SOURCE_TOUCH_NAVIGATION -> MotionEvent.TOOL_TYPE_FINGER + else -> MotionEvent.TOOL_TYPE_UNKNOWN + } + + private fun sleep(milliseconds: Long) { + try { + Thread.sleep(milliseconds) + } catch (e: InterruptedException) { + throw RuntimeException(e) + } + } + + private fun lerp(a: Float, b: Float, alpha: Float): Float { + return (b - a) * alpha + a + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index ceda50db21..d0bbb211c7 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -3,28 +3,8 @@ package li.songe.gkd.shizuku import android.content.pm.IPackageManager import android.content.pm.PackageInfo import li.songe.gkd.META +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass -import kotlin.reflect.typeOf - - -private var pkgFcType: Int? = null -private fun IPackageManager.compatGetInstalledPackages( - flags: Int, - userId: Int -): List { - pkgFcType = pkgFcType ?: findCompatMethod( - "getInstalledPackages", - listOf( - 1 to listOf(typeOf(), typeOf()), - 2 to listOf(typeOf(), typeOf()), - ) - ) - return when (pkgFcType) { - 1 -> getInstalledPackages(flags, userId).list - 2 -> getInstalledPackages(flags.toLong(), userId).list - else -> emptyList() - } -} class SafePackageManager(private val value: IPackageManager) { companion object { @@ -39,9 +19,15 @@ class SafePackageManager(private val value: IPackageManager) { } } - fun getInstalledPackages(flags: Int, userId: Int): List { - return safeInvokeMethod { value.compatGetInstalledPackages(flags, userId) } ?: emptyList() - } + val isSafeMode get() = safeInvokeMethod { value.isSafeMode } + + fun getInstalledPackages(flags: Int, userId: Int): List = safeInvokeMethod { + if (AndroidTarget.TIRAMISU) { + value.getInstalledPackages(flags.toLong(), userId).list + } else { + value.getInstalledPackages(flags, userId).list + } + } ?: emptyList() fun grantRuntimePermission( packageName: String, diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 01d7140cb7..37bed8e758 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -3,7 +3,6 @@ package li.songe.gkd.shizuku import android.content.ComponentName import android.content.pm.PackageManager -import android.os.IInterface import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -22,35 +21,6 @@ import li.songe.gkd.util.toast import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper -import kotlin.reflect.KType -import kotlin.reflect.full.declaredMemberFunctions -import kotlin.reflect.full.valueParameters -import kotlin.reflect.jvm.jvmName - -private val hiddenFunctionMap = HashMap() -fun IInterface.findCompatMethod( - name: String, - typePairs: List>> -): Int { - val key = "${this::class.jvmName}::$name" - hiddenFunctionMap[key]?.let { return it } - val functions = this::class.declaredMemberFunctions.filter { it.name == name } - for (f in functions) { - val types = f.valueParameters.map { it.type } - typePairs.find { it.second == types }?.first?.let { - hiddenFunctionMap[key] = it - return it - } - } - LogUtils.d( - "获取签名 ${this::class.jvmName}::$name 失败", - functions.joinToString("\n") { - it.valueParameters.map { p -> p.type.toString() }.toString() - } - ) - hiddenFunctionMap[key] = -1 - return -1 -} // shizuku 会概率断开 inline fun safeInvokeMethod( @@ -58,7 +28,7 @@ inline fun safeInvokeMethod( ): T? = try { block() } catch (e: Throwable) { - e.message + e.printStackTrace() null } @@ -84,15 +54,24 @@ class ShizukuContext( val activityManager: SafeActivityManager?, val activityTaskManager: SafeActivityTaskManager?, val appOpsManager: SafeAppOpsManager?, + val inputManager: SafeInputManager?, ) { init { - activityTaskManager?.registerDefault() + if (activityTaskManager != null) { + activityTaskManager.registerDefault() + } else { + activityManager?.registerDefault() + } } val ok get() = this !== defaultShizukuContext fun destroy() { serviceWrapper?.destroy() - activityTaskManager?.unregisterDefault() + if (activityTaskManager != null) { + activityTaskManager.unregisterDefault() + } else { + activityManager?.unregisterDefault() + } } val states = listOf( @@ -102,6 +81,7 @@ class ShizukuContext( "IActivityManager" to activityManager, "IActivityTaskManager" to activityTaskManager, "IAppOpsService" to appOpsManager, + "IInputManager" to inputManager, ) } @@ -112,6 +92,7 @@ private val defaultShizukuContext = ShizukuContext( activityManager = null, activityTaskManager = null, appOpsManager = null, + inputManager = null, ) val currentUserId by lazy { android.os.Process.myUserHandle().hashCode() } @@ -119,7 +100,7 @@ val currentUserId by lazy { android.os.Process.myUserHandle().hashCode() } val shizukuContextFlow = MutableStateFlow(defaultShizukuContext) fun safeGetTopCpn(): ComponentName? = shizukuContextFlow.value.run { - activityTaskManager?.getTopCpn() ?: activityManager?.getTopCpn() + (activityTaskManager?.getTasks(1) ?: activityManager?.getTasks(1))?.firstOrNull()?.topActivity } fun shizukuCheckGranted(): Boolean { @@ -129,8 +110,8 @@ fun shizukuCheckGranted(): Boolean { false } if (!granted) return false - val u = shizukuContextFlow.value.activityManager ?: SafeActivityManager.newBinder() - return u?.getTopCpn() != null + val u = shizukuContextFlow.value.packageManager ?: SafePackageManager.newBinder() + return u?.isSafeMode != null } val updateBinderMutex = MutexState() @@ -146,6 +127,7 @@ private fun updateShizukuBinder() = updateBinderMutex.launchTry(appScope, Dispat activityManager = SafeActivityManager.newBinder(), activityTaskManager = SafeActivityTaskManager.newBinder(), appOpsManager = SafeAppOpsManager.newBinder(), + inputManager = SafeInputManager.newBinder(), ) if (isActivityVisible()) { val delayMillis = if (app.justStarted) 1200L else 0L diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt index 7b1fc26aa6..f237e3f07a 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt @@ -3,28 +3,8 @@ package li.songe.gkd.shizuku import android.content.Context import android.os.IUserManager import li.songe.gkd.data.UserInfo +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass -import kotlin.reflect.typeOf - -private var getUsersFcType: Int? = null -private fun IUserManager.compatGetUsers( - excludePartial: Boolean, - excludeDying: Boolean, - excludePreCreated: Boolean, -): List { - getUsersFcType = getUsersFcType ?: findCompatMethod( - "getUsers", - listOf( - 1 to listOf(typeOf()), - 3 to listOf(typeOf(), typeOf(), typeOf()), - ) - ) - return when (getUsersFcType) { - 1 -> getUsers(excludeDying) - 3 -> getUsers(excludePartial, excludeDying, excludePreCreated) - else -> emptyList() - }.map { UserInfo(id = it.id, name = it.name) } -} class SafeUserManager(private val value: IUserManager) { companion object { @@ -43,9 +23,11 @@ class SafeUserManager(private val value: IUserManager) { excludePartial: Boolean = true, excludeDying: Boolean = true, excludePreCreated: Boolean = true - ): List { - return safeInvokeMethod { - value.compatGetUsers(excludePartial, excludeDying, excludePreCreated) - } ?: emptyList() - } + ): List = safeInvokeMethod { + if (AndroidTarget.R) { + value.getUsers(excludePartial, excludeDying, excludePreCreated) + } else { + value.getUsers(excludeDying) + }.map { UserInfo(id = it.id, name = it.name) } + } ?: emptyList() } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index 0cd63c08ac..ada90e306a 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -112,6 +112,7 @@ data class CommandResult( get() = code == 0 } +@Suppress("unused") data class UserServiceWrapper( val userService: IUserService, val connection: ServiceConnection, @@ -128,8 +129,8 @@ data class UserServiceWrapper( CommandResult(code = null, result = "", error = e.message) } - fun safeTap(x: Float, y: Float, duration: Long? = null): Boolean? { - val command = if (duration != null) { + fun tap(x: Float, y: Float, duration: Long = 0): Boolean { + val command = if (duration > 0) { "input swipe $x $y $x $y $duration" } else { "input tap $x $y" diff --git a/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java b/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java index cabfd3732f..3bb9622cac 100644 --- a/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java +++ b/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java @@ -15,6 +15,9 @@ public class AppOpsManagerHidden { public static int OP_POST_NOTIFICATION; public static int OP_SYSTEM_ALERT_WINDOW; + @RequiresApi(Build.VERSION_CODES.Q) + public static int OP_ACCESS_ACCESSIBILITY; + @RequiresApi(Build.VERSION_CODES.TIRAMISU) public static int OP_ACCESS_RESTRICTED_SETTINGS; diff --git a/hidden_api/src/main/java/android/app/IActivityManager.java b/hidden_api/src/main/java/android/app/IActivityManager.java index 880a47294e..32e896978e 100644 --- a/hidden_api/src/main/java/android/app/IActivityManager.java +++ b/hidden_api/src/main/java/android/app/IActivityManager.java @@ -1,12 +1,18 @@ package android.app; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.IInterface; +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + import java.util.List; -@SuppressWarnings("unused") +/** + * @noinspection unused + */ public interface IActivityManager extends IInterface { abstract class Stub extends Binder implements IActivityManager { public static IActivityManager asInterface(IBinder obj) { @@ -14,5 +20,13 @@ public static IActivityManager asInterface(IBinder obj) { } } + @RequiresApi(Build.VERSION_CODES.P) List getTasks(int maxNum); + + @DeprecatedSinceApi(api = Build.VERSION_CODES.P, message = "NoSuchMethodError") + List getTasks(int maxNum, int flags); + + void registerTaskStackListener(ITaskStackListener listener); + + void unregisterTaskStackListener(ITaskStackListener listener); } diff --git a/hidden_api/src/main/java/android/app/IActivityTaskManager.java b/hidden_api/src/main/java/android/app/IActivityTaskManager.java index a174279d9e..2fcaf8d626 100644 --- a/hidden_api/src/main/java/android/app/IActivityTaskManager.java +++ b/hidden_api/src/main/java/android/app/IActivityTaskManager.java @@ -1,27 +1,34 @@ package android.app; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.IInterface; +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + import java.util.List; -@SuppressWarnings("unused") +/** + * @noinspection unused + */ +//@RequiresApi(api = Build.VERSION_CODES.Q) public interface IActivityTaskManager extends IInterface { - // android10+ abstract class Stub extends Binder implements IActivityTaskManager { public static IActivityTaskManager asInterface(IBinder obj) { throw new RuntimeException("Stub!"); } } - // android10 - android11 + @DeprecatedSinceApi(api = Build.VERSION_CODES.R, message = "NoSuchMethodError") List getTasks(int maxNum); - // android12 + @DeprecatedSinceApi(api = Build.VERSION_CODES.TIRAMISU, message = "NoSuchMethodError") + @RequiresApi(Build.VERSION_CODES.S) List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra); - // android13+ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); void registerTaskStackListener(ITaskStackListener listener); diff --git a/hidden_api/src/main/java/android/app/ITaskStackListener.java b/hidden_api/src/main/java/android/app/ITaskStackListener.java index 78e0e832a0..71faf21e3a 100644 --- a/hidden_api/src/main/java/android/app/ITaskStackListener.java +++ b/hidden_api/src/main/java/android/app/ITaskStackListener.java @@ -2,7 +2,9 @@ import android.os.IBinder; -@SuppressWarnings("unused") +/** + * @noinspection unused + */ public interface ITaskStackListener { abstract class Stub extends android.os.Binder implements ITaskStackListener { public static ITaskStackListener asInterface(IBinder obj) { diff --git a/hidden_api/src/main/java/android/content/pm/IPackageManager.java b/hidden_api/src/main/java/android/content/pm/IPackageManager.java index aaae8033db..890881e729 100644 --- a/hidden_api/src/main/java/android/content/pm/IPackageManager.java +++ b/hidden_api/src/main/java/android/content/pm/IPackageManager.java @@ -2,23 +2,29 @@ import android.content.IntentFilter; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.IInterface; -@SuppressWarnings("unused") +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + +/** + * @noinspection unused + */ public interface IPackageManager extends IInterface { abstract class Stub extends Binder implements IPackageManager { public static IPackageManager asInterface(IBinder binder) { throw new IllegalArgumentException("Stub!"); } } - // android8 - android12.1 -> int flags - // android13+ -> long flags - // android8 - android12 + boolean isSafeMode(); + + @DeprecatedSinceApi(api = Build.VERSION_CODES.TIRAMISU, message = "NoSuchMethodError") ParceledListSlice getInstalledPackages(int flags, int userId); - // android13+ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) ParceledListSlice getInstalledPackages(long flags, int userId); ParceledListSlice getAllIntentFilters(String packageName); diff --git a/hidden_api/src/main/java/android/content/pm/UserInfo.java b/hidden_api/src/main/java/android/content/pm/UserInfo.java index 3edbb05fab..4ade6044a1 100644 --- a/hidden_api/src/main/java/android/content/pm/UserInfo.java +++ b/hidden_api/src/main/java/android/content/pm/UserInfo.java @@ -1,6 +1,8 @@ package android.content.pm; -@SuppressWarnings("unused") +/** + * @noinspection unused + */ public class UserInfo { public int id; public String name; diff --git a/hidden_api/src/main/java/android/hardware/input/IInputManager.java b/hidden_api/src/main/java/android/hardware/input/IInputManager.java new file mode 100644 index 0000000000..5a0ec089df --- /dev/null +++ b/hidden_api/src/main/java/android/hardware/input/IInputManager.java @@ -0,0 +1,25 @@ +package android.hardware.input; + +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.IInterface; +import android.view.InputEvent; + +import androidx.annotation.RequiresApi; + +/** + * @noinspection unused + */ +public interface IInputManager extends IInterface { + abstract class Stub extends Binder implements IInputManager { + public static IInputManager asInterface(IBinder binder) { + throw new IllegalArgumentException("Stub!"); + } + } + + boolean injectInputEvent(InputEvent ev, int mode); + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + boolean injectInputEventToTarget(InputEvent ev, int mode, int targetUid); +} diff --git a/hidden_api/src/main/java/android/hardware/input/InputManagerHidden.java b/hidden_api/src/main/java/android/hardware/input/InputManagerHidden.java new file mode 100644 index 0000000000..d42c03f548 --- /dev/null +++ b/hidden_api/src/main/java/android/hardware/input/InputManagerHidden.java @@ -0,0 +1,14 @@ +package android.hardware.input; + + +import dev.rikka.tools.refine.RefineAs; + +/** + * @noinspection unused + */ +@RefineAs(InputManager.class) +public class InputManagerHidden { + public static int INJECT_INPUT_EVENT_MODE_ASYNC; + public static int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH; + public static int INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT; +} diff --git a/hidden_api/src/main/java/android/os/IUserManager.java b/hidden_api/src/main/java/android/os/IUserManager.java index 8fc4ef45a7..2a1e8aa609 100644 --- a/hidden_api/src/main/java/android/os/IUserManager.java +++ b/hidden_api/src/main/java/android/os/IUserManager.java @@ -2,9 +2,14 @@ import android.content.pm.UserInfo; +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + import java.util.List; -@SuppressWarnings("unused") +/** + * @noinspection unused + */ public interface IUserManager extends IInterface { abstract class Stub extends Binder implements IUserManager { public static IUserManager asInterface(IBinder obj) { @@ -12,9 +17,9 @@ public static IUserManager asInterface(IBinder obj) { } } - // android8 - android10 + @DeprecatedSinceApi(api = Build.VERSION_CODES.R, message = "NoSuchMethodError") List getUsers(boolean excludeDying); - // android11+ + @RequiresApi(Build.VERSION_CODES.R) List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); } diff --git a/hidden_api/src/main/java/android/view/MotionEventHidden.java b/hidden_api/src/main/java/android/view/MotionEventHidden.java new file mode 100644 index 0000000000..a9749d190c --- /dev/null +++ b/hidden_api/src/main/java/android/view/MotionEventHidden.java @@ -0,0 +1,23 @@ +package android.view; + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import dev.rikka.tools.refine.RefineAs; + +/** + * @noinspection unused + */ +@RefineAs(MotionEvent.class) +public class MotionEventHidden { + @RequiresApi(Build.VERSION_CODES.Q) + public static MotionEvent obtain(long downTime, long eventTime, int action, int pointerCount, MotionEvent.PointerProperties[] pointerProperties, MotionEvent.PointerCoords[] pointerCoords, int metaState, int buttonState, float xPrecision, float yPrecision, int deviceId, int edgeFlags, int source, int displayId, int flags) { + throw new RuntimeException("Stub"); + } + + @RequiresApi(Build.VERSION_CODES.Q) + public void setDisplayId(int displayId) { + throw new RuntimeException("Stub"); + } +} From 33e10f8ae0793fb0f3bc54c246a3c631bbbcf4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 18:37:32 +0800 Subject: [PATCH 049/245] perf: up AboutPage state --- app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt | 9 ++++++--- app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt index 464efaa47c..44fce2e977 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import kotlinx.coroutines.Dispatchers @@ -69,6 +70,7 @@ import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalDarkTheme import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions import li.songe.gkd.ui.style.itemPadding @@ -97,9 +99,10 @@ import java.io.File fun AboutPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current + val vm = viewModel() val store by storeFlow.collectAsState() - var showInfoDlg by remember { mutableStateOf(false) } + var showInfoDlg by vm.showInfoDlgFlow.asMutableState() if (showInfoDlg) { AlertDialog( onDismissRequest = { showInfoDlg = false }, @@ -144,8 +147,8 @@ fun AboutPage() { }, ) } - var showShareLogDlg by remember { mutableStateOf(false) } - var showShareAppDlg by remember { mutableStateOf(false) } + var showShareLogDlg by vm.showShareLogDlgFlow.asMutableState() + var showShareAppDlg by vm.showShareAppDlgFlow.asMutableState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt new file mode 100644 index 0000000000..2a64c864de --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt @@ -0,0 +1,10 @@ +package li.songe.gkd.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow + +class AboutVm : ViewModel() { + val showInfoDlgFlow = MutableStateFlow(false) + val showShareLogDlgFlow = MutableStateFlow(false) + val showShareAppDlgFlow = MutableStateFlow(false) +} \ No newline at end of file From 836f29933cf25792cb8996fc9f24e0494528871d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 18:42:18 +0800 Subject: [PATCH 050/245] perf: shizuku state --- .../songe/gkd/permission/PermissionState.kt | 6 ++-- .../{AppOpsManager.kt => AppOpsService.kt} | 4 +-- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 12 +++---- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 33 +++++++++++++++---- .../main/kotlin/li/songe/gkd/ui/AdvancedVm.kt | 1 + .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/share/StateExt.kt | 7 ++-- .../kotlin/li/songe/gkd/util/FolderExt.kt | 7 ++++ 9 files changed, 50 insertions(+), 24 deletions(-) rename app/src/main/kotlin/li/songe/gkd/shizuku/{AppOpsManager.kt => AppOpsService.kt} (94%) diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index c6290e3e79..5d6b0b1cac 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -106,7 +106,7 @@ val foregroundServiceSpecialUseState by lazy { } }, grantSelf = { - shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + shizukuContextFlow.value.appOpsService?.allowAllSelfMode() }, reason = AuthReason( text = { "当前操作权限「特殊用途的前台服务」已被限制, 请先解除限制" }, @@ -127,7 +127,7 @@ val notificationState by lazy { XXPermissions.isGrantedPermission(app, permission) }, grantSelf = { - shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + shizukuContextFlow.value.appOpsService?.allowAllSelfMode() }, request = { asyncRequestPermission(it, permission) }, reason = AuthReason( @@ -164,7 +164,7 @@ val canDrawOverlaysState by lazy { Settings.canDrawOverlays(app) }, grantSelf = { - shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + shizukuContextFlow.value.appOpsService?.allowAllSelfMode() }, reason = AuthReason( text = { diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt similarity index 94% rename from app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt rename to app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt index 3b8d1db995..accc3e701f 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt @@ -8,7 +8,7 @@ import li.songe.gkd.META import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass -class SafeAppOpsManager( +class SafeAppOpsService( private val value: IAppOpsService ) { companion object { @@ -19,7 +19,7 @@ class SafeAppOpsManager( Context.APP_OPS_SERVICE, isAvailable, )?.let { - SafeAppOpsManager(IAppOpsService.Stub.asInterface(it)) + SafeAppOpsService(IAppOpsService.Stub.asInterface(it)) } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 37bed8e758..03c9de488a 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -53,7 +53,7 @@ class ShizukuContext( val userManager: SafeUserManager?, val activityManager: SafeActivityManager?, val activityTaskManager: SafeActivityTaskManager?, - val appOpsManager: SafeAppOpsManager?, + val appOpsService: SafeAppOpsService?, val inputManager: SafeInputManager?, ) { init { @@ -76,12 +76,12 @@ class ShizukuContext( val states = listOf( "IUserService" to serviceWrapper, - "IUserManager" to userManager, - "IPackageManager" to packageManager, "IActivityManager" to activityManager, "IActivityTaskManager" to activityTaskManager, - "IAppOpsService" to appOpsManager, + "IAppOpsService" to appOpsService, "IInputManager" to inputManager, + "IPackageManager" to packageManager, + "IUserManager" to userManager, ) } @@ -91,7 +91,7 @@ private val defaultShizukuContext = ShizukuContext( userManager = null, activityManager = null, activityTaskManager = null, - appOpsManager = null, + appOpsService = null, inputManager = null, ) @@ -126,7 +126,7 @@ private fun updateShizukuBinder() = updateBinderMutex.launchTry(appScope, Dispat userManager = SafeUserManager.newBinder(), activityManager = SafeActivityManager.newBinder(), activityTaskManager = SafeActivityTaskManager.newBinder(), - appOpsManager = SafeAppOpsManager.newBinder(), + appOpsService = SafeAppOpsService.newBinder(), inputManager = SafeInputManager.newBinder(), ) if (isActivityVisible()) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 5f82b237aa..d937683a7e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -163,6 +163,31 @@ fun AdvancedPage() { ) } + var showShizukuState by vm.showShizukuStateFlow.asMutableState() + if (showShizukuState) { + val onDismissRequest = { showShizukuState = false } + AlertDialog( + title = { Text(text = "授权状态") }, + text = { + val states = shizukuContextFlow.collectAsState().value.states + Column { + states.forEach { (name, value) -> + Text( + text = name, + textDecoration = if (value != null) null else TextDecoration.LineThrough, + ) + } + } + }, + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = "我知道了") + } + }, + ) + } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -203,13 +228,7 @@ fun AdvancedPage() { modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { - val c = shizukuContextFlow.value - mainVm.dialogFlow.updateDialogOptions( - title = "授权状态", - text = c.states.joinToString("\n") { (name, state) -> - if (state != null) "$name ✅" else "$name ❎" - } - ) + showShizukuState = true }) .size(lineHeightDp), imageVector = PerfIcon.Api, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt index 2c3a368153..c35cadc7a5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt @@ -6,4 +6,5 @@ import kotlinx.coroutines.flow.MutableStateFlow class AdvancedVm : ViewModel() { val showEditPortDlgFlow = MutableStateFlow(false) + val showShizukuStateFlow = MutableStateFlow(false) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index ab4d7521b4..4f2960dd1c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -89,7 +89,7 @@ fun AppOpsAllowPage() { AuthButtonGroup( onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { mainVm.guardShizukuContext() - shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + shizukuContextFlow.value.appOpsService?.allowAllSelfMode() toast("授权成功") }, onClickManual = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index c6c89c0f86..80e2e4e1d2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -267,7 +267,7 @@ private fun A11yAuthButtonGroup() { onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { mainVm.guardShizukuContext() writeSecureSettingsState.grantSelf?.invoke() - shizukuContextFlow.value.appOpsManager?.allowAllSelfMode() + shizukuContextFlow.value.appOpsService?.allowAllSelfMode() successAuthExec() }, onClickManual = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt index 9719c3babd..345f1a6b4d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt @@ -16,14 +16,13 @@ fun MutableStateFlow.asMutableState(): MutableState { return remember(this) { val stateFlow = this object : MutableState { + val setter: (T) -> Unit = { stateFlow.value = it } override var value: T get() = state.value - set(newValue) { - stateFlow.value = newValue - } + set(newValue) = setter(newValue) override fun component1() = value - override fun component2(): (T) -> Unit = { value = it } + override fun component2() = setter } } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index 0ed9516523..0e4d42bdaf 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -3,6 +3,7 @@ package li.songe.gkd.util import android.text.format.DateUtils import com.blankj.utilcode.util.LogUtils import li.songe.gkd.app +import li.songe.gkd.shizuku.shizukuContextFlow import java.io.File fun File.autoMk(): File { @@ -67,6 +68,12 @@ fun buildLogFile(): File { it.writeText(json.encodeToString(appInfoMapFlow.value.values.toList())) files.add(it) } + tempDir.resolve("shizuku.txt").also { + it.writeText(shizukuContextFlow.value.states.joinToString("\n") { state -> + state.first + ": " + state.second.toString() + }) + files.add(it) + } val logZipFile = sharedDir.resolve("log-${System.currentTimeMillis()}.zip") ZipUtils.zipFiles(files, logZipFile) tempDir.deleteRecursively() From 33b1cfef06357a3308dd9e7b9bf0976cd4c4e279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 22:21:45 +0800 Subject: [PATCH 051/245] perf: blockA11yAppList topAppId --- app/src/main/kotlin/li/songe/gkd/App.kt | 8 +++++ .../main/kotlin/li/songe/gkd/MainActivity.kt | 4 ++- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 31 ++++++++++++++++++- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 5 ++- .../li/songe/gkd/service/GkdTileService.kt | 20 ++++++------ .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 1 - .../kotlin/li/songe/gkd/util/AppInfoState.kt | 11 ++----- .../src/main/java/com/android/internal/R.java | 15 +++++++++ 8 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 hidden_api/src/main/java/com/android/internal/R.java diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index b8bf097e49..12f52561a7 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -8,6 +8,7 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.provider.Settings import android.view.WindowManager @@ -23,6 +24,7 @@ import li.songe.gkd.service.initA11yWhiteAppList import li.songe.gkd.shizuku.initShizuku import li.songe.gkd.store.initStore import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.SafeR import li.songe.gkd.util.initAppState import li.songe.gkd.util.initSubsState @@ -111,6 +113,12 @@ class App : Application() { return intent.resolveActivity(packageManager)?.packageName } + fun getPkgInfo(appId: String): PackageInfo? = try { + packageManager.getPackageInfo(appId, PKG_FLAGS) + } catch (_: PackageManager.NameNotFoundException) { + null + } + fun resolveAppId(action: String, category: String? = null): String? { val intent = Intent(action) if (category != null) { diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index 3659d8b6a3..d9a8b537e5 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -192,7 +192,9 @@ class MainActivity : ComponentActivity() { } watchKeyboardVisible() StatusService.autoStart() - topAppIdFlow.value = META.appId + if (storeFlow.value.enableBlockA11yAppList) { + topAppIdFlow.value = META.appId + } setContent { val latestInsets = TopAppBarDefaults.windowInsets val density = LocalDensity.current diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index 1e6d39d0b5..bee4d0f017 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -20,15 +20,19 @@ import li.songe.gkd.data.AttrInfo import li.songe.gkd.data.ResetMatchType import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus +import li.songe.gkd.data.isSystem import li.songe.gkd.db.DbSet +import li.songe.gkd.shizuku.safeInvokeMethod import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.blockA11yAppListFlow import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.RuleSummary import li.songe.gkd.util.launchTry import li.songe.gkd.util.ruleSummaryFlow +import li.songe.gkd.util.systemUiAppId data class TopActivity( val appId: String = "", @@ -52,6 +56,10 @@ data class TopActivity( fun sameAs(a: String, b: String?): Boolean { return appId == a && activityId == b } + + fun sameAs(cn: ComponentName): Boolean { + return appId == cn.packageName && activityId == cn.className + } } val topActivityFlow = MutableStateFlow(TopActivity()) @@ -242,11 +250,32 @@ var appChangeTime = 0L var imeAppId = "" var launcherAppId = "" +var systemRecentCn = ComponentName("", "") fun updateSystemDefaultAppId() { - launcherAppId = app.resolveAppId(Intent.ACTION_MAIN, Intent.CATEGORY_HOME) ?: "" imeAppId = app.getSecureString(Settings.Secure.DEFAULT_INPUT_METHOD) ?.let(ComponentName::unflattenFromString)?.packageName ?: "" + val launcherCn = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + .resolveActivity(app.packageManager) + launcherAppId = launcherCn.packageName + if (app.getPkgInfo(launcherAppId)?.applicationInfo?.isSystem == true) { + systemRecentCn = launcherCn + } else { + safeInvokeMethod { + if (AndroidTarget.P) { + systemRecentCn = ComponentName.unflattenFromString( + app.getString(com.android.internal.R.string.config_recentsComponentName) + ) ?: systemRecentCn + } + } + if (systemRecentCn.packageName.isEmpty()) { + // https://github.com/android-cs/8/blob/main/packages/SystemUI/src/com/android/systemui/recents/RecentsActivity.java + systemRecentCn = ComponentName( + systemUiAppId, + "$systemUiAppId.recents.RecentsActivity", + ) + } + } } private val actionLogMutex = Mutex() diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index 5c328be74d..a820e48538 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -55,6 +55,9 @@ private val PackageInfo.isOverlay: Boolean false } +val ApplicationInfo.isSystem: Boolean + get() = flags and ApplicationInfo.FLAG_SYSTEM != 0 + fun PackageInfo.toAppInfo( userId: Int = currentUserId, ) = AppInfo( @@ -63,7 +66,7 @@ fun PackageInfo.toAppInfo( versionCode = compatVersionCode, versionName = versionName, mtime = lastUpdateTime, - isSystem = applicationInfo?.let { it.flags and ApplicationInfo.FLAG_SYSTEM != 0 } ?: false, + isSystem = applicationInfo?.isSystem ?: false, name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, hidden = activities?.isEmpty() != false || isOverlay, enabled = applicationInfo?.enabled ?: true, diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 844908ea60..67f51b205e 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -10,7 +10,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import li.songe.gkd.META -import li.songe.gkd.a11y.launcherAppId +import li.songe.gkd.a11y.systemRecentCn +import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.a11y.topAppIdFlow import li.songe.gkd.accessRestrictedSettingsShowFlow import li.songe.gkd.app @@ -22,7 +23,6 @@ import li.songe.gkd.store.blockA11yAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.launchTry import li.songe.gkd.util.mapState -import li.songe.gkd.util.systemUiAppId import li.songe.gkd.util.toast class GkdTileService : BaseTileService() { @@ -141,15 +141,13 @@ val a11yPartDisabledFlow by lazy { } } - fun initA11yWhiteAppList() { - val actualFlow = a11yPartDisabledFlow.drop(1) + val actualFlow = topAppIdFlow.drop(1) appScope.launch(Dispatchers.Main) { - actualFlow.collect { disabled -> - if (!disabled) { - val appId = topAppIdFlow.value - if (appId == launcherAppId || appId == systemUiAppId) { - // 检测最近任务界面,开启或关闭无障碍会造成卡顿 + actualFlow.collect { appId -> + if (!blockA11yAppListFlow.value.contains(appId)) { + if (topActivityFlow.value.sameAs(systemRecentCn)) { + // 切换无障碍会造成卡顿,在最近任务界面时,延迟这个卡顿 appScope.launch { delay(A11Y_WHITE_APP_AWAIT_TIME) if (appId == topAppIdFlow.value) { @@ -163,8 +161,8 @@ fun initA11yWhiteAppList() { } } appScope.launch(Dispatchers.Main) { - actualFlow.debounce(A11Y_WHITE_APP_AWAIT_TIME).collect { disabled -> - if (disabled) { + actualFlow.debounce(A11Y_WHITE_APP_AWAIT_TIME).collect { appId -> + if (blockA11yAppListFlow.value.contains(appId)) { forcedUpdateA11yService(true) } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 03c9de488a..3e1fb74e75 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -22,7 +22,6 @@ import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper -// shizuku 会概率断开 inline fun safeInvokeMethod( block: () -> T ): T? = try { diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index bc605e8c9b..a74b72f827 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -4,7 +4,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import androidx.core.content.ContextCompat @@ -99,12 +98,6 @@ private val packageReceiver by lazy { const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES -private fun getPkgInfo(appId: String): PackageInfo? = try { - app.packageManager.getPackageInfo(appId, PKG_FLAGS) -} catch (_: PackageManager.NameNotFoundException) { - null -} - val updateAppMutex = MutexState() private fun updateOtherUserAppInfo(userAppInfoMap: Map? = null) { @@ -146,7 +139,7 @@ private fun updatePartAppInfo( val newAppMap = HashMap(userAppInfoMapFlow.value) val newIconMap = HashMap(userAppIconMapFlow.value) appIds.forEach { appId -> - val info = getPkgInfo(appId) + val info = app.getPkgInfo(appId) if (info != null) { newAppMap[appId] = info.toAppInfo() } else { @@ -184,7 +177,7 @@ fun updateAllAppInfo( ) }.flatten() .map { it.activityInfo.packageName }.toSet() - .filter { !newAppMap.contains(it) }.mapNotNull { getPkgInfo(it) } + .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) } visiblePkgList.forEach { packageInfo -> newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() packageInfo.pkgIcon?.let { icon -> diff --git a/hidden_api/src/main/java/com/android/internal/R.java b/hidden_api/src/main/java/com/android/internal/R.java new file mode 100644 index 0000000000..ec78c28e26 --- /dev/null +++ b/hidden_api/src/main/java/com/android/internal/R.java @@ -0,0 +1,15 @@ +package com.android.internal; + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +/** + * @noinspection unused + */ +public class R { + public static final class string { + @RequiresApi(api = Build.VERSION_CODES.P) + public static int config_recentsComponentName; + } +} From 3b48d6f03e0baa541656ba4cce97b3e728cf9e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 22:41:15 +0800 Subject: [PATCH 052/245] fix: appVisitLog --- app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index bee4d0f017..18c0b47430 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -201,10 +201,11 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { topActivity = topActivity, ) if (idChanged) { + val oldAppId = lastAppId lastAppId = appId appChangeTime = t appScope.launchTry { - DbSet.appVisitLogDao.insert(lastAppId, appId, t) + DbSet.appVisitLogDao.insert(oldAppId, appId, t) appLogCount++ if (appLogCount % 100 == 0) { DbSet.appVisitLogDao.deleteKeepLatest() From 8f361abe92169265458da9db13ce46a7840417f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 23:03:20 +0800 Subject: [PATCH 053/245] perf: block a11y tip --- app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 83ce478e7a..ea82995316 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -379,7 +379,7 @@ fun useSettingsPage(): ScaffoldExt { if (it) { mainVm.dialogFlow.waitResult( title = "使用说明", - text = "开启或关闭无障碍时会造成短暂触摸卡顿,请自行考虑后再编辑无障碍白名单\n\n如果你还使用其它无障碍软件,此功能无效\n\n此外需确保无障碍关闭后后台运行\n1. 开启「常驻通知」\n2. 在「最近任务界面」锁定\n3. 允许自启动\n4. 设置省电策略为无限制\n不设置会被系统暂停或结束运行,导致无法恢复无障碍", + text = "「局部关闭」可解决某些应用无障碍检测或界面异常的问题\n\n切换无障碍会造成触摸卡顿,请自行考虑后再编辑无障碍白名单\n\n如果还使用其它无障碍软件会导致优化无效,因为无障碍没有被完全关闭\n\n此外需额外设置确保无障碍关闭后的持续后台运行\n1. 开启「常驻通知」\n2. 在「最近任务界面」锁定\n3. 允许自启动\n4. 省电策略设置为无限制\n不设置会被系统暂停或结束运行,导致无法恢复无障碍", confirmText = "继续", dismissRequest = true, ) From 05eb30b68aadb96a6deb99210f2e4b394d26c6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 21 Sep 2025 23:51:36 +0800 Subject: [PATCH 054/245] perf: latestRecordDesc --- .../li/songe/gkd/ui/home/ControlPage.kt | 16 ++++--- .../kotlin/li/songe/gkd/ui/home/HomeVm.kt | 45 +++---------------- .../kotlin/li/songe/gkd/util/SubsState.kt | 34 ++++++++++++++ 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index d467e00aa7..88ea7628eb 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -39,6 +39,7 @@ import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDest import com.ramcosta.composedestinations.generated.destinations.AuthA11YPageDestination import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination import li.songe.gkd.MainActivity +import li.songe.gkd.data.SubsConfig import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission @@ -60,6 +61,8 @@ import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.ui.style.surfaceCardColors import li.songe.gkd.util.HOME_PAGE_URL import li.songe.gkd.util.SafeR +import li.songe.gkd.util.latestRecordDescFlow +import li.songe.gkd.util.latestRecordFlow import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle @@ -310,7 +313,6 @@ private fun ServerStatusCard(vm: HomeVm) { horizontal = itemVerticalPadding, ) ) { - val latestRecordDesc by vm.latestRecordDescFlow.collectAsState() val subsStatus by vm.subsStatusFlow.collectAsState() AnimatedVisibility(subsStatus.isNotEmpty()) { Text( @@ -320,14 +322,15 @@ private fun ServerStatusCard(vm: HomeVm) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - AnimatedVisibility(latestRecordDesc != null) { - val isGlobal by vm.latestRecordIsGlobalFlow.collectAsState() + + val latestRecordDesc by latestRecordDescFlow.collectAsState() + if (latestRecordDesc != null) { Row( modifier = Modifier .padding(horizontal = 4.dp) .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { - vm.latestRecordFlow.value?.let { + latestRecordFlow.value?.let { mainVm.navigatePage( AppConfigPageDestination( appId = it.appId, @@ -340,10 +343,9 @@ private fun ServerStatusCard(vm: HomeVm) { .padding(horizontal = 4.dp) ) { GroupNameText( - modifier = Modifier - .weight(1f), + modifier = Modifier.weight(1f), preText = "最近触发: ", - isGlobal = isGlobal, + isGlobal = latestRecordFlow.collectAsState().value?.groupType == SubsConfig.GlobalGroupType, text = latestRecordDesc ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt index 9f2fb15116..2366cb1906 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt @@ -3,8 +3,6 @@ package li.songe.gkd.ui.home import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import li.songe.gkd.MainViewModel -import li.songe.gkd.data.SubsConfig -import li.songe.gkd.db.DbSet import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow @@ -12,44 +10,12 @@ import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.ui.share.useAppFilter import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.EMPTY_RULE_TIP -import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.findOption import li.songe.gkd.util.getSubsStatus import li.songe.gkd.util.ruleSummaryFlow -import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.usedSubsEntriesFlow class HomeVm : BaseViewModel() { - val latestRecordFlow = DbSet.actionLogDao.queryLatest().stateInit(null) - - val latestRecordIsGlobalFlow = - latestRecordFlow.mapNew { it?.groupType == SubsConfig.GlobalGroupType } - val latestRecordDescFlow = combine( - latestRecordFlow, subsMapFlow, appInfoMapFlow - ) { latestRecord, subsIdToRaw, appInfoCache -> - if (latestRecord == null) return@combine null - val isAppRule = latestRecord.groupType == SubsConfig.AppGroupType - val groupName = if (isAppRule) { - subsIdToRaw[latestRecord.subsId]?.apps?.find { a -> a.id == latestRecord.appId }?.groups?.find { g -> g.key == latestRecord.groupKey }?.name - } else { - subsIdToRaw[latestRecord.subsId]?.globalGroups?.find { g -> g.key == latestRecord.groupKey }?.name - } - val appName = appInfoCache[latestRecord.appId]?.name - val appShowName = appName ?: latestRecord.appId - if (groupName != null) { - if (groupName.startsWith(appShowName)) { - groupName - } else { - if (isAppRule) { - "$appShowName/$groupName" - } else { - "$groupName/$appShowName" - } - } - } else { - appShowName - } - }.stateInit(null) val subsStatusFlow by lazy { combine(ruleSummaryFlow, actionCountFlow) { ruleSummary, count -> @@ -82,16 +48,15 @@ class HomeVm : BaseViewModel() { ) val searchStrFlow = appFilter.searchStrFlow - val showSearchBarFlow = MutableStateFlow(false) - val appInfosFlow = appFilter.appListFlow - - init { - showSearchBarFlow.launchCollect { + val showSearchBarFlow = MutableStateFlow(false).apply { + launchCollect { if (!it) { searchStrFlow.value = "" } } - appInfosFlow.launchOnChange { + } + val appInfosFlow = appFilter.appListFlow.apply { + launchOnChange { MainViewModel.instance.appListKeyFlow.value++ } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index ab0359a6bd..3a80e18906 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -66,6 +66,40 @@ val subsLoadErrorsFlow = MutableStateFlow>(emptyMap()) val subsRefreshErrorsFlow = MutableStateFlow>(emptyMap()) val subsMapFlow = MutableStateFlow>(emptyMap()) +val latestRecordFlow by lazy { + DbSet.actionLogDao.queryLatest().stateIn(appScope, SharingStarted.Eagerly, null) +} +val latestRecordDescFlow by lazy { + combine( + latestRecordFlow, + subsMapFlow, + appInfoMapFlow, + ) { record, subsMap, appMap -> + if (record == null) return@combine null + val isAppRule = record.groupType == SubsConfig.AppGroupType + val groupName = if (isAppRule) { + subsMap[record.subsId]?.apps?.find { a -> a.id == record.appId }?.groups?.find { g -> g.key == record.groupKey }?.name + } else { + subsMap[record.subsId]?.globalGroups?.find { g -> g.key == record.groupKey }?.name + } + val appName = appMap[record.appId]?.name + val appShowName = appName ?: record.appId + if (groupName != null) { + if (groupName.startsWith(appShowName)) { + groupName + } else { + if (isAppRule) { + "$appShowName/$groupName" + } else { + "$groupName/$appShowName" + } + } + } else { + appShowName + } + }.stateIn(appScope, SharingStarted.Eagerly, null) +} + val subsEntriesFlow by lazy { combine( subsItemsFlow, From b01a51cfe5717f435b5a6bee4ddaab56809f2a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 23 Sep 2025 20:18:33 +0800 Subject: [PATCH 055/245] perf: check hasOtherA11y --- app/src/main/kotlin/li/songe/gkd/App.kt | 13 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 11 +- .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 14 +- .../li/songe/gkd/service/A11yService.kt | 5 +- .../li/songe/gkd/service/BaseTileService.kt | 2 +- .../li/songe/gkd/service/GkdTileService.kt | 16 +- .../li/songe/gkd/service/HttpService.kt | 2 +- .../songe/gkd/service/OverlayWindowService.kt | 2 + .../li/songe/gkd/service/ScreenshotService.kt | 4 + .../li/songe/gkd/service/StatusService.kt | 2 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 9 +- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 30 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 272 +++++++++--------- .../kotlin/li/songe/gkd/ui/component/Hooks.kt | 3 +- .../li/songe/gkd/ui/component/PerfIcon.kt | 2 + .../songe/gkd/ui/component/PerfTopAppBar.kt | 15 +- .../li/songe/gkd/ui/home/SettingsPage.kt | 37 ++- .../kotlin/li/songe/gkd/ui/style/Padding.kt | 4 +- .../li/songe/gkd/util/LifecycleCallbacks.kt | 1 + 19 files changed, 250 insertions(+), 194 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 12f52561a7..c887d7f914 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -5,6 +5,7 @@ import android.app.AppOpsManager import android.app.Application import android.app.KeyguardManager import android.content.ClipboardManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo @@ -96,16 +97,18 @@ class App : Application() { return Settings.Secure.putInt(contentResolver, name, value) } - fun getSecureA11yServices(): MutableSet { - return (getSecureString(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) ?: "").split( + fun getSecureA11yServices(): MutableSet { + val value = getSecureString(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + if (value.isNullOrEmpty()) return mutableSetOf() + return value.split( ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR - ).toHashSet() + ).mapNotNull { ComponentName.unflattenFromString(it) }.toHashSet() } - fun putSecureA11yServices(services: Set) { + fun putSecureA11yServices(services: Set) { putSecureString( Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, - services.joinToString(ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR.toString()) + services.joinToString(ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR.toString()) { it.flattenToShortString() } ) } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 570d8c3d10..384899e17d 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -17,6 +17,7 @@ import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestin import com.ramcosta.composedestinations.spec.Direction import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -26,12 +27,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import li.songe.gkd.a11y.useA11yServiceEnabledFlow +import li.songe.gkd.a11y.useEnabledA11yServicesFlow import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.permission.AuthReason import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.service.A11yService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.createTextFlow @@ -74,6 +77,9 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { addCloseable { _instance = null } } + override val scope: CoroutineScope + get() = viewModelScope + private lateinit var navController: NavHostController fun updateNavController(navController: NavHostController) { this.navController = navController @@ -309,7 +315,10 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { stopCoroutine() } - val a11yServiceEnabledFlow = useA11yServiceEnabledFlow() + private val a11yServicesFlow = useEnabledA11yServicesFlow() + val a11yServiceEnabledFlow = useA11yServiceEnabledFlow(a11yServicesFlow) + val hasOtherA11yFlow = + a11yServicesFlow.mapNew { it.isNotEmpty() && !it.contains(A11yService.a11yCn) } init { // preload diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index 8cccf0b8e4..a552e706e7 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -16,16 +16,17 @@ import li.songe.gkd.app import li.songe.gkd.service.A11yService import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.OnSimpleLife +import li.songe.gkd.util.mapState import li.songe.selector.initDefaultTypeInfo import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine context(context: OnSimpleLife) -fun useA11yServiceEnabledFlow(): StateFlow { - val stateFlow = MutableStateFlow(getA11yServiceEnabled()) +fun useEnabledA11yServicesFlow(): StateFlow> { + val stateFlow = MutableStateFlow(app.getSecureA11yServices()) val contextObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean) { - stateFlow.value = getA11yServiceEnabled() + stateFlow.value = app.getSecureA11yServices() } } app.contentResolver.registerContentObserver( @@ -39,8 +40,11 @@ fun useA11yServiceEnabledFlow(): StateFlow { return stateFlow } -private fun getA11yServiceEnabled(): Boolean = app.getSecureA11yServices().any { - ComponentName.unflattenFromString(it) == A11yService.a11yComponentName +context(context: OnSimpleLife) +fun useA11yServiceEnabledFlow(servicesFlow: StateFlow> = useEnabledA11yServicesFlow()): StateFlow { + return servicesFlow.mapState(context.scope) { + it.contains(A11yService.a11yCn) + } } const val STATE_CHANGED = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index 6b8b685e32..aa4fac496d 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -57,7 +57,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { val safeActiveWindowAppId: String? get() = safeActiveWindow?.packageName?.toString() - val scope = useScope() + override val scope = useScope() val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } var isInteractive = true private set @@ -109,8 +109,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { } companion object { - val a11yComponentName by lazy { SelectToSpeakService::class.componentName } - val a11yClsName by lazy { a11yComponentName.flattenToShortString() } + val a11yCn by lazy { SelectToSpeakService::class.componentName } val isRunning = MutableStateFlow(false) private var a11yRef: A11yService? = null diff --git a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt index 2a624f896b..40043fe6ee 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt @@ -17,7 +17,7 @@ abstract class BaseTileService : TileService(), OnTileLife { abstract val activeFlow: StateFlow - val scope = useScope() + override val scope = useScope() val listeningFlow = MutableStateFlow(false).apply { onStartListened { value = true } onStopListened { value = false } diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 67f51b205e..903262a9dc 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -58,12 +58,12 @@ fun switchA11yService() = modifyA11yRun { } val names = app.getSecureA11yServices() app.putSecureInt(Settings.Secure.ACCESSIBILITY_ENABLED, 1) - if (names.contains(A11yService.a11yClsName)) { // 当前无障碍异常, 重启服务 - names.remove(A11yService.a11yClsName) + if (names.contains(A11yService.a11yCn)) { // 当前无障碍异常, 重启服务 + names.remove(A11yService.a11yCn) app.putSecureA11yServices(names) delay(A11Y_AWAIT_FIX_TIME) } - names.add(A11yService.a11yClsName) + names.add(A11yService.a11yCn) app.putSecureA11yServices(names) delay(A11Y_AWAIT_START_TIME) // https://github.com/orgs/gkd-kit/discussions/799 @@ -89,15 +89,15 @@ fun fixRestartService() = modifyA11yRun { } } val names = app.getSecureA11yServices() - val a11yBroken = names.contains(A11yService.a11yClsName) + val a11yBroken = names.contains(A11yService.a11yCn) if (a11yBroken) { // 无障碍出现故障, 重启服务 - names.remove(A11yService.a11yClsName) + names.remove(A11yService.a11yCn) app.putSecureA11yServices(names) // 必须等待一段时间, 否则概率不会触发系统重启无障碍 delay(A11Y_AWAIT_FIX_TIME) } - names.add(A11yService.a11yClsName) + names.add(A11yService.a11yCn) app.putSecureA11yServices(names) delay(A11Y_AWAIT_START_TIME) if (!A11yService.isRunning.value) { @@ -118,7 +118,7 @@ private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { return@modifyA11yRun } val names = app.getSecureA11yServices() - val hasA11y = names.contains(A11yService.a11yClsName) + val hasA11y = names.contains(A11yService.a11yCn) if (disabled == !hasA11y) { return@modifyA11yRun } @@ -128,7 +128,7 @@ private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { disableSelf() } } else { - names.add(A11yService.a11yClsName) + names.add(A11yService.a11yCn) app.putSecureA11yServices(names) } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index 6aaff2c4b5..6b0b7ebe20 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -77,7 +77,7 @@ class HttpService : Service(), OnSimpleLife { onDestroyed() } - val scope = useScope() + override val scope = useScope() val httpServerPortFlow = storeFlow.mapState(scope) { s -> s.httpServerPort } diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index cc2e72dacf..b556a75b1f 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -94,6 +94,8 @@ abstract class OverlayWindowService( onCreated() } + override val scope get() = lifecycleScope + private val resizeFlow = MutableSharedFlow() override fun onConfigurationChanged(newConfig: Configuration) { diff --git a/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt index 3168744594..ef263bdbad 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt @@ -4,6 +4,7 @@ import android.app.Service import android.content.Intent import coil3.Bitmap import com.blankj.utilcode.util.LogUtils +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withTimeoutOrNull import li.songe.gkd.app @@ -16,6 +17,9 @@ import li.songe.gkd.util.stopServiceByClass import java.lang.ref.WeakReference class ScreenshotService : Service(), OnSimpleLife { + override val scope: CoroutineScope + get() = throw NotImplementedError() + override fun onBind(intent: Intent?) = null override fun onCreate() = onCreated() override fun onDestroy() = onDestroyed() diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index b49748463b..5920c48c89 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -31,7 +31,7 @@ class StatusService : Service(), OnSimpleLife { override fun onCreate() = onCreated() override fun onDestroy() = onDestroyed() - val scope = useScope() + override val scope = useScope() val shizukuWarnFlow = combine( shizukuOkState.stateFlow, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index d937683a7e..d2944b0a61 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -78,6 +77,7 @@ import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.AndroidTarget @@ -221,16 +221,13 @@ fun AdvancedPage() { style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) - val lineHeightDp = LocalDensity.current.run { - MaterialTheme.typography.titleSmall.lineHeight.toDp() - } PerfIcon( modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { showShizukuState = true }) - .size(lineHeightDp), + .iconTextSize(textStyle = MaterialTheme.typography.titleSmall), imageVector = PerfIcon.Api, tint = MaterialTheme.colorScheme.primary, ) @@ -349,7 +346,7 @@ fun AdvancedPage() { TextSwitch( title = "清除订阅", - subtitle = "服务关闭时,删除内存订阅", + subtitle = "关闭服务时删除内存订阅", checked = store.autoClearMemorySubs ) { storeFlow.value = store.copy( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index e569cc8bb8..e24a0f2104 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -60,6 +59,7 @@ import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions @@ -86,8 +86,8 @@ fun BlockA11yAppListPage() { val appInfos by vm.appInfosFlow.collectAsState() val searchStr by vm.searchStrFlow.collectAsState() val showSearchBar by vm.showSearchBarFlow.collectAsState() - val (scrollBehavior, listState) = useListScrollState(vm.resetKey) - val editable by vm.editableFlow.collectAsState() + var editable by vm.editableFlow.asMutableState() + val (scrollBehavior, listState) = useListScrollState(vm.resetKey, canScroll = { !editable }) BackHandler(editable, vm.viewModelScope.launchAsFn { context.justHideSoftInput() if (vm.textChanged) { @@ -96,26 +96,18 @@ fun BlockA11yAppListPage() { text = "当前内容未保存,是否放弃编辑?", ) } - vm.editableFlow.value = false + editable = false }) Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar( - scrollBehavior = if (editable) { - remember(scrollBehavior) { - object : TopAppBarScrollBehavior by scrollBehavior { - override val isPinned: Boolean - get() = true - } - } - } else { - scrollBehavior - }, + scrollBehavior = scrollBehavior, + canScroll = !editable, navigationIcon = { IconButton( onClick = throttle(vm.viewModelScope.launchAsFn { - if (vm.editableFlow.value) { + if (editable) { if (vm.textChanged) { context.justHideSoftInput() mainVm.dialogFlow.waitResult( @@ -123,7 +115,7 @@ fun BlockA11yAppListPage() { text = "当前内容未保存,是否放弃编辑?", ) } - vm.editableFlow.update { !it } + editable = !editable } else { context.hideSoftInput() mainVm.popBackStack() @@ -178,7 +170,7 @@ fun BlockA11yAppListPage() { toast("未修改") } context.justHideSoftInput() - vm.editableFlow.value = false + editable = false }, ) }, @@ -187,14 +179,14 @@ fun BlockA11yAppListPage() { PerfIconButton( imageVector = PerfIcon.Edit, onClick = vm.viewModelScope.launchAsFn { - if (vm.editableFlow.value && vm.textChanged) { + if (editable && vm.textChanged) { context.justHideSoftInput() mainVm.dialogFlow.waitResult( title = "提示", text = "当前内容未保存,是否放弃编辑?", ) } - vm.editableFlow.update { !it } + editable = !editable }) IconButton(onClick = throttle { if (showSearchBar) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index 80c6da3e39..432ebb2fb4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -108,6 +108,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { }) val (scrollBehavior, listState) = useListScrollState( vm.resetKey, + canScroll = { !editable } ) BackHandler(editable, onBack = throttle(vm.viewModelScope.launchAsFn { @@ -122,155 +123,160 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { })) Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { - if (vm.editableFlow.value) { - editable = false - context.justHideSoftInput() - } else { - context.hideSoftInput() - mainVm.popBackStack() - } - })) { - BackCloseIcon(backOrClose = !editable) - } - }, title = { - if (showSearchBar) { - BackHandler { - if (!context.justHideSoftInput()) { - showSearchBar = false + PerfTopAppBar( + scrollBehavior = scrollBehavior, + canScroll = !editable, + navigationIcon = { + IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { + if (vm.editableFlow.value) { + editable = false + context.justHideSoftInput() + } else { + context.hideSoftInput() + mainVm.popBackStack() } + })) { + BackCloseIcon(backOrClose = !editable) } - AppBarTextField( - value = searchStr, - onValueChange = { newValue -> - searchStr = newValue.trim() - }, - hint = "请输入应用名称/ID", - modifier = Modifier.autoFocus(), - ) - } else { - TowLineText( - title = group.name, - subtitle = "编辑禁用", - modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ } - ) - } - }, actions = { - AnimatedBooleanContent( - targetState = editable, - contentAlignment = Alignment.TopEnd, - contentTrue = { - PerfIconButton( - imageVector = PerfIcon.Save, - onClick = throttle(vm.viewModelScope.launchAsFn { - val newExclude = vm.changedValue - if (newExclude != null) { - val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( - type = SubsConfig.GlobalGroupType, - subsId = subsItemId, - groupKey = groupKey, - )).copy( - exclude = newExclude.stringify() - ) - DbSet.subsConfigDao.insert(subsConfig) - toast("更新成功") - } else { - toast("未修改") - } - context.justHideSoftInput() - editable = false - }), + }, + title = { + if (showSearchBar) { + BackHandler { + if (!context.justHideSoftInput()) { + showSearchBar = false + } + } + AppBarTextField( + value = searchStr, + onValueChange = { newValue -> + searchStr = newValue.trim() + }, + hint = "请输入应用名称/ID", + modifier = Modifier.autoFocus(), ) - }, - contentFalse = { - Row { + } else { + TowLineText( + title = group.name, + subtitle = "编辑禁用", + modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ } + ) + } + }, + actions = { + AnimatedBooleanContent( + targetState = editable, + contentAlignment = Alignment.TopEnd, + contentTrue = { PerfIconButton( - imageVector = PerfIcon.Edit, - onClick = { - editable = true - showSearchBar = false - }, + imageVector = PerfIcon.Save, + onClick = throttle(vm.viewModelScope.launchAsFn { + val newExclude = vm.changedValue + if (newExclude != null) { + val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( + type = SubsConfig.GlobalGroupType, + subsId = subsItemId, + groupKey = groupKey, + )).copy( + exclude = newExclude.stringify() + ) + DbSet.subsConfigDao.insert(subsConfig) + toast("更新成功") + } else { + toast("未修改") + } + context.justHideSoftInput() + editable = false + }), ) - IconButton(onClick = { - if (showSearchBar) { - if (searchStr.isEmpty()) { + }, + contentFalse = { + Row { + PerfIconButton( + imageVector = PerfIcon.Edit, + onClick = { + editable = true showSearchBar = false + }, + ) + IconButton(onClick = { + if (showSearchBar) { + if (searchStr.isEmpty()) { + showSearchBar = false + } else { + searchStr = "" + } } else { - searchStr = "" + showSearchBar = true } - } else { - showSearchBar = true + }) { + AnimatedIcon( + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, + var expanded by remember { mutableStateOf(false) } + PerfIconButton( + imageVector = PerfIcon.Sort, + onClick = { + expanded = true + }, ) - } - var expanded by remember { mutableStateOf(false) } - PerfIconButton( - imageVector = PerfIcon.Sort, - onClick = { - expanded = true - }, - ) - Box( - modifier = Modifier - .wrapContentSize(Alignment.TopStart) - ) { - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) ) { - Text( - text = "排序", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - AppSortOption.objects.forEach { sortOption -> - DropdownMenuRadioButtonItem( - text = sortOption.label, - selected = sortType == sortOption, - onClick = { - vm.sortTypeFlow.value = sortOption + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Text( + text = "排序", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + AppSortOption.objects.forEach { sortOption -> + DropdownMenuRadioButtonItem( + text = sortOption.label, + selected = sortType == sortOption, + onClick = { + vm.sortTypeFlow.value = sortOption + } + ) + } + Text( + text = "筛选", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + DropdownMenuCheckboxItem( + text = "显示系统应用", + checked = showSystemApp, + onCheckedChange = { + vm.showSystemAppFlow.value = it + } + ) + DropdownMenuCheckboxItem( + text = "显示内置禁用", + checked = showInnerDisabledApp, + onCheckedChange = { + vm.showInnerDisabledAppFlow.value = it + } + ) + DropdownMenuCheckboxItem( + text = "显示白名单", + checked = showBlockApp, + onCheckedChange = { + vm.showBlockAppFlow.value = it } ) } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - DropdownMenuCheckboxItem( - text = "显示系统应用", - checked = showSystemApp, - onCheckedChange = { - vm.showSystemAppFlow.value = it - } - ) - DropdownMenuCheckboxItem( - text = "显示内置禁用", - checked = showInnerDisabledApp, - onCheckedChange = { - vm.showInnerDisabledAppFlow.value = it - } - ) - DropdownMenuCheckboxItem( - text = "显示白名单", - checked = showBlockApp, - onCheckedChange = { - vm.showBlockAppFlow.value = it - } - ) } } - } - }, - ) - }) + }, + ) + }) }) { contentPadding -> if (editable) { MultiTextField( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt index 12a299b099..49e35e10c1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt @@ -76,13 +76,14 @@ fun useListScrollState( v1: Any?, v2: Any? = null, v3: Any? = null, + canScroll: () -> Boolean = { true }, ): Pair { return key( getCompatStateValue(v1), getCompatStateValue(v2), getCompatStateValue(v3) ) { - TopAppBarDefaults.enterAlwaysScrollBehavior() to rememberLazyListState() + TopAppBarDefaults.enterAlwaysScrollBehavior(canScroll = canScroll) to rememberLazyListState() } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index 8c5027657d..9315614c2d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.outlined.LightMode import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.SentimentDissatisfied import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.ToggleOff import androidx.compose.material.icons.outlined.ToggleOn @@ -156,5 +157,6 @@ object PerfIcon { val Notifications get() = Icons.Outlined.Notifications val Layers get() = Icons.Outlined.Layers val Equalizer get() = Icons.Outlined.Equalizer + val SentimentDissatisfied get() = Icons.Outlined.SentimentDissatisfied } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt index 2c0bac9e26..60cde2ed69 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.key +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import li.songe.gkd.MainActivity @@ -22,7 +23,19 @@ fun PerfTopAppBar( expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), scrollBehavior: TopAppBarScrollBehavior? = null, + canScroll: Boolean = true, ) { + val actualScrollBehavior = if (canScroll || scrollBehavior == null) { + scrollBehavior + } else { + remember(scrollBehavior) { + object : TopAppBarScrollBehavior by scrollBehavior { + // disable inner scroll effect + override val isPinned: Boolean + get() = true + } + } + } // SingleRowTopAppBar 内部 containerColor+scrolledContainerColor 合成了一个动画 // 应用主题颜色更新时形成叠加动画,导致和周围正常组件视觉变换效果表现割裂 key(MaterialTheme.colorScheme.surface) { @@ -34,7 +47,7 @@ fun PerfTopAppBar( expandedHeight = expandedHeight, windowInsets = (LocalActivity.current as MainActivity).topBarWindowInsets, colors = colors, - scrollBehavior = scrollBehavior, + scrollBehavior = actualScrollBehavior, ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index ea82995316..756e1d2a16 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -57,6 +58,7 @@ import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.DarkThemeOption @@ -364,12 +366,33 @@ fun useSettingsPage(): ScaffoldExt { if (store.enableShizuku && writeSecureSettingsState.stateFlow.collectAsState().value || META.debuggable) { AnimatedVisibility(visible = store.enableBlockA11yAppList) { - Text( - text = "无障碍", - modifier = Modifier.titleItemPadding(), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .titleItemPadding(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "无障碍", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + if (mainVm.hasOtherA11yFlow.collectAsState().value) { + PerfIcon( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + mainVm.dialogFlow.updateDialogOptions( + title = "无效优化", + text = "检测到已启用其它应用的无障碍服务,在此情况下,局部关闭是无效优化,因为无障碍不会被完全关闭,建议关闭「局部关闭」功能或其它应用的无障碍服务", + ) + }) + .iconTextSize(textStyle = MaterialTheme.typography.titleSmall), + imageVector = PerfIcon.SentimentDissatisfied, + tint = MaterialTheme.colorScheme.primary, + ) + } + } } TextSwitch( title = "局部关闭", @@ -379,7 +402,7 @@ fun useSettingsPage(): ScaffoldExt { if (it) { mainVm.dialogFlow.waitResult( title = "使用说明", - text = "「局部关闭」可解决某些应用无障碍检测或界面异常的问题\n\n切换无障碍会造成触摸卡顿,请自行考虑后再编辑无障碍白名单\n\n如果还使用其它无障碍软件会导致优化无效,因为无障碍没有被完全关闭\n\n此外需额外设置确保无障碍关闭后的持续后台运行\n1. 开启「常驻通知」\n2. 在「最近任务界面」锁定\n3. 允许自启动\n4. 省电策略设置为无限制\n不设置会被系统暂停或结束运行,导致无法恢复无障碍", + text = "「局部关闭」可解决某些应用无障碍检测或界面异常的问题\n\n切换无障碍会造成触摸卡顿,请自行考虑后再编辑无障碍白名单\n\n如果还使用其它无障碍应用会导致优化无效,因为无障碍不会被完全关闭\n\n此外需额外设置确保无障碍关闭后的持续后台运行\n1. 开启「常驻通知」\n2. 在「最近任务界面」锁定\n3. 允许自启动\n4. 省电策略设置为无限制\n不设置会被系统暂停或结束运行,导致无法恢复无障碍", confirmText = "继续", dismissRequest = true, ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt index ad23db639b..89e093f14c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.MenuDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp val itemHorizontalPadding = 16.dp @@ -38,9 +39,8 @@ fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { } @Composable -fun Modifier.iconTextSize(): Modifier { +fun Modifier.iconTextSize(textStyle: TextStyle = LocalTextStyle.current): Modifier { val density = LocalDensity.current - val textStyle = LocalTextStyle.current val lineHeightDp = density.run { textStyle.lineHeight.toDp() } val fontSizeDp = density.run { textStyle.fontSize.toDp() } return padding((lineHeightDp - fontSizeDp) / 2).size(fontSizeDp) diff --git a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt index 7a90aca891..4c0674456e 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -38,6 +38,7 @@ interface OnSimpleLife { } } + val scope: CoroutineScope fun useScope(): CoroutineScope = MainScope().apply { onDestroyed { cancel() } } fun useAliveFlow(stateFlow: MutableStateFlow) { From af4d7e5fd51a036ffddfcb6615d98b795c58193e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 23 Sep 2025 20:27:41 +0800 Subject: [PATCH 056/245] perf: record activity log --- app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index 18c0b47430..d6a92102fb 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -22,6 +22,7 @@ import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus import li.songe.gkd.data.isSystem import li.songe.gkd.db.DbSet +import li.songe.gkd.service.RecordService import li.songe.gkd.shizuku.safeInvokeMethod import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.blockA11yAppListFlow @@ -172,7 +173,7 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { ) lastValidActivity = oldActivity lastActivityUpdateTime = t - if (storeFlow.value.enableActivityLog) { + if (storeFlow.value.enableActivityLog || RecordService.isRunning.value) { appScope.launchTry(Dispatchers.IO) { activityLogMutex.withLock { DbSet.activityLogDao.insert( From c70717722805e62efb30318d20d86f33edd81f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 23 Sep 2025 23:06:48 +0800 Subject: [PATCH 057/245] perf: show page block icon --- .../kotlin/li/songe/gkd/data/SubsConfig.kt | 5 +- .../kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 4 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 128 +++++++++--------- .../li/songe/gkd/ui/component/FlagCard.kt | 33 ----- .../songe/gkd/ui/component/RuleGroupCard.kt | 65 ++++++--- .../songe/gkd/ui/component/RuleGroupDialog.kt | 2 +- .../songe/gkd/ui/component/RuleGroupState.kt | 10 +- .../li/songe/gkd/ui/component/SubsAppCard.kt | 8 +- .../main/kotlin/li/songe/gkd/util/Others.kt | 22 +++ 9 files changed, 151 insertions(+), 126 deletions(-) delete mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt diff --git a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt index 43cfce9f69..4c46d9983e 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt @@ -12,6 +12,7 @@ import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable +import li.songe.gkd.util.isValidActivityId import li.songe.gkd.util.isValidAppId @@ -219,7 +220,9 @@ data class ExcludeData( if (appId.isValidAppId()) { val activityId = a.getOrNull(1) if (activityId != null) { - activityIds.add(appId to activityId) + if (activityId.isValidActivityId()) { + activityIds.add(appId to activityId) + } } else { appIds[appId] = true } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index 5b3e2503d1..a7a93e339a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -232,7 +232,7 @@ fun SubsAppListPage( context.justHideSoftInput() mainVm.navigatePage(SubsAppGroupListPageDestination(subsItemId, a.id)) }, - onValueChange = throttle(fn = vm.viewModelScope.launchAsFn { enable -> + onValueChange = vm.viewModelScope.launchAsFn { enable -> val newItem = a.appConfig?.copy( enable = enable ) ?: AppConfig( @@ -241,7 +241,7 @@ fun SubsAppListPage( appId = a.id, ) DbSet.appConfigDao.insert(newItem) - }), + }, ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index 432ebb2fb4..bef9fbb63b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -51,7 +51,6 @@ import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.DropdownMenuCheckboxItem import li.songe.gkd.ui.component.DropdownMenuRadioButtonItem import li.songe.gkd.ui.component.EmptyText -import li.songe.gkd.ui.component.FlagCard import li.songe.gkd.ui.component.InnerDisableSwitch import li.songe.gkd.ui.component.MultiTextField import li.songe.gkd.ui.component.PerfIcon @@ -64,6 +63,7 @@ import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon +import li.songe.gkd.ui.icon.ResetSettings import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState @@ -291,72 +291,74 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { state = listState, ) { items(showAppInfos, { it.id }) { appInfo -> - FlagCard( - visible = excludeData.appIds.contains(appInfo.id), + + Row( modifier = Modifier - .itemPadding() - .fillMaxWidth(), + .fillMaxWidth() + .itemPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + AppIcon(appId = appInfo.id) + Column( + modifier = Modifier.weight(1f), ) { - AppIcon(appId = appInfo.id) - Column( - modifier = Modifier.weight(1f), - ) { - AppNameText(appInfo = appInfo) - Text( - text = appInfo.id, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - val blockMatch = - blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) - if (blockMatch) { - PerfIcon( - modifier = Modifier - .padding(2.dp) - .size(20.dp), - imageVector = PerfIcon.Block, - tint = MaterialTheme.colorScheme.secondary, - ) - } - val checked = getGlobalGroupChecked( - subs, - excludeData, - group, - appInfo.id + AppNameText(appInfo = appInfo) + Text( + text = appInfo.id, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (checked != null) { - PerfSwitch( - key = appInfo.id, - checked = checked, - onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newChecked -> - val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( - type = SubsConfig.GlobalGroupType, - subsId = subsItemId, - groupKey = groupKey, - )).copy( - exclude = excludeData.copy( - appIds = excludeData.appIds.toMutableMap() - .apply { - set(appInfo.id, !newChecked) - }) - .stringify() - ) - DbSet.subsConfigDao.insert(subsConfig) - }), - ) - } else { - InnerDisableSwitch() - } + } + val blockMatch = + blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) + if (blockMatch) { + PerfIcon( + modifier = Modifier + .padding(2.dp) + .size(20.dp), + imageVector = PerfIcon.Block, + tint = MaterialTheme.colorScheme.secondary, + ) + } + val checked = getGlobalGroupChecked( + subs, + excludeData, + group, + appInfo.id + ) + if (checked != null) { + PerfSwitch( + key = appInfo.id, + checked = checked, + onCheckedChange = vm.viewModelScope.launchAsFn { newChecked -> + val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( + type = SubsConfig.GlobalGroupType, + subsId = subsItemId, + groupKey = groupKey, + )).copy( + exclude = excludeData.copy( + appIds = excludeData.appIds.toMutableMap() + .apply { + set(appInfo.id, !newChecked) + }) + .stringify() + ) + DbSet.subsConfigDao.insert(subsConfig) + }, + thumbContent = if (excludeData.appIds.contains(appInfo.id)) ({ + PerfIcon( + imageVector = ResetSettings, + modifier = Modifier.size(8.dp) + ) + }) else null, + ) + } else { + InnerDisableSwitch() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt deleted file mode 100644 index 4b1780275c..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/FlagCard.kt +++ /dev/null @@ -1,33 +0,0 @@ -package li.songe.gkd.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp - -@Composable -fun FlagCard( - visible: Boolean, - modifier: Modifier = Modifier, - content: @Composable (() -> Unit), -) = Box( - modifier = modifier, -) { - content() - if (visible) { - Spacer( - modifier = Modifier - .align(Alignment.TopEnd) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.tertiary) - .size(4.dp) - ) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt index 3343a4554c..7b5642216f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt @@ -4,13 +4,16 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize @@ -90,19 +93,23 @@ fun RuleGroupCard( } } } - - val (checked, excludeData) = if (inGlobalAppPage) { - val excludeData = remember(subsConfig?.exclude) { - ExcludeData.parse(subsConfig?.exclude) - } - getGlobalGroupChecked(subs, excludeData, group, appId) to excludeData + val excludeData = remember(subsConfig?.exclude) { + ExcludeData.parse(subsConfig?.exclude) + } + val checked = if (inGlobalAppPage) { + getGlobalGroupChecked( + subs, + excludeData, + group, + appId, + ) } else { getGroupEnable( group, subsConfig, category, - categoryConfig - ) to null + categoryConfig, + ) } val onCheckedChange = appScope.launchAsFn { newChecked -> val newConfig = if (appId != null) { @@ -173,19 +180,21 @@ fun RuleGroupCard( containerColor = containerColor.value ), ) { - val visible = if (inGlobalAppPage) { - excludeData != null && excludeData.appIds.contains(appId) + val canRest = if (inGlobalAppPage) { + excludeData.appIds.contains(appId) } else { subsConfig?.enable != null } - FlagCard( - visible = visible, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { + val hasExcludeActivity = if (inGlobalAppPage) { + checked != null && excludeData.activityIds.any { it.first == appId } + } else { + excludeData.activityIds.isNotEmpty() + } + Box { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -241,7 +250,13 @@ fun RuleGroupCard( key = Objects.hash(subs.id, appId, group.key), modifier = switchModifier.minimumInteractiveComponentSize(), checked = checked, - onCheckedChange = if (isSelectedMode) null else throttle(onCheckedChange) + onCheckedChange = if (isSelectedMode) null else onCheckedChange, + thumbContent = if (canRest) ({ + PerfIcon( + imageVector = ResetSettings, + modifier = Modifier.size(8.dp) + ) + }) else null, ) } else { InnerDisableSwitch( @@ -250,6 +265,20 @@ fun RuleGroupCard( ) } } + if (hasExcludeActivity) { + PerfIcon( + imageVector = PerfIcon.Block, + tint = if (isSelectedMode) { + LocalContentColor.current.copy(alpha = 0.5f) + } else { + LocalContentColor.current + }, + modifier = Modifier + .padding(top = 4.dp, end = 4.dp) + .align(Alignment.TopEnd) + .size(8.dp) + ) + } } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt index 58521aee35..202f1225ab 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt @@ -161,7 +161,7 @@ fun RuleGroupDialog( PerfIconButton(imageVector = PerfIcon.Edit, onClick = throttle(onClickEdit)) } PerfIconButton( - imageVector = PerfIcon.AppRegistration, + imageVector = PerfIcon.Block, onClick = throttle(onClickEditExclude), ) AnimatedVisibility( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt index dd2f7fa2ff..72c3bc9f7c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt @@ -2,7 +2,6 @@ package li.songe.gkd.ui.component import androidx.activity.compose.BackHandler import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember @@ -373,7 +372,9 @@ class RuleGroupState( val excludeGroupState = editExcludeGroupFlow.collectAsState().value val excludeSubs = useSubs(excludeGroupState?.subsId) - if (excludeGroupState?.groupKey != null && excludeGroupState.appId != null && excludeSubs != null) { + val excludeGroup = + useSubsGroup(excludeSubs, excludeGroupState?.groupKey, excludeGroupState?.appId) + if (excludeGroupState?.groupKey != null && excludeGroupState.appId != null && excludeSubs != null && excludeGroup is RawSubscription.RawAppGroup) { FullscreenDialog(onDismissRequest = dismissExcludeGroupShow) { val keyboardController = LocalSoftwareKeyboardController.current val onBack = mainVm.viewModelScope.launchAsFn { @@ -398,7 +399,10 @@ class RuleGroupState( ) }, title = { - Text(text = "编辑禁用") + TowLineText( + title = excludeGroup.name, + subtitle = "编辑禁用", + ) }, actions = { PerfIconButton(imageVector = PerfIcon.Save, onClick = throttle { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt index 21b32316c9..45f42e5e69 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt @@ -24,15 +24,13 @@ import li.songe.gkd.ui.style.appItemPadding fun SubsAppCard( data: SubsAppInfoItem, enableSize: Int = data.rawApp.groups.count { g -> g.enable ?: true }, - onClick: (() -> Unit)? = null, - onValueChange: ((Boolean) -> Unit)? = null, + onClick: (() -> Unit), + onValueChange: ((Boolean) -> Unit), ) { val rawApp = data.rawApp Row( modifier = Modifier - .clickable { - onClick?.invoke() - } + .clickable(onClick = onClick) .appItemPadding(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/kotlin/li/songe/gkd/util/Others.kt b/app/src/main/kotlin/li/songe/gkd/util/Others.kt index a8113b54ee..e3634c4cf0 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -164,6 +164,10 @@ private fun Char.isAsciiVar(): Boolean { return this.isAsciiLetter() || this in '0'..'9' || this == '_' } +private fun Char.isAsciiClassVar(): Boolean { + return this.isAsciiVar() || this == '$' +} + // https://developer.android.com/build/configure-app-module?hl=zh-cn fun String.isValidAppId(): Boolean { if (!contains('.')) return false @@ -184,6 +188,24 @@ fun String.isValidAppId(): Boolean { return true } +fun String.isValidActivityId(): Boolean { + if (isEmpty()) return false + var i = 0 + while (i < length) { + val c = get(i) + if (c == '.') { + i++ + if (getOrNull(i)?.isAsciiClassVar() == false) { + return false + } + } else if (!c.isAsciiClassVar()) { + return false + } + i++ + } + return true +} + object AppListString { fun decode(text: String): Set { return text.split('\n').filter { a -> a.isValidAppId() }.toHashSet() From 0943164150942329631e32ba64e42297b9bf6e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 23 Sep 2025 23:40:06 +0800 Subject: [PATCH 058/245] perf: item padding --- .../kotlin/li/songe/gkd/ui/ActionLogPage.kt | 45 +++++++++++++++++-- .../kotlin/li/songe/gkd/ui/AppConfigPage.kt | 22 ++++----- .../li/songe/gkd/ui/SubsAppGroupListPage.kt | 3 ++ .../songe/gkd/ui/SubsGlobalGroupListPage.kt | 3 ++ .../songe/gkd/ui/component/RuleGroupCard.kt | 5 +-- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index 6691f86419..691ecff1e6 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -10,8 +11,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider @@ -28,7 +31,9 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow @@ -42,6 +47,7 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import kotlinx.coroutines.flow.SharingStarted @@ -70,6 +76,7 @@ import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.launchAsFn @@ -228,19 +235,51 @@ private fun ActionLogCard( modifier = modifier .fillMaxWidth() .padding( - start = itemHorizontalPadding, - end = itemHorizontalPadding, + start = itemHorizontalPadding / 2, + end = itemHorizontalPadding / 2, top = verticalPadding ) ) { if (isDiffApp && appId == null) { - AppNameText(appId = actionLog.appId) + Row( + modifier = Modifier + .padding(start = itemHorizontalPadding / 4) + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + mainVm.navigatePage( + AppConfigPageDestination( + appId = actionLog.appId, + ) + ) + }) + .fillMaxWidth() + .padding(start = 5.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + Spacer( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + .size(4.dp) + ) + AppNameText(appId = actionLog.appId, modifier = Modifier.weight(1f)) + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, + modifier = Modifier + .iconTextSize() + ) + } + } } Row( modifier = Modifier + .padding(start = itemHorizontalPadding / 4) .clickable(onClick = onClick) .fillMaxWidth() .height(IntrinsicSize.Min) + .padding(start = itemHorizontalPadding / 4) ) { if (appId == null) { Spacer(modifier = Modifier.width(2.dp)) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index eace3fe44c..fb89d8ba2c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -3,13 +3,13 @@ package li.songe.gkd.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -34,7 +34,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope @@ -67,6 +66,7 @@ import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.LOCAL_SUBS_ID @@ -289,6 +289,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { subsPairs.forEach { (entry, groups) -> val subsId = entry.subsItem.id @@ -296,7 +297,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { Row( modifier = Modifier .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 8.dp) .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { mainVm.navigatePage( @@ -307,7 +308,9 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { ) }) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.weight(1f), @@ -318,19 +321,10 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { softWrap = false, overflow = TextOverflow.Ellipsis, ) - val fontSizeDp = LocalDensity.current.run { - MaterialTheme.typography.titleSmall.fontSize.toDp() - } - val lineHeightDp = LocalDensity.current.run { - MaterialTheme.typography.titleSmall.lineHeight.toDp() - } PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(start = 4.dp) - .width(fontSizeDp) - .height(lineHeightDp) + modifier = Modifier.iconTextSize() ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index fbcba6df89..04f1760d0e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination @@ -266,6 +268,7 @@ fun SubsAppGroupListPage( LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(app.groups, { it.key }) { group -> val category = groupToCategoryMap[group] diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index dc27b4b1b2..09f7d7bd0e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination @@ -237,6 +239,7 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { LazyColumn( modifier = Modifier.scaffoldPadding(paddingValues), state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(globalGroups, { g -> g.key }) { group -> val subsConfig = subsConfigs.find { it.groupKey == group.key } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt index 7b5642216f..ecb4ad61ba 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt @@ -170,10 +170,7 @@ fun RuleGroupCard( ) Card( modifier = modifier - .padding( - vertical = 2.dp, - horizontal = 8.dp - ) + .padding(horizontal = 8.dp) .combinedClickable(onClick = onClick, onLongClick = onLongClick), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( From cc1cb0865f63cdbb12db54350f388f81b01537b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 23 Sep 2025 23:49:01 +0800 Subject: [PATCH 059/245] perf: ActivityLogCard --- .../kotlin/li/songe/gkd/ui/ActivityLogPage.kt | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt index 84a1107ec9..af8a45d5bf 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt @@ -3,6 +3,7 @@ package li.songe.gkd.ui import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -11,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -24,7 +27,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -35,6 +40,7 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination import li.songe.gkd.MainActivity import li.songe.gkd.data.ActivityLog import li.songe.gkd.db.DbSet @@ -53,6 +59,7 @@ import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.appInfoMapFlow @@ -118,7 +125,7 @@ fun ActivityLogPage() { CompositionLocalProvider( LocalNumberCharWidth provides timeTextWidth ) { - ActivityLogCard(i = i, actionLog = actionLog, lastActionLog = lastActionLog) + ActivityLogCard(i = i, activityLog = actionLog, lastActivityLog = lastActionLog) } } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { @@ -134,38 +141,68 @@ fun ActivityLogPage() { @Composable private fun ActivityLogCard( i: Int, - actionLog: ActivityLog, - lastActionLog: ActivityLog?, + activityLog: ActivityLog, + lastActivityLog: ActivityLog?, ) { val mainVm = LocalMainViewModel.current - val isDiffApp = actionLog.appId != lastActionLog?.appId + val isDiffApp = activityLog.appId != lastActivityLog?.appId val verticalPadding = if (i == 0) 0.dp else if (isDiffApp) 12.dp else 8.dp - val showActivityId = actionLog.showActivityId + val showActivityId = activityLog.showActivityId Column( modifier = Modifier .fillMaxWidth() .padding( - start = itemHorizontalPadding, - end = itemHorizontalPadding, + start = itemHorizontalPadding / 2, + end = itemHorizontalPadding / 2, top = verticalPadding ) ) { if (isDiffApp) { - AppNameText(appId = actionLog.appId) + Row( + modifier = Modifier + .padding(start = itemHorizontalPadding / 4) + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + mainVm.navigatePage( + AppConfigPageDestination( + appId = activityLog.appId, + ) + ) + }) + .fillMaxWidth() + .padding(start = 5.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + Spacer( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + .size(4.dp) + ) + AppNameText(appId = activityLog.appId, modifier = Modifier.weight(1f)) + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, + modifier = Modifier + .iconTextSize() + ) + } + } } Row( modifier = Modifier - .clickable( - onClick = { - mainVm.textFlow.value = listOfNotNull( - appInfoMapFlow.value[actionLog.appId]?.name, - actionLog.appId, - actionLog.showActivityId, - ).joinToString("\n") - }, - ) + .padding(start = itemHorizontalPadding / 4) + .clickable(onClick = { + mainVm.textFlow.value = listOfNotNull( + appInfoMapFlow.value[activityLog.appId]?.name, + activityLog.appId, + activityLog.showActivityId, + ).joinToString("\n") + }) .fillMaxWidth() .height(IntrinsicSize.Min) + .padding(start = itemHorizontalPadding / 4) ) { Spacer(modifier = Modifier.width(2.dp)) Spacer( @@ -179,7 +216,7 @@ private fun ActivityLogCard( modifier = Modifier.weight(1f) ) { FixedTimeText( - text = actionLog.date, + text = activityLog.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, ) From 88c3f57640c159521ee4d4a98fceb2588ea877b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 29 Sep 2025 22:03:57 +0800 Subject: [PATCH 060/245] refactor: event log, app list filter --- app/build.gradle.kts | 1 + app/schemas/li.songe.gkd.db.AppDb/14.json | 427 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 29 +- app/src/main/kotlin/li/songe/gkd/App.kt | 2 + .../main/kotlin/li/songe/gkd/MainActivity.kt | 4 +- .../kotlin/li/songe/gkd/a11y/A11yContext.kt | 1 - .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 17 +- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 81 +++- .../li/songe/gkd/a11y/A11yRuleEngine.kt | 4 +- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 15 +- .../kotlin/li/songe/gkd/data/A11yEventLog.kt | 106 +++++ .../kotlin/li/songe/gkd/data/ActionLog.kt | 6 +- .../kotlin/li/songe/gkd/data/ActivityLog.kt | 6 +- .../kotlin/li/songe/gkd/data/AppVisitLog.kt | 4 +- app/src/main/kotlin/li/songe/gkd/db/AppDb.kt | 50 +- app/src/main/kotlin/li/songe/gkd/db/DbSet.kt | 34 -- .../main/kotlin/li/songe/gkd/notif/Notif.kt | 12 +- .../songe/gkd/permission/PermissionDialog.kt | 9 +- .../songe/gkd/permission/PermissionState.kt | 15 +- .../li/songe/gkd/service/A11yService.kt | 2 +- .../{RecordService.kt => ActivityService.kt} | 22 +- .../songe/gkd/service/ActivityTileService.kt | 15 + .../li/songe/gkd/service/ButtonService.kt | 3 +- .../li/songe/gkd/service/EventService.kt | 229 ++++++++++ .../li/songe/gkd/service/EventTileService.kt | 15 + .../li/songe/gkd/service/GkdTileService.kt | 43 +- .../songe/gkd/service/OverlayWindowService.kt | 7 +- .../li/songe/gkd/service/RecordTileService.kt | 15 - .../li/songe/gkd/service/ScreenshotService.kt | 13 +- .../li/songe/gkd/shizuku/PackageManager.kt | 16 +- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 5 + .../li/songe/gkd/store/SettingsStore.kt | 7 +- .../kotlin/li/songe/gkd/store/StoreExt.kt | 17 + .../li/songe/gkd/ui/A11yEventLogPage.kt | 363 +++++++++++++++ .../kotlin/li/songe/gkd/ui/A11yEventLogVm.kt | 21 + .../kotlin/li/songe/gkd/ui/ActionLogPage.kt | 5 +- .../kotlin/li/songe/gkd/ui/ActivityLogPage.kt | 8 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 175 ++++++- .../main/kotlin/li/songe/gkd/ui/AdvancedVm.kt | 1 + .../kotlin/li/songe/gkd/ui/AppConfigPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 3 +- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 121 ++--- .../li/songe/gkd/ui/BlockA11yAppListVm.kt | 2 - .../kotlin/li/songe/gkd/ui/SnapshotPage.kt | 4 +- .../li/songe/gkd/ui/SubsAppGroupListPage.kt | 2 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 288 ++++++------ .../songe/gkd/ui/SubsGlobalGroupExcludeVm.kt | 7 - .../songe/gkd/ui/SubsGlobalGroupListPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/WebViewPage.kt | 15 +- .../li/songe/gkd/ui/component/Animation.kt | 2 +- .../li/songe/gkd/ui/component/AppNameText.kt | 17 +- .../songe/gkd/ui/component/FixedTimeText.kt | 8 +- .../kotlin/li/songe/gkd/ui/component/Hooks.kt | 24 + .../li/songe/gkd/ui/component/PerfIcon.kt | 11 +- .../li/songe/gkd/ui/home/AppListPage.kt | 18 +- .../li/songe/gkd/ui/home/ControlPage.kt | 8 +- .../kotlin/li/songe/gkd/ui/home/HomeVm.kt | 6 +- .../li/songe/gkd/ui/home/SettingsPage.kt | 15 +- .../kotlin/li/songe/gkd/ui/icon/DragPan.kt | 63 +++ .../li/songe/gkd/ui/icon/LockOpenRight.kt | 75 +++ .../kotlin/li/songe/gkd/ui/share/AppFilter.kt | 10 +- .../kotlin/li/songe/gkd/ui/style/Padding.kt | 11 +- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 34 +- .../kotlin/li/songe/gkd/util/Constants.kt | 1 + .../main/kotlin/li/songe/gkd/util/Others.kt | 5 + app/src/main/res/drawable/ic_event_list.xml | 9 + app/src/main/res/values/strings.xml | 3 +- gradle/libs.versions.toml | 12 +- 68 files changed, 2102 insertions(+), 481 deletions(-) create mode 100644 app/schemas/li.songe.gkd.db.AppDb/14.json create mode 100644 app/src/main/kotlin/li/songe/gkd/data/A11yEventLog.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/db/DbSet.kt rename app/src/main/kotlin/li/songe/gkd/service/{RecordService.kt => ActivityService.kt} (88%) create mode 100644 app/src/main/kotlin/li/songe/gkd/service/ActivityTileService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/EventService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/service/EventTileService.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogPage.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogVm.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/icon/DragPan.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/icon/LockOpenRight.kt create mode 100644 app/src/main/res/drawable/ic_event_list.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 71eece1dca..67e60772ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -170,6 +170,7 @@ kotlin { jvmTarget.set(rootProject.ext["kotlin.jvmTarget"] as JvmTarget) freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", + "-opt-in=kotlin.contracts.ExperimentalContracts", "-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", diff --git a/app/schemas/li.songe.gkd.db.AppDb/14.json b/app/schemas/li.songe.gkd.db.AppDb/14.json new file mode 100644 index 0000000000..7d86dfd7b7 --- /dev/null +++ b/app/schemas/li.songe.gkd.db.AppDb/14.json @@ -0,0 +1,427 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "8c34795e4b3ae52bf0188358d7bd3037", + "entities": [ + { + "tableName": "subs_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ctime` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `enable_update` INTEGER NOT NULL, `order` INTEGER NOT NULL, `update_url` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableUpdate", + "columnName": "enable_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updateUrl", + "columnName": "update_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "snapshot", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, `github_asset_id` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "screenHeight", + "columnName": "screen_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "screenWidth", + "columnName": "screen_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLandscape", + "columnName": "is_landscape", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "githubAssetId", + "columnName": "github_asset_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subs_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, `exclude` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupKey", + "columnName": "group_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exclude", + "columnName": "exclude", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "category_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_id` INTEGER NOT NULL, `category_key` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryKey", + "columnName": "category_key", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "action_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ctime` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `subs_version` INTEGER NOT NULL DEFAULT 0, `group_key` INTEGER NOT NULL, `group_type` INTEGER NOT NULL DEFAULT 2, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subsVersion", + "columnName": "subs_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "groupKey", + "columnName": "group_key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupType", + "columnName": "group_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2" + }, + { + "fieldPath": "ruleIndex", + "columnName": "rule_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleKey", + "columnName": "rule_key", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "activity_log_v2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ctime` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityId", + "columnName": "activity_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subsId", + "columnName": "subs_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_visit_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `mtime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "a11y_event_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ctime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `appId` TEXT NOT NULL, `name` TEXT NOT NULL, `desc` TEXT, `text` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctime", + "columnName": "ctime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "desc", + "columnName": "desc", + "affinity": "TEXT" + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c34795e4b3ae52bf0188358d7bd3037')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e85e8f3822..9327f4d325 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -156,13 +156,21 @@ android:value="Display a screenshot button for users to actively save screen information." /> + + + + + + + + + + + + toast(e.message ?: e.toString()) LogUtils.d("UncaughtExceptionHandler", t, e) } initToast() diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index d9a8b537e5..17045335b1 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -64,7 +64,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import li.songe.gkd.a11y.topActivityFlow -import li.songe.gkd.a11y.topAppIdFlow import li.songe.gkd.a11y.updateSystemDefaultAppId import li.songe.gkd.a11y.updateTopActivity import li.songe.gkd.permission.AuthDialog @@ -75,6 +74,7 @@ import li.songe.gkd.service.HttpService import li.songe.gkd.service.ScreenshotService import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService +import li.songe.gkd.service.updateTopAppId import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.BuildDialog import li.songe.gkd.ui.component.PerfIcon @@ -193,7 +193,7 @@ class MainActivity : ComponentActivity() { watchKeyboardVisible() StatusService.autoStart() if (storeFlow.value.enableBlockA11yAppList) { - topAppIdFlow.value = META.appId + updateTopAppId(META.appId) } setContent { val latestInsets = TopAppBarDefaults.windowInsets diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt index 057b58935f..f2609ed4e9 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt @@ -347,7 +347,6 @@ class A11yContext( else -> null } - }, getName = { node -> node.className }, getChildren = ::getCacheChildren, diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index a552e706e7..01af093cdd 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -18,6 +18,7 @@ import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.mapState import li.songe.selector.initDefaultTypeInfo +import kotlin.contracts.contract import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -113,8 +114,14 @@ val AccessibilityNodeInfo.compatChecked: Boolean? private const val interestedEvents = STATE_CHANGED or CONTENT_CHANGED -val AccessibilityEvent.isUseful: Boolean - get() = packageName != null && className != null && eventType.and(interestedEvents) != 0 +fun AccessibilityEvent?.isUseful(): Boolean { + contract { + returns(true) implies (this@isUseful != null) + } + return (this != null && packageName != null && className != null && eventType.and( + interestedEvents + ) != 0) +} suspend fun AccessibilityService.screenshot(): Bitmap? = suspendCoroutine { @@ -148,7 +155,7 @@ data class A11yEvent( val type: Int, val time: Long, val appId: String, - val className: String, + val name: String, val event: AccessibilityEvent, ) { val safeSource: AccessibilityNodeInfo? @@ -156,7 +163,7 @@ data class A11yEvent( fun sameAs(other: A11yEvent): Boolean { if (other === this) return true - return type == other.type && appId == other.appId && className == other.className + return type == other.type && appId == other.appId && name == other.name } } @@ -168,7 +175,7 @@ fun AccessibilityEvent.toA11yEvent(): A11yEvent? { type = eventType, time = System.currentTimeMillis(), appId = appId.toString(), - className = b.toString(), + name = b.toString(), event = this, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt index 36ffdaf8f3..918e162836 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -6,6 +6,7 @@ import android.content.Context.WINDOW_SERVICE import android.content.Intent import android.content.IntentFilter import android.graphics.PixelFormat +import android.util.Log import android.view.View import android.view.WindowManager import android.view.accessibility.AccessibilityEvent @@ -29,6 +30,14 @@ import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.launchTry import li.songe.gkd.util.mapState import li.songe.gkd.util.toast +import li.songe.selector.MatchOption +import li.songe.selector.QueryContext +import li.songe.selector.Selector +import li.songe.selector.Transform +import li.songe.selector.getBooleanInvoke +import li.songe.selector.getCharSequenceAttr +import li.songe.selector.getCharSequenceInvoke +import li.songe.selector.getIntInvoke context(service: A11yService) @@ -98,17 +107,73 @@ private fun watchCheckShizukuState() { } } +private var tempEventSelector = "" to (null as Selector?) +private fun AccessibilityEvent.getEventAttr(name: String): Any? = when (name) { + "name" -> className + "desc" -> contentDescription + "text" -> text + else -> null +} + +private val a11yEventTransform by lazy { + Transform( + getAttr = { target, name -> + when (target) { + is QueryContext<*> -> when (name) { + "prev" -> target.prev + "current" -> target.current + else -> (target.current as AccessibilityEvent).getEventAttr(name) + } + + is CharSequence -> getCharSequenceAttr(target, name) + is AccessibilityEvent -> target.getEventAttr(name) + is List<*> -> when (name) { + "size" -> target.size + else -> null + } + + else -> null + } + }, + getInvoke = { target, name, args -> + Log.d("A11yEventTransform", "getInvoke: $name(${args.joinToString()}) on $target") + when (target) { + is Int -> getIntInvoke(target, name, args) + is Boolean -> getBooleanInvoke(target, name, args) + is CharSequence -> getCharSequenceInvoke(target, name, args) + is List<*> -> when (name) { + "get" -> { + (args.singleOrNull() as? Int)?.let { index -> + target.getOrNull(index) + } + } + + else -> null + } + + else -> null + } + }, + getName = { it.className }, + getChildren = { emptySequence() }, + getParent = { null } + ) +} + context(event: AccessibilityEvent) private fun watchCaptureScreenshot() { if (!storeFlow.value.captureScreenshot) return - val appId = event.packageName.toString() - val appCls = event.className.toString() - if (!event.isFullScreen && appId == "com.miui.screenshot" && appCls == "android.widget.RelativeLayout" && event.text.firstOrNull() - ?.contentEquals("截屏缩略图") == true - ) { - appScope.launchTry { - SnapshotExt.captureSnapshot(skipScreenshot = true) - } + if (event.packageName != storeFlow.value.screenshotTargetAppId) return + if (tempEventSelector.first != storeFlow.value.screenshotEventSelector) { + tempEventSelector = + storeFlow.value.screenshotEventSelector to Selector.parseOrNull(storeFlow.value.screenshotEventSelector) + } + val selector = tempEventSelector.second ?: return + selector.match(event, a11yEventTransform, MatchOption(fastQuery = false)).let { + if (it == null) return + } + appScope.launchTry { + SnapshotExt.captureSnapshot(skipScreenshot = true) } } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index 043d6c5f84..9443138cca 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -15,6 +15,7 @@ import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus import li.songe.gkd.isActivityVisible import li.songe.gkd.service.A11yService +import li.songe.gkd.service.EventService import li.songe.gkd.service.a11yPartDisabledFlow import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.store.storeFlow @@ -64,6 +65,7 @@ class A11yRuleEngine(val service: A11yService) { } lastContentEventTime = a11yEvent.time } + EventService.logEvent(event) if (META.debuggable) { Log.d( "onNewA11yEvent", @@ -90,7 +92,7 @@ class A11yRuleEngine(val service: A11yService) { } val latestEvent = consumedEvents.last() val evAppId = latestEvent.appId - val evActivityId = latestEvent.className + val evActivityId = latestEvent.name val oldAppId = topActivityFlow.value.appId val rightAppId = if (oldAppId == evAppId) { evAppId diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index d6a92102fb..ea046abbf9 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -22,11 +22,11 @@ import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus import li.songe.gkd.data.isSystem import li.songe.gkd.db.DbSet -import li.songe.gkd.service.RecordService +import li.songe.gkd.service.ActivityService +import li.songe.gkd.service.updateTopAppId import li.songe.gkd.shizuku.safeInvokeMethod import li.songe.gkd.store.actionCountFlow -import li.songe.gkd.store.blockA11yAppListFlow -import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.store.checkAppBlockMatch import li.songe.gkd.store.storeFlow import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.PKG_FLAGS @@ -99,10 +99,7 @@ class ActivityRule( val topActivity: TopActivity = TopActivity(), val ruleSummary: RuleSummary = RuleSummary(), ) { - val blockMatch = (blockMatchAppListFlow.value.contains(topActivity.appId) - || storeFlow.value.enableBlockA11yAppList && blockA11yAppListFlow.value.contains( - topActivity.appId - )) + val blockMatch = checkAppBlockMatch(topActivity.appId) val appRules = ruleSummary.appIdToRules[topActivity.appId] ?: emptyList() val activityRules = if (blockMatch) emptyList() else appRules.filter { rule -> rule.matchActivity(topActivity.appId, topActivity.activityId) @@ -146,7 +143,7 @@ private var lastAppId = "" fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { val t = System.currentTimeMillis() if (type > 0 && storeFlow.value.enableBlockA11yAppList) { - topAppIdFlow.value = appId + updateTopAppId(appId) } val oldActivity = topActivityFlow.value val forced = type > 0 @@ -173,7 +170,7 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { ) lastValidActivity = oldActivity lastActivityUpdateTime = t - if (storeFlow.value.enableActivityLog || RecordService.isRunning.value) { + if (ActivityService.isRunning.value) { appScope.launchTry(Dispatchers.IO) { activityLogMutex.withLock { DbSet.activityLogDao.insert( diff --git a/app/src/main/kotlin/li/songe/gkd/data/A11yEventLog.kt b/app/src/main/kotlin/li/songe/gkd/data/A11yEventLog.kt new file mode 100644 index 0000000000..a88efb76b0 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/data/A11yEventLog.kt @@ -0,0 +1,106 @@ +package li.songe.gkd.data + +import android.view.accessibility.AccessibilityEvent +import androidx.paging.PagingSource +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import li.songe.gkd.a11y.STATE_CHANGED + +@Serializable +@Entity(tableName = "a11y_event_log") +class A11yEventLog( + @PrimaryKey() @ColumnInfo(name = "id") val id: Int, + @ColumnInfo(name = "ctime") val ctime: Long, + @ColumnInfo(name = "type") val type: Int, + @ColumnInfo(name = "appId") val appId: String, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "desc") val desc: String?, + @ColumnInfo(name = "text") val text: List, +) { + override fun equals(other: Any?): Boolean { + if (other !is A11yEventLog) return false + return id == other.id + } + + override fun hashCode(): Int { + return id + } + + val isStateChanged: Boolean + get() = type == STATE_CHANGED + + val fixedName: String + get() { + if (isStateChanged && name.startsWith(appId)) { + return name.substring(appId.length) + } + if (name.contains("View") || name.contains("Layout") || viewSuffixes.any { + name.startsWith( + it + ) + }) { + return name.substring(name.lastIndexOf('.') + 1) + } + return name + } + + @Dao + interface A11yEventLogDao { + @Insert + suspend fun insert(objects: List): List + + @Query("DELETE FROM a11y_event_log") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM a11y_event_log") + fun count(): Flow + + @Query("SELECT * FROM a11y_event_log ORDER BY ctime DESC ") + fun pagingSource(): PagingSource + + @Query("SELECT MAX(id) FROM a11y_event_log") + suspend fun maxId(): Int? + + @Query( + """ + DELETE FROM a11y_event_log + WHERE ( + SELECT COUNT(*) + FROM a11y_event_log + ) > 1000 + AND id <= ( + SELECT id + FROM a11y_event_log + ORDER BY id DESC + LIMIT 1 OFFSET 1000 + ) + """ + ) + suspend fun deleteKeepLatest(): Int + + + } + +} + +private val viewSuffixes = listOf( + "android.widget.", + "android.view.", + "android.support.", +) + +fun AccessibilityEvent.toA11yEventLog(id: Int) = A11yEventLog( + id = id, + ctime = System.currentTimeMillis(), + type = eventType, + appId = packageName.toString(), + name = className.toString(), + desc = contentDescription?.toString(), + text = text.map { it.toString() } +) diff --git a/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt b/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt index ab4fb0d366..4c277d830a 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt @@ -33,7 +33,7 @@ data class ActionLog( val showActivityId by lazy { getShowActivityId(appId, activityId) } - val date by lazy { ctime.format("MM-dd HH:mm:ss SSS") } + val date by lazy { ctime.format("HH:mm:ss SSS") } @DeleteTable.Entries( DeleteTable(tableName = "click_log") @@ -102,12 +102,12 @@ data class ActionLog( WHERE ( SELECT COUNT(*) FROM action_log - ) > 1000 + ) > 500 AND id <= ( SELECT id FROM action_log ORDER BY id DESC - LIMIT 1 OFFSET 1000 + LIMIT 1 OFFSET 500 ) """ ) diff --git a/app/src/main/kotlin/li/songe/gkd/data/ActivityLog.kt b/app/src/main/kotlin/li/songe/gkd/data/ActivityLog.kt index b3b42d3475..6b32be7649 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ActivityLog.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ActivityLog.kt @@ -25,7 +25,7 @@ data class ActivityLog( @ColumnInfo(name = "activity_id") val activityId: String? = null, ) { val showActivityId by lazy { getShowActivityId(appId, activityId) } - val date by lazy { ctime.format("MM-dd HH:mm:ss SSS") } + val date by lazy { ctime.format("HH:mm:ss SSS") } @Dao interface ActivityLogDao { @@ -47,12 +47,12 @@ data class ActivityLog( WHERE ( SELECT COUNT(*) FROM activity_log_v2 - ) > 1000 + ) > 500 AND ctime <= ( SELECT ctime FROM activity_log_v2 ORDER BY ctime DESC - LIMIT 1 OFFSET 1000 + LIMIT 1 OFFSET 500 ) """ ) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt index bf03482e29..679254d955 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt @@ -40,12 +40,12 @@ data class AppVisitLog( WHERE ( SELECT COUNT(*) FROM app_visit_log - ) > 1000 + ) > 500 AND mtime <= ( SELECT mtime FROM app_visit_log ORDER BY mtime DESC - LIMIT 1 OFFSET 1000 + LIMIT 1 OFFSET 500 ) """ ) diff --git a/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt index 631f4ba289..b2b6da15a0 100644 --- a/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt +++ b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt @@ -4,8 +4,13 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteColumn import androidx.room.RenameColumn +import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec +import li.songe.gkd.app +import li.songe.gkd.data.A11yEventLog import li.songe.gkd.data.ActionLog import li.songe.gkd.data.ActivityLog import li.songe.gkd.data.AppConfig @@ -14,9 +19,11 @@ import li.songe.gkd.data.CategoryConfig import li.songe.gkd.data.Snapshot import li.songe.gkd.data.SubsConfig import li.songe.gkd.data.SubsItem +import li.songe.gkd.util.dbFolder +import li.songe.gkd.util.json @Database( - version = 13, + version = 14, entities = [ SubsItem::class, Snapshot::class, @@ -26,6 +33,7 @@ import li.songe.gkd.data.SubsItem ActivityLog::class, AppConfig::class, AppVisitLog::class, + A11yEventLog::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -40,8 +48,10 @@ import li.songe.gkd.data.SubsItem AutoMigration(from = 10, to = 11, spec = Migration10To11Spec::class), AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), ] ) +@TypeConverters(DbConverters::class) abstract class AppDb : RoomDatabase() { abstract fun subsItemDao(): SubsItem.SubsItemDao abstract fun snapshotDao(): Snapshot.SnapshotDao @@ -51,6 +61,7 @@ abstract class AppDb : RoomDatabase() { abstract fun actionLogDao(): ActionLog.ActionLogDao abstract fun activityLogDao(): ActivityLog.ActivityLogDao abstract fun appVisitLogDao(): AppVisitLog.AppLogDao + abstract fun a11yEventLogDao(): A11yEventLog.A11yEventLogDao } @RenameColumn( @@ -78,3 +89,40 @@ class Migration9To10Spec : AutoMigrationSpec columnName = "app_version_name" ) class Migration10To11Spec : AutoMigrationSpec + +@Suppress("unused") +class DbConverters { + @TypeConverter + fun fromListStringToString(list: List): String { + return json.encodeToString(list) + } + + @TypeConverter + fun fromStringToList(value: String): List { + if (value.isEmpty()) return emptyList() + return try { + json.decodeFromString(value) + } catch (_: Exception) { + emptyList() + } + } +} + +object DbSet { + private val db by lazy { + Room.databaseBuilder( + app, + AppDb::class.java, + dbFolder.resolve("gkd.db").absolutePath + ).fallbackToDestructiveMigration(false).build() + } + val subsItemDao get() = db.subsItemDao() + val subsConfigDao get() = db.subsConfigDao() + val snapshotDao get() = db.snapshotDao() + val actionLogDao get() = db.actionLogDao() + val categoryConfigDao get() = db.categoryConfigDao() + val activityLogDao get() = db.activityLogDao() + val appConfigDao get() = db.appConfigDao() + val appVisitLogDao get() = db.appVisitLogDao() + val a11yEventLogDao get() = db.a11yEventLogDao() +} diff --git a/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt b/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt deleted file mode 100644 index eed1daee25..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt +++ /dev/null @@ -1,34 +0,0 @@ -package li.songe.gkd.db - -import androidx.room.Room -import li.songe.gkd.app -import li.songe.gkd.util.dbFolder - -object DbSet { - - private fun buildDb(): AppDb { - return Room.databaseBuilder( - app, - AppDb::class.java, - dbFolder.resolve("gkd.db").absolutePath - ).fallbackToDestructiveMigration(false).build() - } - - private val db by lazy { buildDb() } - val subsItemDao - get() = db.subsItemDao() - val subsConfigDao - get() = db.subsConfigDao() - val snapshotDao - get() = db.snapshotDao() - val actionLogDao - get() = db.actionLogDao() - val categoryConfigDao - get() = db.categoryConfigDao() - val activityLogDao - get() = db.activityLogDao() - val appConfigDao - get() = db.appConfigDao() - val appVisitLogDao - get() = db.appVisitLogDao() -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 3875783ade..8afaa1295e 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -15,9 +15,10 @@ import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app import li.songe.gkd.permission.notificationState +import li.songe.gkd.service.ActivityService import li.songe.gkd.service.ButtonService +import li.songe.gkd.service.EventService import li.songe.gkd.service.HttpService -import li.songe.gkd.service.RecordService import li.songe.gkd.service.ScreenshotService import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.SafeR @@ -137,5 +138,12 @@ val recordNotif = Notif( id = 106, title = "记录服务正在运行", uri = "gkd://page/1", - stopService = RecordService::class, + stopService = ActivityService::class, +) + +val eventNotif = Notif( + id = 107, + title = "事件服务正在运行", + uri = "gkd://page/1", + stopService = EventService::class, ) diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt index db047890b7..2f0d25bdb5 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt @@ -9,7 +9,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.MainActivity +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.util.stopCoroutine +import li.songe.gkd.util.toast data class AuthReason( val text: () -> String, @@ -58,8 +60,11 @@ suspend fun requiredPermission( permissionState: PermissionState ) { if (permissionState.updateAndGet()) return - permissionState.grantSelf?.invoke() - if (permissionState.updateAndGet()) return + shizukuContextFlow.value.grantSelf() + if (permissionState.updateAndGet()) { + toast("已借助 Shizuku 自动授权") + return + } val result = permissionState.request?.invoke(context) if (result == null) { context.mainVm.authReasonFlow.value = permissionState.reason diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 5d6b0b1cac..3d863c8e09 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -26,7 +26,6 @@ import li.songe.gkd.util.updateAppMutex class PermissionState( val check: () -> Boolean, - val grantSelf: (() -> Unit)? = null, val request: (suspend (context: MainActivity) -> PermissionResult)? = null, /** * show it when user doNotAskAgain @@ -41,7 +40,7 @@ class PermissionState( } fun checkOrToast(): Boolean = if (!updateAndGet()) { - grantSelf?.invoke() + shizukuContextFlow.value.grantSelf() val r = updateAndGet() if (!r) { reason?.text?.let { toast(it()) } @@ -105,9 +104,6 @@ val foregroundServiceSpecialUseState by lazy { true } }, - grantSelf = { - shizukuContextFlow.value.appOpsService?.allowAllSelfMode() - }, reason = AuthReason( text = { "当前操作权限「特殊用途的前台服务」已被限制, 请先解除限制" }, renderConfirm = { @@ -126,9 +122,6 @@ val notificationState by lazy { check = { XXPermissions.isGrantedPermission(app, permission) }, - grantSelf = { - shizukuContextFlow.value.appOpsService?.allowAllSelfMode() - }, request = { asyncRequestPermission(it, permission) }, reason = AuthReason( text = { "当前操作需要「通知权限」\n请先前往权限页面授权" }, @@ -163,9 +156,6 @@ val canDrawOverlaysState by lazy { // https://developer.android.com/security/fraud-prevention/activities?hl=zh-cn#hide_overlay_windows Settings.canDrawOverlays(app) }, - grantSelf = { - shizukuContextFlow.value.appOpsService?.allowAllSelfMode() - }, reason = AuthReason( text = { "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" @@ -211,9 +201,6 @@ val canWriteExternalStorage by lazy { val writeSecureSettingsState by lazy { PermissionState( check = { checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, - grantSelf = { - shizukuContextFlow.value.packageManager?.grantSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) - }, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index aa4fac496d..b877dc0efe 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -39,7 +39,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { override fun onDestroy() = onDestroyed() override val a11yEventCbs = mutableListOf<(AccessibilityEvent) -> Unit>() override fun onAccessibilityEvent(event: AccessibilityEvent?) { - if (event == null || !event.isUseful) return + if (!event.isUseful()) return onA11yEvent(event) } diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt similarity index 88% rename from app/src/main/kotlin/li/songe/gkd/service/RecordService.kt rename to app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt index cb0d9a8d09..bcb2f47744 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt @@ -27,14 +27,15 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import li.songe.gkd.a11y.topActivityFlow +import li.songe.gkd.a11y.updateTopActivity import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.recordNotif import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.shizuku.SafeTaskListener +import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.PerfIcon -import li.songe.gkd.ui.style.AppTheme import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.copyText @@ -42,8 +43,8 @@ import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass -class RecordService : OverlayWindowService( - positionKey = "record" +class ActivityService : OverlayWindowService( + positionKey = "activity" ) { val topAppInfoFlow by lazy { @@ -59,7 +60,7 @@ class RecordService : OverlayWindowService( } @Composable - override fun ComposeContent() = AppTheme(invertedTheme = true) { + override fun ComposeContent() { val bgColor = MaterialTheme.colorScheme.surface Box( modifier = Modifier @@ -111,6 +112,15 @@ class RecordService : OverlayWindowService( recordNotif.copy(text = it.format()).notifyService() } } + if (!A11yService.isRunning.value) { + safeGetTopCpn()?.let { cpn -> + updateTopActivity( + appId = cpn.packageName, + activityId = cpn.className, + type = 2, + ) + } + } } } @@ -118,10 +128,10 @@ class RecordService : OverlayWindowService( val isRunning = MutableStateFlow(false) fun start() { if (!canDrawOverlaysState.checkOrToast()) return - startForegroundServiceByClass(RecordService::class) + startForegroundServiceByClass(ActivityService::class) } - fun stop() = stopServiceByClass(RecordService::class) + fun stop() = stopServiceByClass(ActivityService::class) } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/ActivityTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/ActivityTileService.kt new file mode 100644 index 0000000000..36752642e1 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ActivityTileService.kt @@ -0,0 +1,15 @@ +package li.songe.gkd.service + +class ActivityTileService : BaseTileService() { + override val activeFlow = ActivityService.isRunning + + init { + onTileClicked { + if (ActivityService.isRunning.value) { + ActivityService.stop() + } else { + ActivityService.start() + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt index aa056bad76..9511a92769 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt @@ -13,7 +13,6 @@ import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.buttonNotif import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.ui.component.PerfIcon -import li.songe.gkd.ui.style.AppTheme import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.launchTry import li.songe.gkd.util.startForegroundServiceByClass @@ -27,7 +26,7 @@ class ButtonService : OverlayWindowService( }.let { } @Composable - override fun ComposeContent() = AppTheme(invertedTheme = true) { + override fun ComposeContent() { val alpha = 0.75f PerfIcon( imageVector = PerfIcon.CenterFocusWeak, diff --git a/app/src/main/kotlin/li/songe/gkd/service/EventService.kt b/app/src/main/kotlin/li/songe/gkd/service/EventService.kt new file mode 100644 index 0000000000..ead815bff8 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/EventService.kt @@ -0,0 +1,229 @@ +package li.songe.gkd.service + +import android.view.accessibility.AccessibilityEvent +import androidx.annotation.MainThread +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import li.songe.gkd.META +import li.songe.gkd.appScope +import li.songe.gkd.data.A11yEventLog +import li.songe.gkd.data.toA11yEventLog +import li.songe.gkd.db.DbSet +import li.songe.gkd.notif.StopServiceReceiver +import li.songe.gkd.notif.eventNotif +import li.songe.gkd.permission.canDrawOverlaysState +import li.songe.gkd.ui.EventLogCard +import li.songe.gkd.ui.component.LocalNumberCharWidth +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.isAtBottom +import li.songe.gkd.ui.component.measureNumberTextWidth +import li.songe.gkd.ui.icon.DragPan +import li.songe.gkd.ui.share.ListPlaceholder +import li.songe.gkd.ui.style.iconTextSize +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.startForegroundServiceByClass +import li.songe.gkd.util.stopServiceByClass + +class EventService : OverlayWindowService(positionKey = "event") { + + val eventLogs = mutableStateListOf() + private var tempEventId = 0 + private var firstToBottom = false + + @Composable + override fun ComposeContent() { + val bgColor = MaterialTheme.colorScheme.surface + CompositionLocalProvider( + LocalContentColor provides contentColorFor(bgColor), + ) { + val listState = key(eventLogs.isEmpty()) { rememberLazyListState() } + val isAtBottom by listState.isAtBottom() + val subScope = rememberCoroutineScope() + SideEffect { + val latestId = eventLogs.lastOrNull()?.id ?: 0 + if (tempEventId != latestId) { + tempEventId = latestId + if (isAtBottom) { + subScope.launch { listState.scrollToItem(eventLogs.lastIndex) } + } + } + } + Column( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(bgColor.copy(alpha = 0.9f)) + .width(256.dp) + .padding(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val title = if (A11yService.isRunning.collectAsState().value) { + "事件服务" + } else { + "事件服务(无障碍已关闭)" + } + Text(text = title, modifier = Modifier.weight(1f)) + PerfIcon(imageVector = DragPan, modifier = Modifier.iconTextSize()) + } + val textStyle = MaterialTheme.typography.labelSmall + val numCharWidth = measureNumberTextWidth(textStyle) + CompositionLocalProvider( + LocalTextStyle provides textStyle, + LocalNumberCharWidth provides numCharWidth, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(eventLogs, { it.id }) { + EventLogCard( + eventLog = it, + modifier = Modifier.padding(horizontal = 2.dp) + ) + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + if (eventLogs.isNotEmpty() && !isAtBottom) { + if (!firstToBottom) { + firstToBottom = true + SideEffect { + subScope.launch { listState.scrollToItem(eventLogs.lastIndex) } + } + } + var count by remember { mutableIntStateOf(-1) } + LaunchedEffect(eventLogs.last().id) { count++ } + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .width(IntrinsicSize.Min), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (count > 0) { + Text(text = "+$count") + } + PerfIconButton( + imageVector = PerfIcon.ArrowDownward, + onClick = { + subScope.launch { + listState.scrollToItem(eventLogs.lastIndex) + } + }, + ) + } + } + } + } + } + } + } + + val tempEventListFlow = MutableStateFlow(emptyList()).apply { + appScope.launch { + while (scope.isActive) { + delay(1000) + val list = getAndUpdate { emptyList() } + if (list.isNotEmpty()) { + DbSet.a11yEventLogDao.insert(list) + } + } + } + } + + init { + logAutoId = 0 + instance = this + onDestroyed { + instance = null + logAutoId = 0 + } + scope.launch { + logAutoId = (DbSet.a11yEventLogDao.maxId() ?: 0).coerceAtLeast(1) + } + + useLogLifecycle() + useAliveFlow(isRunning) + useAliveToast("事件服务") + StopServiceReceiver.autoRegister() + onCreated { eventNotif.notifyService() } + } + + companion object { + private var instance: EventService? = null + private var logAutoId = 0 + + @MainThread + fun logEvent(event: AccessibilityEvent) = instance?.apply { + if (event.packageName == META.appId) return + if (logAutoId == 0) return + logAutoId++ + val eventLog = event.toA11yEventLog(logAutoId) + eventLogs.add(eventLog) + tempEventListFlow.update { it + eventLog } + if (eventLogs.size >= 256) { + eventLogs.removeRange(0, 64) + } + if (eventLog.id % 100 == 0) { + appScope.launchTry { DbSet.a11yEventLogDao.deleteKeepLatest() } + } + }.let { } + + val isRunning = MutableStateFlow(false) + fun start() { + if (!canDrawOverlaysState.checkOrToast()) return + startForegroundServiceByClass(EventService::class) + } + + fun stop() = stopServiceByClass(EventService::class) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/service/EventTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/EventTileService.kt new file mode 100644 index 0000000000..03f579ff72 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/EventTileService.kt @@ -0,0 +1,15 @@ +package li.songe.gkd.service + +class EventTileService : BaseTileService() { + override val activeFlow = EventService.isRunning + + init { + onTileClicked { + if (EventService.isRunning.value) { + EventService.stop() + } else { + EventService.start() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 903262a9dc..9e47e87e1c 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -19,7 +19,8 @@ import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.shizuku.safeGetTopCpn -import li.songe.gkd.store.blockA11yAppListFlow +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.store.actualBlockA11yAppList import li.songe.gkd.store.storeFlow import li.songe.gkd.util.launchTry import li.songe.gkd.util.mapState @@ -50,7 +51,7 @@ fun switchA11yService() = modifyA11yRun { A11yService.instance?.disableSelf() } else { if (!writeSecureSettingsState.updateAndGet()) { - writeSecureSettingsState.grantSelf?.invoke() + shizukuContextFlow.value.grantSelf() if (!writeSecureSettingsState.updateAndGet()) { toast("请先授予「写入安全设置权限」") return@modifyA11yRun @@ -84,7 +85,7 @@ fun fixRestartService() = modifyA11yRun { } else { safeGetTopCpn()?.packageName } - if (topAppId != null && topAppId in blockA11yAppListFlow.value) { + if (topAppId != null && topAppId in actualBlockA11yAppList) { return@modifyA11yRun } } @@ -107,21 +108,20 @@ fun fixRestartService() = modifyA11yRun { } } -private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { +private fun forcedUpdateA11yService(disabled: Boolean) { if (!storeFlow.value.enableService) { - return@modifyA11yRun + return } if (!storeFlow.value.enableBlockA11yAppList) { - return@modifyA11yRun + return } if (!writeSecureSettingsState.updateAndGet()) { - return@modifyA11yRun + return } - val names = app.getSecureA11yServices() - val hasA11y = names.contains(A11yService.a11yCn) - if (disabled == !hasA11y) { - return@modifyA11yRun + if (!disabled == A11yService.isRunning.value) { + return } + val names = app.getSecureA11yServices() if (disabled) { A11yService.instance?.apply { willDestroyByBlock = true @@ -135,22 +135,31 @@ private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { private const val A11Y_WHITE_APP_AWAIT_TIME = 3000L +@Volatile +var lastAppIdChangeTime = 0L + +fun updateTopAppId(value: String) { + lastAppIdChangeTime = System.currentTimeMillis() + topAppIdFlow.value = value +} + val a11yPartDisabledFlow by lazy { topAppIdFlow.mapState(appScope) { - blockA11yAppListFlow.value.contains(it) + actualBlockA11yAppList.contains(it) } } fun initA11yWhiteAppList() { val actualFlow = topAppIdFlow.drop(1) appScope.launch(Dispatchers.Main) { - actualFlow.collect { appId -> - if (!blockA11yAppListFlow.value.contains(appId)) { + actualFlow.collect { + if (!actualBlockA11yAppList.contains(topAppIdFlow.value)) { + val tempTime = lastAppIdChangeTime if (topActivityFlow.value.sameAs(systemRecentCn)) { // 切换无障碍会造成卡顿,在最近任务界面时,延迟这个卡顿 appScope.launch { delay(A11Y_WHITE_APP_AWAIT_TIME) - if (appId == topAppIdFlow.value) { + if (tempTime == lastAppIdChangeTime) { forcedUpdateA11yService(false) } } @@ -161,8 +170,8 @@ fun initA11yWhiteAppList() { } } appScope.launch(Dispatchers.Main) { - actualFlow.debounce(A11Y_WHITE_APP_AWAIT_TIME).collect { appId -> - if (blockA11yAppListFlow.value.contains(appId)) { + actualFlow.debounce(A11Y_WHITE_APP_AWAIT_TIME).collect { + if (actualBlockA11yAppList.contains(topAppIdFlow.value)) { forcedUpdateA11yService(true) } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index b556a75b1f..f461e76225 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.launch import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.store.createAnyFlow +import li.songe.gkd.ui.style.AppTheme import li.songe.gkd.util.BarUtils import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.ScreenUtils @@ -124,7 +125,11 @@ abstract class OverlayWindowService( ComposeView(this).apply { setViewTreeSavedStateRegistryOwner(this@OverlayWindowService) setViewTreeLifecycleOwner(this@OverlayWindowService) - setContent { ComposeContent() } + setContent { + AppTheme(invertedTheme = true) { + ComposeContent() + } + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt deleted file mode 100644 index caf164d120..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/RecordTileService.kt +++ /dev/null @@ -1,15 +0,0 @@ -package li.songe.gkd.service - -class RecordTileService : BaseTileService() { - override val activeFlow = RecordService.isRunning - - init { - onTileClicked { - if (RecordService.isRunning.value) { - RecordService.stop() - } else { - RecordService.start() - } - } - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt index ef263bdbad..e5d2256cfd 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt @@ -14,7 +14,6 @@ import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.ScreenshotUtil import li.songe.gkd.util.componentName import li.songe.gkd.util.stopServiceByClass -import java.lang.ref.WeakReference class ScreenshotService : Service(), OnSimpleLife { override val scope: CoroutineScope @@ -44,18 +43,20 @@ class ScreenshotService : Service(), OnSimpleLife { useAliveToast("截屏服务") StopServiceReceiver.autoRegister() onCreated { screenshotNotif.notifyService() } - onCreated { instance = WeakReference(this) } - onDestroyed { instance = WeakReference(null) } - onDestroyed { screenshotUtil?.destroy() } + onCreated { instance = this } + onDestroyed { + screenshotUtil?.destroy() + instance = null + } } companion object { - private var instance = WeakReference(null) + private var instance: ScreenshotService? = null val isRunning = MutableStateFlow(false) suspend fun screenshot(): Bitmap? { if (!isRunning.value) return null return withTimeoutOrNull(3_000) { - instance.get()?.screenshotUtil?.execute() + instance?.screenshotUtil?.execute() } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index d0bbb211c7..311aed4e1c 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -1,5 +1,6 @@ package li.songe.gkd.shizuku +import android.Manifest import android.content.pm.IPackageManager import android.content.pm.PackageInfo import li.songe.gkd.META @@ -21,7 +22,10 @@ class SafePackageManager(private val value: IPackageManager) { val isSafeMode get() = safeInvokeMethod { value.isSafeMode } - fun getInstalledPackages(flags: Int, userId: Int): List = safeInvokeMethod { + fun getInstalledPackages( + flags: Int, + userId: Int = currentUserId, + ): List = safeInvokeMethod { if (AndroidTarget.TIRAMISU) { value.getInstalledPackages(flags.toLong(), userId).list } else { @@ -41,8 +45,16 @@ class SafePackageManager(private val value: IPackageManager) { ) } - fun grantSelfPermission(permissionName: String) = grantRuntimePermission( + private fun grantSelfPermission(permissionName: String) = grantRuntimePermission( packageName = META.appId, permissionName = permissionName, ) + + fun allowAllSelfPermission() { + grantSelfPermission("com.android.permission.GET_INSTALLED_APPS") + grantSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) + if (AndroidTarget.TIRAMISU) { + grantSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + } + } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 3e1fb74e75..fcdbade08c 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -82,6 +82,11 @@ class ShizukuContext( "IPackageManager" to packageManager, "IUserManager" to userManager, ) + + fun grantSelf() { + appOpsService?.allowAllSelfMode() + packageManager?.allowAllSelfPermission() + } } private val defaultShizukuContext = ShizukuContext( diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index ffc0ae517c..794d3b297d 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -14,6 +14,8 @@ data class SettingsStore( val enableStatusService: Boolean = false, val excludeFromRecents: Boolean = false, val captureScreenshot: Boolean = false, + val screenshotTargetAppId: String = "", + val screenshotEventSelector: String = "", val httpServerPort: Int = 8888, val updateSubsInterval: Long = UpdateTimeOption.Everyday.value, val captureVolumeChange: Boolean = false, @@ -28,21 +30,18 @@ data class SettingsStore( val useCustomNotifText: Boolean = false, val customNotifTitle: String = META.appName, val customNotifText: String = "\${i}全局/\${k}应用/\${u}规则组/\${n}触发", - val enableActivityLog: Boolean = false, val updateChannel: Int = if (META.isBeta) UpdateChannelOption.Beta.value else UpdateChannelOption.Stable.value, val appSort: Int = AppSortOption.ByUsedTime.value, - val showSystemApp: Boolean = true, val showBlockApp: Boolean = false, val appRuleSort: Int = RuleSortOption.ByDefault.value, val subsAppSort: Int = AppSortOption.ByUsedTime.value, val subsAppShowUninstallApp: Boolean = false, val subsExcludeSort: Int = AppSortOption.ByUsedTime.value, - val subsExcludeShowSystemApp: Boolean = true, val subsExcludeShowInnerDisabledApp: Boolean = false, val subsExcludeShowBlockApp: Boolean = false, val subsPowerWarn: Boolean = true, val enableShizuku: Boolean = false, val enableBlockA11yAppList: Boolean = false, + val blockA11yAppListFollowMatch: Boolean = false, val a11yAppSort: Int = AppSortOption.ByUsedTime.value, - val a11yShowSystemApp: Boolean = true, ) diff --git a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt index c5664a39e3..24330ef9e8 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt @@ -39,6 +39,23 @@ val blockA11yAppListFlow: MutableStateFlow> by lazy { ) } +val actualBlockA11yAppList: Set + get() = if (storeFlow.value.blockA11yAppListFollowMatch) { + blockMatchAppListFlow.value + } else { + blockA11yAppListFlow.value + } + +fun checkAppBlockMatch(appId: String): Boolean { + if (blockMatchAppListFlow.value.contains(appId)) { + return true + } + if (storeFlow.value.enableBlockA11yAppList) { + return actualBlockA11yAppList.contains(appId) + } + return false +} + fun initStore() = appScope.launchTry(Dispatchers.IO) { // preload storeFlow.value diff --git a/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogPage.kt new file mode 100644 index 0000000000..f020512a65 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogPage.kt @@ -0,0 +1,363 @@ +package li.songe.gkd.ui + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import li.songe.gkd.MainActivity +import li.songe.gkd.data.A11yEventLog +import li.songe.gkd.db.DbSet +import li.songe.gkd.ui.component.AppNameText +import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.FixedTimeText +import li.songe.gkd.ui.component.LocalNumberCharWidth +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfTopAppBar +import li.songe.gkd.ui.component.measureNumberTextWidth +import li.songe.gkd.ui.component.useListScrollState +import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.share.ListPlaceholder +import li.songe.gkd.ui.share.LocalDarkTheme +import li.songe.gkd.ui.share.noRippleClickable +import li.songe.gkd.ui.style.EmptyHeight +import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.getJson5AnnotatedString +import li.songe.gkd.ui.style.iconTextSize +import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.copyText +import li.songe.gkd.util.format +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toJson5String +import li.songe.gkd.util.toast + +@Destination(style = ProfileTransitions::class) +@Composable +fun A11yEventLogPage() { + val context = LocalActivity.current as MainActivity + val mainVm = context.mainVm + val vm = viewModel() + + val logCount by vm.logCountFlow.collectAsState() + val list = vm.pagingDataFlow.collectAsLazyPagingItems() + val (scrollBehavior, listState) = useListScrollState(vm.resetKey, list.itemCount > 0) + + Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + navigationIcon = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popBackStack() + }) + }, + title = { + Text( + text = "事件日志", + modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ }, + ) + }, + actions = { + if (logCount > 0) { + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(fn = vm.viewModelScope.launchAsFn { + mainVm.dialogFlow.waitResult( + title = "删除日志", + text = "确定删除所有事件日志?", + error = true, + ) + DbSet.a11yEventLogDao.deleteAll() + toast("删除成功") + }) + ) + } + } + ) + }) { contentPadding -> + CompositionLocalProvider( + LocalNumberCharWidth provides measureNumberTextWidth(), + ) { + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + count = list.itemCount, + key = list.itemKey { it.id } + ) { i -> + val eventLog = list[i] ?: return@items + EventLogCard( + eventLog = eventLog, + modifier = Modifier + .padding(horizontal = 16.dp) + .clickable(onClick = { + vm.showEventLogFlow.value = eventLog + }) + ) + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (logCount == 0 && list.loadState.refresh !is LoadState.Loading) { + EmptyText(text = "暂无数据") + } + } + } + } + } + + vm.showEventLogFlow.collectAsState().value?.let { eventLog -> + val onDismissRequest = { vm.showEventLogFlow.value = null } + val dark = LocalDarkTheme.current + val eventText = remember(dark) { + getJson5AnnotatedString( + toJson5String( + JsonObject( + mapOf( + "name" to JsonPrimitive(eventLog.name), + "desc" to JsonPrimitive(eventLog.desc), + "text" to JsonArray(eventLog.text.map(::JsonPrimitive)), + ) + ) + ), + dark, + ) + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = "事件详情") }, + text = { + val textModifier = Modifier + .background( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = MaterialTheme.shapes.extraSmall, + ) + .padding(horizontal = 4.dp) + Column { + Text(text = "类型: " + if (eventLog.isStateChanged) "状态变化" else "内容变化") + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "应用ID") + Row { + Text( + text = eventLog.appId, + modifier = textModifier + ) + Spacer(modifier = Modifier.width(4.dp)) + CopyIcon(onClick = { + copyText(eventLog.appId) + }) + } + Spacer(modifier = Modifier.height(12.dp)) + Text(text = "事件数据") + Box( + modifier = Modifier.fillMaxWidth() + ) { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + Text( + text = eventText, + modifier = textModifier.fillMaxWidth() + ) + } + CopyIcon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp), + onClick = { + copyText(eventText.text) + }) + } + if (eventLog.isStateChanged) { + Spacer(modifier = Modifier.height(12.dp)) + val selectorText = remember(eventLog.id) { + (listOf( + "name" to eventLog.name, + "desc" to eventLog.desc, + "text.size" to eventLog.text.size, + ) + eventLog.text.mapIndexed { i, s -> "text.get($i)" to s }).joinToString( + "" + ) { (key, value) -> + val v = + if (value is String) toJson5String(value) else value.toString() + "[${key}=${v}]" + } + } + Text(text = "特征选择器") + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = selectorText, + modifier = textModifier.weight(1f) + ) + Spacer(modifier = Modifier.width(4.dp)) + CopyIcon(onClick = { + copyText(selectorText) + }) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(text = "关闭") + } + }, + ) + } +} + +@Composable +fun EventLogCard(eventLog: A11yEventLog, modifier: Modifier = Modifier) { + var parentHeight by remember { mutableIntStateOf(0) } + Row( + modifier = modifier + .fillMaxWidth() + .onSizeChanged { + parentHeight = it.height + } + ) { + Spacer( + modifier = Modifier + .background(MaterialTheme.colorScheme.secondary) + .width(2.dp) + .height((parentHeight / LocalDensity.current.density).dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + FixedTimeText( + text = eventLog.ctime.format("HH:mm:ss SSS"), + ) + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .background(MaterialTheme.colorScheme.tertiary) + .size(height = 8.dp, width = 1.dp) + ) + AppNameText( + appId = eventLog.appId, + ) + } + Text( + text = eventLog.fixedName, + color = if (eventLog.isStateChanged) MaterialTheme.colorScheme.primary else Color.Unspecified, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + ) + if (eventLog.desc != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + PerfIcon( + imageVector = PerfIcon.Title, + modifier = Modifier.iconTextSize( + square = false + ), + ) + Text( + text = eventLog.desc, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.extraSmall, + ) + .padding(horizontal = 2.dp), + ) + } + } + if (eventLog.text.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + PerfIcon( + imageVector = PerfIcon.TextFields, + modifier = Modifier.iconTextSize( + square = false + ), + ) + // 如果祖先容器有设置了 height(IntrinsicSize.Min) 会导致 FlowRow 不会自动换行 + FlowRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + eventLog.text.forEach { subText -> + Text( + text = subText, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = MaterialTheme.shapes.extraSmall, + ) + .padding(horizontal = 2.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun CopyIcon(modifier: Modifier = Modifier, onClick: () -> Unit) { + PerfIcon( + imageVector = PerfIcon.ContentCopy, + modifier = modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = onClick) + .iconTextSize(), + ) +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogVm.kt new file mode 100644 index 0000000000..bcabfa3a3b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogVm.kt @@ -0,0 +1,21 @@ +package li.songe.gkd.ui + +import androidx.compose.runtime.mutableIntStateOf +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import kotlinx.coroutines.flow.MutableStateFlow +import li.songe.gkd.data.A11yEventLog +import li.songe.gkd.db.DbSet +import li.songe.gkd.ui.share.BaseViewModel + +class A11yEventLogVm : BaseViewModel() { + val pagingDataFlow = + Pager(PagingConfig(pageSize = 100)) { DbSet.a11yEventLogDao.pagingSource() } + .flow.cachedIn(viewModelScope) + + val logCountFlow = DbSet.a11yEventLogDao.count().stateInit(0) + val resetKey = mutableIntStateOf(0) + val showEventLogFlow = MutableStateFlow(null) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index 691ecff1e6..c9c6a9eaa7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -181,7 +181,7 @@ fun ActionLogPage( val item = list[i] ?: return@items val lastItem = if (i > 0) list[i - 1] else null ActionLogCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), i = i, item = item, lastItem = lastItem, @@ -195,7 +195,7 @@ fun ActionLogPage( item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (list.itemCount == 0 && list.loadState.refresh !is LoadState.Loading) { - EmptyText(text = "暂无记录") + EmptyText(text = "暂无数据") } } } @@ -304,7 +304,6 @@ private fun ActionLogCard( if (showActivityId != null) { Text( text = showActivityId, - modifier = Modifier.height(LocalTextStyle.current.lineHeight.value.dp), softWrap = false, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt index af8a45d5bf..9da039f66a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt @@ -90,7 +90,7 @@ fun ActivityLogPage() { }, title = { Text( - text = "界面记录", + text = "界面日志", modifier = Modifier.noRippleClickable { resetKey.intValue++ }, ) }, @@ -100,8 +100,8 @@ fun ActivityLogPage() { imageVector = PerfIcon.Delete, onClick = throttle(fn = vm.viewModelScope.launchAsFn { mainVm.dialogFlow.waitResult( - title = "删除记录", - text = "确定删除所有界面记录?", + title = "删除日志", + text = "确定删除所有界面日志?", error = true, ) DbSet.activityLogDao.deleteAll() @@ -131,7 +131,7 @@ fun ActivityLogPage() { item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (logCount == 0 && list.loadState.refresh !is LoadState.Loading) { - EmptyText(text = "暂无记录") + EmptyText(text = "暂无数据") } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index d2944b0a61..91e07c9b71 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -50,29 +50,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.dylanc.activityresult.launcher.launchForResult import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.A11YEventLogPageDestination import com.ramcosta.composedestinations.generated.destinations.ActivityLogPageDestination import com.ramcosta.composedestinations.generated.destinations.SnapshotPageDestination +import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.service.ActivityService import li.songe.gkd.service.ButtonService +import li.songe.gkd.service.EventService import li.songe.gkd.service.HttpService -import li.songe.gkd.service.RecordService import li.songe.gkd.service.ScreenshotService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthCard +import li.songe.gkd.ui.component.CustomIconButton +import li.songe.gkd.ui.component.CustomOutlinedTextField import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus -import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight @@ -81,10 +85,13 @@ import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.SafeR import li.songe.gkd.util.ShortUrlSet +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast +import li.songe.selector.Selector @Destination(style = ProfileTransitions::class) @Composable @@ -188,6 +195,100 @@ fun AdvancedPage() { ) } + var showCaptureScreenshotDlg by vm.showCaptureScreenshotDlgFlow.asMutableState() + if (showCaptureScreenshotDlg) { + var appIdValue by remember { mutableStateOf(store.screenshotTargetAppId) } + var eventSelectorValue by remember { mutableStateOf(store.screenshotEventSelector) } + AlertDialog( + properties = DialogProperties(dismissOnClickOutside = false), + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "截屏快照") + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + onClick = throttle { + showCaptureScreenshotDlg = false + mainVm.navigateWebPage(ShortUrlSet.URL15) + }, + ) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + CustomOutlinedTextField( + label = { Text("应用ID") }, + value = appIdValue, + placeholder = { Text(text = "请输入目标应用ID") }, + onValueChange = { + appIdValue = it + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + CustomOutlinedTextField( + label = { Text("特征事件选择器") }, + value = eventSelectorValue, + placeholder = { Text(text = "请输入特征事件选择器") }, + onValueChange = { + eventSelectorValue = it + }, + maxLines = 4, + modifier = Modifier + .fillMaxWidth() + .autoFocus(), + ) + } + }, + onDismissRequest = { + showCaptureScreenshotDlg = false + }, + confirmButton = { + TextButton(onClick = throttle { + if (appIdValue == store.screenshotTargetAppId && eventSelectorValue == store.screenshotEventSelector) { + showCaptureScreenshotDlg = false + return@throttle + } + if (appIdValue.isNotEmpty() && !appInfoMapFlow.value.contains(appIdValue)) { + toast("无效应用ID") + return@throttle + } + if (eventSelectorValue.isNotEmpty()) { + val s = Selector.parseOrNull(eventSelectorValue) + if (s == null) { + toast("无效事件选择器") + return@throttle + } + } + storeFlow.update { + it.copy( + screenshotTargetAppId = appIdValue, + screenshotEventSelector = eventSelectorValue, + ) + } + toast("更新成功") + showCaptureScreenshotDlg = false + }) { + Text( + text = "确认", + ) + } + }, + dismissButton = { + TextButton(onClick = { showCaptureScreenshotDlg = false }) { + Text( + text = "取消", + ) + } + }) + } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -394,7 +495,7 @@ fun AdvancedPage() { TextSwitch( title = "快照按钮", - subtitle = "悬浮显示按钮点击保存快照", + subtitle = "显示按钮点击保存快照", checked = ButtonService.isRunning.collectAsState().value, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { @@ -421,18 +522,27 @@ fun AdvancedPage() { TextSwitch( title = "截屏快照", subtitle = "截屏时保存快照", - suffix = "查看限制", - onSuffixClick = { - mainVm.dialogFlow.updateDialogOptions( - title = "限制说明", - text = "仅支持部分小米设备截屏触发\n\n只保存节点信息不保存图片,用户需要在快照记录里替换截图", - ) + checked = store.captureScreenshot, + suffixIcon = { + CustomIconButton( + size = 32.dp, + onClick = throttle { + showCaptureScreenshotDlg = true + }, + ) { + PerfIcon( + modifier = Modifier.size(20.dp), + id = SafeR.ic_page_info, + ) + } }, - checked = store.captureScreenshot ) { storeFlow.value = store.copy( captureScreenshot = it ) + if (it && store.screenshotTargetAppId.isEmpty() || store.screenshotEventSelector.isEmpty()) { + toast("请配置目标应用和特征事件选择器") + } } TextSwitch( @@ -470,41 +580,56 @@ fun AdvancedPage() { ) Text( - text = "界面", + text = "日志", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) SettingItem( - title = "界面记录", + title = "界面日志", + subtitle = "界面切换日志", onClick = { mainVm.navigatePage(ActivityLogPageDestination) } ) TextSwitch( - title = "记录界面", - subtitle = "记录打开的应用及界面", - checked = store.enableActivityLog - ) { - storeFlow.value = store.copy( - enableActivityLog = it - ) - } + title = "界面服务", + subtitle = "显示当前界面信息", + checked = ActivityService.isRunning.collectAsState().value, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + requiredPermission(context, foregroundServiceSpecialUseState) + requiredPermission(context, notificationState) + requiredPermission(context, canDrawOverlaysState) + ActivityService.start() + } else { + ActivityService.stop() + } + } + ) + SettingItem( + title = "事件日志", + subtitle = "无障碍事件日志", + onClick = { + mainVm.navigatePage(A11YEventLogPageDestination) + } + ) TextSwitch( - title = "记录服务", - subtitle = "悬浮显示界面信息", - checked = RecordService.isRunning.collectAsState().value, + title = "事件服务", + subtitle = "显示无障碍事件", + checked = EventService.isRunning.collectAsState().value, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { requiredPermission(context, foregroundServiceSpecialUseState) requiredPermission(context, notificationState) requiredPermission(context, canDrawOverlaysState) - RecordService.start() + EventService.start() } else { - RecordService.stop() + EventService.stop() } } ) + Spacer(modifier = Modifier.height(EmptyHeight)) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt index c35cadc7a5..2f46f8ea36 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt @@ -7,4 +7,5 @@ class AdvancedVm : ViewModel() { val showEditPortDlgFlow = MutableStateFlow(false) val showShizukuStateFlow = MutableStateFlow(false) + val showCaptureScreenshotDlgFlow = MutableStateFlow(false) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index fb89d8ba2c..08cacfe750 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -366,7 +366,7 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { ) } RuleGroupCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), subs = entry.subscription, appId = appId, group = group, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index 80e2e4e1d2..9ec91ed4f2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -266,8 +266,7 @@ private fun A11yAuthButtonGroup() { AuthButtonGroup( onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { mainVm.guardShizukuContext() - writeSecureSettingsState.grantSelf?.invoke() - shizukuContextFlow.value.appOpsService?.allowAllSelfMode() + shizukuContextFlow.value.grantSelf() successAuthExec() }, onClickManual = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index e24a0f2104..c5992c305c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -2,17 +2,18 @@ package li.songe.gkd.ui import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton @@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope @@ -39,10 +41,12 @@ import com.ramcosta.composedestinations.annotation.RootGraph import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity import li.songe.gkd.data.AppInfo +import li.songe.gkd.service.fixRestartService import li.songe.gkd.store.blockA11yAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AnimatedBooleanContent import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon import li.songe.gkd.ui.component.AppNameText @@ -54,9 +58,11 @@ import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.autoFocus +import li.songe.gkd.ui.component.isFullVisible import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon +import li.songe.gkd.ui.icon.LockOpenRight import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState @@ -78,14 +84,14 @@ import li.songe.gkd.util.toast @Destination(style = ProfileTransitions::class) @Composable fun BlockA11yAppListPage() { + val store by storeFlow.collectAsState() val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity val vm = viewModel() - val showSystemApp by vm.showSystemAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() val appInfos by vm.appInfosFlow.collectAsState() val searchStr by vm.searchStrFlow.collectAsState() - val showSearchBar by vm.showSearchBarFlow.collectAsState() + var showSearchBar by vm.showSearchBarFlow.asMutableState() var editable by vm.editableFlow.asMutableState() val (scrollBehavior, listState) = useListScrollState(vm.resetKey, canScroll = { !editable }) BackHandler(editable, vm.viewModelScope.launchAsFn { @@ -103,7 +109,7 @@ fun BlockA11yAppListPage() { topBar = { PerfTopAppBar( scrollBehavior = scrollBehavior, - canScroll = !editable, + canScroll = !editable && !store.blockA11yAppListFollowMatch, navigationIcon = { IconButton( onClick = throttle(vm.viewModelScope.launchAsFn { @@ -130,7 +136,7 @@ fun BlockA11yAppListPage() { if (showSearchBar) { BackHandler { if (!context.justHideSoftInput()) { - vm.showSearchBarFlow.value = false + showSearchBar = false } } AppBarTextField( @@ -177,37 +183,38 @@ fun BlockA11yAppListPage() { contentFalse = { Row { PerfIconButton( - imageVector = PerfIcon.Edit, - onClick = vm.viewModelScope.launchAsFn { - if (editable && vm.textChanged) { - context.justHideSoftInput() - mainVm.dialogFlow.waitResult( - title = "提示", - text = "当前内容未保存,是否放弃编辑?", + imageVector = if (store.blockA11yAppListFollowMatch) PerfIcon.Lock else LockOpenRight, + onClick = throttle { + showSearchBar = false + storeFlow.update { it.copy(blockA11yAppListFollowMatch = !it.blockA11yAppListFollowMatch) } + fixRestartService() + } + ) + + var expanded by remember { mutableStateOf(false) } + AnimatedVisibility(!store.blockA11yAppListFollowMatch) { + Row { + IconButton(onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + showSearchBar = false + } else { + vm.searchStrFlow.value = "" + } + } else { + showSearchBar = true + } + }) { + AnimatedIcon( + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, ) } - editable = !editable - }) - IconButton(onClick = throttle { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - vm.showSearchBarFlow.value = false - } else { - vm.searchStrFlow.value = "" - } - } else { - vm.showSearchBarFlow.value = true + PerfIconButton(imageVector = PerfIcon.Sort, onClick = { + expanded = true + }) } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - ) } - var expanded by remember { mutableStateOf(false) } - PerfIconButton(imageVector = PerfIcon.Sort, onClick = { - expanded = true - }) Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) @@ -243,27 +250,6 @@ fun BlockA11yAppListPage() { }, ) } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - val handle1 = { - storeFlow.update { s -> s.copy(a11yShowSystemApp = !showSystemApp) } - } - DropdownMenuItem( - text = { - Text("显示系统应用") - }, - trailingIcon = { - Checkbox( - checked = showSystemApp, - onCheckedChange = { handle1() } - ) - }, - onClick = handle1, - ) } } } @@ -271,9 +257,31 @@ fun BlockA11yAppListPage() { ) }) }, - floatingActionButton = {}, + floatingActionButton = { + AnimationFloatingActionButton( + visible = !editable && scrollBehavior.isFullVisible && !store.blockA11yAppListFollowMatch, + onClick = { + editable = !editable + }, + content = { + PerfIcon(imageVector = PerfIcon.Edit) + } + ) + }, ) { contentPadding -> - if (editable) { + if (store.blockA11yAppListFollowMatch) { + Column( + modifier = Modifier.scaffoldPadding(contentPadding), + ) { + Spacer(modifier = Modifier.height(EmptyHeight)) + Text( + text = "已设置为跟随应用白名单", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.tertiary, + ) + } + } else if (editable) { MultiTextField( modifier = Modifier.scaffoldPadding(contentPadding), textFlow = vm.textFlow, @@ -292,8 +300,7 @@ fun BlockA11yAppListPage() { item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (appInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = showSystemApp - EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + EmptyText(text = "暂无搜索结果") Spacer(modifier = Modifier.height(EmptyHeight / 2)) } QueryPkgAuthCard() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt index 5023ea34ab..b7b963487a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt @@ -17,11 +17,9 @@ class BlockA11yAppListVm : BaseViewModel() { val sortTypeFlow = storeFlow.mapNew { AppSortOption.objects.findOption(it.a11yAppSort) } - val showSystemAppFlow = storeFlow.mapNew { s -> s.a11yShowSystemApp } val appFilter = useAppFilter( sortTypeFlow = sortTypeFlow, - showSystemAppFlow = showSystemAppFlow, ) val searchStrFlow = appFilter.searchStrFlow diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt index aff6ad17ed..03cabc6943 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt @@ -140,7 +140,7 @@ fun SnapshotPage() { ) { items(snapshots, { it.id }) { snapshot -> SnapshotCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), snapshot = snapshot, onClick = { selectedSnapshot = snapshot @@ -150,7 +150,7 @@ fun SnapshotPage() { item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (snapshots.isEmpty() && !firstLoading) { - EmptyText(text = "暂无记录") + EmptyText(text = "暂无数据") } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index 04f1760d0e..ebe193c9e0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -277,7 +277,7 @@ fun SubsAppGroupListPage( it.categoryKey == category?.key } RuleGroupCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), subs = subs, appId = appId, group = group, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index bef9fbb63b..0716a10363 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -45,6 +45,7 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.ui.component.AnimatedBooleanContent import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon import li.songe.gkd.ui.component.AppNameText @@ -60,6 +61,7 @@ import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus +import li.songe.gkd.ui.component.isFullVisible import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon @@ -92,7 +94,6 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { val showAppInfos = vm.showAppInfosFlow.collectAsState().value var searchStr by vm.searchStrFlow.asMutableState() - val showSystemApp by vm.showSystemAppFlow.collectAsState() val showBlockApp by vm.showBlockAppFlow.collectAsState() val showInnerDisabledApp by vm.showInnerDisabledAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() @@ -122,162 +123,162 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { editable = false })) - Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - PerfTopAppBar( - scrollBehavior = scrollBehavior, - canScroll = !editable, - navigationIcon = { - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { - if (vm.editableFlow.value) { - editable = false - context.justHideSoftInput() - } else { - context.hideSoftInput() - mainVm.popBackStack() - } - })) { - BackCloseIcon(backOrClose = !editable) - } - }, - title = { - if (showSearchBar) { - BackHandler { - if (!context.justHideSoftInput()) { - showSearchBar = false + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + canScroll = !editable, + navigationIcon = { + IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { + if (vm.editableFlow.value) { + editable = false + context.justHideSoftInput() + } else { + context.hideSoftInput() + mainVm.popBackStack() } + })) { + BackCloseIcon(backOrClose = !editable) } - AppBarTextField( - value = searchStr, - onValueChange = { newValue -> - searchStr = newValue.trim() - }, - hint = "请输入应用名称/ID", - modifier = Modifier.autoFocus(), - ) - } else { - TowLineText( - title = group.name, - subtitle = "编辑禁用", - modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ } - ) - } - }, - actions = { - AnimatedBooleanContent( - targetState = editable, - contentAlignment = Alignment.TopEnd, - contentTrue = { - PerfIconButton( - imageVector = PerfIcon.Save, - onClick = throttle(vm.viewModelScope.launchAsFn { - val newExclude = vm.changedValue - if (newExclude != null) { - val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( - type = SubsConfig.GlobalGroupType, - subsId = subsItemId, - groupKey = groupKey, - )).copy( - exclude = newExclude.stringify() - ) - DbSet.subsConfigDao.insert(subsConfig) - toast("更新成功") - } else { - toast("未修改") - } - context.justHideSoftInput() - editable = false - }), + }, + title = { + if (showSearchBar) { + BackHandler { + if (!context.justHideSoftInput()) { + showSearchBar = false + } + } + AppBarTextField( + value = searchStr, + onValueChange = { newValue -> + searchStr = newValue.trim() + }, + hint = "请输入应用名称/ID", + modifier = Modifier.autoFocus(), + ) + } else { + TowLineText( + title = group.name, + subtitle = "编辑禁用", + modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ } ) - }, - contentFalse = { - Row { + } + }, + actions = { + AnimatedBooleanContent( + targetState = editable, + contentAlignment = Alignment.TopEnd, + contentTrue = { PerfIconButton( - imageVector = PerfIcon.Edit, - onClick = { - editable = true - showSearchBar = false - }, + imageVector = PerfIcon.Save, + onClick = throttle(vm.viewModelScope.launchAsFn { + val newExclude = vm.changedValue + if (newExclude != null) { + val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( + type = SubsConfig.GlobalGroupType, + subsId = subsItemId, + groupKey = groupKey, + )).copy( + exclude = newExclude.stringify() + ) + DbSet.subsConfigDao.insert(subsConfig) + toast("更新成功") + } else { + toast("未修改") + } + context.justHideSoftInput() + editable = false + }), ) - IconButton(onClick = { - if (showSearchBar) { - if (searchStr.isEmpty()) { - showSearchBar = false + }, + contentFalse = { + Row { + IconButton(onClick = { + if (showSearchBar) { + if (searchStr.isEmpty()) { + showSearchBar = false + } else { + searchStr = "" + } } else { - searchStr = "" + showSearchBar = true } - } else { - showSearchBar = true + }) { + AnimatedIcon( + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, + var expanded by remember { mutableStateOf(false) } + PerfIconButton( + imageVector = PerfIcon.Sort, + onClick = { + expanded = true + }, ) - } - var expanded by remember { mutableStateOf(false) } - PerfIconButton( - imageVector = PerfIcon.Sort, - onClick = { - expanded = true - }, - ) - Box( - modifier = Modifier - .wrapContentSize(Alignment.TopStart) - ) { - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) ) { - Text( - text = "排序", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - AppSortOption.objects.forEach { sortOption -> - DropdownMenuRadioButtonItem( - text = sortOption.label, - selected = sortType == sortOption, - onClick = { - vm.sortTypeFlow.value = sortOption + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Text( + text = "排序", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + AppSortOption.objects.forEach { sortOption -> + DropdownMenuRadioButtonItem( + text = sortOption.label, + selected = sortType == sortOption, + onClick = { + vm.sortTypeFlow.value = sortOption + } + ) + } + Text( + text = "筛选", + modifier = Modifier.menuPadding(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + DropdownMenuCheckboxItem( + text = "显示内置禁用", + checked = showInnerDisabledApp, + onCheckedChange = { + vm.showInnerDisabledAppFlow.value = it + } + ) + DropdownMenuCheckboxItem( + text = "显示白名单", + checked = showBlockApp, + onCheckedChange = { + vm.showBlockAppFlow.value = it } ) } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - DropdownMenuCheckboxItem( - text = "显示系统应用", - checked = showSystemApp, - onCheckedChange = { - vm.showSystemAppFlow.value = it - } - ) - DropdownMenuCheckboxItem( - text = "显示内置禁用", - checked = showInnerDisabledApp, - onCheckedChange = { - vm.showInnerDisabledAppFlow.value = it - } - ) - DropdownMenuCheckboxItem( - text = "显示白名单", - checked = showBlockApp, - onCheckedChange = { - vm.showBlockAppFlow.value = it - } - ) } } - } - }, - ) - }) - }) { contentPadding -> + }, + ) + }) + }, + floatingActionButton = { + AnimationFloatingActionButton( + visible = !editable && scrollBehavior.isFullVisible, + onClick = { + editable = !editable + }, + content = { + PerfIcon(imageVector = PerfIcon.Edit) + } + ) + } + ) { contentPadding -> if (editable) { MultiTextField( modifier = Modifier.scaffoldPadding(contentPadding), @@ -365,8 +366,7 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (showAppInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = - showSystemApp && showBlockApp && showInnerDisabledApp + val hasShowAll = showBlockApp && showInnerDisabledApp EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") Spacer(modifier = Modifier.height(EmptyHeight / 2)) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt index a955c37466..d6239314c9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt @@ -30,12 +30,6 @@ class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : BaseViewModel() storeFlow.value.copy(subsExcludeSort = it.value) } ) - val showSystemAppFlow = storeFlow.asMutableStateFlow( - getter = { it.subsExcludeShowSystemApp }, - setter = { - storeFlow.value.copy(subsExcludeShowSystemApp = it) - } - ) val showInnerDisabledAppFlow = storeFlow.asMutableStateFlow( getter = { it.subsExcludeShowInnerDisabledApp }, setter = { @@ -55,7 +49,6 @@ class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : BaseViewModel() args.groupKey ).stateInit(emptyList()), sortTypeFlow = sortTypeFlow, - showSystemAppFlow = showSystemAppFlow, showBlockAppFlow = showBlockAppFlow, ) val searchStrFlow = appFilter.searchStrFlow diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index 09f7d7bd0e..9b3cd3325e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -244,7 +244,7 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { items(globalGroups, { g -> g.key }) { group -> val subsConfig = subsConfigs.find { it.groupKey == group.key } RuleGroupCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), subs = subs, appId = null, group = group, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt index 6e48370403..90683d787e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt @@ -9,13 +9,10 @@ import android.webkit.WebView import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,7 +22,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import com.blankj.utilcode.util.LogUtils import com.kevinnzou.web.AccompanistWebViewClient @@ -48,6 +44,7 @@ import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.client @@ -77,16 +74,8 @@ fun WebViewPage( title = { val loadingState = webViewState.loadingState if (loadingState is LoadingState.Loading) { - val fontSizeDp = LocalDensity.current.run { - LocalTextStyle.current.fontSize.toDp() - } - val lineHeightDp = LocalDensity.current.run { - LocalTextStyle.current.lineHeight.toDp() - } CircularProgressIndicator( - modifier = Modifier - .padding(lineHeightDp - fontSizeDp) - .size(fontSizeDp), + modifier = Modifier.iconTextSize(), ) } else { Text( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/Animation.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/Animation.kt index a97f51f592..53fabdd6b5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/Animation.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/Animation.kt @@ -28,8 +28,8 @@ fun usePercentAnimatable( return percent } +context(scope: LazyItemScope, ) fun Modifier.animateListItem( - scope: LazyItemScope, enabled: Boolean = true, ): Modifier { if (!enabled) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt index 577ce6ef0b..44858c1757 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt @@ -1,6 +1,5 @@ package li.songe.gkd.ui.component -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -17,6 +16,7 @@ import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration @@ -26,8 +26,6 @@ import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.shizuku.currentUserId import li.songe.gkd.util.appInfoMapFlow -import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast @Composable fun AppNameText( @@ -35,6 +33,7 @@ fun AppNameText( appId: String? = null, appInfo: AppInfo? = null, fallbackName: String? = null, + style: TextStyle = LocalTextStyle.current, ) { val info = appInfo ?: appInfoMapFlow.collectAsState().value[appId] val showSystemIcon = info?.isSystem == true @@ -56,6 +55,7 @@ fun AppNameText( softWrap = false, overflow = TextOverflow.Ellipsis, textDecoration = textDecoration, + style = style, ) } else { val userNameColor = MaterialTheme.colorScheme.tertiary @@ -79,14 +79,13 @@ fun AppNameText( } } val inlineContent = if (showSystemIcon) { - val textStyle = LocalTextStyle.current - val contentColor = textStyle.color.takeOrElse { LocalContentColor.current } - remember(textStyle, contentColor) { + val contentColor = style.color.takeOrElse { LocalContentColor.current } + remember(style, contentColor) { mapOf( "icon" to InlineTextContent( placeholder = Placeholder( - width = textStyle.fontSize, - height = textStyle.lineHeight, + width = style.fontSize, + height = style.lineHeight, placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { @@ -94,7 +93,6 @@ fun AppNameText( imageVector = PerfIcon.VerifiedUser, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) - .clickable(onClick = throttle { toast("当前是系统应用") }) .fillMaxSize(), tint = contentColor ) @@ -112,6 +110,7 @@ fun AppNameText( softWrap = false, overflow = TextOverflow.Ellipsis, textDecoration = textDecoration, + style = style, ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/FixedTimeText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/FixedTimeText.kt index 80a0e2b8ed..e607b596fb 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/FixedTimeText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/FixedTimeText.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui.component import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.width +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf @@ -16,7 +17,7 @@ import androidx.compose.ui.unit.Dp val LocalNumberCharWidth = compositionLocalOf { error("not found DestinationsNavigator") } @Composable -fun measureNumberTextWidth(style: TextStyle): Dp { +fun measureNumberTextWidth(style: TextStyle = LocalTextStyle.current): Dp { val textMeasurer = rememberTextMeasurer() val widthInPixels = "1234567890".map { c -> textMeasurer.measure(c.toString(), style).size.width @@ -27,11 +28,12 @@ fun measureNumberTextWidth(style: TextStyle): Dp { @Composable fun FixedTimeText( text: String, - style: TextStyle, + modifier: Modifier = Modifier, color: Color = Color.Unspecified, + style: TextStyle = LocalTextStyle.current, charWidth: Dp = LocalNumberCharWidth.current, ) { - Row { + Row(modifier = modifier) { text.forEach { c -> Text( text = c.toString(), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt index 49e35e10c1..d70ccf70cd 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt @@ -10,7 +10,9 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -88,6 +90,28 @@ fun useListScrollState( } @Composable +fun LazyListState.isAtBottom(): androidx.compose.runtime.State = remember(this) { + derivedStateOf { + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (layoutInfo.totalItemsCount == 0) { + false + } else { + val lastVisibleItem = visibleItemsInfo.last() + val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset + (lastVisibleItem.index + 1 == layoutInfo.totalItemsCount && + lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight) + } + } +} + + +val TopAppBarScrollBehavior.isFullVisible: Boolean + @Composable + @ReadOnlyComposable + get() = state.collapsedFraction == 0f + +@Composable +@ReadOnlyComposable fun Modifier.textSize( style: TextStyle = LocalTextStyle.current, density: Density = LocalDensity.current, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index 9315614c2d..b90b358246 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -23,7 +23,7 @@ import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Api -import androidx.compose.material.icons.outlined.AppRegistration +import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.AutoMode import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.DarkMode @@ -36,11 +36,14 @@ import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.SentimentDissatisfied import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.material.icons.outlined.Title import androidx.compose.material.icons.outlined.ToggleOff import androidx.compose.material.icons.outlined.ToggleOn import androidx.compose.material.icons.outlined.VerifiedUser @@ -143,7 +146,6 @@ object PerfIcon { val ArrowForward get() = Icons.AutoMirrored.Filled.ArrowForward val Image get() = Icons.Outlined.Image val WarningAmber get() = Icons.Default.WarningAmber - val AppRegistration get() = Icons.Outlined.AppRegistration val RocketLaunch get() = Icons.Outlined.RocketLaunch val CenterFocusWeak get() = Icons.Default.CenterFocusWeak val AutoMode get() = Icons.Outlined.AutoMode @@ -158,5 +160,8 @@ object PerfIcon { val Layers get() = Icons.Outlined.Layers val Equalizer get() = Icons.Outlined.Equalizer val SentimentDissatisfied get() = Icons.Outlined.SentimentDissatisfied - + val Lock get() = Icons.Outlined.Lock + val Title get() = Icons.Outlined.Title + val TextFields get() = Icons.Outlined.TextFields + val ArrowDownward get() = Icons.Outlined.ArrowDownward } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 992e8f91c7..5662c753a9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -82,7 +82,6 @@ fun useAppListPage(): ScaffoldExt { val context = LocalActivity.current as MainActivity val vm = viewModel() - val showSystemApp by vm.showSystemAppFlow.collectAsState() val showBlockApp by vm.showBlockAppFlow.collectAsState() val sortType by vm.sortTypeFlow.collectAsState() val appInfos by vm.appInfosFlow.collectAsState() @@ -230,21 +229,6 @@ fun useAppListPage(): ScaffoldExt { style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) - val handle1 = { - storeFlow.update { s -> s.copy(showSystemApp = !showSystemApp) } - } - DropdownMenuItem( - text = { - Text("显示系统应用") - }, - trailingIcon = { - Checkbox( - checked = showSystemApp, - onCheckedChange = { handle1() } - ) - }, - onClick = handle1, - ) val handle3 = { storeFlow.update { s -> s.copy(showBlockApp = !s.showBlockApp) } } @@ -319,7 +303,7 @@ fun useAppListPage(): ScaffoldExt { item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (appInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = showSystemApp && showBlockApp + val hasShowAll = showBlockApp EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") Spacer(modifier = Modifier.height(EmptyHeight / 2)) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 88ea7628eb..6f38feb9a1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -45,9 +45,11 @@ import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService +import li.songe.gkd.service.ActivityService import li.songe.gkd.service.StatusService import li.songe.gkd.service.a11yPartDisabledFlow import li.songe.gkd.service.switchA11yService +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.GroupNameText import li.songe.gkd.ui.component.PerfIcon @@ -121,7 +123,7 @@ fun useControlPage(): ScaffoldExt { checked = a11yRunning, onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newEnabled -> if (newEnabled && !writeSecureSettingsState.updateAndGet()) { - writeSecureSettingsState.grantSelf?.invoke() + shizukuContextFlow.value.grantSelf() } if (newEnabled && !writeSecureSettingsState.updateAndGet()) { mainVm.navigatePage(AuthA11YPageDestination) @@ -167,9 +169,9 @@ fun useControlPage(): ScaffoldExt { } ) - if (store.enableActivityLog) { + if (ActivityService.isRunning.collectAsState().value) { PageItemCard( - title = "界面记录", + title = "界面日志", subtitle = "记录打开的应用及界面", imageVector = PerfIcon.Layers, onClick = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt index 2366cb1906..d397ebf908 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt @@ -28,7 +28,6 @@ class HomeVm : BaseViewModel() { val sortTypeFlow = storeFlow.mapNew { AppSortOption.objects.findOption(it.appSort) } - val showSystemAppFlow = storeFlow.mapNew { s -> s.showSystemApp } val showBlockAppFlow = storeFlow.mapNew { s -> s.showBlockApp } val editWhiteListModeFlow = MutableStateFlow(false) @@ -42,7 +41,6 @@ class HomeVm : BaseViewModel() { val appFilter = useAppFilter( sortTypeFlow = sortTypeFlow, - showSystemAppFlow = showSystemAppFlow, showBlockAppFlow = showBlockAppFlow, blockAppListFlow = blockAppListFlow, ) @@ -60,4 +58,8 @@ class HomeVm : BaseViewModel() { MainViewModel.instance.appListKeyFlow.value++ } } + + val showToastInputDlgFlow = MutableStateFlow(false) + val showNotifTextInputDlgFlow = MutableStateFlow(false) + val showToastSettingsDlgFlow = MutableStateFlow(false) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 756e1d2a16..5f511c2581 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -39,7 +39,6 @@ import com.ramcosta.composedestinations.generated.destinations.AboutPageDestinat import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import com.ramcosta.composedestinations.generated.destinations.BlockA11YAppListPageDestination import kotlinx.coroutines.flow.update -import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.fixRestartService @@ -57,6 +56,7 @@ import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.titleItemPadding @@ -75,12 +75,7 @@ fun useSettingsPage(): ScaffoldExt { val store by storeFlow.collectAsState() val vm = viewModel() - var showToastInputDlg by remember { - mutableStateOf(false) - } - var showNotifTextInputDlg by remember { - mutableStateOf(false) - } + var showToastInputDlg by vm.showToastInputDlgFlow.asMutableState() if (showToastInputDlg) { var value by remember { @@ -135,6 +130,8 @@ fun useSettingsPage(): ScaffoldExt { } ) } + + var showNotifTextInputDlg by vm.showNotifTextInputDlgFlow.asMutableState() if (showNotifTextInputDlg) { var titleValue by remember { mutableStateOf(store.customNotifTitle) } var textValue by remember { mutableStateOf(store.customNotifText) } @@ -244,7 +241,7 @@ fun useSettingsPage(): ScaffoldExt { }) } - var showToastSettingsDlg by remember { mutableStateOf(false) } + var showToastSettingsDlg by vm.showToastSettingsDlgFlow.asMutableState() if (showToastSettingsDlg) { AlertDialog( onDismissRequest = { showToastSettingsDlg = false }, @@ -364,7 +361,7 @@ fun useSettingsPage(): ScaffoldExt { ) }) - if (store.enableShizuku && writeSecureSettingsState.stateFlow.collectAsState().value || META.debuggable) { + if (store.enableShizuku && writeSecureSettingsState.stateFlow.collectAsState().value) { AnimatedVisibility(visible = store.enableBlockA11yAppList) { Row( modifier = Modifier diff --git a/app/src/main/kotlin/li/songe/gkd/ui/icon/DragPan.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/DragPan.kt new file mode 100644 index 0000000000..62615ccce5 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/icon/DragPan.kt @@ -0,0 +1,63 @@ +package li.songe.gkd.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val DragPan: ImageVector + get() { + if (_IconName != null) { + return _IconName!! + } + _IconName = ImageVector.Builder( + name = "IconName", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + path(fill = SolidColor(Color(0xFF5F6368))) { + moveTo(480f, 880f) + lineTo(310f, 710f) + lineToRelative(57f, -57f) + lineToRelative(73f, 73f) + verticalLineToRelative(-206f) + lineTo(235f, 520f) + lineToRelative(73f, 72f) + lineToRelative(-58f, 58f) + lineTo(80f, 480f) + lineToRelative(169f, -169f) + lineToRelative(57f, 57f) + lineToRelative(-72f, 72f) + horizontalLineToRelative(206f) + verticalLineToRelative(-206f) + lineToRelative(-73f, 73f) + lineToRelative(-57f, -57f) + lineToRelative(170f, -170f) + lineToRelative(170f, 170f) + lineToRelative(-57f, 57f) + lineToRelative(-73f, -73f) + verticalLineToRelative(206f) + horizontalLineToRelative(205f) + lineToRelative(-73f, -72f) + lineToRelative(58f, -58f) + lineToRelative(170f, 170f) + lineToRelative(-170f, 170f) + lineToRelative(-57f, -57f) + lineToRelative(73f, -73f) + lineTo(520f, 520f) + verticalLineToRelative(205f) + lineToRelative(72f, -73f) + lineToRelative(58f, 58f) + lineTo(480f, 880f) + close() + } + }.build() + + return _IconName!! + } + +@Suppress("ObjectPropertyName") +private var _IconName: ImageVector? = null diff --git a/app/src/main/kotlin/li/songe/gkd/ui/icon/LockOpenRight.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/LockOpenRight.kt new file mode 100644 index 0000000000..0ede5af6aa --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/icon/LockOpenRight.kt @@ -0,0 +1,75 @@ +package li.songe.gkd.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val LockOpenRight: ImageVector + get() { + if (_LockOpenRight != null) { + return _LockOpenRight!! + } + _LockOpenRight = ImageVector.Builder( + name = "LockOpenRight", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + path(fill = SolidColor(Color(0xFF5F6368))) { + moveTo(240f, 800f) + horizontalLineToRelative(480f) + verticalLineToRelative(-400f) + lineTo(240f, 400f) + verticalLineToRelative(400f) + close() + moveTo(480f, 680f) + quadToRelative(33f, 0f, 56.5f, -23.5f) + reflectiveQuadTo(560f, 600f) + quadToRelative(0f, -33f, -23.5f, -56.5f) + reflectiveQuadTo(480f, 520f) + quadToRelative(-33f, 0f, -56.5f, 23.5f) + reflectiveQuadTo(400f, 600f) + quadToRelative(0f, 33f, 23.5f, 56.5f) + reflectiveQuadTo(480f, 680f) + close() + moveTo(240f, 800f) + verticalLineToRelative(-400f) + verticalLineToRelative(400f) + close() + moveTo(240f, 880f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(160f, 800f) + verticalLineToRelative(-400f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(240f, 320f) + horizontalLineToRelative(280f) + verticalLineToRelative(-80f) + quadToRelative(0f, -83f, 58.5f, -141.5f) + reflectiveQuadTo(720f, 40f) + quadToRelative(83f, 0f, 141.5f, 58.5f) + reflectiveQuadTo(920f, 240f) + horizontalLineToRelative(-80f) + quadToRelative(0f, -50f, -35f, -85f) + reflectiveQuadToRelative(-85f, -35f) + quadToRelative(-50f, 0f, -85f, 35f) + reflectiveQuadToRelative(-35f, 85f) + verticalLineToRelative(80f) + horizontalLineToRelative(120f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(800f, 400f) + verticalLineToRelative(400f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(720f, 880f) + lineTo(240f, 880f) + close() + } + }.build() + + return _LockOpenRight!! + } + +@Suppress("ObjectPropertyName") +private var _LockOpenRight: ImageVector? = null diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt index e4db551486..4d1224c299 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt @@ -1,5 +1,6 @@ package li.songe.gkd.ui.share +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -13,7 +14,6 @@ import li.songe.gkd.util.visibleAppInfosFlow fun BaseViewModel.useAppFilter( sortTypeFlow: StateFlow, - showSystemAppFlow: StateFlow, appOrderListFlow: StateFlow> = MainViewModel.instance.appOrderListFlow, showBlockAppFlow: StateFlow? = null, blockAppListFlow: StateFlow> = blockMatchAppListFlow, @@ -26,13 +26,7 @@ fun BaseViewModel.useAppFilter( it.mapIndexed { i, appId -> appId to i }.toMap() } - var tempListFlow = visibleAppInfosFlow.combine(showSystemAppFlow) { appInfos, showSystemApp -> - if (showSystemApp) { - appInfos - } else { - appInfos.filterNot { it.isSystem } - } - } + var tempListFlow: Flow> = visibleAppInfosFlow if (showBlockAppFlow != null) { tempListFlow = combine( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt index 89e093f14c..3f4c2bc211 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt @@ -39,9 +39,16 @@ fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { } @Composable -fun Modifier.iconTextSize(textStyle: TextStyle = LocalTextStyle.current): Modifier { +fun Modifier.iconTextSize( + textStyle: TextStyle = LocalTextStyle.current, + square: Boolean = true, +): Modifier { val density = LocalDensity.current val lineHeightDp = density.run { textStyle.lineHeight.toDp() } val fontSizeDp = density.run { textStyle.fontSize.toDp() } - return padding((lineHeightDp - fontSizeDp) / 2).size(fontSizeDp) + return if (square) { + padding((lineHeightDp - fontSizeDp) / 2).size(fontSizeDp) + } else { + size(height = lineHeightDp, width = fontSizeDp) + } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index a74b72f827..28ebb3c5cd 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -170,18 +170,28 @@ fun updateAllAppInfo( } } if (!canQueryPkgState.updateAndGet() || newAppMap.getMayQueryPkgNoAccess()) { - val visiblePkgList = arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> - app.packageManager.queryIntentActivities( - Intent(action), - PackageManager.MATCH_DISABLED_COMPONENTS - ) - }.flatten() - .map { it.activityInfo.packageName }.toSet() - .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) } - visiblePkgList.forEach { packageInfo -> - newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() - packageInfo.pkgIcon?.let { icon -> - newIconMap[packageInfo.packageName] = icon + val pkgList2 = shizukuContextFlow.value.packageManager?.getInstalledPackages(PKG_FLAGS) + if (!pkgList2.isNullOrEmpty()) { + pkgList2.forEach { packageInfo -> + newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() + packageInfo.pkgIcon?.let { icon -> + newIconMap[packageInfo.packageName] = icon + } + } + } else { + val visiblePkgList = arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> + app.packageManager.queryIntentActivities( + Intent(action), + PackageManager.MATCH_DISABLED_COMPONENTS + ) + }.flatten() + .map { it.activityInfo.packageName }.toSet() + .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) } + visiblePkgList.forEach { packageInfo -> + newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() + packageInfo.pkgIcon?.let { icon -> + newIconMap[packageInfo.packageName] = icon + } } } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index 231aaaa3e3..812689b764 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -29,6 +29,7 @@ object ShortUrlSet { const val URL12 = "https://gkd.li?r=12" const val URL13 = "https://gkd.li?r=13" const val URL14 = "https://gkd.li?r=14" + const val URL15 = "https://gkd.li?r=15" } const val shizukuAppId = "moe.shizuku.privileged.api" diff --git a/app/src/main/kotlin/li/songe/gkd/util/Others.kt b/app/src/main/kotlin/li/songe/gkd/util/Others.kt index e3634c4cf0..37c0937952 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -23,9 +23,11 @@ import androidx.compose.animation.togetherWith import androidx.compose.ui.unit.sp import androidx.core.graphics.get import com.blankj.utilcode.util.LogUtils +import kotlinx.serialization.json.JsonElement import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.app +import li.songe.json5.Json5 import li.songe.json5.Json5EncoderConfig import li.songe.json5.encodeToJson5String import java.io.DataOutputStream @@ -112,6 +114,9 @@ suspend fun runCommandByRoot(commandText: String) { val defaultJson5Config = Json5EncoderConfig(indent = "\u0020\u0020", trailingComma = true) inline fun toJson5String(value: T): String { + if (value is JsonElement) { + return Json5.encodeToString(value, defaultJson5Config) + } return json.encodeToJson5String(value, defaultJson5Config) } diff --git a/app/src/main/res/drawable/ic_event_list.xml b/app/src/main/res/drawable/ic_event_list.xml new file mode 100644 index 0000000000..0facbed324 --- /dev/null +++ b/app/src/main/res/drawable/ic_event_list.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f48cdb3cd0..86827c38a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,5 +7,6 @@ HTTP服务 快照按钮 规则匹配 - 记录服务 + 界面服务 + 事件服务 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71fe620be3..3992a022f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ kotlin = "2.2.20" ksp = "2.2.20-2.0.3" agp = "8.13.0" -compose = "1.9.1" -room = "2.8.0" +compose = "1.9.2" +room = "2.8.1" paging = "3.3.6" ktor = "3.3.0" atomicfu = "0.29.0" @@ -32,9 +32,9 @@ compose_preview = { module = "androidx.compose.ui:ui-tooling-preview", version.r compose_tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose_junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose_icons = "androidx.compose.material:material-icons-extended:1.7.8" -compose_material3 = "androidx.compose.material3:material3:1.3.2" +compose_material3 = "androidx.compose.material3:material3:1.4.0" compose_activity = "androidx.activity:activity-compose:1.11.0" -compose_navigation = "androidx.navigation:navigation-compose:2.9.4" +compose_navigation = "androidx.navigation:navigation-compose:2.9.5" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.17.0" androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.4" @@ -66,7 +66,7 @@ reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" toaster = "com.github.getActivity:Toaster:13.6" permissions = "com.github.getActivity:XXPermissions:26.5" -json5 = "li.songe:json5:0.3.6" +json5 = "li.songe:json5:0.4.1" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" kevinnzouWebview = "io.github.kevinnzou:compose-webview:0.33.6" @@ -82,5 +82,5 @@ android_application = { id = "com.android.application", version.ref = "agp" } androidx_room = { id = "androidx.room", version.ref = "room" } google_ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } rikka_refine = { id = "dev.rikka.tools.refine", version.ref = "rikka_refine" } -benmanes_version = "com.github.ben-manes.versions:0.52.0" +benmanes_version = "com.github.ben-manes.versions:0.53.0" littlerobots_version = "nl.littlerobots.version-catalog-update:1.0.0" From 44cca3673f657a6dc5636ad10758f81d50d3f54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 1 Oct 2025 03:48:27 +0800 Subject: [PATCH 061/245] refactor: BlockA11yAppList --- app/src/main/AndroidManifest.xml | 1 + app/src/main/kotlin/li/songe/gkd/App.kt | 2 + .../main/kotlin/li/songe/gkd/MainActivity.kt | 7 + .../kotlin/li/songe/gkd/a11y/A11yContext.kt | 21 +- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 4 +- .../li/songe/gkd/a11y/A11yRuleEngine.kt | 46 +++- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 4 - .../kotlin/li/songe/gkd/data/GkdAction.kt | 6 +- .../songe/gkd/permission/PermissionState.kt | 22 ++ .../li/songe/gkd/service/A11yService.kt | 16 +- .../li/songe/gkd/service/GkdTileService.kt | 28 +- .../li/songe/gkd/service/StatusService.kt | 15 +- .../li/songe/gkd/shizuku/InputManager.kt | 2 + .../li/songe/gkd/shizuku/InputShellCommand.kt | 38 ++- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 6 + .../li/songe/gkd/shizuku/UserService.kt | 1 - .../li/songe/gkd/ui/component/PerfIcon.kt | 4 +- .../li/songe/gkd/ui/home/ControlPage.kt | 15 +- .../kotlin/li/songe/gkd/ui/home/HomeVm.kt | 1 + .../li/songe/gkd/ui/home/SettingsPage.kt | 252 ++++++++++++++---- .../kotlin/li/songe/gkd/util/IntentExt.kt | 8 + .../java/android/view/KeyEventHidden.java | 19 ++ 22 files changed, 410 insertions(+), 108 deletions(-) create mode 100644 hidden_api/src/main/java/android/view/KeyEventHidden.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9327f4d325..68c900098f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ + , AccessibilityNodeInfo>(MAX_CACHE_SIZE) private var indexCache = LruCache(MAX_CACHE_SIZE) private var parentCache = LruCache(MAX_CACHE_SIZE) - var rootCache: AccessibilityNodeInfo? = null + val rootCache = atomic(null) private fun clearChildCache(node: AccessibilityNodeInfo) { repeat(node.childCount.coerceAtMost(MAX_CHILD_SIZE)) { i -> @@ -56,8 +57,8 @@ class A11yContext( } fun clearNodeCache(eventNode: AccessibilityNodeInfo? = null) { - if (rootCache?.packageName != topActivityFlow.value.appId) { - rootCache = null + if (rootCache.value?.packageName != topActivityFlow.value.appId) { + rootCache.value = null } if (eventNode != null) { clearChildCache(eventNode) @@ -66,8 +67,8 @@ class A11yContext( childCache[p to i] = eventNode } } - if (rootCache == eventNode) { - rootCache = eventNode + if (rootCache.value == eventNode) { + rootCache.value = eventNode } else { if (META.debuggable) { Log.d( @@ -174,11 +175,11 @@ class A11yContext( } private fun getCacheRoot(node: AccessibilityNodeInfo? = null): AccessibilityNodeInfo? { - if (rootCache.notExpiredNode == null) { - rootCache = getA11Root() + if (rootCache.value.notExpiredNode == null) { + rootCache.value = getA11Root() } - if (node == rootCache) return null - return rootCache + if (node == rootCache.value) return null + return rootCache.value } private fun getCacheParent(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { @@ -190,7 +191,7 @@ class A11yContext( if (this != null) { parentCache[node] = this } else { - rootCache = node + rootCache.value = node } } } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt index 918e162836..4abab1d5aa 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.IntentFilter import android.graphics.PixelFormat import android.util.Log +import android.view.Gravity import android.view.View import android.view.WindowManager import android.view.accessibility.AccessibilityEvent @@ -67,7 +68,7 @@ private fun A11yService.useAttachState() { onDestroyed { if (isActivityVisible()) { if (willDestroyByBlock) { - toast("无障碍已局部关闭") + toast("无障碍局部关闭") } else { toast("无障碍已停止") } @@ -209,6 +210,7 @@ private fun A11yService.useAliveOverlayView() { format = PixelFormat.TRANSLUCENT flags = flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + gravity = Gravity.START or Gravity.TOP width = 1 height = 1 packageName = context.packageName diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index 9443138cca..1718cfd0a6 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -2,7 +2,10 @@ package li.songe.gkd.a11y import android.util.Log import android.view.accessibility.AccessibilityEvent -import kotlinx.coroutines.Job +import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.getAndUpdate +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -23,6 +26,9 @@ import li.songe.gkd.util.launchTry import li.songe.gkd.util.showActionToast import li.songe.gkd.util.systemUiAppId import java.util.concurrent.Executors +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine private val eventDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() @@ -132,13 +138,28 @@ class A11yRuleEngine(val service: A11yService) { // 某些应用通过无障碍获取 safeActiveWindow 耗时长,导致多个事件连续堆积堵塞,无法检测到 appId 切换导致状态异常 // https://github.com/gkd-kit/gkd/issues/622 lastAppId = withTimeoutOrNull(100) { - runInterruptible { service.safeActiveWindowAppId } + runInterruptible(Dispatchers.IO) { service.safeActiveWindowAppId } } ?: safeGetTopCpn()?.packageName lastGetAppIdTime = System.currentTimeMillis() return lastAppId } - var queryJob: Job? = null + // 某些场景耗时 5000 ms + suspend fun getTimeoutActiveWindow(): AccessibilityNodeInfo? = suspendCoroutine { s -> + val temp = atomic?>(s) + scope.launch(Dispatchers.IO) { + delay(500L) + temp.getAndUpdate { null }?.resume(null) + } + scope.launch(Dispatchers.IO) { + val a = service.safeActiveWindow + temp.getAndUpdate { null }?.resume(a) + } + } + + + @Volatile + var querying = false @Synchronized fun startQueryJob( @@ -148,9 +169,18 @@ class A11yRuleEngine(val service: A11yService) { ) { if (!storeFlow.value.enableMatch) return if (activityRuleFlow.value.currentRules.isEmpty()) return - if (queryJob?.isActive == true) return - queryJob = scope.launchTry(queryDispatcher) { - queryAction(byEvent, byForced, byDelayRule) + if (querying) return + // 刚启动时获取 safeActiveWindow 非常耗时 + if (byEvent == null && service.justStarted) return + scope.launchTry(queryDispatcher) { + querying = true + try { + Log.d("A11yRuleEngine", "startQueryJob start") + queryAction(byEvent, byForced, byDelayRule) + } finally { + Log.d("A11yRuleEngine", "startQueryJob end") + querying = false + } } } @@ -183,7 +213,7 @@ class A11yRuleEngine(val service: A11yService) { } } - fun queryAction( + suspend fun queryAction( byEvent: A11yEvent? = null, byForced: Boolean = false, delayRule: ResolvedRule? = null, @@ -271,7 +301,7 @@ class A11yRuleEngine(val service: A11yService) { lastNode = null } } - val nodeVal = (lastNode ?: service.safeActiveWindow) ?: continue + val nodeVal = (lastNode ?: getTimeoutActiveWindow()) ?: continue val rightAppId = nodeVal.packageName?.toString() ?: break val matchApp = rule.matchActivity(rightAppId) if (topActivityFlow.value.appId != rightAppId || (!matchApp && rule is AppRule)) { diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index ea046abbf9..a924ebbf28 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -132,10 +132,6 @@ class ActivityRule( val activityRuleFlow = MutableStateFlow(ActivityRule()) -val topAppIdFlow by lazy { - MutableStateFlow(launcherAppId) -} - private var appLogCount = 0 private var lastAppId = "" diff --git a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt index 2125decc5c..1441f401cd 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt @@ -61,7 +61,7 @@ sealed class ActionPerformer(val action: String) { return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - if (shizukuContextFlow.value.inputManager?.tap(x, y) != null) { + if (shizukuContextFlow.value.tap(x, y)) { return ActionResult( action = action, result = true, @@ -129,11 +129,11 @@ sealed class ActionPerformer(val action: String) { return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - if (shizukuContextFlow.value.inputManager?.tap( + if (shizukuContextFlow.value.tap( x, y, longClickDuration - ) != null + ) ) { return ActionResult( action = action, diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 3d863c8e09..32e394eafe 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -198,6 +198,27 @@ val canWriteExternalStorage by lazy { ) } +val ignoreBatteryOptimizationsState by lazy { + val permission = PermissionLists.getRequestIgnoreBatteryOptimizationsPermission() + PermissionState( + check = { + app.powerManager.isIgnoringBatteryOptimizations(app.packageName) + }, + request = { + asyncRequestPermission(it, permission) + }, + reason = AuthReason( + text = { "当前操作需要「忽略电池优化权限」\n请先前往权限页面授权" }, + confirm = { + XXPermissions.startPermissionActivity( + app, + permission + ) + } + ), + ) +} + val writeSecureSettingsState by lazy { PermissionState( check = { checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, @@ -220,6 +241,7 @@ fun updatePermissionState() { foregroundServiceSpecialUseState, canDrawOverlaysState, canWriteExternalStorage, + ignoreBatteryOptimizationsState, writeSecureSettingsState, shizukuOkState, ).forEach { it.updateAndGet() } diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index b877dc0efe..bfd1d49372 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -6,7 +6,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.os.PowerManager import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import androidx.core.content.ContextCompat @@ -22,6 +21,7 @@ import li.songe.gkd.a11y.isUseful import li.songe.gkd.a11y.onA11yFeatInit import li.songe.gkd.a11y.setGeneratedTime import li.songe.gkd.a11y.typeInfo +import li.songe.gkd.app import li.songe.gkd.data.ActionPerformer import li.songe.gkd.data.ActionResult import li.songe.gkd.data.GkdAction @@ -43,6 +43,15 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { onA11yEvent(event) } + val startTime = System.currentTimeMillis() + var justStarted: Boolean = true + get() { + if (field) { + field = System.currentTimeMillis() - startTime < 3_000 + } + return field + } + val safeActiveWindow: AccessibilityNodeInfo? get() = try { // 某些应用耗时 554ms @@ -51,14 +60,13 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { } catch (_: Throwable) { null }.apply { - a11yContext.rootCache = this + a11yContext.rootCache.value = this } val safeActiveWindowAppId: String? get() = safeActiveWindow?.packageName?.toString() override val scope = useScope() - val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } var isInteractive = true private set var outStartQueryJob = {} @@ -92,7 +100,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { onCreated { a11yRef = this } onDestroyed { a11yRef = null } onCreated { - isInteractive = powerManager.isInteractive + isInteractive = app.powerManager.isInteractive ContextCompat.registerReceiver( this, screenStateReceiver, diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 9e47e87e1c..9ec60b7176 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -3,6 +3,7 @@ package li.songe.gkd.service import android.provider.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update @@ -12,7 +13,6 @@ import kotlinx.coroutines.sync.withLock import li.songe.gkd.META import li.songe.gkd.a11y.systemRecentCn import li.songe.gkd.a11y.topActivityFlow -import li.songe.gkd.a11y.topAppIdFlow import li.songe.gkd.accessRestrictedSettingsShowFlow import li.songe.gkd.app import li.songe.gkd.appScope @@ -36,7 +36,7 @@ class GkdTileService : BaseTileService() { private val modifyA11yMutex = Mutex() private const val A11Y_AWAIT_START_TIME = 2000L -private const val A11Y_AWAIT_FIX_TIME = 500L +private const val A11Y_AWAIT_FIX_TIME = 1000L private fun modifyA11yRun(block: suspend () -> Unit) { appScope.launchTry(Dispatchers.IO) { @@ -108,18 +108,18 @@ fun fixRestartService() = modifyA11yRun { } } -private fun forcedUpdateA11yService(disabled: Boolean) { +private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { if (!storeFlow.value.enableService) { - return + return@modifyA11yRun } if (!storeFlow.value.enableBlockA11yAppList) { - return + return@modifyA11yRun } - if (!writeSecureSettingsState.updateAndGet()) { - return + if (!writeSecureSettingsState.stateFlow.value) { + return@modifyA11yRun } if (!disabled == A11yService.isRunning.value) { - return + return@modifyA11yRun } val names = app.getSecureA11yServices() if (disabled) { @@ -137,18 +137,18 @@ private const val A11Y_WHITE_APP_AWAIT_TIME = 3000L @Volatile var lastAppIdChangeTime = 0L - -fun updateTopAppId(value: String) { - lastAppIdChangeTime = System.currentTimeMillis() - topAppIdFlow.value = value -} - +val topAppIdFlow = MutableStateFlow("") val a11yPartDisabledFlow by lazy { topAppIdFlow.mapState(appScope) { actualBlockA11yAppList.contains(it) } } +fun updateTopAppId(value: String) { + lastAppIdChangeTime = System.currentTimeMillis() + topAppIdFlow.value = value +} + fun initA11yWhiteAppList() { val actualFlow = topAppIdFlow.drop(1) appScope.launch(Dispatchers.Main) { diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index 5920c48c89..d31467fdab 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -8,19 +8,23 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import li.songe.gkd.META +import li.songe.gkd.MainActivity import li.songe.gkd.a11y.useA11yServiceEnabledFlow import li.songe.gkd.app import li.songe.gkd.notif.abNotif import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState +import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.shizukuOkState import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.RuleSummary +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.getSubsStatus import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.startForegroundServiceByClass @@ -60,7 +64,8 @@ class StatusService : Service(), OnSimpleLife { "无障碍发生故障" } else if (writeSecureSettingsState.updateAndGet()) { if (store.enableService && store.enableBlockA11yAppList && a11yPartDisabledFlow.value) { - "无障碍已局部关闭" + val name = appInfoMapFlow.value[topAppIdFlow.value]?.name ?: topAppIdFlow.value + "局部关闭「$name」" } else { "无障碍已关闭" } @@ -98,7 +103,7 @@ class StatusService : Service(), OnSimpleLife { shizukuWarnFlow, a11yServiceEnabledFlow, writeSecureSettingsState.stateFlow, - a11yPartDisabledFlow, + topAppIdFlow, actionCountFlow.debounce(1000L), ) { statusTriple() @@ -123,6 +128,12 @@ class StatusService : Service(), OnSimpleLife { val isRunning = MutableStateFlow(false) fun start() = startForegroundServiceByClass(StatusService::class) fun stop() = stopServiceByClass(StatusService::class) + suspend fun requestStart(context: MainActivity) { + requiredPermission(context, foregroundServiceSpecialUseState) + requiredPermission(context, notificationState) + start() + storeFlow.update { it.copy(enableStatusService = true) } + } private var lastAutoStart = 0L fun autoStart() { diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt index 37c8e681df..03484268cb 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt @@ -44,4 +44,6 @@ class SafeInputManager(private val value: IInputManager) { } } + fun key(keyCode: Int) = command.runKeyEvent(keyCode) + } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt index 0c90e059cc..bd7ca91d25 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt @@ -5,6 +5,9 @@ import android.os.Build import android.os.SystemClock import android.view.Display import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.KeyEventHidden import android.view.MotionEvent import android.view.MotionEvent.PointerCoords import android.view.MotionEvent.PointerProperties @@ -18,6 +21,7 @@ import kotlin.math.floor // https://github.com/android-cs/16/blob/main/services/core/java/com/android/server/input/InputShellCommand.java +@Suppress("SameParameterValue") class InputShellCommand(val safeInputManager: SafeInputManager) { companion object { private const val DEFAULT_DEVICE_ID = 0 @@ -49,7 +53,6 @@ class InputShellCommand(val safeInputManager: SafeInputManager) { ) } - @Suppress("SameParameterValue") private fun sendSwipe( inputSource: Int, x1: Float, @@ -104,7 +107,6 @@ class InputShellCommand(val safeInputManager: SafeInputManager) { ) } - @Suppress("SameParameterValue") private fun sendTap( inputSource: Int, x: Float, @@ -235,4 +237,36 @@ class InputShellCommand(val safeInputManager: SafeInputManager) { private fun lerp(a: Float, b: Float, alpha: Float): Float { return (b - a) * alpha + a } + + fun runKeyEvent(keyCode: Int) { + sendKeyEvent(keyCode) + } + + private fun sendKeyEvent(keyCode: Int) { + val inputSource = InputDevice.SOURCE_UNKNOWN + val displayId = Display.INVALID_DISPLAY + val async = false + + val now = SystemClock.uptimeMillis() + val event = KeyEvent( + now, now, KeyEvent.ACTION_DOWN, keyCode, 0, + 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, + inputSource + ) + if (AndroidTarget.Q) { + Refine.unsafeCast(event).setDisplayId(displayId) + } + injectKeyEvent(event, async) + val event2 = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0) + injectKeyEvent(KeyEvent.changeAction(event2, KeyEvent.ACTION_UP), async) + } + + private fun injectKeyEvent(event: KeyEvent, async: Boolean) { + val injectMode: Int = if (async) { + InputManagerHidden.INJECT_INPUT_EVENT_MODE_ASYNC + } else { + InputManagerHidden.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH + } + safeInputManager.compatInjectInputEvent(event, injectMode) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index fcdbade08c..a0bbff74de 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -3,6 +3,7 @@ package li.songe.gkd.shizuku import android.content.ComponentName import android.content.pm.PackageManager +import androidx.annotation.WorkerThread import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -87,6 +88,11 @@ class ShizukuContext( appOpsService?.allowAllSelfMode() packageManager?.allowAllSelfPermission() } + + @WorkerThread + fun tap(x: Float, y: Float, duration: Long = 0): Boolean { + return serviceWrapper?.tap(x, y, duration) ?: (inputManager?.tap(x, y, duration) != null) + } } private val defaultShizukuContext = ShizukuContext( diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index ada90e306a..8b08d67c0b 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -112,7 +112,6 @@ data class CommandResult( get() = code == 0 } -@Suppress("unused") data class UserServiceWrapper( val userService: IUserService, val connection: ServiceConnection, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index b90b358246..0e62175a74 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Api import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.AutoMode +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.Delete @@ -40,7 +41,6 @@ import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Save -import androidx.compose.material.icons.outlined.SentimentDissatisfied import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.TextFields import androidx.compose.material.icons.outlined.Title @@ -159,9 +159,9 @@ object PerfIcon { val Notifications get() = Icons.Outlined.Notifications val Layers get() = Icons.Outlined.Layers val Equalizer get() = Icons.Outlined.Equalizer - val SentimentDissatisfied get() = Icons.Outlined.SentimentDissatisfied val Lock get() = Icons.Outlined.Lock val Title get() = Icons.Outlined.Title val TextFields get() = Icons.Outlined.TextFields val ArrowDownward get() = Icons.Outlined.ArrowDownward + val Check get() = Icons.Outlined.Check } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 6f38feb9a1..76cc098e01 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -40,9 +40,6 @@ import com.ramcosta.composedestinations.generated.destinations.AuthA11YPageDesti import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination import li.songe.gkd.MainActivity import li.songe.gkd.data.SubsConfig -import li.songe.gkd.permission.foregroundServiceSpecialUseState -import li.songe.gkd.permission.notificationState -import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService import li.songe.gkd.service.ActivityService @@ -111,7 +108,7 @@ fun useControlPage(): ScaffoldExt { "无障碍发生故障" } else if (writeSecureSettings) { if (store.enableService && a11yPartDisabledFlow.collectAsState().value) { - "无障碍已局部关闭" + "无障碍局部关闭" } else { "无障碍已关闭" } @@ -144,15 +141,13 @@ fun useControlPage(): ScaffoldExt { checked = manageRunning && store.enableStatusService, onCheckedChange = throttle(fn = vm.viewModelScope.launchAsFn { if (it) { - requiredPermission(context, foregroundServiceSpecialUseState) - requiredPermission(context, notificationState) - StatusService.start() + StatusService.requestStart(context) } else { StatusService.stop() + storeFlow.value = store.copy( + enableStatusService = false + ) } - storeFlow.value = store.copy( - enableStatusService = it - ) }), ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt index d397ebf908..63151057f7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt @@ -62,4 +62,5 @@ class HomeVm : BaseViewModel() { val showToastInputDlgFlow = MutableStateFlow(false) val showNotifTextInputDlgFlow = MutableStateFlow(false) val showToastSettingsDlgFlow = MutableStateFlow(false) + val showA11yBlockDlgFlow = MutableStateFlow(false) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 5f511c2581..dd311136b2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -1,35 +1,47 @@ package li.songe.gkd.ui.home +import android.view.KeyEvent import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties @@ -38,14 +50,22 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import com.ramcosta.composedestinations.generated.destinations.BlockA11YAppListPageDestination +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity -import li.songe.gkd.permission.writeSecureSettingsState +import li.songe.gkd.permission.ignoreBatteryOptimizationsState +import li.songe.gkd.permission.requiredPermission +import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.CustomIconButton import li.songe.gkd.ui.component.CustomOutlinedTextField +import li.songe.gkd.ui.component.FullscreenDialog import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar @@ -54,17 +74,20 @@ import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.TextSwitch import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.updateDialogOptions -import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.iconTextSize +import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.DarkThemeOption import li.songe.gkd.util.SafeR import li.songe.gkd.util.findOption import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.mapState +import li.songe.gkd.util.openA11ySettings +import li.songe.gkd.util.openAppDetailsSettings import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -280,6 +303,11 @@ fun useSettingsPage(): ScaffoldExt { ) } + var showA11yBlockDlg by vm.showA11yBlockDlgFlow.asMutableState() + if (showA11yBlockDlg) { + BlockA11yDialog(onDismissRequest = { showA11yBlockDlg = false }) + } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollState = rememberScrollState() return ScaffoldExt( @@ -361,35 +389,21 @@ fun useSettingsPage(): ScaffoldExt { ) }) - if (store.enableShizuku && writeSecureSettingsState.stateFlow.collectAsState().value) { - AnimatedVisibility(visible = store.enableBlockA11yAppList) { - Row( + if (store.enableShizuku) { + val scope = rememberCoroutineScope() + val lazyOn = remember { + storeFlow.mapState(scope) { it.enableBlockA11yAppList }.debounce(300) + .stateIn(scope, SharingStarted.Eagerly, store.enableBlockA11yAppList) + }.collectAsState() + AnimatedVisibility(visible = lazyOn.value) { + Text( modifier = Modifier .fillMaxWidth() .titleItemPadding(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "无障碍", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - if (mainVm.hasOtherA11yFlow.collectAsState().value) { - PerfIcon( - modifier = Modifier - .clip(MaterialTheme.shapes.extraSmall) - .clickable(onClick = throttle { - mainVm.dialogFlow.updateDialogOptions( - title = "无效优化", - text = "检测到已启用其它应用的无障碍服务,在此情况下,局部关闭是无效优化,因为无障碍不会被完全关闭,建议关闭「局部关闭」功能或其它应用的无障碍服务", - ) - }) - .iconTextSize(textStyle = MaterialTheme.typography.titleSmall), - imageVector = PerfIcon.SentimentDissatisfied, - tint = MaterialTheme.colorScheme.primary, - ) - } - } + text = "无障碍", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) } TextSwitch( title = "局部关闭", @@ -397,29 +411,14 @@ fun useSettingsPage(): ScaffoldExt { checked = store.enableBlockA11yAppList, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { - mainVm.dialogFlow.waitResult( - title = "使用说明", - text = "「局部关闭」可解决某些应用无障碍检测或界面异常的问题\n\n切换无障碍会造成触摸卡顿,请自行考虑后再编辑无障碍白名单\n\n如果还使用其它无障碍应用会导致优化无效,因为无障碍不会被完全关闭\n\n此外需额外设置确保无障碍关闭后的持续后台运行\n1. 开启「常驻通知」\n2. 在「最近任务界面」锁定\n3. 允许自启动\n4. 省电策略设置为无限制\n不设置会被系统暂停或结束运行,导致无法恢复无障碍", - confirmText = "继续", - dismissRequest = true, - ) - } - storeFlow.value = store.copy( - enableBlockA11yAppList = it - ) - if (!it) { + showA11yBlockDlg = true + } else { fixRestartService() - } - if (it) { - if (!shizukuContextFlow.value.ok) { - toast("请先连接 Shizuku") - } else { - !writeSecureSettingsState.updateAndGet() - } + storeFlow.value = store.copy(enableBlockA11yAppList = false) } }, ) - AnimatedVisibility(visible = store.enableBlockA11yAppList) { + AnimatedVisibility(visible = lazyOn.value) { SettingItem(title = "白名单", onClick = { mainVm.navigatePage(BlockA11YAppListPageDestination) }) @@ -470,3 +469,162 @@ fun useSettingsPage(): ScaffoldExt { } } } + +@Composable +private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onDismissRequest) { + val mainVm = LocalMainViewModel.current + val statusRunning by StatusService.isRunning.collectAsState() + val shizukuOk by shizukuOkState.stateFlow.collectAsState() + val ignoreBatteryOptimizations by ignoreBatteryOptimizationsState.stateFlow.collectAsState() + val hasOtherA11y by mainVm.hasOtherA11yFlow.collectAsState() + val context = LocalActivity.current as MainActivity + Scaffold( + topBar = { + PerfTopAppBar( + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.Close, + onClick = onDismissRequest, + ) + }, + title = { + Text(text = "局部关闭") + }, + ) + }, + bottomBar = { + BottomAppBar { + Spacer(modifier = Modifier.weight(1f)) + TextButton( + enabled = statusRunning && shizukuOk && ignoreBatteryOptimizations && !hasOtherA11y, + onClick = mainVm.viewModelScope.launchAsFn { + onDismissRequest() + delay(200) + storeFlow.update { it.copy(enableBlockA11yAppList = true) } + } + ) { + Text(text = "继续") + } + Spacer(modifier = Modifier.width(itemHorizontalPadding)) + } + }, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) + .padding(horizontal = itemHorizontalPadding) + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + Text(text = "「局部关闭」可在白名单应用内关闭无障碍,来解决某些应用界面异常或无障碍检测的问题") + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "使用须知", style = MaterialTheme.typography.titleMedium) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + RequiredTextItem(text = "切换无障碍会造成短暂触摸卡顿,请自行测试考虑后再编辑白名单") + RequiredTextItem(text = "如果还使用其它无障碍应用会导致优化无效,因为无障碍不会被完全关闭") + RequiredTextItem(text = "必须确保无障碍关闭后的持续后台运行,否则会被系统暂停或结束运行,导致无法恢复无障碍") + } + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "使用条件", style = MaterialTheme.typography.titleMedium) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + RequiredTextItem( + text = "Shizuku 授权", + enabled = !shizukuOk, + imageVector = if (shizukuOk) PerfIcon.Check else PerfIcon.ArrowForward, + onClick = { + mainVm.requestShizuku() + }, + ) + RequiredTextItem( + text = "开启「常驻通知」", + enabled = !statusRunning, + imageVector = if (statusRunning) PerfIcon.Check else PerfIcon.ArrowForward, + onClick = mainVm.viewModelScope.launchAsFn { + StatusService.requestStart(context) + }, + ) + RequiredTextItem( + text = "省电策略设置为无限制", + enabled = !ignoreBatteryOptimizations, + imageVector = if (ignoreBatteryOptimizations) PerfIcon.Check else PerfIcon.ArrowForward, + onClick = mainVm.viewModelScope.launchAsFn { + requiredPermission(context, ignoreBatteryOptimizationsState) + }, + ) + RequiredTextItem( + text = "关闭其它应用的无障碍", + enabled = hasOtherA11y, + imageVector = if (!hasOtherA11y) PerfIcon.Check else PerfIcon.ArrowForward, + onClick = { + openA11ySettings() + }, + ) + RequiredTextItem( + text = "允许自启动", + enabled = true, + imageVector = PerfIcon.OpenInNew, + onClick = { + openAppDetailsSettings() + }, + ) + RequiredTextItem( + text = "在「最近任务界面」锁定", + enabled = true, + imageVector = PerfIcon.OpenInNew, + onClick = { + shizukuContextFlow.value.inputManager?.key(KeyEvent.KEYCODE_APP_SWITCH) + }, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "某些场景下无障碍刚启动时概率不工作,如多次遇到此情况则不建议使用此功能") + } + Spacer(modifier = Modifier.height(EmptyHeight)) + } + } +} + +@Composable +private fun RequiredTextItem( + text: String, + imageVector: ImageVector? = null, + enabled: Boolean = false, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .run { + if (onClick != null) { + clickable(enabled = enabled, onClick = throttle(onClick)) + } else { + this + } + } + .padding(horizontal = 4.dp), + ) { + val density = LocalDensity.current + val lineHeightDp = density.run { LocalTextStyle.current.lineHeight.toDp() } + Spacer( + modifier = Modifier + .padding(vertical = (lineHeightDp - 4.dp) / 2) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + .size(4.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text) + if (imageVector != null) { + PerfIcon( + imageVector = imageVector, + modifier = Modifier.iconTextSize(), + ) + } + } + +} diff --git a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt index ecfaa9e771..64564f19d4 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt @@ -96,6 +96,14 @@ fun openA11ySettings() { app.tryStartActivity(intent) } +fun openAppDetailsSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${app.packageName}".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + app.tryStartActivity(intent) +} + fun openUri(uri: String) { val u = try { uri.toUri() diff --git a/hidden_api/src/main/java/android/view/KeyEventHidden.java b/hidden_api/src/main/java/android/view/KeyEventHidden.java new file mode 100644 index 0000000000..978a834492 --- /dev/null +++ b/hidden_api/src/main/java/android/view/KeyEventHidden.java @@ -0,0 +1,19 @@ +package android.view; + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import dev.rikka.tools.refine.RefineAs; + +/** + * @noinspection unused + */ +@RefineAs(KeyEvent.class) +public class KeyEventHidden { + + @RequiresApi(Build.VERSION_CODES.Q) + public void setDisplayId(int displayId) { + throw new RuntimeException("Stub"); + } +} From dce4d5e37312b5a2ca80ec3251dee208d47318da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 1 Oct 2025 04:50:19 +0800 Subject: [PATCH 062/245] feat: show action log subs version (#1007) --- .../kotlin/li/songe/gkd/ui/ActionLogPage.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt index c9c6a9eaa7..0b14efa650 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -315,8 +316,7 @@ private fun ActionLogCard( ) } if (subsId == null) { - Text( - text = subscription?.name ?: "id=${actionLog.subsId}", + Row( modifier = Modifier.clickable(onClick = throttle { if (subsItemsFlow.value.any { it.id == actionLog.subsId }) { mainVm.sheetSubsIdFlow.value = actionLog.subsId @@ -324,7 +324,28 @@ private fun ActionLogCard( toast("订阅不存在") } }) - ) + ) { + Text(text = subscription?.name ?: "id=${actionLog.subsId}") + val lineHeightDp = LocalDensity.current.run { + LocalTextStyle.current.lineHeight.toDp() + } + Row( + modifier = Modifier + .height(lineHeightDp) + .padding(start = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "v${item.first.subsVersion}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 2.dp), + ) + } + } } Row( modifier = Modifier.fillMaxWidth() From faf9836035f5cfde66c43ca1dfedfa6777ee8601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 1 Oct 2025 05:21:28 +0800 Subject: [PATCH 063/245] chore: v1.11.0-beta.1 --- .github/workflows/Build-Release.yml | 5 +++++ CHANGELOG.md | 33 +++++++++++++++++++++++------ app/build.gradle.kts | 4 ++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/Build-Release.yml b/.github/workflows/Build-Release.yml index 1aa7a5e47e..5172bc5497 100644 --- a/.github/workflows/Build-Release.yml +++ b/.github/workflows/Build-Release.yml @@ -64,6 +64,11 @@ jobs: permissions: write-all runs-on: ubuntu-latest steps: + - uses: actions/download-artifact@v4 + with: + name: outputs + path: outputs + - uses: actions/download-artifact@v4 with: name: release diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c92c638d..772c12534f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,34 @@ -# v1.10.4 +# v1.11.0-beta.1 以下是本次更新的主要内容 ## 优化和修复 -- 修复选择器使用某些字段时查询失败 -- 修复应用打开时长按控制中心图标无法跳转 -- 修复规则有时不参与匹配的问题 -- 优化新增 [resetMatch](https://gkd.li/api/interfaces/RawCommonProps#resetmatch) 字段场景 -- 优化适配 android 16 -- 其他优化和修复 +- 优化多个页面的显示和使用体验 +- 优化规则编辑弹窗为页面并支持代码高亮 +- 重构应用列表移除部分排序筛选,增加按最近使用排序,显示冻结应用,隐藏无界面应用,支持下拉刷新 +- 新增多个通知栏开关 +- 新增使用协议和隐私政策 +- 新增通知文案支持主标题和副标题 +- 优化任意悬浮窗支持保存上次位置 +- 新增无障碍事件悬浮窗及日志页面 +- 新增截屏快照的应用ID和特征事件选择器 +- 新增应用白名单内暂停匹配 +- 新增局部关闭,在无障碍白名单内关闭无障碍 +- 新增界面服务悬浮窗显示 Activity +- 重构 Shizuku 授权为单个 `启用优化` 开关,所有功能内部自动判断开启 +- 优化连接 Shizuku 后自动给 GKD 授权 +- 优化通知管理,服务类常驻通知均增加关闭按钮 +- 优化关于页面反馈提示 +- 优化空白截图增加文字提示 +- 优化所有列表页面点击标题返回顶部 +- 优化规则执行逻辑 +- 新增订阅字段 `versionCode` 和 `versionName` +- 新增订阅字段值 `action:'none'` +- 修复在设备重启时启动常驻通知报错 +- 修复 resetMatch=app 且 activityIds 有值时匹配异常 +- 修复 com.android.systemui 系统界面识别异常 +- 其它多个优化和修复 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67e60772ee..6cc3156236 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,8 +65,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 65 - versionName = "1.10.4" + versionCode = 67 + versionName = "1.11.0-beta.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From dd53c2163505e7391bd9dc926822e6a1c09737b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 1 Oct 2025 22:23:58 +0800 Subject: [PATCH 064/245] perf: EditBlockAppListPage onBack --- .../li/songe/gkd/ui/EditBlockAppListPage.kt | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt index 0546c8c4f1..5ca831138c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt @@ -1,5 +1,6 @@ package li.songe.gkd.ui +import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Scaffold @@ -31,24 +32,26 @@ fun EditBlockAppListPage() { val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity val vm = viewModel() + val onBack = throttle(vm.viewModelScope.launchAsFn { + if (vm.getChangedSet() != null) { + context.justHideSoftInput() + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } else { + context.hideSoftInput() + } + mainVm.popBackStack() + }) + BackHandler(onBack = onBack) Scaffold(modifier = Modifier, topBar = { PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { PerfIconButton( imageVector = PerfIcon.ArrowBack, - onClick = throttle(vm.viewModelScope.launchAsFn { - if (vm.getChangedSet() != null) { - context.justHideSoftInput() - mainVm.dialogFlow.waitResult( - title = "提示", - text = "当前内容未保存,是否放弃编辑?", - ) - } else { - context.hideSoftInput() - } - mainVm.popBackStack() - }) + onClick = onBack, ) }, title = { Text(text = "应用白名单") }, From c1e5fee586dc925331900e0c399adfba0b460cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 2 Oct 2025 23:12:07 +0800 Subject: [PATCH 065/245] perf: indicatorSize --- .../main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt | 2 +- app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt | 6 +++--- .../main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt | 2 +- app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt | 6 +++--- .../kotlin/li/songe/gkd/ui/component/MultiTextField.kt | 7 ++++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index c5992c305c..72e53a2612 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -287,7 +287,7 @@ fun BlockA11yAppListPage() { textFlow = vm.textFlow, immediateFocus = true, placeholderText = "请输入应用ID列表\n示例:\ncom.android.systemui\ncom.android.settings", - indicatorText = vm.indicatorTextFlow.collectAsState().value, + indicatorSize = vm.indicatorSizeFlow.collectAsState().value, ) } else { LazyColumn( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt index b7b963487a..7ab1eab236 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt @@ -32,9 +32,9 @@ class BlockA11yAppListVm : BaseViewModel() { val textFlow = MutableStateFlow("") val textChanged get() = blockA11yAppListFlow.value != AppListString.decode(textFlow.value) - val indicatorTextFlow = textFlow.debounce(500).map { - AppListString.decode(it).size.toString() - }.stateInit("") + val indicatorSizeFlow = textFlow.debounce(500).map { + AppListString.decode(it).size + }.stateInit(0) init { showSearchBarFlow.launchCollect { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt index 5ca831138c..5c98397b3b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt @@ -76,7 +76,7 @@ fun EditBlockAppListPage() { MultiTextField( modifier = Modifier.scaffoldPadding(contentPadding), textFlow = vm.textFlow, - indicatorText = vm.indicatorTextFlow.collectAsState().value + indicatorSize = vm.indicatorSizeFlow.collectAsState().value ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt index 4fda442ce2..6d3d4d566b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt @@ -16,9 +16,9 @@ class EditBlockAppListVm : BaseViewModel() { ) ) - val indicatorTextFlow = textFlow.debounce(500).map { - AppListString.decode(it).size.toString() - }.stateInit("") + val indicatorSizeFlow = textFlow.debounce(500).map { + AppListString.decode(it).size + }.stateInit(0) fun getChangedSet(): Set? { val newSet = AppListString.decode(textFlow.value) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt index 8901c38105..850e84a082 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt @@ -30,7 +30,7 @@ fun MultiTextField( modifier: Modifier = Modifier, textFlow: MutableStateFlow, immediateFocus: Boolean = false, - indicatorText: String? = null, + indicatorSize: Int? = null, placeholderText: String? = null, ) { val text by textFlow.collectAsState() @@ -55,9 +55,10 @@ fun MultiTextField( colors = textColors, ) } - if (text.isNotEmpty()) { + val actualSize = indicatorSize ?: text.length + if (actualSize > 0 && text.isNotEmpty()) { Text( - text = indicatorText ?: text.length.toString(), + text = actualSize.toString(), modifier = Modifier .padding(8.dp) .align(Alignment.TopEnd) From 1daa633677a9597a0c5a6efcaf271ea2b56adf68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 3 Oct 2025 00:25:28 +0800 Subject: [PATCH 066/245] chore: AccessibilityEvent isUseful --- app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index 01af093cdd..d3723fc265 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -118,9 +118,7 @@ fun AccessibilityEvent?.isUseful(): Boolean { contract { returns(true) implies (this@isUseful != null) } - return (this != null && packageName != null && className != null && eventType.and( - interestedEvents - ) != 0) + return (this != null && packageName != null && className != null && eventType and interestedEvents != 0) } From c35dee0a585f748dcd36834976737e1341ade543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 4 Oct 2025 18:35:07 +0800 Subject: [PATCH 067/245] perf: enableBlockA11yAppList fixRestartService --- app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index dd311136b2..7fddb8526b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -413,8 +413,8 @@ fun useSettingsPage(): ScaffoldExt { if (it) { showA11yBlockDlg = true } else { - fixRestartService() storeFlow.value = store.copy(enableBlockA11yAppList = false) + fixRestartService() } }, ) From d425e6489f732c2d7ad248c96894287bd72df52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 7 Oct 2025 00:23:43 +0800 Subject: [PATCH 068/245] perf: a11y auth --- .../main/kotlin/li/songe/gkd/MainActivity.kt | 9 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 44 ++++--- .../main/kotlin/li/songe/gkd/a11y/A11yFeat.kt | 8 +- .../li/songe/gkd/a11y/A11yRuleEngine.kt | 8 +- .../songe/gkd/permission/PermissionDialog.kt | 7 - .../songe/gkd/permission/PermissionState.kt | 19 ++- .../li/songe/gkd/service/ActivityService.kt | 3 +- .../li/songe/gkd/service/GkdTileService.kt | 6 +- .../li/songe/gkd/service/StatusService.kt | 4 +- .../li/songe/gkd/shizuku/ActivityManager.kt | 4 +- .../songe/gkd/shizuku/ActivityTaskManager.kt | 4 +- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 60 ++++----- .../li/songe/gkd/shizuku/TaskStackListener.kt | 4 +- .../li/songe/gkd/shizuku/UserService.kt | 4 +- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 15 +-- .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 24 ++-- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 123 +++++++++++------- .../songe/gkd/ui/component/AuthButtonGroup.kt | 28 ++-- .../gkd/ui/component/ManualAuthDialog.kt | 2 +- .../li/songe/gkd/ui/home/ControlPage.kt | 6 +- .../li/songe/gkd/ui/home/SettingsPage.kt | 110 +++++++++------- .../main/kotlin/li/songe/gkd/util/Others.kt | 23 ---- .../kotlin/li/songe/gkd/util/SnapshotExt.kt | 4 +- 23 files changed, 258 insertions(+), 261 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index c59fa6bc5b..e829c675e7 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -76,6 +76,7 @@ import li.songe.gkd.service.ScreenshotService import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.service.updateTopAppId +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.BuildDialog import li.songe.gkd.ui.component.PerfIcon @@ -88,6 +89,7 @@ import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.LocalNavController import li.songe.gkd.ui.style.AppTheme import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.BarUtils import li.songe.gkd.util.EditGithubCookieDlg import li.songe.gkd.util.KeyboardUtils import li.songe.gkd.util.ShortUrlSet @@ -119,7 +121,7 @@ class MainActivity : ComponentActivity() { get() = ViewCompat.getRootWindowInsets(window.decorView)!! .isVisible(WindowInsetsCompat.Type.ime()) - var topBarWindowInsets by mutableStateOf(WindowInsets()) + var topBarWindowInsets by mutableStateOf(WindowInsets(top = BarUtils.getStatusBarHeight())) private fun watchKeyboardVisible() { if (AndroidTarget.R) { @@ -325,6 +327,7 @@ fun syncFixState() { syncStateMutex.withLock { updateSystemDefaultAppId() updateServiceRunning() + shizukuContextFlow.value.grantSelf() updatePermissionState() fixRestartService() } @@ -345,9 +348,9 @@ private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { Column { Text( text = if (installed) { - "Shizuku 授权失败, 请检查是否运行" + "Shizuku 授权失败,请检查是否运行" } else { - "Shizuku 授权失败, 检测到 Shizuku 未安装, 请先下载后安装, 如果你是通过其它方式授权, 请忽略此提示自行查找原因" + "Shizuku 授权失败,检测到 Shizuku 未安装,请先下载后安装,如果你是通过其它方式授权,请忽略此提示自行查找原因" } ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 384899e17d..f00ae91857 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -19,7 +19,6 @@ import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map @@ -33,7 +32,7 @@ import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.permission.AuthReason -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.service.A11yService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.shizuku.updateBinderMutex @@ -287,29 +286,35 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { ) } - - fun requestShizuku() = try { - Shizuku.requestPermission(Activity.RESULT_OK) - } catch (e: Throwable) { - shizukuErrorFlow.value = e + fun switchEnableShizuku(value: Boolean) { + if (updateBinderMutex.mutex.isLocked) { + toast("正在连接中,请稍后") + return + } + storeFlow.update { s -> s.copy(enableShizuku = value) } } - suspend fun guardShizukuContext() { + fun requestShizuku() { if (shizukuContextFlow.value.ok) return if (updateBinderMutex.mutex.isLocked) { - toast("正在连接 Shizuku 服务,请稍后") - stopCoroutine() + toast("正在连接中,请稍后") + return } - if (!shizukuOkState.stateFlow.value) { - requestShizuku() - stopCoroutine() + try { + Shizuku.requestPermission(Activity.RESULT_OK) + } catch (e: Throwable) { + shizukuErrorFlow.value = e } + } + + suspend fun guardShizukuContext() { + if (shizukuContextFlow.value.ok) return if (!storeFlow.value.enableShizuku) { storeFlow.update { it.copy(enableShizuku = true) } - delay(500) - while (updateBinderMutex.mutex.isLocked) { - delay(100) - } + } + if (!shizukuGrantedState.updateAndGet()) { + requestShizuku() + stopCoroutine() } if (shizukuContextFlow.value.ok) return stopCoroutine() @@ -317,8 +322,9 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { private val a11yServicesFlow = useEnabledA11yServicesFlow() val a11yServiceEnabledFlow = useA11yServiceEnabledFlow(a11yServicesFlow) - val hasOtherA11yFlow = - a11yServicesFlow.mapNew { it.isNotEmpty() && !it.contains(A11yService.a11yCn) } + val hasOtherA11yFlow = a11yServicesFlow.mapNew { list -> + list.any { it != A11yService.a11yCn } + } init { // preload diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt index 4abab1d5aa..3fafc216fa 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -19,10 +19,10 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.service.A11yService import li.songe.gkd.service.StatusService -import li.songe.gkd.shizuku.safeGetTopCpn +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.ScreenUtils import li.songe.gkd.util.SnapshotExt @@ -50,7 +50,7 @@ fun onA11yFeatInit() = service.run { onA11yEvent { onA11yFeatEvent(it) } onCreated { StatusService.autoStart() } onDestroyed { - safeGetTopCpn()?.let { + shizukuContextFlow.value.topCpn()?.let { // com.android.systemui if (!topActivityFlow.value.sameAs(it.packageName, it.className)) { updateTopActivity(it.packageName, it.className) @@ -102,7 +102,7 @@ private fun watchCheckShizukuState() { if (t - lastCheckShizukuTime > 60 * 60_000L) { lastCheckShizukuTime = t appScope.launchTry(Dispatchers.IO) { - shizukuOkState.updateAndGet() + shizukuGrantedState.updateAndGet() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index 1718cfd0a6..c28ab109a1 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -20,7 +20,7 @@ import li.songe.gkd.isActivityVisible import li.songe.gkd.service.A11yService import li.songe.gkd.service.EventService import li.songe.gkd.service.a11yPartDisabledFlow -import li.songe.gkd.shizuku.safeGetTopCpn +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.util.launchTry import li.songe.gkd.util.showActionToast @@ -115,7 +115,7 @@ class A11yRuleEngine(val service: A11yService) { } if (rightAppId != topActivityFlow.value.appId) { // 从 锁屏,下拉通知栏 返回等情况, 应用不会发送事件, 但是系统组件会发送事件 - val topCpn = safeGetTopCpn() + val topCpn = shizukuContextFlow.value.topCpn() if (topCpn?.packageName == rightAppId) { updateTopActivity(topCpn.packageName, topCpn.className) } else { @@ -139,7 +139,7 @@ class A11yRuleEngine(val service: A11yService) { // https://github.com/gkd-kit/gkd/issues/622 lastAppId = withTimeoutOrNull(100) { runInterruptible(Dispatchers.IO) { service.safeActiveWindowAppId } - } ?: safeGetTopCpn()?.packageName + } ?: shizukuContextFlow.value.topCpn()?.packageName lastGetAppIdTime = System.currentTimeMillis() return lastAppId } @@ -201,7 +201,7 @@ class A11yRuleEngine(val service: A11yService) { fun fixAppId(rightAppId: String) { if (topActivityFlow.value.appId == rightAppId) return - val topCpn = safeGetTopCpn() + val topCpn = shizukuContextFlow.value.topCpn() if (topCpn?.packageName == rightAppId) { updateTopActivity(topCpn.packageName, topCpn.className) } else { diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt index 2f0d25bdb5..08854c9bf3 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt @@ -9,9 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.MainActivity -import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.util.stopCoroutine -import li.songe.gkd.util.toast data class AuthReason( val text: () -> String, @@ -60,11 +58,6 @@ suspend fun requiredPermission( permissionState: PermissionState ) { if (permissionState.updateAndGet()) return - shizukuContextFlow.value.grantSelf() - if (permissionState.updateAndGet()) { - toast("已借助 Shizuku 自动授权") - return - } val result = permissionState.request?.invoke(context) if (result == null) { context.mainVm.authReasonFlow.value = permissionState.reason diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 32e394eafe..584f2cb8fe 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.updateAndGet import li.songe.gkd.MainActivity import li.songe.gkd.app -import li.songe.gkd.shizuku.shizukuCheckGranted +import li.songe.gkd.shizuku.SafePackageManager import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.AndroidTarget @@ -23,6 +23,7 @@ import li.songe.gkd.util.mayQueryPkgNoAccessFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex +import rikka.shizuku.Shizuku class PermissionState( val check: () -> Boolean, @@ -40,7 +41,6 @@ class PermissionState( } fun checkOrToast(): Boolean = if (!updateAndGet()) { - shizukuContextFlow.value.grantSelf() val r = updateAndGet() if (!r) { reason?.text?.let { toast(it()) } @@ -225,7 +225,18 @@ val writeSecureSettingsState by lazy { ) } -val shizukuOkState by lazy { +private fun shizukuCheckGranted(): Boolean { + val granted = try { + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + } catch (_: Throwable) { + false + } + if (!granted) return false + val u = shizukuContextFlow.value.packageManager ?: SafePackageManager.newBinder() + return u?.isSafeMode != null +} + +val shizukuGrantedState by lazy { PermissionState( check = { shizukuCheckGranted() }, ) @@ -243,6 +254,6 @@ fun updatePermissionState() { canWriteExternalStorage, ignoreBatteryOptimizationsState, writeSecureSettingsState, - shizukuOkState, + shizukuGrantedState, ).forEach { it.updateAndGet() } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt index bcb2f47744..46fd581ce1 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt @@ -32,7 +32,6 @@ import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.recordNotif import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.shizuku.SafeTaskListener -import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.PerfIcon @@ -113,7 +112,7 @@ class ActivityService : OverlayWindowService( } } if (!A11yService.isRunning.value) { - safeGetTopCpn()?.let { cpn -> + shizukuContextFlow.value.topCpn()?.let { cpn -> updateTopActivity( appId = cpn.packageName, activityId = cpn.className, diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt index 9ec60b7176..10dcb5638f 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -18,7 +18,6 @@ import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.writeSecureSettingsState -import li.songe.gkd.shizuku.safeGetTopCpn import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.actualBlockA11yAppList import li.songe.gkd.store.storeFlow @@ -51,8 +50,7 @@ fun switchA11yService() = modifyA11yRun { A11yService.instance?.disableSelf() } else { if (!writeSecureSettingsState.updateAndGet()) { - shizukuContextFlow.value.grantSelf() - if (!writeSecureSettingsState.updateAndGet()) { + if (!writeSecureSettingsState.value) { toast("请先授予「写入安全设置权限」") return@modifyA11yRun } @@ -83,7 +81,7 @@ fun fixRestartService() = modifyA11yRun { val topAppId = if (isActivityVisible() || app.justStarted) { META.appId } else { - safeGetTopCpn()?.packageName + shizukuContextFlow.value.topCpn()?.packageName } if (topAppId != null && topAppId in actualBlockA11yAppList) { return@modifyA11yRun diff --git a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt index d31467fdab..d1c29a9066 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -18,7 +18,7 @@ import li.songe.gkd.notif.abNotif import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow @@ -38,7 +38,7 @@ class StatusService : Service(), OnSimpleLife { override val scope = useScope() val shizukuWarnFlow = combine( - shizukuOkState.stateFlow, + shizukuGrantedState.stateFlow, storeFlow.map { it.enableShizuku }, ) { a, b -> !a && b diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt index fdce83d190..79e9104e29 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt @@ -2,7 +2,7 @@ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.IActivityManager -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass @@ -35,7 +35,7 @@ class SafeActivityManager(private val value: IActivityManager) { } fun unregisterDefault() { - if (!shizukuOkState.stateFlow.value) return + if (!shizukuGrantedState.stateFlow.value) return if (!SafeTaskListener.isAvailable) return safeInvokeMethod { value.unregisterTaskStackListener(SafeTaskListener.instance) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt index 47fae15b5b..7d7cdff981 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt @@ -3,7 +3,7 @@ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.IActivityTaskManager import android.view.Display -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass @@ -44,7 +44,7 @@ class SafeActivityTaskManager(private val value: IActivityTaskManager) { } fun unregisterDefault() { - if (!shizukuOkState.stateFlow.value) return + if (!shizukuGrantedState.stateFlow.value) return if (!SafeTaskListener.isAvailable) return safeInvokeMethod { value.unregisterTaskStackListener(SafeTaskListener.instance) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index a0bbff74de..d3d5b7cdaf 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -2,7 +2,6 @@ package li.songe.gkd.shizuku import android.content.ComponentName -import android.content.pm.PackageManager import androidx.annotation.WorkerThread import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers @@ -14,7 +13,8 @@ import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState +import li.songe.gkd.permission.updatePermissionState import li.songe.gkd.store.storeFlow import li.songe.gkd.util.MutexState import li.songe.gkd.util.launchTry @@ -38,15 +38,6 @@ fun getStubService(name: String, condition: Boolean): ShizukuBinderWrapper? { return ShizukuBinderWrapper(service) } -private val shizukuUsedFlow by lazy { - combine( - shizukuOkState.stateFlow, - storeFlow.map { it.enableShizuku }, - ) { a, b -> - a && b - }.stateIn(appScope, SharingStarted.Eagerly, false) -} - class ShizukuContext( val serviceWrapper: UserServiceWrapper?, val packageManager: SafePackageManager?, @@ -56,14 +47,6 @@ class ShizukuContext( val appOpsService: SafeAppOpsService?, val inputManager: SafeInputManager?, ) { - init { - if (activityTaskManager != null) { - activityTaskManager.registerDefault() - } else { - activityManager?.registerDefault() - } - } - val ok get() = this !== defaultShizukuContext fun destroy() { serviceWrapper?.destroy() @@ -93,6 +76,20 @@ class ShizukuContext( fun tap(x: Float, y: Float, duration: Long = 0): Boolean { return serviceWrapper?.tap(x, y, duration) ?: (inputManager?.tap(x, y, duration) != null) } + + fun topCpn(): ComponentName? { + return (activityTaskManager?.getTasks(1) + ?: activityManager?.getTasks(1))?.firstOrNull()?.topActivity + } + + init { + if (activityTaskManager != null) { + activityTaskManager.registerDefault() + } else { + activityManager?.registerDefault() + } + grantSelf() + } } private val defaultShizukuContext = ShizukuContext( @@ -109,19 +106,13 @@ val currentUserId by lazy { android.os.Process.myUserHandle().hashCode() } val shizukuContextFlow = MutableStateFlow(defaultShizukuContext) -fun safeGetTopCpn(): ComponentName? = shizukuContextFlow.value.run { - (activityTaskManager?.getTasks(1) ?: activityManager?.getTasks(1))?.firstOrNull()?.topActivity -} - -fun shizukuCheckGranted(): Boolean { - val granted = try { - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - } catch (_: Throwable) { - false - } - if (!granted) return false - val u = shizukuContextFlow.value.packageManager ?: SafePackageManager.newBinder() - return u?.isSafeMode != null +private val shizukuUsedFlow by lazy { + combine( + shizukuGrantedState.stateFlow, + storeFlow.map { it.enableShizuku }, + ) { a, b -> + a && b + }.stateIn(appScope, SharingStarted.Eagerly, false) } val updateBinderMutex = MutexState() @@ -139,6 +130,7 @@ private fun updateShizukuBinder() = updateBinderMutex.launchTry(appScope, Dispat appOpsService = SafeAppOpsService.newBinder(), inputManager = SafeInputManager.newBinder(), ) + updatePermissionState() if (isActivityVisible()) { val delayMillis = if (app.justStarted) 1200L else 0L val newValue = shizukuContextFlow.value @@ -165,12 +157,12 @@ fun initShizuku() { Shizuku.addBinderReceivedListener { LogUtils.d("Shizuku.addBinderReceivedListener") appScope.launchTry(Dispatchers.IO) { - shizukuOkState.updateAndGet() + shizukuGrantedState.updateAndGet() } } Shizuku.addBinderDeadListener { LogUtils.d("Shizuku.addBinderDeadListener") - shizukuOkState.stateFlow.value = false + shizukuGrantedState.stateFlow.value = false } appScope.launchTry { shizukuUsedFlow.collect { updateShizukuBinder() } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt index 2bdd74d644..6d9957f909 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt @@ -20,7 +20,7 @@ class FixedTaskStackListener : ITaskStackListener.Stub() { lastFront = 0 return } - val cpn = safeGetTopCpn() ?: return + val cpn = shizukuContextFlow.value.topCpn() ?: return updateTopActivity( appId = cpn.packageName, activityId = cpn.className, @@ -31,7 +31,7 @@ class FixedTaskStackListener : ITaskStackListener.Stub() { private var lastFront = 0L fun onTaskMovedToFrontCompat(cpn: ComponentName? = null) { lastFront = System.currentTimeMillis() - val cpn = cpn ?: safeGetTopCpn() ?: return + val cpn = cpn ?: shizukuContextFlow.value.topCpn() ?: return updateTopActivity( appId = cpn.packageName, activityId = cpn.className, diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index 8b08d67c0b..729cddeeb8 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import li.songe.gkd.META -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.util.componentName import li.songe.gkd.util.json import rikka.shizuku.Shizuku @@ -91,7 +91,7 @@ private fun unbindUserService( connection: ServiceConnection, reason: String? = null, ) { - if (!shizukuOkState.stateFlow.value) return + if (!shizukuGrantedState.stateFlow.value) return LogUtils.d("unbindUserService", serviceArgs, reason) // https://github.com/RikkaApps/Shizuku-API/blob/master/server-shared/src/main/java/rikka/shizuku/server/UserServiceManager.java#L62 try { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 91e07c9b71..03da03bc5a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -59,7 +59,7 @@ import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.service.ActivityService import li.songe.gkd.service.ButtonService import li.songe.gkd.service.EventService @@ -333,8 +333,8 @@ fun AdvancedPage() { tint = MaterialTheme.colorScheme.primary, ) } - val shizukuOk by shizukuOkState.stateFlow.collectAsState() - if (!shizukuOk) { + val shizukuGranted by shizukuGrantedState.stateFlow.collectAsState() + AnimatedVisibility(store.enableShizuku && !shizukuGranted) { AuthCard( title = "未授权", subtitle = "点击授权以优化体验", @@ -359,14 +359,7 @@ fun AdvancedPage() { } }, ) { - if (updateBinderMutex.mutex.isLocked) { - toast("正在连接中,请稍后") - return@TextSwitch - } - if (it && !shizukuOk) { - toast("未授权") - } - storeFlow.value = store.copy(enableShizuku = it) + mainVm.switchEnableShizuku(it) } val server by HttpService.httpServerFlow.collectAsState() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index 4f2960dd1c..5985ec1a18 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -26,7 +26,6 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import kotlinx.coroutines.Dispatchers import li.songe.gkd.permission.foregroundServiceSpecialUseState -import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.component.AuthButtonGroup import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.ManualAuthDialog @@ -40,7 +39,6 @@ import li.songe.gkd.ui.style.cardHorizontalPadding import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.surfaceCardColors import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.runCommandByRoot import li.songe.gkd.util.toast @Destination(style = ProfileTransitions::class) @@ -87,23 +85,19 @@ fun AppOpsAllowPage() { style = MaterialTheme.typography.bodySmall, ) AuthButtonGroup( - onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.guardShizukuContext() - shizukuContextFlow.value.appOpsService?.allowAllSelfMode() - toast("授权成功") - }, - onClickManual = { - vm.showCopyDlgFlow.value = true - }, - onClickRoot = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - runCommandByRoot(appOpsCommand) - toast("授权成功") - } + buttons = listOf( + "Shizuku 授权" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.guardShizukuContext() + toast("授权成功") + }, + "外部授权" to { + vm.showCopyDlgFlow.value = true + }, + ) ) Spacer(modifier = Modifier.height(12.dp)) } } - Spacer(modifier = Modifier.height(EmptyHeight)) AnimatedVisibility(visible = restrictedCount == 0) { Spacer(modifier = Modifier.height(EmptyHeight)) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index 9ec91ed4f2..eaf1dd83e2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -1,6 +1,8 @@ package li.songe.gkd.ui import android.Manifest +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,9 +10,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -20,13 +26,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import li.songe.gkd.META @@ -51,7 +59,6 @@ import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.openA11ySettings -import li.songe.gkd.util.runCommandByRoot import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -96,18 +103,21 @@ fun AuthA11yPage() { Text( modifier = Modifier.padding(cardHorizontalPadding, 8.dp), text = "普通授权(简单)", - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, ) - Text( + TextListItem( modifier = Modifier.padding(cardHorizontalPadding, 0.dp), style = MaterialTheme.typography.bodyMedium, - text = "1. 授予「无障碍权限」\n2. 无障碍关闭后需重新授权" + list = listOf( + "授予「无障碍权限」", + "无障碍关闭后需重新授权" + ), ) if (writeSecureSettings || a11yRunning) { Spacer(modifier = Modifier.height(12.dp)) Text( modifier = Modifier.padding(cardHorizontalPadding, 0.dp), - text = "已持有「无障碍权限」, 可继续使用", + text = "已持有「无障碍权限」,可继续使用", style = MaterialTheme.typography.bodySmall, ) Spacer(modifier = Modifier.height(12.dp)) @@ -128,7 +138,7 @@ fun AuthA11yPage() { modifier = Modifier .padding(cardHorizontalPadding, 0.dp) .clickable { - mainVm.navigatePage(WebViewPageDestination(initUrl = (ShortUrlSet.URL2))) + mainVm.navigateWebPage(ShortUrlSet.URL2) }, text = "无法开启无障碍?", style = MaterialTheme.typography.bodySmall, @@ -148,12 +158,17 @@ fun AuthA11yPage() { Text( modifier = Modifier.padding(cardHorizontalPadding, 8.dp), text = "高级授权(推荐)", - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, ) - Text( + TextListItem( modifier = Modifier.padding(cardHorizontalPadding, 0.dp), style = MaterialTheme.typography.bodyMedium, - text = "1. 授予「写入安全设置权限」\n2. 授权永久有效, 包含「无障碍权限」\n3. 应用可自行控制开关无障碍\n4. 在通知栏快捷开关可快捷重启, 无感保活" + list = listOf( + "授予「写入安全设置权限」", + "授权永久有效,包含「无障碍权限」", + "应用可自行控制开关无障碍", + "在通知栏快捷开关可快捷重启,无感保活" + ), ) if (!writeSecureSettings) { A11yAuthButtonGroup() @@ -187,8 +202,8 @@ fun AuthA11yPage() { } Spacer(modifier = Modifier.height(4.dp)) } - if (writeSecureSettings) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) + AnimatedVisibility(writeSecureSettings && !shizukuContextFlow.collectAsState().value.ok) { Card( modifier = Modifier .padding(itemHorizontalPadding, 0.dp) @@ -199,12 +214,16 @@ fun AuthA11yPage() { Text( modifier = Modifier.padding(cardHorizontalPadding, 8.dp), text = "解除可能受到的无障碍限制", - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, ) - Text( + TextListItem( + list = listOf( + "某些系统有更严格的无障碍限制", + "在 GKD 更新后会限制其开关无障碍", + "重新授权可解决此问题", + ), modifier = Modifier.padding(cardHorizontalPadding, 0.dp), style = MaterialTheme.typography.bodyMedium, - text = "1. 某些系统有更严格的无障碍限制\n2. 在 GKD 更新后会限制其开关无障碍\n3. 重新授权可解决此问题" ) Spacer(modifier = Modifier.height(12.dp)) Text( @@ -213,16 +232,6 @@ fun AuthA11yPage() { style = MaterialTheme.typography.bodySmall, ) A11yAuthButtonGroup() - Text( - modifier = Modifier - .padding(cardHorizontalPadding, 0.dp) - .clickable(onClick = throttle { - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL2)) - }), - text = "其他方式解除限制", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) Spacer(modifier = Modifier.height(12.dp)) } } @@ -251,30 +260,52 @@ private val a11yCommandText by lazy { ).joinToString("; ") } -private fun successAuthExec() { - if (writeSecureSettingsState.updateAndGet()) { - toast("授权成功") - storeFlow.update { it.copy(enableService = true) } - fixRestartService() - } -} - @Composable private fun A11yAuthButtonGroup() { val mainVm = LocalMainViewModel.current val vm = viewModel() AuthButtonGroup( - onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.guardShizukuContext() - shizukuContextFlow.value.grantSelf() - successAuthExec() - }, - onClickManual = { - vm.showCopyDlgFlow.value = true - }, - onClickRoot = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - runCommandByRoot(a11yCommandText) - toast("授权成功") - } + buttons = listOf( + "手动解除" to { + mainVm.navigateWebPage(ShortUrlSet.URL2) + }, + "Shizuku 授权" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.guardShizukuContext() + if (writeSecureSettingsState.value) { + toast("授权成功") + storeFlow.update { it.copy(enableService = true) } + fixRestartService() + } + }, + "外部授权" to { + vm.showCopyDlgFlow.value = true + }, + ) ) -} \ No newline at end of file +} + +@Composable +private fun TextListItem( + list: List, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, +) { + val lineHeightDp = LocalDensity.current.run { style.lineHeight.toDp() } + Column( + modifier = modifier, + ) { + list.forEach { text -> + Row { + Spacer( + modifier = Modifier + .padding(vertical = (lineHeightDp - 4.dp) / 2) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + .size(4.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, style = style) + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt index 7d70edca0d..b4cd24b88e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt @@ -13,32 +13,20 @@ import li.songe.gkd.util.throttle @Composable fun AuthButtonGroup( - onClickShizuku: () -> Unit, - onClickManual: () -> Unit, - onClickRoot: () -> Unit, + buttons: List Unit>>, ) { FlowRow( modifier = Modifier .padding(4.dp, 0.dp) .fillMaxWidth(), ) { - TextButton(onClick = throttle(onClickShizuku)) { - Text( - text = "Shizuku授权", - style = MaterialTheme.typography.bodyLarge, - ) - } - TextButton(onClick = throttle(onClickManual)) { - Text( - text = "手动授权", - style = MaterialTheme.typography.bodyLarge, - ) - } - TextButton(onClick = throttle(onClickRoot)) { - Text( - text = "ROOT授权", - style = MaterialTheme.typography.bodyLarge, - ) + buttons.forEach { (text, click) -> + TextButton(onClick = throttle(click)) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + ) + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt index a0f1a35cd2..6f01515c2e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt @@ -39,7 +39,7 @@ fun ManualAuthDialog( } AlertDialog( onDismissRequest = { onUpdateShow(false) }, - title = { Text(text = "手动授权") }, + title = { Text(text = "外部授权") }, text = { Column(modifier = Modifier.fillMaxWidth()) { Text(text = "1. 有一台安装了 adb 的电脑\n\n2.手机开启调试模式后连接电脑授权调试\n\n3. 在电脑 cmd/pwsh 中运行如下命令") diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 76cc098e01..d65573d7c3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -46,7 +46,6 @@ import li.songe.gkd.service.ActivityService import li.songe.gkd.service.StatusService import li.songe.gkd.service.a11yPartDisabledFlow import li.songe.gkd.service.switchA11yService -import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.GroupNameText import li.songe.gkd.ui.component.PerfIcon @@ -119,10 +118,7 @@ fun useControlPage(): ScaffoldExt { Switch( checked = a11yRunning, onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newEnabled -> - if (newEnabled && !writeSecureSettingsState.updateAndGet()) { - shizukuContextFlow.value.grantSelf() - } - if (newEnabled && !writeSecureSettingsState.updateAndGet()) { + if (newEnabled && !writeSecureSettingsState.value) { mainVm.navigatePage(AuthA11YPageDestination) } else { switchA11yService() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 7fddb8526b..a2d5f512d1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -50,15 +50,18 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.generated.destinations.AboutPageDestination import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination import com.ramcosta.composedestinations.generated.destinations.BlockA11YAppListPageDestination +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity +import li.songe.gkd.app import li.songe.gkd.permission.ignoreBatteryOptimizationsState import li.songe.gkd.permission.requiredPermission -import li.songe.gkd.permission.shizukuOkState +import li.songe.gkd.permission.writeSecureSettingsState +import li.songe.gkd.service.A11yService import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.shizuku.shizukuContextFlow @@ -389,40 +392,38 @@ fun useSettingsPage(): ScaffoldExt { ) }) - if (store.enableShizuku) { - val scope = rememberCoroutineScope() - val lazyOn = remember { - storeFlow.mapState(scope) { it.enableBlockA11yAppList }.debounce(300) - .stateIn(scope, SharingStarted.Eagerly, store.enableBlockA11yAppList) - }.collectAsState() - AnimatedVisibility(visible = lazyOn.value) { - Text( - modifier = Modifier - .fillMaxWidth() - .titleItemPadding(), - text = "无障碍", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - } - TextSwitch( - title = "局部关闭", - subtitle = "白名单应用内关闭无障碍", - checked = store.enableBlockA11yAppList, - onCheckedChange = vm.viewModelScope.launchAsFn { - if (it) { - showA11yBlockDlg = true - } else { - storeFlow.value = store.copy(enableBlockA11yAppList = false) - fixRestartService() - } - }, + val scope = rememberCoroutineScope() + val lazyOn = remember { + storeFlow.mapState(scope) { it.enableBlockA11yAppList }.debounce(300) + .stateIn(scope, SharingStarted.Eagerly, store.enableBlockA11yAppList) + }.collectAsState() + AnimatedVisibility(visible = lazyOn.value) { + Text( + modifier = Modifier + .fillMaxWidth() + .titleItemPadding(), + text = "无障碍", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, ) - AnimatedVisibility(visible = lazyOn.value) { - SettingItem(title = "白名单", onClick = { - mainVm.navigatePage(BlockA11YAppListPageDestination) - }) - } + } + TextSwitch( + title = "局部关闭", + subtitle = "白名单应用内关闭无障碍", + checked = store.enableBlockA11yAppList && shizukuContextFlow.collectAsState().value.ok, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + showA11yBlockDlg = true + } else { + storeFlow.value = store.copy(enableBlockA11yAppList = false) + fixRestartService() + } + }, + ) + AnimatedVisibility(visible = lazyOn.value) { + SettingItem(title = "白名单", onClick = { + mainVm.navigatePage(BlockA11YAppListPageDestination) + }) } Text( @@ -474,7 +475,7 @@ fun useSettingsPage(): ScaffoldExt { private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onDismissRequest) { val mainVm = LocalMainViewModel.current val statusRunning by StatusService.isRunning.collectAsState() - val shizukuOk by shizukuOkState.stateFlow.collectAsState() + val shizukuContext by shizukuContextFlow.collectAsState() val ignoreBatteryOptimizations by ignoreBatteryOptimizationsState.stateFlow.collectAsState() val hasOtherA11y by mainVm.hasOtherA11yFlow.collectAsState() val context = LocalActivity.current as MainActivity @@ -496,7 +497,7 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD BottomAppBar { Spacer(modifier = Modifier.weight(1f)) TextButton( - enabled = statusRunning && shizukuOk && ignoreBatteryOptimizations && !hasOtherA11y, + enabled = shizukuContext.ok && statusRunning && ignoreBatteryOptimizations && !hasOtherA11y, onClick = mainVm.viewModelScope.launchAsFn { onDismissRequest() delay(200) @@ -517,14 +518,14 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD .padding(horizontal = itemHorizontalPadding) ) { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { - Text(text = "「局部关闭」可在白名单应用内关闭无障碍,来解决某些应用界面异常或无障碍检测的问题") + Text(text = "「局部关闭」可在白名单应用内关闭无障碍,来解决界面异常,游戏掉帧或无障碍检测的问题") Spacer(modifier = Modifier.height(16.dp)) Text(text = "使用须知", style = MaterialTheme.typography.titleMedium) Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { - RequiredTextItem(text = "切换无障碍会造成短暂触摸卡顿,请自行测试考虑后再编辑白名单") - RequiredTextItem(text = "如果还使用其它无障碍应用会导致优化无效,因为无障碍不会被完全关闭") + RequiredTextItem(text = "切换无障碍会造成短暂触摸卡顿,请自行测试后再编辑白名单") + RequiredTextItem(text = "使用其它无障碍应用会导致优化无效,因为无障碍不会被完全关闭") RequiredTextItem(text = "必须确保无障碍关闭后的持续后台运行,否则会被系统暂停或结束运行,导致无法恢复无障碍") } Spacer(modifier = Modifier.height(16.dp)) @@ -534,10 +535,10 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD ) { RequiredTextItem( text = "Shizuku 授权", - enabled = !shizukuOk, - imageVector = if (shizukuOk) PerfIcon.Check else PerfIcon.ArrowForward, - onClick = { - mainVm.requestShizuku() + enabled = !shizukuContext.ok, + imageVector = if (shizukuContext.ok) PerfIcon.Check else PerfIcon.ArrowForward, + onClick = mainVm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.guardShizukuContext() }, ) RequiredTextItem( @@ -561,7 +562,18 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD enabled = hasOtherA11y, imageVector = if (!hasOtherA11y) PerfIcon.Check else PerfIcon.ArrowForward, onClick = { - openA11ySettings() + if (writeSecureSettingsState.updateAndGet()) { + if (A11yService.isRunning.value) { + setOf(A11yService.a11yCn) + } else { + emptySet() + }.let { + app.putSecureA11yServices(it) + } + toast("关闭成功") + } else { + openA11ySettings() + } }, ) RequiredTextItem( @@ -577,7 +589,12 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD enabled = true, imageVector = PerfIcon.OpenInNew, onClick = { - shizukuContextFlow.value.inputManager?.key(KeyEvent.KEYCODE_APP_SWITCH) + val m = shizukuContextFlow.value.inputManager + if (m != null) { + m.key(KeyEvent.KEYCODE_APP_SWITCH) + } else { + toast("请先授权 Shizuku") + } }, ) } @@ -608,8 +625,7 @@ private fun RequiredTextItem( } .padding(horizontal = 4.dp), ) { - val density = LocalDensity.current - val lineHeightDp = density.run { LocalTextStyle.current.lineHeight.toDp() } + val lineHeightDp = LocalDensity.current.run { LocalTextStyle.current.lineHeight.toDp() } Spacer( modifier = Modifier .padding(vertical = (lineHeightDp - 4.dp) / 2) diff --git a/app/src/main/kotlin/li/songe/gkd/util/Others.kt b/app/src/main/kotlin/li/songe/gkd/util/Others.kt index 37c0937952..4c96a19c3c 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -22,7 +22,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.ui.unit.sp import androidx.core.graphics.get -import com.blankj.utilcode.util.LogUtils import kotlinx.serialization.json.JsonElement import li.songe.gkd.META import li.songe.gkd.MainActivity @@ -30,7 +29,6 @@ import li.songe.gkd.app import li.songe.json5.Json5 import li.songe.json5.Json5EncoderConfig import li.songe.json5.encodeToJson5String -import java.io.DataOutputStream import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName @@ -91,27 +89,6 @@ fun > AnimatedContentTransitionScope.getUpDownTransform(): ) } -suspend fun runCommandByRoot(commandText: String) { - var p: Process? = null - try { - p = Runtime.getRuntime().exec("su") - val o = DataOutputStream(p.outputStream) - o.writeBytes("${commandText}\nexit\n") - o.flush() - o.close() - p.waitFor() - if (p.exitValue() == 0) { - return - } - } catch (e: Exception) { - toast("运行失败:${e.message}") - LogUtils.d(e) - } finally { - p?.destroy() - } - stopCoroutine() -} - val defaultJson5Config = Json5EncoderConfig(indent = "\u0020\u0020", trailingComma = true) inline fun toJson5String(value: T): String { if (value is JsonElement) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt index 67f18f7256..12cb28ffeb 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -19,7 +19,7 @@ import li.songe.gkd.db.DbSet import li.songe.gkd.notif.snapshotNotif import li.songe.gkd.service.A11yService import li.songe.gkd.service.ScreenshotService -import li.songe.gkd.shizuku.safeGetTopCpn +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import java.io.File import kotlin.math.min @@ -119,7 +119,7 @@ object SnapshotExt { val (snapshot, bitmap) = coroutineScope { val d1 = async(Dispatchers.IO) { val appId = rootNode.packageName.toString() - var activityId = safeGetTopCpn()?.className + var activityId = shizukuContextFlow.value.topCpn()?.className if (activityId == null) { var topActivity = topActivityFlow.value var i = 0L From 6d4e85dfb648f64578b78be80463eb9829356541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 8 Oct 2025 15:35:31 +0800 Subject: [PATCH 069/245] perf: GkdWebViewJsApi --- app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt index 90683d787e..c9b669f924 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt @@ -148,7 +148,7 @@ fun WebViewPage( client = webViewClient, onCreated = { webView.value = it - it.addJavascriptInterface(GkdJavascriptInterface(), "gkd") + it.addJavascriptInterface(GkdWebViewJsApi, "gkd") it.settings.apply { @SuppressLint("SetJavaScriptEnabled") javaScriptEnabled = true @@ -163,10 +163,13 @@ fun WebViewPage( } @Suppress("unused") -private class GkdJavascriptInterface() { +private object GkdWebViewJsApi { @JavascriptInterface fun getAppId() = META.appId + @JavascriptInterface + fun getAppName() = META.appName + @JavascriptInterface fun getVersionCode() = META.versionCode @@ -175,10 +178,12 @@ private class GkdJavascriptInterface() { @JavascriptInterface fun getChannel() = META.channel + + @JavascriptInterface + fun getDebuggable() = META.debuggable } -// 兼容性检测为最近 3 年, 2022-03-29 -private const val MINI_CHROME_VERSION = 100 +private const val MINI_CHROME_VERSION = 107 private val chromeVersion by lazy { WebView.getCurrentWebViewPackage()?.versionName?.run { splitToSequence('.').first().toIntOrNull() From 8101986987468da81ccf3035fa287cd38181a7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 8 Oct 2025 18:46:20 +0800 Subject: [PATCH 070/245] perf: diable local image cache --- .../li/songe/gkd/ui/ImagePreviewPage.kt | 50 ++++++++++++++++++- .../kotlin/li/songe/gkd/util/Singleton.kt | 38 -------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt index dc1108a80e..cd076c0716 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt @@ -35,20 +35,32 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import coil3.ImageLoader import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter +import coil3.disk.DiskCache +import coil3.gif.AnimatedImageDecoder +import coil3.gif.GifDecoder +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import li.songe.gkd.MainActivity +import li.songe.gkd.app import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.ProfileTransitions -import li.songe.gkd.util.imageLoader +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.coilCacheDir import li.songe.gkd.util.throttle +import okhttp3.OkHttpClient +import okio.Path.Companion.toOkioPath +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration @Destination(style = ProfileTransitions::class) @Composable @@ -139,7 +151,13 @@ private fun UriImage(uri: String) { val context = LocalContext.current val model = remember(uri) { ImageRequest.Builder(context).data(uri) - .crossfade(DefaultDurationMillis) + .crossfade(DefaultDurationMillis).run { + if (URLUtil.isNetworkUrl(uri)) { + this + } else { + diskCachePolicy(CachePolicy.DISABLED).memoryCachePolicy(CachePolicy.DISABLED) + } + } .build().apply { imageLoader.enqueue(this) } @@ -191,3 +209,31 @@ private fun UriImage(uri: String) { } } } + +private val imageLoader by lazy { + ImageLoader.Builder(app) + .diskCache { + DiskCache.Builder() + .directory(coilCacheDir.toOkioPath()) + .maxSizePercent(0.1) + .build() + } + .components { + if (AndroidTarget.P) { + add(AnimatedImageDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add( + OkHttpNetworkFetcherFactory( + callFactory = { + OkHttpClient.Builder() + .connectTimeout(30.seconds.toJavaDuration()) + .readTimeout(30.seconds.toJavaDuration()) + .writeTimeout(30.seconds.toJavaDuration()) + .build() + } + )) + } + .build() +} diff --git a/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt b/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt index f659c62a86..741b1f4fe8 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt @@ -1,23 +1,13 @@ package li.songe.gkd.util -import coil3.ImageLoader -import coil3.disk.DiskCache -import coil3.gif.AnimatedImageDecoder -import coil3.gif.GifDecoder -import coil3.network.okhttp.OkHttpNetworkFetcherFactory import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.ContentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import li.songe.gkd.app -import okhttp3.OkHttpClient -import okio.Path.Companion.toOkioPath import java.text.Collator import java.util.Locale -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration val json by lazy { @@ -45,32 +35,4 @@ val client by lazy { } } -val imageLoader by lazy { - ImageLoader.Builder(app) - .diskCache { - DiskCache.Builder() - .directory(coilCacheDir.toOkioPath()) - .maxSizePercent(0.1) - .build() - } - .components { - // https://coil-kt.github.io/coil/gifs/ - if (AndroidTarget.P) { - add(AnimatedImageDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - add(OkHttpNetworkFetcherFactory( - callFactory = { - OkHttpClient.Builder() - .connectTimeout(30.seconds.toJavaDuration()) - .readTimeout(30.seconds.toJavaDuration()) - .writeTimeout(30.seconds.toJavaDuration()) - .build() - } - )) - } - .build() -} - val collator by lazy { Collator.getInstance(Locale.CHINESE)!! } From fab9287def5ed1009679f12c1b94fd9ae044215c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 8 Oct 2025 18:51:07 +0800 Subject: [PATCH 071/245] perf: start.sh + expose.sh --- app/src/main/AndroidManifest.xml | 2 +- .../main/kotlin/li/songe/gkd/notif/Notif.kt | 6 +- .../li/songe/gkd/service/ExposeService.kt | 64 +++++++++++++++++++ .../gkd/service/SnapshotActionService.kt | 27 -------- .../kotlin/li/songe/gkd/store/StoreExt.kt | 4 ++ .../kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt | 2 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 25 +++++--- .../gkd/ui/component/ManualAuthDialog.kt | 8 +-- .../kotlin/li/songe/gkd/util/FolderExt.kt | 2 + 9 files changed, 92 insertions(+), 48 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt delete mode 100644 app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 68c900098f..1903d603bf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -173,7 +173,7 @@ android:value="Display a text card for users to show a11y event log" /> diff --git a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 8afaa1295e..91e426d937 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -119,10 +119,10 @@ val httpNotif = Notif( stopService = HttpService::class, ) -val snapshotActionNotif = Notif( +val exposeNotif = Notif( id = 104, - title = "快照服务正在运行", - text = "捕获快照完成后自动关闭", + title = "运行外部调用任务中", + text = "任务完成后自动关闭", ) val snapshotNotif = Notif( diff --git a/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt new file mode 100644 index 0000000000..5d7fcac11b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt @@ -0,0 +1,64 @@ +package li.songe.gkd.service + +import android.app.Service +import android.content.Intent +import android.os.Binder +import com.blankj.utilcode.util.LogUtils +import li.songe.gkd.appScope +import li.songe.gkd.notif.exposeNotif +import li.songe.gkd.util.SnapshotExt +import li.songe.gkd.util.componentName +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.shFolder +import li.songe.gkd.util.toast + +class ExposeService : Service() { + override fun onBind(intent: Intent?): Binder? = null + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + appScope.launchTry { + try { + handleIntent(intent) + } finally { + stopSelf() + } + } + return super.onStartCommand(intent, flags, startId) + } + + suspend fun handleIntent(intent: Intent?) { + val expose = intent?.getIntExtra("expose", 0) ?: 0 + val data = intent?.getStringExtra("data") + LogUtils.d("ExposeService::handleIntent", expose, data) + when (expose) { + 0 -> SnapshotExt.captureSnapshot() + else -> { + toast("未知调用: expose=$expose data=$data") + } + } + } + + override fun onCreate() { + super.onCreate() + exposeNotif.notifyService() + } + + companion object { + fun initCommandFile() { + val commandText = template + .replace("__N__", ExposeService::class.componentName.flattenToShortString()) + shFolder.resolve("expose.sh").writeText(commandText) + } + } +} + +private const val template = """ +p='' +if [ -n "$1" ]; then + p+=" --ei expose $1" +fi +if [ -n "$2" ]; then + p+=" --es data $2" +fi +am start-foreground-service -n __N__ ${'$'}p +echo 'Execution Successful' +""" \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt deleted file mode 100644 index 9720f996a4..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/SnapshotActionService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package li.songe.gkd.service - -import android.app.Service -import android.content.Intent -import android.os.Binder -import li.songe.gkd.appScope -import li.songe.gkd.notif.snapshotActionNotif -import li.songe.gkd.util.SnapshotExt -import li.songe.gkd.util.launchTry - -/** - * https://github.com/gkd-kit/gkd/issues/253 - */ -class SnapshotActionService : Service() { - override fun onBind(intent: Intent?): Binder? = null - override fun onCreate() { - super.onCreate() - snapshotActionNotif.notifyService() - appScope.launchTry { - try { - SnapshotExt.captureSnapshot() - } finally { - stopSelf() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt index 24330ef9e8..d25d49a5b0 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import li.songe.gkd.appScope +import li.songe.gkd.service.ExposeService +import li.songe.gkd.ui.gkdStartCommandText import li.songe.gkd.util.AppListString import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast @@ -62,6 +64,8 @@ fun initStore() = appScope.launchTry(Dispatchers.IO) { actionCountFlow.value blockMatchAppListFlow.value blockA11yAppListFlow.value + gkdStartCommandText + ExposeService.initCommandFile() } fun switchStoreEnableMatch() { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt index 5985ec1a18..a3bc46bc2b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -108,7 +108,7 @@ fun AppOpsAllowPage() { val showCopyDlg by vm.showCopyDlgFlow.collectAsState() ManualAuthDialog( - commandText = appOpsCommand, + commandText = gkdStartCommandText, show = showCopyDlg, onUpdateShow = { vm.showCopyDlgFlow.value = it diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index eaf1dd83e2..a23f719ab7 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -59,6 +59,7 @@ import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.openA11ySettings +import li.songe.gkd.util.shFolder import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @@ -240,7 +241,7 @@ fun AuthA11yPage() { } ManualAuthDialog( - commandText = a11yCommandText, + commandText = gkdStartCommandText, show = showCopyDlg, onUpdateShow = { vm.showCopyDlgFlow.value = it @@ -248,16 +249,20 @@ fun AuthA11yPage() { ) } -private val String.appopsAllowCommand: String - get() = "appops set ${META.appId} $this allow" +private val String.appopsAllow get() = "appops set ${META.appId} $this allow" +private val String.pmGrant get() = "pm grant ${META.appId} $this" -val appOpsCommand by lazy { "FOREGROUND_SERVICE_SPECIAL_USE".appopsAllowCommand } - -private val a11yCommandText by lazy { - listOfNotNull( - "pm grant ${META.appId} ${Manifest.permission.WRITE_SECURE_SETTINGS}", - if (AndroidTarget.TIRAMISU) "ACCESS_RESTRICTED_SETTINGS".appopsAllowCommand else null, - ).joinToString("; ") +val gkdStartCommandText by lazy { + val commandText = listOfNotNull( + "set -euo pipefail", + Manifest.permission.WRITE_SECURE_SETTINGS.pmGrant, + if (AndroidTarget.TIRAMISU) "ACCESS_RESTRICTED_SETTINGS".appopsAllow else null, + if (AndroidTarget.UPSIDE_DOWN_CAKE) "FOREGROUND_SERVICE_SPECIAL_USE".appopsAllow else null, + "echo 'Execution Successful'", + ).joinToString("\n") + val file = shFolder.resolve("start.sh") + file.writeText(commandText) + "adb shell sh ${file.absolutePath}" } @Composable diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt index 6f01515c2e..c0928cd73b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -34,9 +33,6 @@ fun ManualAuthDialog( ) { if (show) { val mainVm = LocalMainViewModel.current - val adbCommandText = remember(commandText) { - "adb shell \"$commandText\"" - } AlertDialog( onDismissRequest = { onUpdateShow(false) }, title = { Text(text = "外部授权") }, @@ -53,7 +49,7 @@ fun ManualAuthDialog( .fillMaxWidth() ) { Text( - text = adbCommandText, + text = commandText, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) @@ -65,7 +61,7 @@ fun ManualAuthDialog( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { - copyText(adbCommandText) + copyText(commandText) }) .padding(4.dp) .size(20.dp), diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index 0e4d42bdaf..ed859d03b0 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -19,6 +19,8 @@ private val filesDir: File by lazy { val dbFolder: File get() = filesDir.resolve("db").autoMk() +val shFolder: File + get() = filesDir.resolve("sh").autoMk() val storeFolder: File get() = filesDir.resolve("store").autoMk() val subsFolder: File From a58c10417823a280359ec6d56fc35ee679cf946c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 12 Oct 2025 20:19:27 +0800 Subject: [PATCH 072/245] perf: add permission.txt to log --- .../songe/gkd/permission/PermissionState.kt | 28 ++++++++++++++----- .../kotlin/li/songe/gkd/util/FolderExt.kt | 7 +++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 584f2cb8fe..dd43048496 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -26,6 +26,7 @@ import li.songe.gkd.util.updateAppMutex import rikka.shizuku.Shizuku class PermissionState( + val name: String, val check: () -> Boolean, val request: (suspend (context: MainActivity) -> PermissionResult)? = null, /** @@ -97,6 +98,7 @@ private fun checkAllowedOp(op: String): Boolean = app.appOpsManager.checkOpNoThr // https://github.com/gkd-kit/gkd/issues/887 val foregroundServiceSpecialUseState by lazy { PermissionState( + name = "特殊用途的前台服务", check = { if (AndroidTarget.UPSIDE_DOWN_CAKE) { checkAllowedOp("android:foreground_service_special_use") @@ -119,6 +121,7 @@ val foregroundServiceSpecialUseState by lazy { val notificationState by lazy { val permission = PermissionLists.getNotificationServicePermission() PermissionState( + name = "通知权限", check = { XXPermissions.isGrantedPermission(app, permission) }, @@ -135,6 +138,7 @@ val notificationState by lazy { val canQueryPkgState by lazy { val permission = PermissionLists.getGetInstalledAppsPermission() PermissionState( + name = "读取应用列表权限", check = { XXPermissions.isGrantedPermission(app, permission) }, @@ -152,6 +156,7 @@ val canQueryPkgState by lazy { val canDrawOverlaysState by lazy { PermissionState( + name = "悬浮窗权限", check = { // https://developer.android.com/security/fraud-prevention/activities?hl=zh-cn#hide_overlay_windows Settings.canDrawOverlays(app) @@ -172,6 +177,7 @@ val canDrawOverlaysState by lazy { val canWriteExternalStorage by lazy { PermissionState( + name = "写入外部存储权限", check = { if (AndroidTarget.Q) { true @@ -201,6 +207,7 @@ val canWriteExternalStorage by lazy { val ignoreBatteryOptimizationsState by lazy { val permission = PermissionLists.getRequestIgnoreBatteryOptimizationsPermission() PermissionState( + name = "忽略电池优化权限", check = { app.powerManager.isIgnoringBatteryOptimizations(app.packageName) }, @@ -221,6 +228,7 @@ val ignoreBatteryOptimizationsState by lazy { val writeSecureSettingsState by lazy { PermissionState( + name = "写入安全设置权限", check = { checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, ) } @@ -238,22 +246,28 @@ private fun shizukuCheckGranted(): Boolean { val shizukuGrantedState by lazy { PermissionState( + name = "Shizuku 权限", check = { shizukuCheckGranted() }, ) } -fun updatePermissionState() { - val stateChanged = canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet() - if (!updateAppMutex.mutex.isLocked && (stateChanged || mayQueryPkgNoAccessFlow.value)) { - updateAllAppInfo() - } - arrayOf( +val allPermissionStates by lazy { + listOf( notificationState, foregroundServiceSpecialUseState, canDrawOverlaysState, canWriteExternalStorage, ignoreBatteryOptimizationsState, writeSecureSettingsState, + canQueryPkgState, shizukuGrantedState, - ).forEach { it.updateAndGet() } + ) +} + +fun updatePermissionState() { + val stateChanged = canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet() + if (!updateAppMutex.mutex.isLocked && (stateChanged || mayQueryPkgNoAccessFlow.value)) { + updateAllAppInfo() + } + allPermissionStates.forEach { it.updateAndGet() } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index ed859d03b0..929a6af478 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -3,6 +3,7 @@ package li.songe.gkd.util import android.text.format.DateUtils import com.blankj.utilcode.util.LogUtils import li.songe.gkd.app +import li.songe.gkd.permission.allPermissionStates import li.songe.gkd.shizuku.shizukuContextFlow import java.io.File @@ -76,6 +77,12 @@ fun buildLogFile(): File { }) files.add(it) } + tempDir.resolve("permission.txt").also { + it.writeText(allPermissionStates.joinToString("\n") { state -> + state.name + ": " + state.stateFlow.value.toString() + }) + files.add(it) + } val logZipFile = sharedDir.resolve("log-${System.currentTimeMillis()}.zip") ZipUtils.zipFiles(files, logZipFile) tempDir.deleteRecursively() From 4ea1db11b3502c57044fdaa767dfe951ddf13b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 12 Oct 2025 20:59:56 +0800 Subject: [PATCH 073/245] perf: AppListPage QueryPkgAuthCard --- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 5 ++ .../songe/gkd/permission/PermissionState.kt | 17 +++-- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 2 - .../kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 2 - .../gkd/ui/SubsGlobalGroupExcludePage.kt | 2 - .../songe/gkd/ui/component/QueryPkgTipCard.kt | 73 +++++++------------ .../li/songe/gkd/ui/home/AppListPage.kt | 10 ++- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 21 +----- 8 files changed, 56 insertions(+), 76 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index f00ae91857..431638b4e8 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -32,6 +32,7 @@ import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.permission.AuthReason +import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.service.A11yService import li.songe.gkd.shizuku.shizukuContextFlow @@ -364,6 +365,10 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { githubCookieFlow.value } + canQueryPkgState.stateFlow.launchOnChange { + appListKeyFlow.update { it + 1 } + } + // for OnSimpleLife onCreated() addCloseable { onDestroyed() } diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index dd43048496..99ac696088 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -19,7 +19,6 @@ import li.songe.gkd.shizuku.SafePackageManager import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.AndroidTarget -import li.songe.gkd.util.mayQueryPkgNoAccessFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex @@ -41,6 +40,10 @@ class PermissionState( return stateFlow.updateAndGet { check() } } + fun updateChanged(): Boolean { + return value != updateAndGet() + } + fun checkOrToast(): Boolean = if (!updateAndGet()) { val r = updateAndGet() if (!r) { @@ -265,9 +268,13 @@ val allPermissionStates by lazy { } fun updatePermissionState() { - val stateChanged = canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet() - if (!updateAppMutex.mutex.isLocked && (stateChanged || mayQueryPkgNoAccessFlow.value)) { - updateAllAppInfo() + allPermissionStates.forEach { + if (it === canQueryPkgState && !updateAppMutex.mutex.isLocked) { + if (canQueryPkgState.updateChanged()) { + updateAllAppInfo() + } + } else { + it.updateAndGet() + } } - allPermissionStates.forEach { it.updateAndGet() } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index 72e53a2612..d4729e5c77 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -56,7 +56,6 @@ import li.songe.gkd.ui.component.PerfCheckbox import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar -import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.isFullVisible import li.songe.gkd.ui.component.useListScrollState @@ -303,7 +302,6 @@ fun BlockA11yAppListPage() { EmptyText(text = "暂无搜索结果") Spacer(modifier = Modifier.height(EmptyHeight / 2)) } - QueryPkgAuthCard() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index a7a93e339a..7c7ec31395 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -45,7 +45,6 @@ import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar -import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.SubsAppCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus @@ -257,7 +256,6 @@ fun SubsAppListPage( ) Spacer(modifier = Modifier.height(EmptyHeight / 2)) } - QueryPkgAuthCard() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index 0716a10363..df56611b8e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -58,7 +58,6 @@ import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfSwitch import li.songe.gkd.ui.component.PerfTopAppBar -import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.TowLineText import li.songe.gkd.ui.component.autoFocus import li.songe.gkd.ui.component.isFullVisible @@ -370,7 +369,6 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") Spacer(modifier = Modifier.height(EmptyHeight / 2)) } - QueryPkgAuthCard() } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt index e51a32694f..2de5a9ffc2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt @@ -6,13 +6,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -22,56 +20,39 @@ import li.songe.gkd.MainActivity import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.permission.requiredPermission import li.songe.gkd.ui.share.LocalMainViewModel -import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.mayQueryPkgNoAccessFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.updateAppMutex @Composable -fun QueryPkgAuthCard(hideLoading: Boolean = false) { - val canQueryPkg by canQueryPkgState.stateFlow.collectAsState() - val mayQueryPkgNoAccess by mayQueryPkgNoAccessFlow.collectAsState() - val appRefreshing by updateAppMutex.state.collectAsState() - if (appRefreshing) { - if (!hideLoading) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(EmptyHeight)) - } - } - } else if (!canQueryPkg || mayQueryPkgNoAccess) { - val mainVm = LocalMainViewModel.current - val context = LocalActivity.current as MainActivity - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, +fun QueryPkgAuthCard( + modifier: Modifier = Modifier, +) { + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PerfIcon( + imageVector = PerfIcon.WarningAmber, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "如需显示所有应用\n请授予「读取应用列表权限」", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + TextButton( + enabled = !updateAppMutex.state.collectAsState().value, + onClick = throttle(fn = mainVm.viewModelScope.launchAsFn { + requiredPermission(context, canQueryPkgState) + }) ) { - PerfIcon( - imageVector = PerfIcon.WarningAmber, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = if (!canQueryPkg) "如需显示所有应用\n请授予「读取应用列表权限」" else "检测到当前用户应用数量过少\n可尝试授予「读取应用列表权限」\n或关闭权限后重新授权", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - TextButton(onClick = throttle(fn = mainVm.viewModelScope.launchAsFn { - if (!canQueryPkg) { - requiredPermission(context, canQueryPkgState) - } else { - canQueryPkgState.reason?.confirm?.invoke(context) - } - })) { - Text(text = "申请权限") - } - Spacer(modifier = Modifier.height(EmptyHeight)) + Text(text = "申请权限") } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 5662c753a9..66fcaab0b5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -46,6 +46,7 @@ import com.ramcosta.composedestinations.generated.destinations.EditBlockAppListP import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity import li.songe.gkd.data.AppInfo +import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AnimatedIcon @@ -260,16 +261,22 @@ fun useAppListPage(): ScaffoldExt { ) } ) { contentPadding -> + val canQueryPkg by canQueryPkgState.stateFlow.collectAsState() PullToRefreshBox( modifier = Modifier.padding(contentPadding), state = pullToRefreshState, isRefreshing = refreshing, - onRefresh = { updateAllAppInfo(true) } + onRefresh = { updateAllAppInfo() } ) { LazyColumn( modifier = Modifier.fillMaxSize(), state = listState ) { + if (!canQueryPkg) { + item(key = 1, contentType = 1) { + QueryPkgAuthCard() + } + } items(appInfos, { it.id }) { appInfo -> val desc = run { if (editWhiteListMode) return@run null @@ -307,7 +314,6 @@ fun useAppListPage(): ScaffoldExt { EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") Spacer(modifier = Modifier.height(EmptyHeight / 2)) } - QueryPkgAuthCard(hideLoading = true) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index 28ebb3c5cd..75f4e49f72 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -15,12 +15,12 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.data.toAppInfo +import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.shizuku.currentUserId import li.songe.gkd.shizuku.shizukuContextFlow @@ -56,17 +56,6 @@ val visibleAppInfosFlow by lazy { } } -private fun Map.getMayQueryPkgNoAccess(): Boolean { - return values.count { a -> !a.isSystem && !a.hidden && a.id != META.appId } < MINIMUM_NORMAL_APP_SIZE -} - -// https://github.com/orgs/gkd-kit/discussions/761 -// 某些设备在应用更新后出现权限错乱/缓存错乱 -private const val MINIMUM_NORMAL_APP_SIZE = 8 -val mayQueryPkgNoAccessFlow by lazy { - userAppInfoMapFlow.mapState(appScope) { it.getMayQueryPkgNoAccess() } -} - private val willUpdateAppIds by lazy { MutableStateFlow(emptySet()) } private val packageReceiver by lazy { @@ -157,9 +146,7 @@ private fun updatePartAppInfo( userAppIconMapFlow.value = newIconMap } -fun updateAllAppInfo( - showToast: Boolean = false, -) = updateAppMutex.launchTry(appScope, Dispatchers.IO) { +fun updateAllAppInfo() = updateAppMutex.launchTry(appScope, Dispatchers.IO) { val newAppMap = HashMap() val newIconMap = HashMap() val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) @@ -169,7 +156,7 @@ fun updateAllAppInfo( newIconMap[packageInfo.packageName] = icon } } - if (!canQueryPkgState.updateAndGet() || newAppMap.getMayQueryPkgNoAccess()) { + if (!canQueryPkgState.updateAndGet()) { val pkgList2 = shizukuContextFlow.value.packageManager?.getInstalledPackages(PKG_FLAGS) if (!pkgList2.isNullOrEmpty()) { pkgList2.forEach { packageInfo -> @@ -198,7 +185,7 @@ fun updateAllAppInfo( updateOtherUserAppInfo(newAppMap) userAppInfoMapFlow.value = newAppMap userAppIconMapFlow.value = newIconMap - if (showToast) { + if (!app.justStarted && isActivityVisible()) { toast("应用列表更新成功") } } From 0652291cfa823bd9639db8b81e4433cac45bd380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 12 Oct 2025 22:37:19 +0800 Subject: [PATCH 074/245] perf: updateAllAppInfo --- app/src/main/kotlin/li/songe/gkd/App.kt | 6 +++++- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 9578c9e3bc..66d1b317aa 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -79,6 +79,10 @@ data class AppMeta( val META by lazy { AppMeta() } class App : Application() { + companion object { + const val START_WAIT_TIME = 3000L + } + init { innerApp = this } @@ -136,7 +140,7 @@ class App : Application() { var justStarted: Boolean = true get() { if (field) { - field = System.currentTimeMillis() - startTime < 3_000 + field = System.currentTimeMillis() - startTime < START_WAIT_TIME } return field } diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index 75f4e49f72..e6d9572959 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -8,13 +8,17 @@ import android.content.pm.PackageManager import android.graphics.drawable.Drawable import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import li.songe.gkd.App import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo @@ -146,7 +150,7 @@ private fun updatePartAppInfo( userAppIconMapFlow.value = newIconMap } -fun updateAllAppInfo() = updateAppMutex.launchTry(appScope, Dispatchers.IO) { +fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO) { val newAppMap = HashMap() val newIconMap = HashMap() val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) @@ -156,7 +160,9 @@ fun updateAllAppInfo() = updateAppMutex.launchTry(appScope, Dispatchers.IO) { newIconMap[packageInfo.packageName] = icon } } - if (!canQueryPkgState.updateAndGet()) { + val mayAuthDenied = newAppMap.count { !it.value.isSystem } <= 4 + canQueryPkgState.updateAndGet() + if (!canQueryPkgState.value || mayAuthDenied) { val pkgList2 = shizukuContextFlow.value.packageManager?.getInstalledPackages(PKG_FLAGS) if (!pkgList2.isNullOrEmpty()) { pkgList2.forEach { packageInfo -> @@ -188,13 +194,20 @@ fun updateAllAppInfo() = updateAppMutex.launchTry(appScope, Dispatchers.IO) { if (!app.justStarted && isActivityVisible()) { toast("应用列表更新成功") } + if (canQueryPkgState.value && mayAuthDenied && app.justStarted) { + // 概率出现:即使有「读取应用列表权限」在刚启动时也只能获取到少量应用,延迟几秒再试一次 + appScope.launch { + delay(App.START_WAIT_TIME) + updateAllAppInfo() + } + } } fun initAppState() { packageReceiver updateAllAppInfo() appScope.launchTry { - shizukuContextFlow.collect { + shizukuContextFlow.drop(1).collect { updateAppMutex.launchTry(appScope, Dispatchers.IO) { updateOtherUserAppInfo() } From 1548873306873dc46d1f5aa9f51224a75232768c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 13 Oct 2025 21:27:42 +0800 Subject: [PATCH 075/245] perf: update libs --- app/build.gradle.kts | 6 ++++++ build.gradle.kts | 6 +----- gradle/libs.versions.toml | 15 +++++++++------ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6cc3156236..f66932e58a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,7 @@ plugins { alias(libs.plugins.kotlinx.atomicfu) alias(libs.plugins.google.ksp) alias(libs.plugins.rikka.refine) +// alias(libs.plugins.loc) } android { @@ -196,6 +197,10 @@ composeCompiler { ) } +//loc { +// template = "{methodName}({fileName}:{lineNumber})" +//} + dependencies { implementation(libs.kotlin.stdlib) @@ -272,6 +277,7 @@ dependencies { implementation(libs.permissions) implementation(libs.json5) + compileOnly(libs.loc.runtime) implementation(libs.kevinnzouWebview) } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 278ced1efa..49bb04fa32 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,20 +14,16 @@ ext { plugins { alias(libs.plugins.google.ksp) apply false - alias(libs.plugins.android.library) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.androidx.room) apply false - alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlinx.atomicfu) apply false - alias(libs.plugins.rikka.refine) apply false - + alias(libs.plugins.loc) apply false alias(libs.plugins.benmanes.version) alias(libs.plugins.littlerobots.version) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3992a022f2..6389025c6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,17 @@ [versions] kotlin = "2.2.20" -ksp = "2.2.20-2.0.3" +ksp = "2.2.20-2.0.4" agp = "8.13.0" -compose = "1.9.2" -room = "2.8.1" +compose = "1.9.3" +room = "2.8.2" paging = "3.3.6" -ktor = "3.3.0" +ktor = "3.3.1" atomicfu = "0.29.0" -destinations = "2.2.0" +destinations = "2.3.0" coil = "3.3.0" rikka_refine = "4.4.0" rikka_shizuku = "13.1.5" +loc = "0.2.0" [libraries] kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -62,6 +63,7 @@ destinations_ksp = { module = "io.github.raamcosta.compose-destinations:ksp", ve coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil_network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } +loc_runtime = { module = "li.songe.loc:loc-runtime", version.ref = "loc" } reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" toaster = "com.github.getActivity:Toaster:13.6" @@ -82,5 +84,6 @@ android_application = { id = "com.android.application", version.ref = "agp" } androidx_room = { id = "androidx.room", version.ref = "room" } google_ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } rikka_refine = { id = "dev.rikka.tools.refine", version.ref = "rikka_refine" } +loc = { id = "li.songe.loc", version.ref = "loc" } benmanes_version = "com.github.ben-manes.versions:0.53.0" -littlerobots_version = "nl.littlerobots.version-catalog-update:1.0.0" +littlerobots_version = "nl.littlerobots.version-catalog-update:1.0.1" From fba4afddd2322ba1f2c3851c3c487c471403f8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 14 Oct 2025 17:29:04 +0800 Subject: [PATCH 076/245] perf: add code stack to toast --- app/build.gradle.kts | 6 +----- app/src/main/kotlin/li/songe/gkd/util/Toast.kt | 16 ++++++++++++++-- gradle/libs.versions.toml | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f66932e58a..5adefda0e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,7 +53,7 @@ plugins { alias(libs.plugins.kotlinx.atomicfu) alias(libs.plugins.google.ksp) alias(libs.plugins.rikka.refine) -// alias(libs.plugins.loc) + alias(libs.plugins.loc) } android { @@ -197,10 +197,6 @@ composeCompiler { ) } -//loc { -// template = "{methodName}({fileName}:{lineNumber})" -//} - dependencies { implementation(libs.kotlin.stdlib) diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 6aa566f269..acd0e3d23e 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.GradientDrawable import android.graphics.text.LineBreaker import android.os.Handler import android.os.Looper +import android.util.Log import android.util.TypedValue import android.view.Gravity import android.view.View @@ -28,12 +29,21 @@ import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.service.A11yService import li.songe.gkd.store.storeFlow +import li.songe.loc.Loc -fun toast(text: CharSequence, delayMillis: Long = 0L) { +fun toast( + text: CharSequence, + delayMillis: Long = 0L, + @Loc loc: String = "{methodName}({fileName}:{lineNumber})", +) { if (delayMillis > 0) { - Toaster.delayedShow(text, delayMillis) + Handler(Looper.getMainLooper()).postDelayed({ + Toaster.show(text) + Log.d("Toast", "$loc -> $text") + }, delayMillis) } else { Toaster.show(text) + Log.d("Toast", "$loc -> $text") } } @@ -176,5 +186,7 @@ fun copyText(text: String) { fun initToast() { Toaster.init(app) + Toaster.setDebugMode(false) + Toaster.setInterceptor { false } // 覆盖默认拦截器 setReactiveToastStyle() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6389025c6a..3e5f6d2557 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ destinations = "2.3.0" coil = "3.3.0" rikka_refine = "4.4.0" rikka_shizuku = "13.1.5" -loc = "0.2.0" +loc = "0.3.0" [libraries] kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } From 6acad6a0f8c1a61bbe50494c8d8c9ea674d3bdeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 14 Oct 2025 17:49:05 +0800 Subject: [PATCH 077/245] perf: use jdk21 build --- .github/workflows/Build-Apk.yml | 10 +++++----- .github/workflows/Build-Release.yml | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/Build-Apk.yml b/.github/workflows/Build-Apk.yml index ef37ed6893..94d8a12763 100644 --- a/.github/workflows/Build-Apk.yml +++ b/.github/workflows/Build-Apk.yml @@ -16,14 +16,14 @@ jobs: if: ${{ !startsWith(github.event.head_commit.message, 'chore:') && !startsWith(github.event.head_commit.message, 'chore(') }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: - distribution: 'adopt' - java-version: '17' + distribution: 'zulu' + java-version: '21' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} diff --git a/.github/workflows/Build-Release.yml b/.github/workflows/Build-Release.yml index 5172bc5497..fdb89c68bb 100644 --- a/.github/workflows/Build-Release.yml +++ b/.github/workflows/Build-Release.yml @@ -9,14 +9,14 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v5 with: - distribution: 'adopt' - java-version: '17' + distribution: 'zulu' + java-version: '21' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} From de9689ffcb00cf71dd97d394d07e34451b0002c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 15 Oct 2025 12:22:56 +0800 Subject: [PATCH 078/245] perf: ExposeService sh --- app/build.gradle.kts | 3 ++- app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5adefda0e7..db2dcf9ce2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -180,7 +180,8 @@ kotlin { "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", - "-Xcontext-parameters" + "-Xcontext-parameters", + "-XXLanguage:+MultiDollarInterpolation" ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt index 5d7fcac11b..2862258764 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt @@ -45,13 +45,13 @@ class ExposeService : Service() { companion object { fun initCommandFile() { val commandText = template - .replace("__N__", ExposeService::class.componentName.flattenToShortString()) + .replace("{service}", ExposeService::class.componentName.flattenToShortString()) shFolder.resolve("expose.sh").writeText(commandText) } } } -private const val template = """ +private const val template = $$"""set -euo pipefail p='' if [ -n "$1" ]; then p+=" --ei expose $1" @@ -59,6 +59,6 @@ fi if [ -n "$2" ]; then p+=" --es data $2" fi -am start-foreground-service -n __N__ ${'$'}p +am start-foreground-service -n {service} $p echo 'Execution Successful' """ \ No newline at end of file From e30402a4c5b0043bd4518c9e827b33e09d171a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 15 Oct 2025 14:04:16 +0800 Subject: [PATCH 079/245] perf: onScreenForcedActive --- .../main/kotlin/li/songe/gkd/MainActivity.kt | 6 +++++ .../li/songe/gkd/a11y/A11yRuleEngine.kt | 17 +++++++++---- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 24 +++++++++++-------- .../li/songe/gkd/service/A11yService.kt | 13 ++++++---- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index e829c675e7..c324c6b3a2 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -179,6 +179,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() fixSomeProblems() super.onCreate(savedInstanceState) + LogUtils.d("MainActivity::onCreate") mainVm launcher pickContentLauncher @@ -268,6 +269,11 @@ class MainActivity : ComponentActivity() { activityVisibleState-- } + override fun onDestroy() { + super.onDestroy() + LogUtils.d("MainActivity::onDestroy") + } + private var lastBackPressedTime = 0L @Suppress("OVERRIDE_DEPRECATION", "GestureBackNavigation") diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index c28ab109a1..faa3aaff0e 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -37,7 +37,14 @@ private val actionDispatcher = Executors.newSingleThreadExecutor().asCoroutineDi class A11yRuleEngine(val service: A11yService) { init { - service.outStartQueryJob = { startQueryJob(byForced = true) } + // 关闭屏幕 -> Activity::onStop -> 点亮屏幕 -> Activity::onStart -> Activity::onResume + service.onScreenForcedActive = { + val oldValue = topActivityFlow.value + updateTopActivity("", null) + updateTopActivity(oldValue.appId, oldValue.activityId) + startQueryJob() + Log.d("A11yRuleEngine", "onScreenForcedActive->${oldValue.appId}") + } service.onA11yConnected { if (storeFlow.value.enableBlockA11yAppList && !a11yPartDisabledFlow.value) { startQueryJob(byForced = true) @@ -52,9 +59,9 @@ class A11yRuleEngine(val service: A11yService) { var lastEventTime = 0L val eventDeque = ArrayDeque() fun onNewA11yEvent(event: AccessibilityEvent) { - if (event.eventType == CONTENT_CHANGED && event.packageName == systemUiAppId) { - if (!service.isInteractive) return // 屏幕关闭后仍然有无障碍事件 - if (event.packageName != topActivityFlow.value.appId) return + if (event.eventType == CONTENT_CHANGED) { + if (!service.isInteractive) return // 屏幕关闭后仍然有无障碍事件 type:2048, time:8094, app:com.miui.aod, cls:android.widget.TextView + if (event.packageName == systemUiAppId && event.packageName != topActivityFlow.value.appId) return } // 过滤部分输入法事件 if (event.packageName == imeAppId && topActivityFlow.value.appId != imeAppId) { @@ -186,7 +193,7 @@ class A11yRuleEngine(val service: A11yService) { fun checkFutureStartJob() { val t = System.currentTimeMillis() - if (t - lastTriggerTime < 3000L || t - appChangeTime < 5000L || t - service.lastScreenOnTime < 3000L) { + if (t - lastTriggerTime < 3000L || t - appChangeTime < 5000L) { scope.launch(actionDispatcher) { delay(300) startQueryJob() diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index a924ebbf28..fef7319e3a 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -166,7 +166,7 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { ) lastValidActivity = oldActivity lastActivityUpdateTime = t - if (ActivityService.isRunning.value) { + if (ActivityService.isRunning.value && appId.isNotEmpty()) { appScope.launchTry(Dispatchers.IO) { activityLogMutex.withLock { DbSet.activityLogDao.insert( @@ -197,14 +197,16 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { if (idChanged) { val oldAppId = lastAppId lastAppId = appId - appChangeTime = t - appScope.launchTry { - DbSet.appVisitLogDao.insert(oldAppId, appId, t) - appLogCount++ - if (appLogCount % 100 == 0) { - DbSet.appVisitLogDao.deleteKeepLatest() + if (oldAppId.isNotEmpty() && appId.isNotEmpty()) { + appScope.launchTry { + DbSet.appVisitLogDao.insert(oldAppId, appId, t) + appLogCount++ + if (appLogCount % 100 == 0) { + DbSet.appVisitLogDao.deleteKeepLatest() + } } } + appChangeTime = t ruleSummary.globalRules.forEach { it.resetState(t) } ruleSummary.appIdToRules[oldActivityRule.topActivity.appId]?.forEach { it.resetState(t) } newActivityRule.appRules.forEach { it.resetState(t) } @@ -228,9 +230,11 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { } } activityRuleFlow.value = newActivityRule - LogUtils.d( - "${oldActivity.format()} -> ${topActivityFlow.value.format()} (type=$type)", - ) + if (appId.isNotEmpty() && oldActivityRule.topActivity.appId.isNotEmpty()) { + LogUtils.d( + "${oldActivity.format()} -> ${topActivityFlow.value.format()} (type=$type)", + ) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt index bfd1d49372..667c7dc552 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import li.songe.gkd.a11y.A11yContext import li.songe.gkd.a11y.A11yRuleEngine import li.songe.gkd.a11y.a11yContext +import li.songe.gkd.a11y.appChangeTime import li.songe.gkd.a11y.isUseful import li.songe.gkd.a11y.onA11yFeatInit import li.songe.gkd.a11y.setGeneratedTime @@ -69,9 +70,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { override val scope = useScope() var isInteractive = true private set - var outStartQueryJob = {} - var lastScreenOnTime = 0L - private set + var onScreenForcedActive = {} private val screenStateReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context?, @@ -82,11 +81,14 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { isInteractive = when (action) { Intent.ACTION_SCREEN_ON -> true Intent.ACTION_SCREEN_OFF -> false + Intent.ACTION_USER_PRESENT -> true else -> isInteractive } if (isInteractive) { - lastScreenOnTime = System.currentTimeMillis() - outStartQueryJob() + val t = System.currentTimeMillis() + if (t - appChangeTime > 500) { // 37.872(a11y) -> 38.228(onReceive) + onScreenForcedActive() + } } } } @@ -107,6 +109,7 @@ abstract class A11yService : AccessibilityService(), OnA11yLife { IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) }, ContextCompat.RECEIVER_EXPORTED ) From 8dd2dca5dbff3e5392ac7d0e03e26f12eea739de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 16 Oct 2025 20:39:13 +0800 Subject: [PATCH 080/245] perf: update buildToolsVersion to 36.1.0 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 49bb04fa32..85bee8f031 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin ext { set("android.namespace", "li.songe.gkd") - set("android.buildToolsVersion", "36.0.0") + set("android.buildToolsVersion", "36.1.0") set("android.compileSdk", 36) set("android.targetSdk", 36) set("android.minSdk", 26) From 84502c3eea8658e8b3054bbd7ae88422133c720d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 16 Oct 2025 20:40:26 +0800 Subject: [PATCH 081/245] fix: app group enableSize (#1162) --- app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt | 4 ++-- app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt index 7b640c0681..a5ad8e7003 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt @@ -151,7 +151,7 @@ class SubsAppListVm(stateHandle: SavedStateHandle) : BaseViewModel() { rawApp = it.first, appInfo = it.second, appConfig = appConfigs.find { s -> s.appId == it.first.id }, - configSize = enableSize, + enableSize = enableSize, ) } }.stateInit(emptyList()) @@ -169,7 +169,7 @@ data class SubsAppInfoItem( val rawApp: RawSubscription.RawApp, val appInfo: AppInfo?, val appConfig: AppConfig?, - val configSize: Int, + val enableSize: Int, ) { val id get() = rawApp.id } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt index 45f42e5e69..101b62bba5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt @@ -23,7 +23,6 @@ import li.songe.gkd.ui.style.appItemPadding @Composable fun SubsAppCard( data: SubsAppInfoItem, - enableSize: Int = data.rawApp.groups.count { g -> g.enable ?: true }, onClick: (() -> Unit), onValueChange: ((Boolean) -> Unit), ) { @@ -43,10 +42,10 @@ fun SubsAppCard( ) { AppNameText(appInfo = data.appInfo, fallbackName = data.rawApp.name) if (rawApp.groups.isNotEmpty()) { - val enableDesc = when (enableSize) { + val enableDesc = when (data.enableSize) { 0 -> "${rawApp.groups.size}组规则/${rawApp.groups.size}关闭" rawApp.groups.size -> "${rawApp.groups.size}组规则" - else -> "${rawApp.groups.size}组规则/${enableSize}启用/${rawApp.groups.size - enableSize}关闭" + else -> "${rawApp.groups.size}组规则/${data.enableSize}启用/${rawApp.groups.size - data.enableSize}关闭" } Text( text = enableDesc, From b9f1b85f9a148037a271cc8cb90975a3f7f890bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 16 Oct 2025 21:22:33 +0800 Subject: [PATCH 082/245] chore: v1.11.0-beta.2 --- CHANGELOG.md | 33 +++++++-------------------------- app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 772c12534f..b691461442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,15 @@ -# v1.11.0-beta.1 +# v1.11.0-beta.2 以下是本次更新的主要内容 ## 优化和修复 -- 优化多个页面的显示和使用体验 -- 优化规则编辑弹窗为页面并支持代码高亮 -- 重构应用列表移除部分排序筛选,增加按最近使用排序,显示冻结应用,隐藏无界面应用,支持下拉刷新 -- 新增多个通知栏开关 -- 新增使用协议和隐私政策 -- 新增通知文案支持主标题和副标题 -- 优化任意悬浮窗支持保存上次位置 -- 新增无障碍事件悬浮窗及日志页面 -- 新增截屏快照的应用ID和特征事件选择器 -- 新增应用白名单内暂停匹配 -- 新增局部关闭,在无障碍白名单内关闭无障碍 -- 新增界面服务悬浮窗显示 Activity -- 重构 Shizuku 授权为单个 `启用优化` 开关,所有功能内部自动判断开启 -- 优化连接 Shizuku 后自动给 GKD 授权 -- 优化通知管理,服务类常驻通知均增加关闭按钮 -- 优化关于页面反馈提示 -- 优化空白截图增加文字提示 -- 优化所有列表页面点击标题返回顶部 -- 优化规则执行逻辑 -- 新增订阅字段 `versionCode` 和 `versionName` -- 新增订阅字段值 `action:'none'` -- 修复在设备重启时启动常驻通知报错 -- 修复 resetMatch=app 且 activityIds 有值时匹配异常 -- 修复 com.android.systemui 系统界面识别异常 -- 其它多个优化和修复 +- 优化高级授权流程 +- 优化本地图片的显示 +- 优化无应用列表权限提示 +- 修复应用规则概览错误 +- 修复重新打开屏幕时规则概率不执行 +- 其它优化和修复 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db2dcf9ce2..c6c3a7cc59 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 67 - versionName = "1.11.0-beta.1" + versionCode = 68 + versionName = "1.11.0-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From f9bb18bc0a7174584d30216b30e8978f52b22adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 17 Oct 2025 20:41:41 +0800 Subject: [PATCH 083/245] fix: app list (#1169) --- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 61 +++++++++++++++---- .../li/songe/gkd/shizuku/PackageManager.kt | 12 ++++ .../kotlin/li/songe/gkd/util/AppInfoState.kt | 38 +++++++----- .../android/content/pm/IPackageManager.java | 6 ++ 4 files changed, 91 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index a820e48538..ad6fff0b87 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -3,11 +3,15 @@ package li.songe.gkd.data import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageInfoHidden +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable import dev.rikka.tools.refine.Refine import kotlinx.serialization.Serializable import li.songe.gkd.app import li.songe.gkd.shizuku.currentUserId +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.pkgIcon @Serializable data class AppInfo( @@ -58,16 +62,51 @@ private val PackageInfo.isOverlay: Boolean val ApplicationInfo.isSystem: Boolean get() = flags and ApplicationInfo.FLAG_SYSTEM != 0 +private fun checkIfNotHasActivity(packageName: String, userId: Int): Boolean { + val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES + return if (userId == currentUserId) { + app.packageManager.getPackageInfo( + packageName, + flags, + ) + } else { + shizukuContextFlow.value.packageManager?.getPackageInfo( + packageName, + flags, + userId, + ) + }?.activities.let { + it == null || it.isEmpty() + } +} + +// all->433 isOverlay->354 checkIfNotHasActivity->271 fun PackageInfo.toAppInfo( userId: Int = currentUserId, -) = AppInfo( - userId = userId, - id = packageName, - versionCode = compatVersionCode, - versionName = versionName, - mtime = lastUpdateTime, - isSystem = applicationInfo?.isSystem ?: false, - name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, - hidden = activities?.isEmpty() != false || isOverlay, - enabled = applicationInfo?.enabled ?: true, -) + hidden: Boolean? = null, +): AppInfo { + val isSystem = applicationInfo?.isSystem ?: false + return AppInfo( + userId = userId, + id = packageName, + versionCode = compatVersionCode, + versionName = versionName, + mtime = lastUpdateTime, + isSystem = isSystem, + name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, + hidden = hidden ?: (isSystem && (isOverlay || checkIfNotHasActivity(packageName, userId))), + enabled = applicationInfo?.enabled ?: true, + ) +} + +fun PackageInfo.toAppInfoAndIcon( + userId: Int = currentUserId, + hidden: Boolean? = null, +): Pair { + val appInfo = toAppInfo(userId, hidden) + return if (appInfo.hidden) { + appInfo to null + } else { + appInfo to pkgIcon + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 311aed4e1c..1ca1a55a45 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -33,6 +33,18 @@ class SafePackageManager(private val value: IPackageManager) { } } ?: emptyList() + fun getPackageInfo( + packageName: String, + flags: Int, + userId: Int = currentUserId, + ): PackageInfo? = safeInvokeMethod { + if (AndroidTarget.TIRAMISU) { + value.getPackageInfo(packageName, flags.toLong(), userId) + } else { + value.getPackageInfo(packageName, flags, userId) + } + } + fun grantRuntimePermission( packageName: String, permissionName: String, diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index e6d9572959..f08052d2a8 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -24,6 +24,7 @@ import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.data.toAppInfo +import li.songe.gkd.data.toAppInfoAndIcon import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.shizuku.currentUserId @@ -89,7 +90,7 @@ private val packageReceiver by lazy { } } -const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES +const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES val updateAppMutex = MutexState() @@ -115,8 +116,11 @@ private fun updateOtherUserAppInfo(userAppInfoMap: Map? = null) userPackageInfoMap.forEach { (userId, pkgInfoList) -> pkgInfoList.forEach { pkgInfo -> if (!newAppMap.contains(pkgInfo.packageName)) { - newAppMap[pkgInfo.packageName] = pkgInfo.toAppInfo(userId = userId) - pkgInfo.pkgIcon?.let { newIconMap[pkgInfo.packageName] = it } + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon(userId) + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon + } } } } @@ -153,11 +157,13 @@ private fun updatePartAppInfo( fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO) { val newAppMap = HashMap() val newIconMap = HashMap() + // see #1169 val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) - pkgList.forEach { packageInfo -> - newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() - packageInfo.pkgIcon?.let { icon -> - newIconMap[packageInfo.packageName] = icon + pkgList.forEach { pkgInfo -> + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon() + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon } } val mayAuthDenied = newAppMap.count { !it.value.isSystem } <= 4 @@ -165,10 +171,11 @@ fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO if (!canQueryPkgState.value || mayAuthDenied) { val pkgList2 = shizukuContextFlow.value.packageManager?.getInstalledPackages(PKG_FLAGS) if (!pkgList2.isNullOrEmpty()) { - pkgList2.forEach { packageInfo -> - newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() - packageInfo.pkgIcon?.let { icon -> - newIconMap[packageInfo.packageName] = icon + pkgList2.forEach { pkgInfo -> + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon() + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon } } } else { @@ -180,10 +187,11 @@ fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO }.flatten() .map { it.activityInfo.packageName }.toSet() .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) } - visiblePkgList.forEach { packageInfo -> - newAppMap[packageInfo.packageName] = packageInfo.toAppInfo() - packageInfo.pkgIcon?.let { icon -> - newIconMap[packageInfo.packageName] = icon + visiblePkgList.forEach { pkgInfo -> + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon(hidden = false) + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon } } } diff --git a/hidden_api/src/main/java/android/content/pm/IPackageManager.java b/hidden_api/src/main/java/android/content/pm/IPackageManager.java index 890881e729..ce0271cf02 100644 --- a/hidden_api/src/main/java/android/content/pm/IPackageManager.java +++ b/hidden_api/src/main/java/android/content/pm/IPackageManager.java @@ -27,6 +27,12 @@ public static IPackageManager asInterface(IBinder binder) { @RequiresApi(Build.VERSION_CODES.TIRAMISU) ParceledListSlice getInstalledPackages(long flags, int userId); + @DeprecatedSinceApi(api = Build.VERSION_CODES.TIRAMISU, message = "NoSuchMethodError") + PackageInfo getPackageInfo(String packageName, int flags, int userId); + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + PackageInfo getPackageInfo(String packageName, long flags, int userId); + ParceledListSlice getAllIntentFilters(String packageName); void grantRuntimePermission(String packageName, String permissionName, int userId); From 8e8615997906deb2ccfac2580595c645ac71f9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 17 Oct 2025 20:43:07 +0800 Subject: [PATCH 084/245] chore: v1.11.0-beta.3 --- CHANGELOG.md | 9 ++------- app/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b691461442..8c7d18a4d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,10 @@ -# v1.11.0-beta.2 +# v1.11.0-beta.3 以下是本次更新的主要内容 ## 优化和修复 -- 优化高级授权流程 -- 优化本地图片的显示 -- 优化无应用列表权限提示 -- 修复应用规则概览错误 -- 修复重新打开屏幕时规则概率不执行 -- 其它优化和修复 +- 修复某些设备应用列表加载失败 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6c3a7cc59..c71ad9bcd4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 68 - versionName = "1.11.0-beta.2" + versionCode = 69 + versionName = "1.11.0-beta.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From a32199b4175a1322a7590d44ec47aa9a3cb68d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 18 Oct 2025 19:35:54 +0800 Subject: [PATCH 085/245] chore: rm AppInfo.visible --- app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt | 2 -- app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index ad6fff0b87..bccb4c6f1f 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -36,8 +36,6 @@ data class AppInfo( result = 31 * result + userId return result } - - val visible get() = !(hidden && isSystem) } val selfAppInfo by lazy { diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index f08052d2a8..a792f7e9b1 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -55,7 +55,7 @@ val systemAppsFlow by lazy { systemAppInfoCacheFlow.mapState(appScope) { c -> c. val visibleAppInfosFlow by lazy { appInfoMapFlow.mapState(appScope) { c -> - c.values.filter { it.visible }.sortedWith { a, b -> + c.values.filterNot { it.hidden }.sortedWith { a, b -> collator.compare(a.name, b.name) } } From e19622bbe16e003be1639e2b19df0b59c31517fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 19 Oct 2025 19:27:46 +0800 Subject: [PATCH 086/245] perf: AppInfo.enabled --- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 21 +++++++++++++++++-- .../li/songe/gkd/shizuku/PackageManager.kt | 9 +++++++- .../android/content/pm/IPackageManager.java | 2 ++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index bccb4c6f1f..875f5328ef 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -58,7 +58,7 @@ private val PackageInfo.isOverlay: Boolean } val ApplicationInfo.isSystem: Boolean - get() = flags and ApplicationInfo.FLAG_SYSTEM != 0 + get() = flags and ApplicationInfo.FLAG_SYSTEM != 0 || flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 private fun checkIfNotHasActivity(packageName: String, userId: Int): Boolean { val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES @@ -78,6 +78,23 @@ private fun checkIfNotHasActivity(packageName: String, userId: Int): Boolean { } } +private fun PackageInfo.getEnabled(userId: Int): Boolean { + val enabled = applicationInfo?.enabled ?: true + if (enabled) return true + val state = if (userId == currentUserId) { + app.packageManager.getApplicationEnabledSetting(packageName) + } else { + shizukuContextFlow.value.packageManager?.getApplicationEnabledSetting( + packageName, + currentUserId + ) ?: 0 + } + return when (state) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> false + else -> true + } +} + // all->433 isOverlay->354 checkIfNotHasActivity->271 fun PackageInfo.toAppInfo( userId: Int = currentUserId, @@ -93,7 +110,7 @@ fun PackageInfo.toAppInfo( isSystem = isSystem, name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, hidden = hidden ?: (isSystem && (isOverlay || checkIfNotHasActivity(packageName, userId))), - enabled = applicationInfo?.enabled ?: true, + enabled = getEnabled(userId), ) } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 1ca1a55a45..0528a24af8 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -36,7 +36,7 @@ class SafePackageManager(private val value: IPackageManager) { fun getPackageInfo( packageName: String, flags: Int, - userId: Int = currentUserId, + userId: Int, ): PackageInfo? = safeInvokeMethod { if (AndroidTarget.TIRAMISU) { value.getPackageInfo(packageName, flags.toLong(), userId) @@ -45,6 +45,13 @@ class SafePackageManager(private val value: IPackageManager) { } } + fun getApplicationEnabledSetting( + packageName: String, + userId: Int, + ): Int = safeInvokeMethod { + value.getApplicationEnabledSetting(packageName, userId) + } ?: 0 + fun grantRuntimePermission( packageName: String, permissionName: String, diff --git a/hidden_api/src/main/java/android/content/pm/IPackageManager.java b/hidden_api/src/main/java/android/content/pm/IPackageManager.java index ce0271cf02..26de43ac5e 100644 --- a/hidden_api/src/main/java/android/content/pm/IPackageManager.java +++ b/hidden_api/src/main/java/android/content/pm/IPackageManager.java @@ -37,4 +37,6 @@ public static IPackageManager asInterface(IBinder binder) { void grantRuntimePermission(String packageName, String permissionName, int userId); + int getApplicationEnabledSetting(String packageName, int userId); + } From 1c49a9dec35af94919b9ad34559098fed0f44357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 21 Oct 2025 21:58:25 +0800 Subject: [PATCH 087/245] refactor: update loc library dependency and improve toast handling --- app/build.gradle.kts | 2 +- .../main/kotlin/li/songe/gkd/MainViewModel.kt | 23 ++++++------------- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 7 +++--- .../li/songe/gkd/util/LifecycleCallbacks.kt | 2 +- .../main/kotlin/li/songe/gkd/util/Others.kt | 10 ++++++++ .../main/kotlin/li/songe/gkd/util/Toast.kt | 19 ++++----------- gradle/libs.versions.toml | 4 ++-- 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c71ad9bcd4..23b7fa9c8e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -274,7 +274,7 @@ dependencies { implementation(libs.permissions) implementation(libs.json5) - compileOnly(libs.loc.runtime) + compileOnly(libs.loc.annotation) implementation(libs.kevinnzouWebview) } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 431638b4e8..3289ef722e 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -4,8 +4,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.net.Uri -import android.os.Handler -import android.os.Looper import android.webkit.URLUtil import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController @@ -55,6 +53,7 @@ import li.songe.gkd.util.client import li.songe.gkd.util.launchTry import li.songe.gkd.util.openUri import li.songe.gkd.util.openWeChatScaner +import li.songe.gkd.util.runMainPost import li.songe.gkd.util.stopCoroutine import li.songe.gkd.util.subsFolder import li.songe.gkd.util.subsItemsFlow @@ -90,12 +89,8 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { if (!backThrottleTimer.expired()) return @SuppressLint("RestrictedApi") if (navController.currentBackStack.value.size == 1) return - if (Looper.getMainLooper() == Looper.myLooper()) { + runMainPost { navController.popBackStack() - } else { - Handler(Looper.getMainLooper()).post { - navController.popBackStack() - } } } @@ -103,16 +98,12 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { if (direction.route == navController.currentDestination?.route) { return } - if (Looper.getMainLooper() != Looper.myLooper()) { - Handler(Looper.getMainLooper()).post { - navigatePage(direction, builder) + runMainPost { + if (builder != null) { + navController.navigate(direction.route, builder) + } else { + navController.navigate(direction.route) } - return - } - if (builder != null) { - navController.navigate(direction.route, builder) - } else { - navController.navigate(direction.route) } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index d3d5b7cdaf..21e93a7059 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -18,6 +18,7 @@ import li.songe.gkd.permission.updatePermissionState import li.songe.gkd.store.storeFlow import li.songe.gkd.util.MutexState import li.songe.gkd.util.launchTry +import li.songe.gkd.util.runMainPost import li.songe.gkd.util.toast import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper @@ -136,12 +137,12 @@ private fun updateShizukuBinder() = updateBinderMutex.launchTry(appScope, Dispat val newValue = shizukuContextFlow.value if (newValue.serviceWrapper == null) { if (newValue.packageManager != null) { - toast("Shizuku 服务连接部分失败", delayMillis) + runMainPost(delayMillis) { toast("Shizuku 服务连接部分失败") } } else { - toast("Shizuku 服务连接失败", delayMillis) + runMainPost(delayMillis) { toast("Shizuku 服务连接失败") } } } else { - toast("Shizuku 服务连接成功", delayMillis) + runMainPost(delayMillis) { toast("Shizuku 服务连接成功") } } } } else if (shizukuContextFlow.value.ok) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt index 4c0674456e..857acac4e1 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -49,7 +49,7 @@ interface OnSimpleLife { fun useAliveToast(name: String, onlyWhenVisible: Boolean = false, delayMillis: Long = 0L) { onCreated { if (isActivityVisible() || !onlyWhenVisible) { - toast("${name}已启动", delayMillis) + runMainPost(delayMillis) { toast("${name}已启动") } } } onDestroyed { diff --git a/app/src/main/kotlin/li/songe/gkd/util/Others.kt b/app/src/main/kotlin/li/songe/gkd/util/Others.kt index 4c96a19c3c..1c11b419ef 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -9,6 +9,8 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper import android.provider.AlarmClock import android.provider.MediaStore import android.provider.Settings @@ -227,3 +229,11 @@ object AppListString { return set } } + +fun runMainPost(delayMillis: Long = 0L, r: Runnable) { + if (delayMillis == 0L && Looper.getMainLooper() == Looper.myLooper()) { + r.run() + return + } + Handler(Looper.getMainLooper()).postDelayed(r, delayMillis) +} diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index acd0e3d23e..2f19fef535 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -8,8 +8,6 @@ import android.graphics.Outline import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable import android.graphics.text.LineBreaker -import android.os.Handler -import android.os.Looper import android.util.Log import android.util.TypedValue import android.view.Gravity @@ -31,20 +29,13 @@ import li.songe.gkd.service.A11yService import li.songe.gkd.store.storeFlow import li.songe.loc.Loc +@Loc fun toast( text: CharSequence, - delayMillis: Long = 0L, @Loc loc: String = "{methodName}({fileName}:{lineNumber})", ) { - if (delayMillis > 0) { - Handler(Looper.getMainLooper()).postDelayed({ - Toaster.show(text) - Log.d("Toast", "$loc -> $text") - }, delayMillis) - } else { - Toaster.show(text) - Log.d("Toast", "$loc -> $text") - } + Toaster.show(text) + Log.d("Toast", "$loc -> $text") } private val darkTheme: Boolean @@ -171,12 +162,12 @@ private fun showA11yToast(message: CharSequence) { windowAnimations = android.R.style.Animation_Toast } wm.addView(textView, layoutParams) - Handler(Looper.getMainLooper()).postDelayed({ + runMainPost(triggerInterval) { try { wm.removeViewImmediate(textView) } catch (_: Exception) { } - }, triggerInterval) + } } fun copyText(text: String) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e5f6d2557..30014464c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ destinations = "2.3.0" coil = "3.3.0" rikka_refine = "4.4.0" rikka_shizuku = "13.1.5" -loc = "0.3.0" +loc = "0.4.0" [libraries] kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -63,7 +63,7 @@ destinations_ksp = { module = "io.github.raamcosta.compose-destinations:ksp", ve coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil_network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } -loc_runtime = { module = "li.songe.loc:loc-runtime", version.ref = "loc" } +loc_annotation = { module = "li.songe.loc:loc-annotation", version.ref = "loc" } reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" toaster = "com.github.getActivity:Toaster:13.6" From f32b634fd7b616cfec9ed349645c71bddeea2678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 25 Oct 2025 00:03:44 +0800 Subject: [PATCH 088/245] perf: update libs and loc not working when incremental compile --- app/src/main/kotlin/li/songe/gkd/util/Toast.kt | 2 +- gradle/libs.versions.toml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt index 2f19fef535..fea3cf529a 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -32,7 +32,7 @@ import li.songe.loc.Loc @Loc fun toast( text: CharSequence, - @Loc loc: String = "{methodName}({fileName}:{lineNumber})", + @Loc("{methodName}({fileName}:{lineNumber})") loc: String = "", ) { Toaster.show(text) Log.d("Toast", "$loc -> $text") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30014464c4..1a565b65a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -kotlin = "2.2.20" -ksp = "2.2.20-2.0.4" +kotlin = "2.2.21" +ksp = "2.3.0" agp = "8.13.0" -compose = "1.9.3" -room = "2.8.2" +compose = "1.9.4" +room = "2.8.3" paging = "3.3.6" ktor = "3.3.1" atomicfu = "0.29.0" @@ -11,7 +11,7 @@ destinations = "2.3.0" coil = "3.3.0" rikka_refine = "4.4.0" rikka_shizuku = "13.1.5" -loc = "0.4.0" +loc = "0.5.0" [libraries] kotlin_stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } From ac7975ebedd0dd04635e8714ca25d74d56004bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 25 Oct 2025 00:05:42 +0800 Subject: [PATCH 089/245] fix: selector isMatchRoot not working --- .../kotlin/li/songe/selector/property/PropertyWrapper.kt | 7 ++++++- .../li/songe/selector/unit/LogicalSelectorExpression.kt | 5 ++++- .../src/jvmTest/kotlin/li/songe/selector/ParserUnitTest.kt | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/selector/src/commonMain/kotlin/li/songe/selector/property/PropertyWrapper.kt b/selector/src/commonMain/kotlin/li/songe/selector/property/PropertyWrapper.kt index 253eb9494b..5ab16584ec 100644 --- a/selector/src/commonMain/kotlin/li/songe/selector/property/PropertyWrapper.kt +++ b/selector/src/commonMain/kotlin/li/songe/selector/property/PropertyWrapper.kt @@ -36,7 +36,12 @@ data class PropertyWrapper( val isMatchRoot = segment.units.any { val e = it.expression - e is BinaryExpression && e.operator == CompareOperator.Equal && e.left.value == "parent" && e.right.value == "null" + e is BinaryExpression && e.operator == CompareOperator.Equal && when { + // null == Identifier(name="parent") + e.right.value == null && e.left.value == "parent" -> true + e.left.value == null && e.right.value == "parent" -> true + else -> false + } } val fastQueryList by lazy { segment.fastQueryList ?: emptyList() } diff --git a/selector/src/commonMain/kotlin/li/songe/selector/unit/LogicalSelectorExpression.kt b/selector/src/commonMain/kotlin/li/songe/selector/unit/LogicalSelectorExpression.kt index c825051a40..98cb250194 100644 --- a/selector/src/commonMain/kotlin/li/songe/selector/unit/LogicalSelectorExpression.kt +++ b/selector/src/commonMain/kotlin/li/songe/selector/unit/LogicalSelectorExpression.kt @@ -57,7 +57,10 @@ data class LogicalSelectorExpression( } override val isMatchRoot: Boolean - get() = left.isMatchRoot && operator == SelectorLogicalOperator.AndOperator + get() = when (operator) { + SelectorLogicalOperator.AndOperator -> left.isMatchRoot || right.isMatchRoot + SelectorLogicalOperator.OrOperator -> left.isMatchRoot && right.isMatchRoot + } override val fastQueryList: List get() = left.fastQueryList + right.fastQueryList diff --git a/selector/src/jvmTest/kotlin/li/songe/selector/ParserUnitTest.kt b/selector/src/jvmTest/kotlin/li/songe/selector/ParserUnitTest.kt index 8c005eb8f8..42b1762843 100644 --- a/selector/src/jvmTest/kotlin/li/songe/selector/ParserUnitTest.kt +++ b/selector/src/jvmTest/kotlin/li/songe/selector/ParserUnitTest.kt @@ -163,4 +163,10 @@ class ParserUnitTest { println("selector: ${ast.value}") println("ast: ${ast.stringify()}") } + + @Test + fun root() { + assert(Selector.parse("[null=parent]").isMatchRoot) + assert(Selector.parse("[parent=null]").isMatchRoot) + } } From 736804c629f4f3228b39ccfb8a58d3bea52fe223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 26 Oct 2025 23:33:10 +0800 Subject: [PATCH 090/245] refactor: enhance updateTopActivity to support ActivityScene and improve logging --- .../li/songe/gkd/a11y/A11yRuleEngine.kt | 6 +- .../kotlin/li/songe/gkd/a11y/A11yState.kt | 75 +++++++++---------- .../kotlin/li/songe/gkd/data/AppVisitLog.kt | 11 ++- .../li/songe/gkd/service/ActivityService.kt | 3 +- .../li/songe/gkd/shizuku/TaskStackListener.kt | 5 +- 5 files changed, 53 insertions(+), 47 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index faa3aaff0e..e44f3a9726 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -39,11 +39,9 @@ class A11yRuleEngine(val service: A11yService) { init { // 关闭屏幕 -> Activity::onStop -> 点亮屏幕 -> Activity::onStart -> Activity::onResume service.onScreenForcedActive = { - val oldValue = topActivityFlow.value - updateTopActivity("", null) - updateTopActivity(oldValue.appId, oldValue.activityId) + val a = topActivityFlow.value + updateTopActivity(a.appId, a.activityId, scene = ActivityScene.ScreenOn) startQueryJob() - Log.d("A11yRuleEngine", "onScreenForcedActive->${oldValue.appId}") } service.onA11yConnected { if (storeFlow.value.enableBlockA11yAppList && !a11yPartDisabledFlow.value) { diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt index fef7319e3a..4791534e09 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.ActionLog @@ -22,7 +23,6 @@ import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.RuleStatus import li.songe.gkd.data.isSystem import li.songe.gkd.db.DbSet -import li.songe.gkd.service.ActivityService import li.songe.gkd.service.updateTopAppId import li.songe.gkd.shizuku.safeInvokeMethod import li.songe.gkd.store.actionCountFlow @@ -71,10 +71,10 @@ private var lastValidActivity: TopActivity = topActivityFlow.value } } -private val activityLogMutex = Mutex() private var activityLogCount = 0 private var lastActivityUpdateTime = 0L private var lastActivityForceUpdateTime = 0L +private val tempActivityLogList = mutableListOf() private object ActivityCache : LruCache, Boolean>(256) { override fun create(key: Pair): Boolean = try { @@ -132,21 +132,29 @@ class ActivityRule( val activityRuleFlow = MutableStateFlow(ActivityRule()) -private var appLogCount = 0 private var lastAppId = "" +sealed class ActivityScene() { + data object ScreenOn : ActivityScene() + data object A11y : ActivityScene() + data object TaskStack : ActivityScene() +} + @Synchronized -fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { +fun updateTopActivity( + appId: String, + activityId: String?, + scene: ActivityScene = ActivityScene.A11y, +) { val t = System.currentTimeMillis() - if (type > 0 && storeFlow.value.enableBlockA11yAppList) { + if (scene == ActivityScene.TaskStack && storeFlow.value.enableBlockA11yAppList) { updateTopAppId(appId) } val oldActivity = topActivityFlow.value - val forced = type > 0 - val isSame = oldActivity.sameAs(appId, activityId) - if (forced) { + val isSame = scene != ActivityScene.ScreenOn && oldActivity.sameAs(appId, activityId) + if (scene == ActivityScene.TaskStack) { lastActivityForceUpdateTime = t - } else { + } else if (scene == ActivityScene.A11y) { if (lastActivityForceUpdateTime > 0) { // ITaskStackListener 的变速快于无障碍 if (t - lastActivityForceUpdateTime < 1000) return @@ -166,27 +174,28 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { ) lastValidActivity = oldActivity lastActivityUpdateTime = t - if (ActivityService.isRunning.value && appId.isNotEmpty()) { - appScope.launchTry(Dispatchers.IO) { - activityLogMutex.withLock { - DbSet.activityLogDao.insert( - ActivityLog( - appId = appId, - activityId = activityId, - ctime = t, - ) - ) - activityLogCount++ - if (activityLogCount % 100 == 0) { - DbSet.activityLogDao.deleteKeepLatest() - } - } + tempActivityLogList.add( + ActivityLog( + appId = appId, + activityId = activityId, + ctime = t, + ) + ) + if (tempActivityLogList.size >= 16 || appId == META.appId) { + val logs = tempActivityLogList.toTypedArray() + tempActivityLogList.clear() + appScope.launchTry { + DbSet.activityLogDao.insert(*logs) } } + if (activityLogCount++ % 100 == 0) { + appScope.launchTry { DbSet.activityLogDao.deleteKeepLatest() } + } val topActivity = topActivityFlow.value val oldActivityRule = activityRuleFlow.value val ruleSummary = ruleSummaryFlow.value - val idChanged = topActivity.appId != oldActivityRule.topActivity.appId + val idChanged = (scene == ActivityScene.ScreenOn || + topActivity.appId != oldActivityRule.topActivity.appId) val topChanged = idChanged || oldActivityRule.topActivity != topActivity val ruleChanged = oldActivityRule.ruleSummary !== ruleSummary if (topChanged || ruleChanged) { @@ -197,14 +206,8 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { if (idChanged) { val oldAppId = lastAppId lastAppId = appId - if (oldAppId.isNotEmpty() && appId.isNotEmpty()) { - appScope.launchTry { - DbSet.appVisitLogDao.insert(oldAppId, appId, t) - appLogCount++ - if (appLogCount % 100 == 0) { - DbSet.appVisitLogDao.deleteKeepLatest() - } - } + appScope.launchTry { + DbSet.appVisitLogDao.insert(oldAppId, appId, t) } appChangeTime = t ruleSummary.globalRules.forEach { it.resetState(t) } @@ -230,11 +233,7 @@ fun updateTopActivity(appId: String, activityId: String?, type: Int = 0) { } } activityRuleFlow.value = newActivityRule - if (appId.isNotEmpty() && oldActivityRule.topActivity.appId.isNotEmpty()) { - LogUtils.d( - "${oldActivity.format()} -> ${topActivityFlow.value.format()} (type=$type)", - ) - } + LogUtils.d("${oldActivity.format()} -> ${topActivityFlow.value.format()} (scene=$scene)") } } diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt index 679254d955..46d1fbc58c 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt @@ -27,8 +27,13 @@ data class AppVisitLog( @Transaction suspend fun insert(oldAppId: String, newAppId: String, mtime: Long) { - insert(AppVisitLog(oldAppId, fixAppVisitTime(oldAppId, mtime - 1))) - insert(AppVisitLog(newAppId, fixAppVisitTime(newAppId, mtime))) + insert( + AppVisitLog(oldAppId, fixAppVisitTime(oldAppId, mtime - 1)), + AppVisitLog(newAppId, fixAppVisitTime(newAppId, mtime)), + ) + if (appLogCount++ % 100 == 0) { + deleteKeepLatest() + } } @Query("SELECT DISTINCT id FROM app_visit_log ORDER BY mtime DESC") @@ -57,3 +62,5 @@ private fun fixAppVisitTime(appId: String, t: Long): Long = when (appId) { META.appId, launcherAppId, systemUiAppId -> t - 60_000 else -> t } + +private var appLogCount = 0 diff --git a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt index 46fd581ce1..a3e7ac3283 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import li.songe.gkd.a11y.ActivityScene import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.a11y.updateTopActivity import li.songe.gkd.notif.StopServiceReceiver @@ -116,7 +117,7 @@ class ActivityService : OverlayWindowService( updateTopActivity( appId = cpn.packageName, activityId = cpn.className, - type = 2, + scene = ActivityScene.TaskStack, ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt index 6d9957f909..c628013c44 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt @@ -4,6 +4,7 @@ import android.app.ActivityManager import android.app.ITaskStackListener import android.content.ComponentName import android.os.Parcel +import li.songe.gkd.a11y.ActivityScene import li.songe.gkd.a11y.updateTopActivity class FixedTaskStackListener : ITaskStackListener.Stub() { @@ -24,7 +25,7 @@ class FixedTaskStackListener : ITaskStackListener.Stub() { updateTopActivity( appId = cpn.packageName, activityId = cpn.className, - type = 1, + scene = ActivityScene.TaskStack, ) } @@ -35,7 +36,7 @@ class FixedTaskStackListener : ITaskStackListener.Stub() { updateTopActivity( appId = cpn.packageName, activityId = cpn.className, - type = 2, + scene = ActivityScene.TaskStack, ) } From 5d6f324689954fd578feee37d4297235b90c9fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 28 Oct 2025 19:05:23 +0800 Subject: [PATCH 091/245] perf: add gkd version info to log file --- app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index 929a6af478..6ff9528829 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -2,6 +2,7 @@ package li.songe.gkd.util import android.text.format.DateUtils import com.blankj.utilcode.util.LogUtils +import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.permission.allPermissionStates import li.songe.gkd.shizuku.shizukuContextFlow @@ -83,6 +84,10 @@ fun buildLogFile(): File { }) files.add(it) } + tempDir.resolve("gkd-${META.versionCode}-v${META.versionName}.json").also { + it.writeText(json.encodeToString(META)) + files.add(it) + } val logZipFile = sharedDir.resolve("log-${System.currentTimeMillis()}.zip") ZipUtils.zipFiles(files, logZipFile) tempDir.deleteRecursively() From 7e966fe10efb8a921a39fc03408755462dfeba78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 28 Oct 2025 20:41:37 +0800 Subject: [PATCH 092/245] fix: prevent nullifying instance when multiple MainViewModel instances exist --- app/src/main/kotlin/li/songe/gkd/MainViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 3289ef722e..031016f521 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -73,7 +73,11 @@ class MainViewModel : BaseViewModel(), OnSimpleLife { init { _instance = this - addCloseable { _instance = null } + addCloseable { + if (_instance == this) { // 可能同时存在 2 个 MainViewModel 实例 + _instance = null + } + } } override val scope: CoroutineScope From 998a6e18bf5f0f57c583e214ab53dbb1ff38907a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 2 Nov 2025 17:10:30 +0800 Subject: [PATCH 093/245] perf: closable overlay window service --- .../li/songe/gkd/service/ActivityService.kt | 90 +++++++++---------- .../li/songe/gkd/service/EventService.kt | 18 +--- .../songe/gkd/service/OverlayWindowService.kt | 30 +++++++ 3 files changed, 78 insertions(+), 60 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt index a3e7ac3283..5044566db4 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding @@ -15,9 +16,11 @@ import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.lifecycleScope @@ -34,10 +37,8 @@ import li.songe.gkd.notif.recordNotif import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.shizuku.SafeTaskListener import li.songe.gkd.shizuku.shizukuContextFlow -import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.style.iconTextSize -import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.copyText import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass @@ -46,13 +47,6 @@ import li.songe.gkd.util.stopServiceByClass class ActivityService : OverlayWindowService( positionKey = "activity" ) { - - val topAppInfoFlow by lazy { - combine(appInfoMapFlow, topActivityFlow) { map, topActivity -> - map[topActivity.appId] - }.stateIn(lifecycleScope, SharingStarted.Eagerly, null) - } - val activityOkFlow by lazy { combine(A11yService.isRunning, shizukuContextFlow) { a, b -> a || SafeTaskListener.isAvailable @@ -62,39 +56,43 @@ class ActivityService : OverlayWindowService( @Composable override fun ComposeContent() { val bgColor = MaterialTheme.colorScheme.surface - Box( + Column( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(bgColor.copy(alpha = 0.9f)) + .width(IntrinsicSize.Max) .padding(4.dp) ) { CompositionLocalProvider(LocalContentColor provides contentColorFor(bgColor)) { - if (activityOkFlow.collectAsState().value) { - val topActivity = topActivityFlow.collectAsState().value - Text( - text = topActivity.number.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.tertiary, - modifier = Modifier - .align(Alignment.TopEnd) - .zIndex(1f) - .clip(MaterialTheme.shapes.extraSmall) - .padding(horizontal = 2.dp), - ) - Column { - topAppInfoFlow.collectAsState().value?.let { - AppNameText(appInfo = it) + val topActivity by topActivityFlow.collectAsState() + val hasAuth by activityOkFlow.collectAsState() + ClosableTitle( + title = if (hasAuth) "记录服务" else "记录服务(无权限)" + ) + if (hasAuth) { + Box { + Column( + modifier = Modifier.padding(start = 4.dp) + ) { + RowText(text = topActivity.appId) + RowText( + text = topActivity.shortActivityId, + color = MaterialTheme.colorScheme.secondary + ) } - RowText(text = topActivity.appId) - topActivity.shortActivityId?.let { - RowText(text = it) + if (topActivity.number > 0) { + Text( + text = topActivity.number.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + .clip(MaterialTheme.shapes.extraSmall) + .padding(end = 4.dp), + ) } } - } else { - Column { - Text(text = "记录服务") - Text(text = "无权限检测界面切换") - } } } } @@ -136,18 +134,20 @@ class ActivityService : OverlayWindowService( } @Composable -private fun RowText(text: String) { +private fun RowText(text: String?, color: Color = Color.Unspecified) { Row { - Text(text = text, modifier = Modifier.weight(1f, false)) - Spacer(modifier = Modifier.width(4.dp)) - PerfIcon( - imageVector = PerfIcon.ContentCopy, - modifier = Modifier - .clip(MaterialTheme.shapes.extraSmall) - .clickable(onClick = { - copyText(text) - }) - .iconTextSize(), - ) + Text(text = text ?: "null", color = color, modifier = Modifier.weight(1f, false)) + if (text != null) { + Spacer(modifier = Modifier.width(4.dp)) + PerfIcon( + imageVector = PerfIcon.ContentCopy, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = { + copyText(text) + }) + .iconTextSize(), + ) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/EventService.kt b/app/src/main/kotlin/li/songe/gkd/service/EventService.kt index ead815bff8..3c79871304 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/EventService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/EventService.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -58,9 +57,7 @@ import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.isAtBottom import li.songe.gkd.ui.component.measureNumberTextWidth -import li.songe.gkd.ui.icon.DragPan import li.songe.gkd.ui.share.ListPlaceholder -import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.util.launchTry import li.songe.gkd.util.startForegroundServiceByClass import li.songe.gkd.util.stopServiceByClass @@ -96,18 +93,9 @@ class EventService : OverlayWindowService(positionKey = "event") { .width(256.dp) .padding(4.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val title = if (A11yService.isRunning.collectAsState().value) { - "事件服务" - } else { - "事件服务(无障碍已关闭)" - } - Text(text = title, modifier = Modifier.weight(1f)) - PerfIcon(imageVector = DragPan, modifier = Modifier.iconTextSize()) - } + ClosableTitle( + title = if (A11yService.isRunning.collectAsState().value) "事件服务" else "事件服务(无权限)" + ) val textStyle = MaterialTheme.typography.labelSmall val numCharWidth = measureNumberTextWidth(textStyle) CompositionLocalProvider( diff --git a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt index f461e76225..95375bd95a 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -9,7 +9,14 @@ import android.view.Gravity import android.view.MotionEvent import android.view.ViewConfiguration import android.view.WindowManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.unit.dp import androidx.core.animation.doOnEnd @@ -33,12 +40,16 @@ import kotlinx.coroutines.launch import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.store.createAnyFlow +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.icon.DragPan import li.songe.gkd.ui.style.AppTheme +import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.util.BarUtils import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.ScreenUtils import li.songe.gkd.util.mapState import li.songe.gkd.util.px +import li.songe.gkd.util.throttle import li.songe.gkd.util.toast private var tempShareContext: ShareContext? = null @@ -119,6 +130,25 @@ abstract class OverlayWindowService( @Composable abstract fun ComposeContent() + @Composable + fun ClosableTitle(title: String) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + PerfIcon(imageVector = DragPan, modifier = Modifier.iconTextSize()) + Text(text = title, modifier = Modifier.weight(1f)) + PerfIcon( + imageVector = PerfIcon.Close, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + stopSelf() + }) + .iconTextSize() + ) + } + } + open fun onClickView() {} val view by lazy { From 6135aea2e3624b5027b048d97a4d14722dfaaa24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 2 Nov 2025 19:41:55 +0800 Subject: [PATCH 094/245] perf: a11y semantics --- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 85 ++++++----- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 9 +- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 26 +++- .../li/songe/gkd/ui/component/AnimatedIcon.kt | 11 +- .../AnimationFloatingActionButton.kt | 22 ++- .../gkd/ui/component/CustomIconButton.kt | 2 + .../gkd/ui/component/FullscreenDialog.kt | 1 + .../songe/gkd/ui/component/GroupNameText.kt | 13 +- .../gkd/ui/component/InnerDisableSwitch.kt | 12 +- .../gkd/ui/component/InputSubsLinkOption.kt | 1 + .../li/songe/gkd/ui/component/PerfIcon.kt | 49 ++++++- .../li/songe/gkd/ui/component/PerfSwitch.kt | 6 +- .../songe/gkd/ui/component/RuleGroupCard.kt | 12 +- .../songe/gkd/ui/component/RuleGroupDialog.kt | 8 +- .../li/songe/gkd/ui/component/SettingItem.kt | 6 +- .../li/songe/gkd/ui/component/SubsItemCard.kt | 34 ++++- .../li/songe/gkd/ui/component/SubsSheet.kt | 18 ++- .../li/songe/gkd/ui/component/TextSwitch.kt | 17 ++- .../li/songe/gkd/ui/home/AppListPage.kt | 51 +++++-- .../li/songe/gkd/ui/home/ControlPage.kt | 136 ++++++++++++------ .../li/songe/gkd/ui/home/SettingsPage.kt | 39 +++-- .../li/songe/gkd/ui/home/SubsManagePage.kt | 70 ++++++--- .../li/songe/gkd/ui/icon/BackCloseIcon.kt | 2 +- 23 files changed, 448 insertions(+), 182 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 03da03bc5a..1a41542293 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -25,7 +25,6 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -73,6 +72,7 @@ import li.songe.gkd.ui.component.CustomIconButton import li.songe.gkd.ui.component.CustomOutlinedTextField import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfSwitch import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextSwitch @@ -325,7 +325,7 @@ fun AdvancedPage() { PerfIcon( modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) - .clickable(onClick = throttle { + .clickable(onClickLabel = "打开 Shizuku 状态弹窗", onClick = throttle { showShizukuState = true }) .iconTextSize(textStyle = MaterialTheme.typography.titleSmall), @@ -358,9 +358,11 @@ fun AdvancedPage() { ) } }, - ) { - mainVm.switchEnableShizuku(it) - } + onCheckedChange = { + mainVm.switchEnableShizuku(it) + }, + onClick = null, + ) val server by HttpService.httpServerFlow.collectAsState() val httpServerRunning = server != null @@ -415,7 +417,7 @@ fun AdvancedPage() { } } } - Switch( + PerfSwitch( checked = httpServerRunning, onCheckedChange = throttle(fn = vm.viewModelScope.launchAsFn { if (it) { @@ -433,6 +435,7 @@ fun AdvancedPage() { title = "服务端口", subtitle = store.httpServerPort.toString(), imageVector = PerfIcon.Edit, + onClickLabel = "编辑服务端口", onClick = { showEditPortDlg = true } @@ -441,12 +444,13 @@ fun AdvancedPage() { TextSwitch( title = "清除订阅", subtitle = "关闭服务时删除内存订阅", - checked = store.autoClearMemorySubs - ) { - storeFlow.value = store.copy( - autoClearMemorySubs = it - ) - } + checked = store.autoClearMemorySubs, + onCheckedChange = { + storeFlow.update { + it.copy(autoClearMemorySubs = !it.autoClearMemorySubs) + } + } + ) Text( text = "快照", @@ -499,18 +503,19 @@ fun AdvancedPage() { } else { ButtonService.stop() } - } + }, ) TextSwitch( title = "音量快照", subtitle = "音量变化时保存快照", - checked = store.captureVolumeChange - ) { - storeFlow.value = store.copy( - captureVolumeChange = it - ) - } + checked = store.captureVolumeChange, + onCheckedChange = { + storeFlow.value = store.copy( + captureVolumeChange = it + ) + }, + ) TextSwitch( title = "截屏快照", @@ -519,6 +524,7 @@ fun AdvancedPage() { suffixIcon = { CustomIconButton( size = 32.dp, + onClickLabel = "打开配置截屏快照弹窗", onClick = throttle { showCaptureScreenshotDlg = true }, @@ -529,34 +535,37 @@ fun AdvancedPage() { ) } }, - ) { - storeFlow.value = store.copy( - captureScreenshot = it - ) - if (it && store.screenshotTargetAppId.isEmpty() || store.screenshotEventSelector.isEmpty()) { - toast("请配置目标应用和特征事件选择器") + onCheckedChange = { + storeFlow.value = store.copy( + captureScreenshot = it + ) + if (it && store.screenshotTargetAppId.isEmpty() || store.screenshotEventSelector.isEmpty()) { + toast("请配置目标应用和特征事件选择器") + } } - } + ) TextSwitch( title = "隐藏状态栏", subtitle = "隐藏快照截图状态栏", - checked = store.hideSnapshotStatusBar - ) { - storeFlow.value = store.copy( - hideSnapshotStatusBar = it - ) - } + checked = store.hideSnapshotStatusBar, + onCheckedChange = { + storeFlow.value = store.copy( + hideSnapshotStatusBar = it + ) + } + ) TextSwitch( title = "保存提示", subtitle = "提示「正在保存快照」", - checked = store.showSaveSnapshotToast - ) { - storeFlow.value = store.copy( - showSaveSnapshotToast = it - ) - } + checked = store.showSaveSnapshotToast, + onCheckedChange = { + storeFlow.value = store.copy( + showSaveSnapshotToast = it + ) + } + ) SettingItem( title = "Github Cookie", diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index a23f719ab7..139e43d033 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -76,9 +76,12 @@ fun AuthA11yPage() { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { - mainVm.popBackStack() - }) + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClickLabel = "返回上级页面", + onClick = { + mainVm.popBackStack() + }) }, title = { Text(text = "授权状态") }) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index d4729e5c77..9d179709ad 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -31,6 +31,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -183,6 +187,8 @@ fun BlockA11yAppListPage() { Row { PerfIconButton( imageVector = if (store.blockA11yAppListFollowMatch) PerfIcon.Lock else LockOpenRight, + contentDescription = if (store.blockA11yAppListFollowMatch) "已设置为跟随应用白名单" else "已设置为独立无障碍白名单", + onClickLabel = "切换模式", onClick = throttle { showSearchBar = false storeFlow.update { it.copy(blockA11yAppListFollowMatch = !it.blockA11yAppListFollowMatch) } @@ -259,6 +265,7 @@ fun BlockA11yAppListPage() { floatingActionButton = { AnimationFloatingActionButton( visible = !editable && scrollBehavior.isFullVisible && !store.blockA11yAppListFollowMatch, + onClickLabel = "进入白名单文本编辑模式", onClick = { editable = !editable }, @@ -313,11 +320,24 @@ private fun AppItemCard( appInfo: AppInfo, ) { val scope = rememberCoroutineScope() + val checked = remember(appInfo.id) { + blockA11yAppListFlow.mapState(scope) { + it.contains(appInfo.id) + } + }.collectAsState().value Row( modifier = Modifier .clickable(onClick = throttle { blockA11yAppListFlow.update { it.switchItem(appInfo.id) } }) + .clearAndSetSemantics { + contentDescription = "应用:${appInfo.name}" + stateDescription = if (checked) "已加入白名单" else "未加入白名单" + onClick( + label = if (checked) "从白名单中移除" else "加入白名单", + action = null + ) + } .appItemPadding(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -340,11 +360,7 @@ private fun AppItemCard( } PerfCheckbox( key = appInfo.id, - checked = remember(appInfo.id) { - blockA11yAppListFlow.mapState(scope) { - it.contains(appInfo.id) - } - }.collectAsState().value, + checked = checked, ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt index 63795aed5b..b287d6f2e5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import li.songe.gkd.util.SafeR @Composable fun AnimatedIcon( @@ -16,6 +17,7 @@ fun AnimatedIcon( @DrawableRes id: Int, atEnd: Boolean = false, tint: Color = LocalContentColor.current, + contentDescription: String? = getIconDesc(id, atEnd), ) { val animation = AnimatedImageVector.animatedVectorResource(id) val painter = rememberAnimatedVectorPainter( @@ -25,7 +27,12 @@ fun AnimatedIcon( Icon( modifier = modifier, painter = painter, - contentDescription = null, + contentDescription = contentDescription, tint = tint, ) -} \ No newline at end of file +} + +private fun getIconDesc(@DrawableRes id: Int, atEnd: Boolean): String? = when (id) { + SafeR.ic_anim_search_close -> if (atEnd) "关闭搜索" else "打开搜索" + else -> null +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt index 081161082e..f520d56856 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt @@ -14,6 +14,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import li.songe.gkd.util.throttle @@ -22,6 +25,8 @@ private const val elevationDurationMillis = 50 @Composable fun AnimationFloatingActionButton( modifier: Modifier = Modifier, + onClickLabel: String? = null, + contentDescription: String? = null, visible: Boolean, onClick: () -> Unit, content: @Composable () -> Unit, @@ -59,10 +64,19 @@ fun AnimationFloatingActionButton( } if (innerVisible) { FloatingActionButton( - modifier = modifier.graphicsLayer( - alpha = percent.value, - translationX = (1f - percent.value) * maxTranslationX - ), + modifier = modifier + .graphicsLayer( + alpha = percent.value, + translationX = (1f - percent.value) * maxTranslationX + ) + .semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, elevation = FloatingActionButtonDefaults.elevation(defaultElevation = (defaultElevation.value * 6f).dp), onClick = throttle(onClick), content = content, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt index 47ebba497e..edbe45eae6 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp fun CustomIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onClickLabel: String? = null, size: Dp = 40.dp, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), @@ -37,6 +38,7 @@ fun CustomIconButton( .background(color = colors.run { if (enabled) containerColor else disabledContainerColor }) .clickable( onClick = onClick, + onClickLabel = onClickLabel, enabled = enabled, role = Role.Button, interactionSource = interactionSource, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt index c2a67c3923..7878e52593 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt @@ -25,6 +25,7 @@ fun FullscreenDialog( dismissOnClickOutside = false, usePlatformDefaultWidth = false, decorFitsSystemWindows = false, + windowTitle = "全局弹窗", ) ) { val activity = LocalActivity.current!! diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt index 1016fa8103..4f2468eff2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt @@ -1,6 +1,5 @@ package li.songe.gkd.ui.component -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -18,8 +17,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import li.songe.gkd.ui.icon.SportsBasketball -import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast @Composable fun GroupNameText( @@ -32,7 +29,6 @@ fun GroupNameText( overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, - clickDisabled: Boolean = false, ) { if (isGlobal) { val text = remember(preText, text) { @@ -45,7 +41,7 @@ fun GroupNameText( } } val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } } - val inlineContent = remember(style, clickDisabled, textColor) { + val inlineContent = remember(style, textColor) { mapOf( "icon" to InlineTextContent( placeholder = Placeholder( @@ -56,11 +52,7 @@ fun GroupNameText( ) { PerfIcon( imageVector = SportsBasketball, - modifier = Modifier - .runIf(!clickDisabled) { - clickable(onClick = throttle { toast("当前是全局规则组") }) - } - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), tint = textColor ) } @@ -78,7 +70,6 @@ fun GroupNameText( ) } else { Text( - modifier = modifier, text = if (preText.isNullOrEmpty()) { text } else { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt index 12699e2134..38daeaa19e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt @@ -2,12 +2,13 @@ package li.songe.gkd.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.material3.Switch import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.throttle @@ -31,11 +32,13 @@ fun InnerDisableSwitch( ) } } - Switch( + PerfSwitch( checked = false, enabled = false, onCheckedChange = null, - modifier = modifier + modifier = modifier.semantics { + stateDescription = "已禁用" + } .minimumInteractiveComponentSize().run { if (isSelectedMode) { this @@ -44,7 +47,8 @@ fun InnerDisableSwitch( interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Switch, - onClick = throttle(onClick) + onClick = throttle(onClick), + onClickLabel = "打开规则禁用说明", ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt index 453e9b27f4..c3b314a5b4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt @@ -88,6 +88,7 @@ class InputSubsLinkOption { Text(text = if (initValue.isNotEmpty()) "修改订阅" else "添加订阅") PerfIconButton( imageVector = PerfIcon.HelpOutline, + contentDescription = "订阅帮助", onClick = throttle { cancel() mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL5)) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index 0e62175a74..b4c621e51f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -57,16 +57,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics @Composable fun PerfIcon( imageVector: ImageVector, modifier: Modifier = Modifier, - tint: Color = LocalContentColor.current + tint: Color = LocalContentColor.current, + contentDescription: String? = getDefaultDesc(imageVector), ) = Icon( imageVector = imageVector, modifier = modifier, - contentDescription = imageVector.name, + contentDescription = contentDescription, tint = tint ) @@ -77,14 +80,21 @@ fun PerfIconButton( modifier: Modifier = Modifier, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + contentDescription: String? = getDefaultDesc(imageVector), + onClickLabel: String? = null, ) = IconButton( - modifier = modifier, + modifier = modifier.semantics { + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, enabled = enabled, onClick = onClick, colors = colors, ) { PerfIcon( imageVector = imageVector, + contentDescription = contentDescription, ) } @@ -92,11 +102,12 @@ fun PerfIconButton( fun PerfIcon( @DrawableRes id: Int, modifier: Modifier = Modifier, - tint: Color = LocalContentColor.current + tint: Color = LocalContentColor.current, + contentDescription: String? = null, ) = Icon( painter = painterResource(id), modifier = modifier, - contentDescription = null, + contentDescription = contentDescription, tint = tint ) @@ -107,17 +118,43 @@ fun PerfIconButton( modifier: Modifier = Modifier, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + contentDescription: String? = null, + onClickLabel: String? = null, ) = IconButton( - modifier = modifier, + modifier = modifier.semantics { + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, enabled = enabled, onClick = onClick, colors = colors, ) { PerfIcon( id = id, + contentDescription = contentDescription, ) } +private fun getDefaultDesc(imageVector: ImageVector): String? = when (imageVector) { + PerfIcon.Add -> "添加" + PerfIcon.Edit -> "编辑" + PerfIcon.Save -> "保存" + PerfIcon.Delete -> "删除" + PerfIcon.Share -> "分享" + PerfIcon.Settings -> "设置" + PerfIcon.Close -> "关闭" + PerfIcon.ArrowBack -> "返回" + PerfIcon.HelpOutline -> "帮助" + PerfIcon.ToggleOff -> "关闭" + PerfIcon.ToggleOn -> "开启" + PerfIcon.History -> "历史记录" + PerfIcon.Sort -> "排序筛选" + PerfIcon.OpenInNew -> "新页面打开" + PerfIcon.ContentCopy -> "复制文本" + else -> null +} + object PerfIcon { val Block get() = Icons.Default.Block val History get() = Icons.Default.History diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt index a98d97d106..f00aec0832 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt @@ -6,6 +6,8 @@ import androidx.compose.material3.SwitchColors import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import li.songe.gkd.util.throttle @Composable @@ -22,7 +24,9 @@ fun PerfSwitch( Switch( checked = checked, onCheckedChange = onCheckedChange?.let { throttle(it) }, - modifier = modifier, + modifier = modifier.semantics { + stateDescription = if (checked) "已开启" else "已关闭" + }, thumbContent = thumbContent, enabled = enabled, colors = colors, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt index ecb4ad61ba..b0a30f82f9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt @@ -171,7 +171,12 @@ fun RuleGroupCard( Card( modifier = modifier .padding(horizontal = 8.dp) - .combinedClickable(onClick = onClick, onLongClick = onLongClick), + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onClickLabel = "打开规则详情弹窗", + onLongClickLabel = "进入多选模式" + ), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( containerColor = containerColor.value @@ -209,7 +214,6 @@ fun RuleGroupCard( maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, - clickDisabled = isSelectedMode, ) if (group.valid) { if (!group.desc.isNullOrBlank()) { @@ -265,6 +269,7 @@ fun RuleGroupCard( if (hasExcludeActivity) { PerfIcon( imageVector = PerfIcon.Block, + contentDescription = "此规则已排除部分页面", tint = if (isSelectedMode) { LocalContentColor.current.copy(alpha = 0.5f) } else { @@ -286,6 +291,7 @@ fun BatchActionButtonGroup(vm: ViewModel, selectedDataSet: Set) val mainVm = LocalMainViewModel.current PerfIconButton( imageVector = PerfIcon.ToggleOff, + contentDescription = "批量关闭规则", onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { mainVm.dialogFlow.waitResult( title = "操作提示", @@ -301,6 +307,7 @@ fun BatchActionButtonGroup(vm: ViewModel, selectedDataSet: Set) ) PerfIconButton( imageVector = PerfIcon.ToggleOn, + contentDescription = "批量打开规则", onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { mainVm.dialogFlow.waitResult( title = "操作提示", @@ -316,6 +323,7 @@ fun BatchActionButtonGroup(vm: ViewModel, selectedDataSet: Set) ) PerfIconButton( imageVector = ResetSettings, + contentDescription = "批量重置规则开关", onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { mainVm.dialogFlow.waitResult( title = "操作提示", diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt index 202f1225ab..a006fcc044 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.unit.dp import com.ramcosta.composedestinations.generated.destinations.ImagePreviewPageDestination import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination @@ -60,7 +62,6 @@ fun RuleGroupDialog( modifier = Modifier.fillMaxWidth() ) { val maxHeight = 300.dp -// val showMaxLine = ceil(maxHeight.value / textStyle.lineHeight.value).toInt() Column( modifier = Modifier .align(Alignment.TopStart) @@ -69,6 +70,9 @@ fun RuleGroupDialog( .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) .verticalScroll(rememberScrollState()) + .clearAndSetSemantics { + contentDescription = "规则组内容" + } ) { SelectionContainer { val textState = remember { @@ -162,6 +166,7 @@ fun RuleGroupDialog( } PerfIconButton( imageVector = PerfIcon.Block, + onClickLabel = "编辑规则排除名单", onClick = throttle(onClickEditExclude), ) AnimatedVisibility( @@ -169,6 +174,7 @@ fun RuleGroupDialog( ) { PerfIconButton( imageVector = ResetSettings, + onClickLabel = "重置开关状态至默认值", onClick = throttle(onClickResetSwitch ?: {}), ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt index 0dfff37051..121241ce0d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt @@ -26,12 +26,16 @@ fun SettingItem( onSuffixClick: (() -> Unit)? = null, imageVector: ImageVector? = PerfIcon.KeyboardArrowRight, onClick: (() -> Unit)? = null, + onClickLabel: String? = null, ) { Row( modifier = Modifier .let { if (onClick != null) { - it.clickable(onClick = throttle(fn = onClick)) + it.clickable( + onClick = throttle(fn = onClick), + onClickLabel = onClickLabel ?: "进入${title}页面" + ) } else { it } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt index 8f8b2c1d90..fdfe0222e3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt @@ -24,6 +24,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope @@ -82,7 +88,17 @@ fun SubsItemCard( ) Card( onClick = onClick, - modifier = modifier.padding(16.dp, 4.dp), + modifier = modifier + .padding(16.dp, 4.dp) + .semantics { + stateDescription = if (isSelectedMode) { + if (isSelected) "已选中" else "未选中" + } else { + if (subsItem.enable) "已启用" else "已禁用" + } + this.onClick(label = "查看订阅详情", action = null) + this.onLongClick(label = "进入多选模式", action = null) + }, shape = MaterialTheme.shapes.small, interactionSource = interactionSource, colors = CardDefaults.cardColors( @@ -99,6 +115,9 @@ fun SubsItemCard( ) { if (subscription != null) { Text( + modifier = Modifier.semantics { + contentDescription = "订阅顺序:$index, 订阅名称 ${subscription.name}" + }, text = "$index. ${subscription.name}", maxLines = 1, softWrap = false, @@ -120,23 +139,34 @@ fun SubsItemCard( if (subsItem.id >= 0) { if (subscription.author != null) { Text( + modifier = Modifier.semantics { + contentDescription = "作者 ${subscription.author}" + }, text = subscription.author, style = MaterialTheme.typography.labelSmall, ) } Text( + modifier = Modifier.semantics { + contentDescription = "订阅版本号 ${subscription.version}" + }, text = "v" + (subscription.version.toString()), style = MaterialTheme.typography.labelSmall, ) } else { Text( + modifier = Modifier.clearAndSetSemantics {}, text = META.appName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary, ) } + val timeStr = formatTimeAgo(subsItem.mtime) Text( - text = formatTimeAgo(subsItem.mtime), + modifier = Modifier.semantics { + contentDescription = "更新时间 $timeStr" + }, + text = timeStr, style = MaterialTheme.typography.labelSmall, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt index 16f7ea9b5c..92c9ad902d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt @@ -29,6 +29,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel @@ -123,7 +125,10 @@ fun SubsSheet( ) if (subscription != null) { Column( - modifier = childModifier + modifier = childModifier.clearAndSetSemantics { + contentDescription = + "作者:${subscription.author ?: "未知"}, 版本号:v${subscription.version}, 更新时间:${subsItem.mtimeStr}" + } ) { Row( modifier = Modifier.fillMaxWidth(), @@ -181,7 +186,7 @@ fun SubsSheet( if (subscription.globalGroups.isNotEmpty() || subsItem.isLocal) { Row( modifier = Modifier - .clickable(onClick = throttle { + .clickable(onClickLabel = "查看全局规则组列表", onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(SubsGlobalGroupListPageDestination(subsItem.id)) @@ -217,7 +222,7 @@ fun SubsSheet( if (subscription.appGroups.isNotEmpty() || subsItem.isLocal) { Row( modifier = Modifier - .clickable(onClick = throttle { + .clickable(onClickLabel = "查看应用规则组列表", onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(SubsAppListPageDestination(subsItem.id)) @@ -254,7 +259,7 @@ fun SubsSheet( if (subscription.categories.isNotEmpty() || subsItem.isLocal) { Row( modifier = Modifier - .clickable(onClick = throttle { + .clickable(onClickLabel = "查看规则类别列表", onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(SubsCategoryPageDestination(subsItem.id)) @@ -290,7 +295,7 @@ fun SubsSheet( if (!subsItem.isLocal && subsItem.updateUrl != null) { Row( modifier = Modifier - .clickable(onClick = throttle { + .clickable(onClickLabel = "编辑订阅链接", onClick = throttle { if (updateSubsMutex.mutex.isLocked) { toast("正在刷新订阅,请稍后操作") return@throttle @@ -319,7 +324,8 @@ fun SubsSheet( softWrap = false, overflow = TextOverflow.MiddleEllipsis, modifier = Modifier - .clickable(onClick = { + .clearAndSetSemantics {} + .clickable(onClickLabel = "查看订阅链接", onClick = { mainVm.textFlow.value = subsItem.updateUrl }) ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt index fdec5854b9..4ca1d4399a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt @@ -9,11 +9,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import li.songe.gkd.ui.style.itemPadding @@ -32,9 +33,16 @@ fun TextSwitch( checked: Boolean = true, enabled: Boolean = true, onCheckedChange: ((Boolean) -> Unit)? = null, + onClick: (() -> Unit)? = { onCheckedChange?.invoke(!checked) }, + onClickLabel: String? = "切换${title}状态", ) { + val topModifier = if (onClick != null) { + modifier.clickable(onClick = onClick, onClickLabel = onClickLabel) + } else { + modifier + } Row( - modifier = if (paddingDisabled) modifier else modifier.itemPadding(), + modifier = if (paddingDisabled) topModifier else topModifier.itemPadding(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -78,10 +86,13 @@ fun TextSwitch( } } suffixIcon?.invoke() - Switch( + PerfSwitch( checked = checked, enabled = enabled, onCheckedChange = onCheckedChange?.let { throttle(fn = it) }, + modifier = Modifier.semantics { + this.stateDescription = title + if (checked) "已开启" else "已关闭" + } ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 66fcaab0b5..591a52a35d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -38,6 +38,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -158,6 +162,8 @@ fun useAppListPage(): ScaffoldExt { }, actions = { PerfIconButton( imageVector = PerfIcon.Block, + contentDescription = "切换白名单编辑模式", + onClickLabel = if (editWhiteListMode) "退出编辑" else "进入编辑", colors = IconButtonDefaults.iconButtonColors( contentColor = if (editWhiteListMode) { CheckboxDefaults.colors().checkedBoxColor @@ -183,12 +189,16 @@ fun useAppListPage(): ScaffoldExt { AnimatedIcon( id = SafeR.ic_anim_search_close, atEnd = showSearchBar, + contentDescription = if (showSearchBar) "关闭搜索" else "搜索应用列表", ) } var expanded by remember { mutableStateOf(false) } - PerfIconButton(imageVector = PerfIcon.Sort, onClick = { - expanded = true - }) + PerfIconButton( + imageVector = PerfIcon.Sort, + contentDescription = "排序筛选", + onClick = { + expanded = true + }) Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) @@ -256,7 +266,7 @@ fun useAppListPage(): ScaffoldExt { mainVm.navigatePage(EditBlockAppListPageDestination) }, content = { - PerfIcon(imageVector = PerfIcon.Edit) + PerfIcon(imageVector = PerfIcon.Edit, contentDescription = "编辑白名单") } ) } @@ -328,16 +338,35 @@ private fun AppItemCard( val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity val vm = viewModel() + val editWhiteListMode = vm.editWhiteListModeFlow.collectAsState().value + val inWhiteList = blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) Row( modifier = Modifier - .clickable(onClick = throttle { - if (vm.editWhiteListModeFlow.value) { - blockMatchAppListFlow.update { it.switchItem(appInfo.id) } + .clickable( + onClick = throttle { + if (vm.editWhiteListModeFlow.value) { + blockMatchAppListFlow.update { it.switchItem(appInfo.id) } + } else { + context.justHideSoftInput() + mainVm.navigatePage(AppConfigPageDestination(appInfo.id)) + } + }) + .clearAndSetSemantics { + contentDescription = if (editWhiteListMode) { + appInfo.name } else { - context.justHideSoftInput() - mainVm.navigatePage(AppConfigPageDestination(appInfo.id)) + "应用:${appInfo.name},${desc ?: appInfo.id}" } - }) + if (inWhiteList) { + stateDescription = "已加入白名单" + } else if (editWhiteListMode) { + stateDescription = "未加入白名单" + } + onClick( + label = if (editWhiteListMode) if (inWhiteList) "从白名单中移除" else "加入白名单" else "进入规则汇总页面", + action = null + ) + } .appItemPadding(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -358,8 +387,6 @@ private fun AppItemCard( softWrap = false ) } - val editWhiteListMode = vm.editWhiteListModeFlow.collectAsState().value - val inWhiteList = blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) if (editWhiteListMode) { PerfCheckbox( key = appInfo.id, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index d65573d7c3..ef95538a8b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,7 +19,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -30,6 +30,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel @@ -50,6 +52,7 @@ import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.GroupNameText import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton +import li.songe.gkd.ui.component.PerfSwitch import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.textSize import li.songe.gkd.ui.share.LocalMainViewModel @@ -80,6 +83,7 @@ fun useControlPage(): ScaffoldExt { }, actions = { PerfIconButton( imageVector = PerfIcon.RocketLaunch, + onClickLabel = "前往无障碍授权页面", onClick = throttle { mainVm.navigatePage(AuthA11YPageDestination) }, @@ -97,8 +101,10 @@ fun useControlPage(): ScaffoldExt { modifier = Modifier .verticalScroll(scrollState) .padding(contentPadding) + .padding(horizontal = itemHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(itemHorizontalPadding / 2) ) { - PageItemCard( + PageSwitchItemCard( imageVector = PerfIcon.Memory, title = "服务状态", subtitle = if (a11yRunning) { @@ -114,47 +120,40 @@ fun useControlPage(): ScaffoldExt { } else { "无障碍未授权" }, - rightContent = { - Switch( - checked = a11yRunning, - onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newEnabled -> - if (newEnabled && !writeSecureSettingsState.value) { - mainVm.navigatePage(AuthA11YPageDestination) - } else { - switchA11yService() - } - }), - ) - } + checked = a11yRunning, + onCheckedChange = vm.viewModelScope.launchAsFn { newEnabled -> + if (newEnabled && !writeSecureSettingsState.value) { + mainVm.navigatePage(AuthA11YPageDestination) + } else { + switchA11yService() + } + }, ) - PageItemCard( + PageSwitchItemCard( imageVector = PerfIcon.Notifications, title = "常驻通知", subtitle = "显示运行状态及统计数据", - rightContent = { - Switch( - checked = manageRunning && store.enableStatusService, - onCheckedChange = throttle(fn = vm.viewModelScope.launchAsFn { - if (it) { - StatusService.requestStart(context) - } else { - StatusService.stop() - storeFlow.value = store.copy( - enableStatusService = false - ) - } - }), - ) - } + checked = manageRunning && store.enableStatusService, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + StatusService.requestStart(context) + } else { + StatusService.stop() + storeFlow.value = store.copy( + enableStatusService = false + ) + } + }, ) - ServerStatusCard(vm) + ServerStatusCard() PageItemCard( title = "触发记录", subtitle = "规则误触可定位关闭", imageVector = PerfIcon.History, + onClickLabel = "打开触发记录页面", onClick = { mainVm.navigatePage(ActionLogPageDestination()) } @@ -165,6 +164,7 @@ fun useControlPage(): ScaffoldExt { title = "界面日志", subtitle = "记录打开的应用及界面", imageVector = PerfIcon.Layers, + onClickLabel = "打开界面日志页面", onClick = { mainVm.navigatePage(ActivityLogPageDestination) } @@ -175,6 +175,7 @@ fun useControlPage(): ScaffoldExt { title = "了解 GKD", subtitle = "查阅规则文档和常见问题", imageVector = PerfIcon.HelpOutline, + onClickLabel = "打开 GKD 文档页面", onClick = { mainVm.navigatePage(WebViewPageDestination(initUrl = HOME_PAGE_URL)) } @@ -190,14 +191,16 @@ private fun PageItemCard( imageVector: ImageVector, title: String, subtitle: String, - onClick: () -> Unit = {}, - rightContent: @Composable (() -> Unit)? = null, + onClickLabel: String, + onClick: () -> Unit, ) { Card( modifier = Modifier - .padding(itemHorizontalPadding, 4.dp) - .fillMaxWidth(), - shape = RoundedCornerShape(20.dp), + .fillMaxWidth() + .semantics { + this.onClick(label = onClickLabel, action = null) + }, + shape = MaterialTheme.shapes.large, colors = surfaceCardColors, onClick = throttle(fn = onClick) ) { @@ -217,10 +220,50 @@ private fun PageItemCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (rightContent != null) { - Spacer(Modifier.width(8.dp)) - rightContent.invoke() + } + } +} + +@Composable +private fun PageSwitchItemCard( + imageVector: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val onClick = throttle { onCheckedChange(!checked) } + Card( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + this.onClick(label = "切换$title", action = null) + }, + shape = MaterialTheme.shapes.large, + colors = surfaceCardColors, + onClick = onClick, + ) { + IconTextCard( + imageVector = imageVector, + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + Spacer(Modifier.width(8.dp)) + PerfSwitch( + checked = checked, + onCheckedChange = null, + ) } } } @@ -251,12 +294,15 @@ private fun IconTextCard( } @Composable -private fun ServerStatusCard(vm: HomeVm) { +private fun ServerStatusCard() { val mainVm = LocalMainViewModel.current + val vm = viewModel() Card( modifier = Modifier - .padding(itemHorizontalPadding, 4.dp) - .fillMaxWidth(), + .fillMaxWidth() + .semantics { + onClick(label = "不执行操作", action = null) + }, shape = RoundedCornerShape(20.dp), colors = surfaceCardColors, onClick = {} @@ -302,9 +348,7 @@ private fun ServerStatusCard(vm: HomeVm) { Column( modifier = Modifier .fillMaxWidth() - .padding( - horizontal = itemVerticalPadding, - ) + .padding(horizontal = itemVerticalPadding) ) { val subsStatus by vm.subsStatusFlow.collectAsState() AnimatedVisibility(subsStatus.isNotEmpty()) { @@ -322,7 +366,7 @@ private fun ServerStatusCard(vm: HomeVm) { modifier = Modifier .padding(horizontal = 4.dp) .clip(MaterialTheme.shapes.extraSmall) - .clickable(onClick = throttle { + .clickable(onClickLabel = "前往应用的规则汇总页面", onClick = throttle { latestRecordFlow.value?.let { mainVm.navigatePage( AppConfigPageDestination( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index a2d5f512d1..701cdf3a3e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -142,16 +142,12 @@ fun useSettingsPage(): ScaffoldExt { } showToastInputDlg = false }) { - Text( - text = "确认", - ) + Text(text = "确认") } }, dismissButton = { TextButton(onClick = { showToastInputDlg = false }) { - Text( - text = "取消", - ) + Text(text = "取消") } } ) @@ -172,6 +168,8 @@ fun useSettingsPage(): ScaffoldExt { Text(text = "通知文案") PerfIconButton( imageVector = PerfIcon.HelpOutline, + contentDescription = "文案规则", + onClickLabel = "打开文案规则弹窗", onClick = throttle { showNotifTextInputDlg = false val confirmAction = { @@ -180,7 +178,7 @@ fun useSettingsPage(): ScaffoldExt { } mainVm.dialogFlow.updateDialogOptions( title = "文案规则", - text = "通知文案支持变量替换,规则如下\n\${i} 全局规则数\n\${k} 应用数\n\${u} 应用规则组数\n\${n} 触发次数\n\n示例模板\n\${i}全局/\${k}应用/\${u}规则组/\${n}触发\n\n替换结果\n0全局/1应用/2规则组/3触发", + text = $$"通知文案支持变量替换,规则如下\n${i} 全局规则数\n${k} 应用数\n${u} 应用规则组数\n${n} 触发次数\n\n示例模板\n${i}全局/${k}应用/${u}规则组/${n}触发\n\n替换结果\n0全局/1应用/2规则组/3触发", confirmAction = confirmAction, onDismissRequest = confirmAction, ) @@ -344,17 +342,20 @@ fun useSettingsPage(): ScaffoldExt { title = "触发提示", subtitle = store.actionToast, checked = store.toastWhenClick, - modifier = Modifier.clickable { + onClickLabel = "打开触发提示弹窗", + onClick = { showToastInputDlg = true }, suffixIcon = { CustomIconButton( size = 32.dp, + onClickLabel = "打开提示设置弹窗", onClick = throttle { showToastSettingsDlg = true }, ) { PerfIcon( modifier = Modifier.size(20.dp), id = SafeR.ic_page_info, + contentDescription = "提示设置", ) } }, @@ -373,9 +374,8 @@ fun useSettingsPage(): ScaffoldExt { subsStatus }, checked = store.useCustomNotifText, - modifier = Modifier.clickable { - showNotifTextInputDlg = true - }, + onClickLabel = "打开修改通知文案弹窗", + onClick = { showNotifTextInputDlg = true }, onCheckedChange = { storeFlow.value = store.copy( useCustomNotifText = it @@ -388,7 +388,7 @@ fun useSettingsPage(): ScaffoldExt { checked = store.excludeFromRecents, onCheckedChange = { storeFlow.value = store.copy( - excludeFromRecents = it + excludeFromRecents = !store.excludeFromRecents ) }) @@ -421,13 +421,13 @@ fun useSettingsPage(): ScaffoldExt { }, ) AnimatedVisibility(visible = lazyOn.value) { - SettingItem(title = "白名单", onClick = { + SettingItem(title = "白名单", onClickLabel = "进入无障碍白名单页面", onClick = { mainVm.navigatePage(BlockA11YAppListPageDestination) }) } Text( - text = "主题", + text = "外观", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, @@ -485,6 +485,7 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD navigationIcon = { PerfIconButton( imageVector = PerfIcon.Close, + onClickLabel = "关闭弹窗", onClick = onDismissRequest, ) }, @@ -553,6 +554,7 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD text = "省电策略设置为无限制", enabled = !ignoreBatteryOptimizations, imageVector = if (ignoreBatteryOptimizations) PerfIcon.Check else PerfIcon.ArrowForward, + onClickLabel = "打开忽略电池优化设置页面", onClick = mainVm.viewModelScope.launchAsFn { requiredPermission(context, ignoreBatteryOptimizationsState) }, @@ -580,6 +582,7 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD text = "允许自启动", enabled = true, imageVector = PerfIcon.OpenInNew, + onClickLabel = "打开应用详情页面", onClick = { openAppDetailsSettings() }, @@ -588,6 +591,7 @@ private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onD text = "在「最近任务界面」锁定", enabled = true, imageVector = PerfIcon.OpenInNew, + onClickLabel = "打开应用详情页面", onClick = { val m = shizukuContextFlow.value.inputManager if (m != null) { @@ -612,13 +616,18 @@ private fun RequiredTextItem( imageVector: ImageVector? = null, enabled: Boolean = false, onClick: (() -> Unit)? = null, + onClickLabel: String? = null, ) { Row( modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .run { if (onClick != null) { - clickable(enabled = enabled, onClick = throttle(onClick)) + clickable( + enabled = enabled, + onClick = throttle(onClick), + onClickLabel = onClickLabel + ) } else { this } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 80cd08e6ae..323ba55d8c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -46,6 +46,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope @@ -149,7 +152,14 @@ fun useSubsManagePage(): ScaffoldExt { } Row( modifier = Modifier - .padding(0.dp, itemVerticalPadding), + .padding(0.dp, itemVerticalPadding) + .clickable( + onClickLabel = if (store.subsPowerWarn) "关闭警告" else "开启警告", + onClick = updateValue + ) + .semantics(mergeDescendants = true) { + stateDescription = if (store.subsPowerWarn) "已开启" else "已关闭" + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -167,13 +177,15 @@ fun useSubsManagePage(): ScaffoldExt { } Checkbox( checked = store.subsPowerWarn, - onCheckedChange = { updateValue() } + onCheckedChange = null, ) } } }, confirmButton = { - TextButton(onClick = { showSettingsDlg = false }) { + TextButton(onClick = { showSettingsDlg = false }, modifier = Modifier.semantics { + onClick(label = "关闭弹窗", action = null) + }) { Text("关闭") } } @@ -189,6 +201,7 @@ fun useSubsManagePage(): ScaffoldExt { if (isSelectedMode) { PerfIconButton( imageVector = PerfIcon.Close, + contentDescription = "取消选择", onClick = { isSelectedMode = false }, ) } @@ -211,9 +224,12 @@ fun useSubsManagePage(): ScaffoldExt { ) { Row { if (it) { - PerfIconButton(imageVector = PerfIcon.Share, onClick = { - mainVm.showShareDataIdsFlow.value = selectedIds - }) + PerfIconButton( + imageVector = PerfIcon.Share, + contentDescription = "分享选中订阅", + onClick = { + mainVm.showShareDataIdsFlow.value = selectedIds + }) val canDeleteIds = if (selectedIds.contains(LOCAL_SUBS_ID)) { selectedIds - LOCAL_SUBS_ID } else { @@ -225,6 +241,7 @@ fun useSubsManagePage(): ScaffoldExt { } PerfIconButton( imageVector = PerfIcon.Delete, + contentDescription = "删除选中订阅", onClick = vm.viewModelScope.launchAsFn { mainVm.dialogFlow.waitResult( title = "删除订阅", @@ -246,9 +263,13 @@ fun useSubsManagePage(): ScaffoldExt { enter = scaleIn(), exit = scaleOut(), ) { - PerfIconButton(imageVector = PerfIcon.Eco, onClick = throttle { - mainVm.navigatePage(SlowGroupPageDestination) - }) + PerfIconButton( + imageVector = PerfIcon.Eco, + contentDescription = "缓慢查询规则列表", + onClickLabel = "查看列表", + onClick = throttle { + mainVm.navigatePage(SlowGroupPageDestination) + }) } val scope = rememberCoroutineScope() val enableMatch by remember { @@ -263,21 +284,30 @@ fun useSubsManagePage(): ScaffoldExt { LocalContentColor.current } ), + contentDescription = "规则匹配" + if (enableMatch) "已启用" else "已禁用", + onClickLabel = "切换开关", onClick = throttle { switchStoreEnableMatch() }, ) - PerfIconButton(id = SafeR.ic_page_info, onClick = { - showSettingsDlg = true - }) + PerfIconButton( + id = SafeR.ic_page_info, + contentDescription = "订阅设置", + onClickLabel = "打开设置弹窗", + onClick = { + showSettingsDlg = true + }) } } } - PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { - if (updateSubsMutex.mutex.isLocked) { - toast("正在刷新订阅,请稍后操作") - } else { - expanded = true - } - }) + PerfIconButton( + imageVector = PerfIcon.MoreVert, + contentDescription = "更多操作", + onClick = { + if (updateSubsMutex.mutex.isLocked) { + toast("正在刷新订阅,请稍后操作") + } else { + expanded = true + } + }) Box( modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { @@ -366,6 +396,8 @@ fun useSubsManagePage(): ScaffoldExt { }, floatingActionButton = { AnimationFloatingActionButton( + contentDescription = "添加订阅", + onClickLabel = "打开添加订阅弹窗", visible = !isSelectedMode, onClick = { if (updateSubsMutex.mutex.isLocked) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt index 2d2265f25d..833c63b29c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt @@ -48,7 +48,7 @@ fun BackCloseIcon( modifier = modifier .size(24.dp) .semantics { - this.contentDescription = if (backOrClose) "back" else "close" + this.contentDescription = if (backOrClose) "返回" else "关闭" this.role = Role.Image }, ) { From 3ad8c6e3c11a184afb1e5b42ee6fba8987276c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 2 Nov 2025 20:10:34 +0800 Subject: [PATCH 095/245] chore: v1.11.0-beta.4 --- CHANGELOG.md | 9 +++++++-- app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7d18a4d5..1bace40401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ -# v1.11.0-beta.3 +# v1.11.0-beta.4 以下是本次更新的主要内容 ## 优化和修复 -- 修复某些设备应用列表加载失败 +- 优化视力障碍用户体验 +- 优化悬浮窗内可点击图标关闭 +- 优化应用列表中禁用应用的显示 +- 修复选择器 isMatchRoot 不生效的问题 +- 修复从通知栏打开应用时报错的问题 +- 其他优化和修复 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23b7fa9c8e..0cf607decb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 69 - versionName = "1.11.0-beta.3" + versionCode = 70 + versionName = "1.11.0-beta.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 972090fb38606b5e36c5d162ad0a922a60192b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 2 Nov 2025 22:47:42 +0800 Subject: [PATCH 096/245] fix: getSnapshots miss appInfo (#1183) --- .../li/songe/gkd/service/HttpService.kt | 10 +++++- .../kotlin/li/songe/gkd/util/SnapshotExt.kt | 32 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt index 6b0b7ebe20..30a6e65164 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/HttpService.kt @@ -51,6 +51,7 @@ import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID import li.songe.gkd.util.OnSimpleLife import li.songe.gkd.util.SERVER_SCRIPT_URL import li.songe.gkd.util.SnapshotExt +import li.songe.gkd.util.SnapshotExt.getMinSnapshot import li.songe.gkd.util.deleteSubscription import li.songe.gkd.util.getIpAddressInLocalNetwork import li.songe.gkd.util.isPortAvailable @@ -203,7 +204,14 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { call.respond(SnapshotExt.captureSnapshot()) } post("/getSnapshots") { - call.respond(DbSet.snapshotDao.query().first()) + val list = DbSet.snapshotDao.query().first().mapNotNull { + try { + getMinSnapshot(it.id) + } catch (_: Throwable) { + null + } + } + call.respond(list) } post("/updateSubscription") { val subscription = diff --git a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt index 12cb28ffeb..7e0f7c52ee 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -28,6 +28,28 @@ object SnapshotExt { private fun snapshotParentPath(id: Long) = snapshotFolder.resolve(id.toString()) fun snapshotFile(id: Long) = snapshotParentPath(id).resolve("${id}.json") + private fun minSnapshotFile(id: Long): File { + return snapshotParentPath(id).resolve("${id}.min.json") + } + + suspend fun getMinSnapshot(id: Long): ComplexSnapshot { + val f = minSnapshotFile(id) + if (!f.exists()) { + val text = withContext(Dispatchers.IO) { snapshotFile(id).readText() } + val snapshot = withContext(Dispatchers.Default) { + json.decodeFromString(text) + } + val minSnapshot = snapshot.copy(nodes = emptyList()) + withContext(Dispatchers.IO) { + f.writeText(keepNullJson.encodeToString(minSnapshot)) + } + } + val text = withContext(Dispatchers.IO) { f.readText() } + return withContext(Dispatchers.Default) { + json.decodeFromString(text) + } + } + fun screenshotFile(id: Long) = snapshotParentPath(id).resolve("${id}.png") suspend fun snapshotZipFile( @@ -164,8 +186,14 @@ object SnapshotExt { screenshotFile(snapshot.id).outputStream().use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) } - val text = keepNullJson.encodeToString(snapshot) - snapshotFile(snapshot.id).writeText(text) + snapshotFile(snapshot.id).writeText(keepNullJson.encodeToString(snapshot)) + minSnapshotFile(snapshot.id).writeText( + keepNullJson.encodeToString( + snapshot.copy( + nodes = emptyList() + ) + ) + ) DbSet.snapshotDao.insert(snapshot.toSnapshot()) } toast("快照成功") From 24bb5e056583fad9ad4e7ded37d287d67e2e1087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 2 Nov 2025 22:50:02 +0800 Subject: [PATCH 097/245] chore: v1.11.0-beta.5 --- CHANGELOG.md | 3 ++- app/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bace40401..d00051bf9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# v1.11.0-beta.4 +# v1.11.0-beta.5 以下是本次更新的主要内容 @@ -9,6 +9,7 @@ - 优化应用列表中禁用应用的显示 - 修复选择器 isMatchRoot 不生效的问题 - 修复从通知栏打开应用时报错的问题 +- 修复HTTP服务快照列表缺失部分信息 - 其他优化和修复 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0cf607decb..8242f1a136 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 70 - versionName = "1.11.0-beta.4" + versionCode = 71 + versionName = "1.11.0-beta.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8cadf2351aaf8bfb7ee5963cb340f99835822ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 3 Nov 2025 00:10:33 +0800 Subject: [PATCH 098/245] fix: http server can not get old snapshot (#1185) --- .../main/kotlin/li/songe/gkd/util/SnapshotExt.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt index 7e0f7c52ee..9e1bab8e94 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject import li.songe.gkd.a11y.TopActivity import li.songe.gkd.a11y.screenshot import li.songe.gkd.a11y.topActivityFlow @@ -32,21 +34,25 @@ object SnapshotExt { return snapshotParentPath(id).resolve("${id}.min.json") } - suspend fun getMinSnapshot(id: Long): ComplexSnapshot { + suspend fun getMinSnapshot(id: Long): JsonObject { val f = minSnapshotFile(id) if (!f.exists()) { val text = withContext(Dispatchers.IO) { snapshotFile(id).readText() } - val snapshot = withContext(Dispatchers.Default) { - json.decodeFromString(text) + val snapshotJson = withContext(Dispatchers.Default) { + // #1185 + json.decodeFromString(text) } - val minSnapshot = snapshot.copy(nodes = emptyList()) + val minSnapshot = JsonObject(snapshotJson.toMutableMap().apply { + this["nodes"] = JsonArray(emptyList()) + }) withContext(Dispatchers.IO) { f.writeText(keepNullJson.encodeToString(minSnapshot)) } + return minSnapshot } val text = withContext(Dispatchers.IO) { f.readText() } return withContext(Dispatchers.Default) { - json.decodeFromString(text) + json.decodeFromString(text) } } From 360581da01f86017ebcfe32d3b6c6cd764579de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 3 Nov 2025 00:11:12 +0800 Subject: [PATCH 099/245] chore: v1.11.0-beta.6 --- CHANGELOG.md | 2 +- app/build.gradle.kts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00051bf9f..ced16d0f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# v1.11.0-beta.5 +# v1.11.0-beta.6 以下是本次更新的主要内容 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8242f1a136..fdd7d805e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 71 - versionName = "1.11.0-beta.5" + versionCode = 72 + versionName = "1.11.0-beta.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 5d8789e54fbbd346550b3046aa2f17fb43d85c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 5 Nov 2025 18:14:39 +0800 Subject: [PATCH 100/245] feat: export AppJsonData to log --- .../main/kotlin/li/songe/gkd/data/UserInfo.kt | 2 ++ .../kotlin/li/songe/gkd/util/FolderExt.kt | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/UserInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/UserInfo.kt index 3afc0a716a..2ea4c8f2c4 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/UserInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/UserInfo.kt @@ -1,7 +1,9 @@ package li.songe.gkd.data import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.Serializable +@Serializable data class UserInfo( val id: Int, val name: String, diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index 6ff9528829..f402d832e4 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -1,10 +1,16 @@ package li.songe.gkd.util import android.text.format.DateUtils +import androidx.annotation.WorkerThread import com.blankj.utilcode.util.LogUtils +import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.app +import li.songe.gkd.data.AppInfo +import li.songe.gkd.data.UserInfo +import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.permission.allPermissionStates +import li.songe.gkd.shizuku.currentUserId import li.songe.gkd.shizuku.shizukuContextFlow import java.io.File @@ -64,12 +70,21 @@ fun clearCache() { removeExpired(tempDir) } +@Serializable +private data class AppJsonData( + val userId: Int = currentUserId, + val apps: List = userAppInfoMapFlow.value.values.toList(), + val otherUsers: List = otherUserMapFlow.value.values.toList(), + val othersApps: List = otherUserAppInfoMapFlow.value.values.toList(), +) + +@WorkerThread fun buildLogFile(): File { val tempDir = createTempDir() val files = mutableListOf(dbFolder, storeFolder, subsFolder) LogUtils.getLogFiles().firstOrNull()?.parentFile?.let { files.add(it) } - tempDir.resolve("appList.json").also { - it.writeText(json.encodeToString(appInfoMapFlow.value.values.toList())) + tempDir.resolve("apps.json").also { + it.writeText(json.encodeToString(AppJsonData())) files.add(it) } tempDir.resolve("shizuku.txt").also { From acf737009113b7051da6494c71e2c8cadf73b306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 5 Nov 2025 18:15:05 +0800 Subject: [PATCH 101/245] fix: catch getApplicationEnabledSetting --- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index 875f5328ef..920df6262a 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -81,16 +81,25 @@ private fun checkIfNotHasActivity(packageName: String, userId: Int): Boolean { private fun PackageInfo.getEnabled(userId: Int): Boolean { val enabled = applicationInfo?.enabled ?: true if (enabled) return true - val state = if (userId == currentUserId) { - app.packageManager.getApplicationEnabledSetting(packageName) - } else { - shizukuContextFlow.value.packageManager?.getApplicationEnabledSetting( - packageName, - currentUserId - ) ?: 0 + val state = try { + // https://github.com/gkd-kit/gkd/issues/1169#issuecomment-3489260246 + if (userId == currentUserId) { + app.packageManager.getApplicationEnabledSetting(packageName) + } else { + shizukuContextFlow.value.packageManager?.getApplicationEnabledSetting( + packageName, + currentUserId + ) + } + } catch (_: IllegalArgumentException) { + null } return when (state) { - PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> false + null, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> false + else -> true } } From a0a6b5e49f8405b0ce5e5a5a32ad16becab6962a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 5 Nov 2025 18:16:08 +0800 Subject: [PATCH 102/245] chore: v1.11.0-beta.7 --- CHANGELOG.md | 10 ++-------- app/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ced16d0f3a..288ac55621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,10 @@ -# v1.11.0-beta.6 +# v1.11.0-beta.7 以下是本次更新的主要内容 ## 优化和修复 -- 优化视力障碍用户体验 -- 优化悬浮窗内可点击图标关闭 -- 优化应用列表中禁用应用的显示 -- 修复选择器 isMatchRoot 不生效的问题 -- 修复从通知栏打开应用时报错的问题 -- 修复HTTP服务快照列表缺失部分信息 -- 其他优化和修复 +- 修复某些设备加载应用列表失败 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fdd7d805e7..0715a8e6ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 72 - versionName = "1.11.0-beta.6" + versionCode = 73 + versionName = "1.11.0-beta.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 41cad9df5932729338e3ed0af6e1fa6dd5656aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 10 Nov 2025 21:33:47 +0800 Subject: [PATCH 103/245] perf: update libs --- gradle/libs.versions.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a565b65a2..b27cbb413b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] kotlin = "2.2.21" -ksp = "2.3.0" +ksp = "2.3.2" agp = "8.13.0" compose = "1.9.4" room = "2.8.3" paging = "3.3.6" -ktor = "3.3.1" +ktor = "3.3.2" atomicfu = "0.29.0" destinations = "2.3.0" coil = "3.3.0" @@ -35,7 +35,7 @@ compose_junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = compose_icons = "androidx.compose.material:material-icons-extended:1.7.8" compose_material3 = "androidx.compose.material3:material3:1.4.0" compose_activity = "androidx.activity:activity-compose:1.11.0" -compose_navigation = "androidx.navigation:navigation-compose:2.9.5" +compose_navigation = "androidx.navigation:navigation-compose:2.9.6" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.17.0" androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.4" @@ -47,7 +47,7 @@ androidx_room_runtime = { module = "androidx.room:room-runtime", version.ref = " androidx_room_compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx_room_ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx_room_paging = { module = "androidx.room:room-paging", version.ref = "room" } -androidx_splashscreen = "androidx.core:core-splashscreen:1.0.1" +androidx_splashscreen = "androidx.core:core-splashscreen:1.2.0" androidx_paging_runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx_paging_compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } google_accompanist_drawablepainter = "com.google.accompanist:accompanist-drawablepainter:0.37.3" @@ -66,9 +66,9 @@ coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } loc_annotation = { module = "li.songe.loc:loc-annotation", version.ref = "loc" } reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" -toaster = "com.github.getActivity:Toaster:13.6" +toaster = "com.github.getActivity:Toaster:13.8" permissions = "com.github.getActivity:XXPermissions:26.5" -json5 = "li.songe:json5:0.4.1" +json5 = "li.songe:json5:0.5.0" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" kevinnzouWebview = "io.github.kevinnzou:compose-webview:0.33.6" From cde8ffca9c3260e480b1df3a1dc81b1c8b3430a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 12 Nov 2025 15:52:51 +0800 Subject: [PATCH 104/245] chore: v1.11.0 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++-- app/build.gradle.kts | 4 ++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 288ac55621..c4ac9afb8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,38 @@ -# v1.11.0-beta.7 +# v1.11.0 以下是本次更新的主要内容 ## 优化和修复 -- 修复某些设备加载应用列表失败 +- 优化视力障碍用户体验 +- 优化多个页面的显示和使用体验 +- 优化规则编辑弹窗为页面并支持代码高亮 +- 重构应用列表移除部分排序筛选,增加按最近使用排序,显示冻结应用,隐藏无界面应用,支持下拉刷新 +- 新增多个通知栏开关 +- 新增使用协议和隐私政策 +- 新增通知文案支持主标题和副标题 +- 优化任意悬浮窗支持保存上次位置 +- 优化悬浮窗内可点击图标关闭 +- 新增无障碍事件悬浮窗及日志页面 +- 新增截屏快照的应用ID和特征事件选择器 +- 新增应用白名单内暂停匹配 +- 新增局部关闭,在无障碍白名单内关闭无障碍 +- 新增界面服务悬浮窗显示 Activity +- 重构 Shizuku 授权为单个 启用优化 开关,所有功能内部自动判断开启 +- 优化连接 Shizuku 后自动给 GKD 授权 +- 优化通知管理,服务类常驻通知均增加关闭按钮 +- 优化关于页面反馈提示 +- 优化空白截图增加文字提示 +- 优化所有列表页面点击标题返回顶部 +- 优化规则执行逻辑 +- 新增订阅字段 versionCode 和 versionName +- 新增订阅字段值 action:'none' +- 修复在设备重启时启动常驻通知报错 +- 修复 resetMatch=app 且 activityIds 有值时匹配异常 +- 修复选择器 isMatchRoot 不生效的问题 +- 修复 com.android.systemui 系统界面识别异常 +- 修复重新打开屏幕时规则概率不执行 +- 其它多个优化和修复 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0715a8e6ea..ab0e77a982 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 73 - versionName = "1.11.0-beta.7" + versionCode = 74 + versionName = "1.11.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From d7ae12f449e6673f0bf175e74e06e15bb4110a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 13 Nov 2025 19:11:06 +0800 Subject: [PATCH 105/245] perf: update libs --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b27cbb413b..5a439c7876 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.2.21" ksp = "2.3.2" -agp = "8.13.0" +agp = "8.13.1" compose = "1.9.4" room = "2.8.3" paging = "3.3.6" From b53413d91eb9fd2230208390559083d509b86763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 13 Nov 2025 19:11:28 +0800 Subject: [PATCH 106/245] perf: ResolvedRule trigger --- app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt index 9c6d73b697..1235ac3d4f 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt @@ -125,10 +125,11 @@ sealed class ResolvedRule( private var actionTriggerTime = atomic(0L) fun trigger() { - actionTriggerTime.value = System.currentTimeMillis() + val t = System.currentTimeMillis() + actionTriggerTime.value = t actionDelayTriggerTime.value = 0L actionCount.incrementAndGet() - lastTriggerTime = actionTriggerTime.value + lastTriggerTime = t lastTriggerRule = this } From ddf6eb5d8a6a1c8771f8420513c8df3075f436c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 13 Nov 2025 19:30:18 +0800 Subject: [PATCH 107/245] fix: isFirstMatchApp resetState (#1059) --- app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt index 1235ac3d4f..530961e13d 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt @@ -137,7 +137,7 @@ sealed class ResolvedRule( private val matchChangedTime = atomic(0L) val isFirstMatchApp: Boolean - get() = matchChangedTime.value == appChangeTime + get() = matchChangedTime.value < appChangeTime private val matchLimitTime = (matchTime ?: 0) + matchDelay From a871a1ae4c6efe0848dd63bf4e1328d206d49f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 13 Nov 2025 19:38:01 +0800 Subject: [PATCH 108/245] chore: v1.11.1 --- CHANGELOG.md | 32 ++------------------------------ app/build.gradle.kts | 6 +++--- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ac9afb8f..b3558f21b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,38 +1,10 @@ -# v1.11.0 +# v1.11.1 以下是本次更新的主要内容 ## 优化和修复 -- 优化视力障碍用户体验 -- 优化多个页面的显示和使用体验 -- 优化规则编辑弹窗为页面并支持代码高亮 -- 重构应用列表移除部分排序筛选,增加按最近使用排序,显示冻结应用,隐藏无界面应用,支持下拉刷新 -- 新增多个通知栏开关 -- 新增使用协议和隐私政策 -- 新增通知文案支持主标题和副标题 -- 优化任意悬浮窗支持保存上次位置 -- 优化悬浮窗内可点击图标关闭 -- 新增无障碍事件悬浮窗及日志页面 -- 新增截屏快照的应用ID和特征事件选择器 -- 新增应用白名单内暂停匹配 -- 新增局部关闭,在无障碍白名单内关闭无障碍 -- 新增界面服务悬浮窗显示 Activity -- 重构 Shizuku 授权为单个 启用优化 开关,所有功能内部自动判断开启 -- 优化连接 Shizuku 后自动给 GKD 授权 -- 优化通知管理,服务类常驻通知均增加关闭按钮 -- 优化关于页面反馈提示 -- 优化空白截图增加文字提示 -- 优化所有列表页面点击标题返回顶部 -- 优化规则执行逻辑 -- 新增订阅字段 versionCode 和 versionName -- 新增订阅字段值 action:'none' -- 修复在设备重启时启动常驻通知报错 -- 修复 resetMatch=app 且 activityIds 有值时匹配异常 -- 修复选择器 isMatchRoot 不生效的问题 -- 修复 com.android.systemui 系统界面识别异常 -- 修复重新打开屏幕时规则概率不执行 -- 其它多个优化和修复 +- 修复规则状态异常导致重复执行 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab0e77a982..aa10d2fdca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 74 - versionName = "1.11.0" + versionCode = 75 + versionName = "1.11.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -112,7 +112,7 @@ android { buildTypes { all { if (gitInfo.tagName == null) { - versionNameSuffix = "-${gitInfo.commitId.substring(0, 7)}" + versionNameSuffix = "-${gitInfo.commitId.take(7)}" } } release { From ba047bbe81c5e4a4c6e0063d4f685edafa3320b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 14 Nov 2025 21:32:11 +0800 Subject: [PATCH 109/245] chore: update screenshot --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5f0b65a314..737cfc93d8 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ ## 截图 -| | | | | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| ![img](https://e.gkd.li/70aa3257-a7f0-4abf-81ba-02486663c248) | ![img](https://e.gkd.li/64c7c0f2-2e6d-4a79-8106-ca1988abe3ef) | ![img](https://e.gkd.li/17c61583-a0d8-4d96-a455-32f88137a1fd) | ![img](https://e.gkd.li/5622e324-ee35-40d5-aad5-5196cc9ac582) | -| ![img](https://e.gkd.li/27e5a936-61a2-45c0-b415-f96f2e27b131) | ![img](https://e.gkd.li/a0a62e53-8ba6-42fe-9b85-25faf26b070f) | ![img](https://e.gkd.li/7cfd74f3-8ff2-4bf0-a5e1-0578c3e9f69d) | ![img](https://e.gkd.li/967e84fa-8673-4b0a-b2a8-2b5374a631ee) | +| | | | | +| ------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | +| ![img](https://e.gkd.li/1e8934c1-2303-4182-9ef2-ad4c46882570) | ![img](https://e.gkd.li/01f230d7-9b89-4314-b573-38bd233d22f9) | ![img](https://e.gkd.li/dfa0a782-b21e-473a-96e4-eef27773b71b) | ![img](https://e.gkd.li/641decd1-2e60-4e95-b78c-df38d1d98a4d) | +| ![img](https://e.gkd.li/b216b703-d3de-4798-81ba-29e0ae63264f) | ![img](https://e.gkd.li/76c25ac9-4189-47cd-b40b-b9e72c79b584) | ![img](https://e.gkd.li/7288502e-808b-4d9a-88b5-1085abaa0d46) | ![img](https://e.gkd.li/aa974940-7773-409a-ae84-3c02fee9c770) | ## 订阅 From 6f2791a66dbeddb2c51413a561187d51e213c60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 14 Nov 2025 22:59:47 +0800 Subject: [PATCH 110/245] chore: format xml --- .../res/drawable/ic_anim_search_close.xml | 51 +++++++++---------- app/src/main/res/drawable/ic_event_list.xml | 4 +- app/src/main/res/drawable/ic_flash_off.xml | 4 +- app/src/main/res/drawable/ic_flash_on.xml | 4 +- .../res/drawable/ic_launcher_foreground.xml | 24 ++++----- app/src/main/res/drawable/ic_layers.xml | 6 +-- app/src/main/res/drawable/ic_page_info.xml | 2 +- 7 files changed, 47 insertions(+), 48 deletions(-) diff --git a/app/src/main/res/drawable/ic_anim_search_close.xml b/app/src/main/res/drawable/ic_anim_search_close.xml index 15f614ddf5..cb8264eb43 100644 --- a/app/src/main/res/drawable/ic_anim_search_close.xml +++ b/app/src/main/res/drawable/ic_anim_search_close.xml @@ -1,5 +1,4 @@ - + android:strokeWidth="1.8" + android:strokeColor="#FFF" /> + android:strokeColor="#FFF" + android:trimPathStart="1" /> + android:strokeColor="#FFF" + android:trimPathStart="0.48" /> + android:valueType="floatType" /> + android:valueType="floatType" /> + android:valueType="floatType" /> + android:valueType="floatType" /> + android:valueType="floatType" /> + android:valueType="floatType" /> diff --git a/app/src/main/res/drawable/ic_event_list.xml b/app/src/main/res/drawable/ic_event_list.xml index 0facbed324..915263f1dc 100644 --- a/app/src/main/res/drawable/ic_event_list.xml +++ b/app/src/main/res/drawable/ic_event_list.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960"> + android:fillColor="#FFF" + android:pathData="M640,840q-33,0 -56.5,-23.5T560,760v-160q0,-33 23.5,-56.5T640,520h160q33,0 56.5,23.5T880,600v160q0,33 -23.5,56.5T800,840L640,840ZM640,760h160v-160L640,600v160ZM80,720v-80h360v80L80,720ZM640,440q-33,0 -56.5,-23.5T560,360v-160q0,-33 23.5,-56.5T640,120h160q33,0 56.5,23.5T880,200v160q0,33 -23.5,56.5T800,440L640,440ZM640,360h160v-160L640,200v160ZM80,320v-80h360v80L80,320ZM720,680ZM720,280Z" /> diff --git a/app/src/main/res/drawable/ic_flash_off.xml b/app/src/main/res/drawable/ic_flash_off.xml index 4583a2054b..f8b19a8461 100644 --- a/app/src/main/res/drawable/ic_flash_off.xml +++ b/app/src/main/res/drawable/ic_flash_off.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960"> + android:fillColor="#FFF" + android:pathData="M280,80h400l-80,280h160L643,529l-57,-57 22,-32h-54l-47,-47 67,-233L360,160v86l-80,-80v-86ZM400,880v-320L280,560v-166L55,169l57,-57 736,736 -57,57 -241,-241L400,880ZM473,359Z" /> diff --git a/app/src/main/res/drawable/ic_flash_on.xml b/app/src/main/res/drawable/ic_flash_on.xml index 7bf033f872..3fdf531027 100644 --- a/app/src/main/res/drawable/ic_flash_on.xml +++ b/app/src/main/res/drawable/ic_flash_on.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960"> + android:fillColor="#FFF" + android:pathData="m480,624 l128,-184L494,440l80,-280L360,160v320h120v144ZM400,880v-320L280,560v-480h400l-80,280h160L400,880ZM480,480L360,480h120Z" /> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 9c738396b6..14d0e0f2c1 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -4,21 +4,21 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:fillColor="@color/ic_launcher_foreground_tint" + android:pathData="M43.91,75C43.87,72.52 44.11,70.03 43.99,67.57C43.83,64.29 43.43,61.02 43.04,57.76C42.92,56.77 43.01,56.13 44.05,55.75C49.61,53.68 55.22,53.42 60.92,55.1C61.83,55.37 62.1,55.76 61.97,56.76C61.72,58.58 61.64,60.44 61.59,62.29C61.46,66.44 61.39,70.59 61.3,74.87C55.54,75 49.79,75 43.91,75Z" /> + android:fillColor="@color/ic_launcher_foreground_tint" + android:pathData="M64,75C64.06,68.79 64.24,62.58 64.43,56C72.15,60.04 75.99,66.65 78,74.9C73.38,75 68.75,75 64,75Z" /> + android:fillColor="@color/ic_launcher_foreground_tint" + android:pathData="M30,75C30.73,70.58 32.1,66.39 34.78,62.98C36.28,61.05 38.13,59.45 39.85,57.73C40.11,57.47 40.47,57.33 41,57C42.27,63 42.09,68.87 41.75,74.87C37.87,75 33.99,75 30,75Z" /> + android:fillColor="@color/ic_launcher_foreground_tint" + android:pathData="M45.08,42l8.47,-8.5l8.47,8.5l-8.47,8.5z" /> + android:fillColor="@color/ic_launcher_foreground_tint" + android:pathData="M41,37.49l8.49,-8.49l2.82,2.84l-8.49,8.49z" /> + android:fillColor="@color/ic_launcher_foreground_tint" + android:pathData="M57.82,29l8.49,8.49l-2.82,2.84l-8.49,-8.49z" /> diff --git a/app/src/main/res/drawable/ic_layers.xml b/app/src/main/res/drawable/ic_layers.xml index b4cb725904..ab787077cd 100644 --- a/app/src/main/res/drawable/ic_layers.xml +++ b/app/src/main/res/drawable/ic_layers.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="960" android:viewportHeight="960"> - + diff --git a/app/src/main/res/drawable/ic_page_info.xml b/app/src/main/res/drawable/ic_page_info.xml index 330564971d..886e6b8be8 100644 --- a/app/src/main/res/drawable/ic_page_info.xml +++ b/app/src/main/res/drawable/ic_page_info.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M710,810Q647,810 603.5,766.5Q560,723 560,660Q560,597 603.5,553.5Q647,510 710,510Q773,510 816.5,553.5Q860,597 860,660Q860,723 816.5,766.5Q773,810 710,810ZM710,730Q739,730 759.5,709.5Q780,689 780,660Q780,631 759.5,610.5Q739,590 710,590Q681,590 660.5,610.5Q640,631 640,660Q640,689 660.5,709.5Q681,730 710,730ZM160,700L160,620L480,620L480,700L160,700ZM250,450Q187,450 143.5,406.5Q100,363 100,300Q100,237 143.5,193.5Q187,150 250,150Q313,150 356.5,193.5Q400,237 400,300Q400,363 356.5,406.5Q313,450 250,450ZM250,370Q279,370 299.5,349.5Q320,329 320,300Q320,271 299.5,250.5Q279,230 250,230Q221,230 200.5,250.5Q180,271 180,300Q180,329 200.5,349.5Q221,370 250,370ZM480,340L480,260L800,260L800,340L480,340ZM710,660Q710,660 710,660Q710,660 710,660Q710,660 710,660Q710,660 710,660Q710,660 710,660Q710,660 710,660Q710,660 710,660Q710,660 710,660ZM250,300Q250,300 250,300Q250,300 250,300Q250,300 250,300Q250,300 250,300Q250,300 250,300Q250,300 250,300Q250,300 250,300Q250,300 250,300Z" /> From ef21a0a515de543fb7e1c6cd196af634b3d01597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Fri, 14 Nov 2025 23:28:21 +0800 Subject: [PATCH 111/245] chore: debug icon --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa10d2fdca..6281193480 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,7 @@ android { debug { signingConfig = gkdSigningConfig applicationIdSuffix = ".debug" + resValue("color", "better_black", "#FF5D92") debugSuffixPairList.onEach { (key, value) -> resValue("string", key, "$value-debug") } From bcf34a8fc9954ef942ff67cc33fca8def586d57d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 15 Nov 2025 19:46:39 +0800 Subject: [PATCH 112/245] perf: invoke shizuku --- .../li/songe/gkd/shizuku/PackageManager.kt | 1 + .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 10 +++++++--- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 19 ++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 0528a24af8..09f191fc0a 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -7,6 +7,7 @@ import li.songe.gkd.META import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass +@Suppress("unused") class SafePackageManager(private val value: IPackageManager) { companion object { val isAvailable: Boolean diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 21e93a7059..fccce7ba4a 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -28,9 +28,13 @@ inline fun safeInvokeMethod( block: () -> T ): T? = try { block() -} catch (e: Throwable) { - e.printStackTrace() - null +} catch (e: IllegalStateException) { + // https://github.com/RikkaApps/Shizuku-API/blob/a27f6e4151ba7b39965ca47edb2bf0aeed7102e5/api/src/main/java/rikka/shizuku/Shizuku.java#L430 + if (e.message == "binder haven't been received") { + null + } else { + throw e + } } fun getStubService(name: String, condition: Boolean): ShizukuBinderWrapper? { diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index a792f7e9b1..30ec34ca01 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -97,13 +97,13 @@ val updateAppMutex = MutexState() private fun updateOtherUserAppInfo(userAppInfoMap: Map? = null) { val pkgManager = shizukuContextFlow.value.packageManager val userManager = shizukuContextFlow.value.userManager - if (pkgManager == null || userManager == null) { + val actualUserAppInfoMap = userAppInfoMap ?: userAppInfoMapFlow.value + if (pkgManager == null || userManager == null || actualUserAppInfoMap.isEmpty()) { otherUserMapFlow.value = emptyMap() otherUserAppIconMapFlow.value = emptyMap() otherUserAppInfoMapFlow.value = emptyMap() return } - val actualUserAppInfoMap = userAppInfoMap ?: userAppInfoMapFlow.value val otherUsers = userManager.getUsers().filter { it.id != currentUserId }.sortedBy { it.id } val userPackageInfoMap = otherUsers.associate { user -> user.id to pkgManager.getInstalledPackages( @@ -157,7 +157,7 @@ private fun updatePartAppInfo( fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO) { val newAppMap = HashMap() val newIconMap = HashMap() - // see #1169 + // see #1169 DeadObjectException BadParcelableException val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) pkgList.forEach { pkgInfo -> val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon() @@ -180,10 +180,15 @@ fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO } } else { val visiblePkgList = arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> - app.packageManager.queryIntentActivities( - Intent(action), - PackageManager.MATCH_DISABLED_COMPONENTS - ) + try { + // DeadObjectException BadParcelableException + app.packageManager.queryIntentActivities( + Intent(action), + PackageManager.MATCH_DISABLED_COMPONENTS + ) + } catch (_: Throwable) { + emptyList() + } }.flatten() .map { it.activityInfo.packageName }.toSet() .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) } From 263021882436fb6c67e5b37d0fa21932e1b705ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 15 Nov 2025 23:15:45 +0800 Subject: [PATCH 113/245] perf: latest record text fillMaxWidth --- .../li/songe/gkd/ui/home/ControlPage.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index ef95538a8b..857bda9dee 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -379,15 +379,18 @@ private fun ServerStatusCard() { .fillMaxWidth() .padding(horizontal = 4.dp) ) { - GroupNameText( + Column( modifier = Modifier.weight(1f), - preText = "最近触发: ", - isGlobal = latestRecordFlow.collectAsState().value?.groupType == SubsConfig.GlobalGroupType, - text = latestRecordDesc ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) + ) { + GroupNameText( + modifier = Modifier.fillMaxWidth(), + preText = "最近触发: ", + isGlobal = latestRecordFlow.collectAsState().value?.groupType == SubsConfig.GlobalGroupType, + text = latestRecordDesc ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, modifier = Modifier.textSize(style = MaterialTheme.typography.bodyMedium), From 87692a28086887e7f55805966e9ee9e2c219c1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 15 Nov 2025 23:16:17 +0800 Subject: [PATCH 114/245] perf: try run grant GET_INSTALLED_APPS --- app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 09f191fc0a..1967a93614 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -71,7 +71,10 @@ class SafePackageManager(private val value: IPackageManager) { ) fun allowAllSelfPermission() { - grantSelfPermission("com.android.permission.GET_INSTALLED_APPS") + try { + grantSelfPermission("com.android.permission.GET_INSTALLED_APPS") + } catch (e: Throwable) { + } grantSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) if (AndroidTarget.TIRAMISU) { grantSelfPermission(Manifest.permission.POST_NOTIFICATIONS) From 2d9dda183dbd5a730f0d4bddf20ad80167cc0721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 15 Nov 2025 23:19:29 +0800 Subject: [PATCH 115/245] fix: checkHasActivity (#1195) --- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index 920df6262a..8b1c9ca589 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -1,5 +1,6 @@ package li.songe.gkd.data +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageInfoHidden @@ -60,21 +61,18 @@ private val PackageInfo.isOverlay: Boolean val ApplicationInfo.isSystem: Boolean get() = flags and ApplicationInfo.FLAG_SYSTEM != 0 || flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 -private fun checkIfNotHasActivity(packageName: String, userId: Int): Boolean { - val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES - return if (userId == currentUserId) { +private fun checkHasActivity(packageName: String): Boolean { + return app.packageManager.getLaunchIntentForPackage(packageName) != null || app.packageManager.queryIntentActivities( + Intent().setPackage(packageName), + PackageManager.MATCH_DISABLED_COMPONENTS + ).isNotEmpty() || try { app.packageManager.getPackageInfo( packageName, - flags, - ) - } else { - shizukuContextFlow.value.packageManager?.getPackageInfo( - packageName, - flags, - userId, - ) - }?.activities.let { - it == null || it.isEmpty() + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES + ).activities?.isNotEmpty() == true + } catch (_: Throwable) { + // #1195 packageManager.getPackageInfo android.os.DeadSystemRuntimeException + true } } @@ -104,7 +102,7 @@ private fun PackageInfo.getEnabled(userId: Int): Boolean { } } -// all->433 isOverlay->354 checkIfNotHasActivity->271 +// all->433 isOverlay->354 checkAppHasActivity->271 fun PackageInfo.toAppInfo( userId: Int = currentUserId, hidden: Boolean? = null, @@ -118,7 +116,7 @@ fun PackageInfo.toAppInfo( mtime = lastUpdateTime, isSystem = isSystem, name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, - hidden = hidden ?: (isSystem && (isOverlay || checkIfNotHasActivity(packageName, userId))), + hidden = hidden ?: (isSystem && (isOverlay || !checkHasActivity(packageName))), enabled = getEnabled(userId), ) } From 3cef1beacaa9627e017aca7ab382d902a74ed560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 15 Nov 2025 23:36:38 +0800 Subject: [PATCH 116/245] perf: change store default value --- app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt index 794d3b297d..0542ab7e4a 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -29,19 +29,19 @@ data class SettingsStore( val useSystemToast: Boolean = false, val useCustomNotifText: Boolean = false, val customNotifTitle: String = META.appName, - val customNotifText: String = "\${i}全局/\${k}应用/\${u}规则组/\${n}触发", + val customNotifText: String = $$"${i}全局/${k}应用/${u}规则组/${n}触发", val updateChannel: Int = if (META.isBeta) UpdateChannelOption.Beta.value else UpdateChannelOption.Stable.value, val appSort: Int = AppSortOption.ByUsedTime.value, - val showBlockApp: Boolean = false, + val showBlockApp: Boolean = true, val appRuleSort: Int = RuleSortOption.ByDefault.value, val subsAppSort: Int = AppSortOption.ByUsedTime.value, val subsAppShowUninstallApp: Boolean = false, val subsExcludeSort: Int = AppSortOption.ByUsedTime.value, val subsExcludeShowInnerDisabledApp: Boolean = false, - val subsExcludeShowBlockApp: Boolean = false, + val subsExcludeShowBlockApp: Boolean = true, val subsPowerWarn: Boolean = true, val enableShizuku: Boolean = false, val enableBlockA11yAppList: Boolean = false, - val blockA11yAppListFollowMatch: Boolean = false, + val blockA11yAppListFollowMatch: Boolean = true, val a11yAppSort: Int = AppSortOption.ByUsedTime.value, ) From 7a9bef700fadbad26358d12fcdc1d687091ae8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 16 Nov 2025 00:11:38 +0800 Subject: [PATCH 117/245] fix: sh file --- .../li/songe/gkd/service/ExposeService.kt | 9 ++-- .../kotlin/li/songe/gkd/ui/AppOpsAllowVm.kt | 3 +- .../kotlin/li/songe/gkd/ui/AuthA11yPage.kt | 45 +++++++++++-------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt index 2862258764..1a76c8897a 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt @@ -31,6 +31,8 @@ class ExposeService : Service() { LogUtils.d("ExposeService::handleIntent", expose, data) when (expose) { 0 -> SnapshotExt.captureSnapshot() + 1 -> toast("执行成功") + else -> { toast("未知调用: expose=$expose data=$data") } @@ -52,13 +54,14 @@ class ExposeService : Service() { } private const val template = $$"""set -euo pipefail +echo '> start expose.sh' p='' -if [ -n "$1" ]; then +if [ -n "${1:-}" ]; then p+=" --ei expose $1" fi -if [ -n "$2" ]; then +if [ -n "${2:-}" ]; then p+=" --es data $2" fi am start-foreground-service -n {service} $p -echo 'Execution Successful' +echo '> expose.sh end' """ \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowVm.kt index c912427399..c44c2acd20 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowVm.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.launch import li.songe.gkd.permission.foregroundServiceSpecialUseState class AppOpsAllowVm : ViewModel() { + val showCopyDlgFlow = MutableStateFlow(false) + init { viewModelScope.launch(Dispatchers.IO) { while (isActive) { @@ -18,5 +20,4 @@ class AppOpsAllowVm : ViewModel() { } } } - val showCopyDlgFlow = MutableStateFlow(false) } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt index 139e43d033..ab29319add 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -57,7 +58,7 @@ import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.surfaceCardColors import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.ShortUrlSet -import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.launchTry import li.songe.gkd.util.openA11ySettings import li.songe.gkd.util.shFolder import li.songe.gkd.util.throttle @@ -235,7 +236,7 @@ fun AuthA11yPage() { text = "若能正常开关无障碍请忽略此项", style = MaterialTheme.typography.bodySmall, ) - A11yAuthButtonGroup() + A11yAuthButtonGroup(showFix = true) Spacer(modifier = Modifier.height(12.dp)) } } @@ -258,10 +259,12 @@ private val String.pmGrant get() = "pm grant ${META.appId} $this" val gkdStartCommandText by lazy { val commandText = listOfNotNull( "set -euo pipefail", + "echo '> start start.sh'", Manifest.permission.WRITE_SECURE_SETTINGS.pmGrant, if (AndroidTarget.TIRAMISU) "ACCESS_RESTRICTED_SETTINGS".appopsAllow else null, if (AndroidTarget.UPSIDE_DOWN_CAKE) "FOREGROUND_SERVICE_SPECIAL_USE".appopsAllow else null, - "echo 'Execution Successful'", + "sh ${shFolder.absolutePath}/expose.sh 1", + "echo '> start.sh end'", ).joinToString("\n") val file = shFolder.resolve("start.sh") file.writeText(commandText) @@ -269,26 +272,32 @@ val gkdStartCommandText by lazy { } @Composable -private fun A11yAuthButtonGroup() { +private fun A11yAuthButtonGroup(showFix: Boolean = false) { val mainVm = LocalMainViewModel.current val vm = viewModel() AuthButtonGroup( - buttons = listOf( - "手动解除" to { - mainVm.navigateWebPage(ShortUrlSet.URL2) - }, - "Shizuku 授权" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.guardShizukuContext() - if (writeSecureSettingsState.value) { - toast("授权成功") - storeFlow.update { it.copy(enableService = true) } - fixRestartService() + buttons = remember(showFix) { + val list = mutableListOf Unit>>() + if (showFix) { + list.add("手动解除" to { + mainVm.navigateWebPage(ShortUrlSet.URL2) + }) + } + list.add("Shizuku 授权" to { + vm.viewModelScope.launchTry(Dispatchers.IO) { + mainVm.guardShizukuContext() + if (writeSecureSettingsState.value) { + toast("授权成功") + storeFlow.update { it.copy(enableService = true) } + fixRestartService() + } } - }, - "外部授权" to { + }) + list.add("外部授权" to { vm.showCopyDlgFlow.value = true - }, - ) + }) + list + } ) } From a3737afe2bd2fe1903137724251f41144f2627db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 16 Nov 2025 00:33:08 +0800 Subject: [PATCH 118/245] chore: v1.11.2 --- CHANGELOG.md | 7 +++++-- app/build.gradle.kts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3558f21b2..c7c445b1f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ -# v1.11.1 +# v1.11.2 以下是本次更新的主要内容 ## 优化和修复 -- 修复规则状态异常导致重复执行 +- 修复某些设备上获取应用列表失败 +- 修复首页最近记录文本过长显示效果 +- 修复执行内置脚本失败 +- 变更应用列表白名单为默认显示 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6281193480..1123518d8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 75 - versionName = "1.11.1" + versionCode = 76 + versionName = "1.11.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 9df4f1c0c2f0c0eb4244a548e6f1af9f5d16d601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 17 Nov 2025 01:24:28 +0800 Subject: [PATCH 119/245] chore: reorder code --- hidden_api/src/main/java/android/app/IActivityManager.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hidden_api/src/main/java/android/app/IActivityManager.java b/hidden_api/src/main/java/android/app/IActivityManager.java index 32e896978e..88b220b693 100644 --- a/hidden_api/src/main/java/android/app/IActivityManager.java +++ b/hidden_api/src/main/java/android/app/IActivityManager.java @@ -20,12 +20,12 @@ public static IActivityManager asInterface(IBinder obj) { } } - @RequiresApi(Build.VERSION_CODES.P) - List getTasks(int maxNum); - @DeprecatedSinceApi(api = Build.VERSION_CODES.P, message = "NoSuchMethodError") List getTasks(int maxNum, int flags); + @RequiresApi(Build.VERSION_CODES.P) + List getTasks(int maxNum); + void registerTaskStackListener(ITaskStackListener listener); void unregisterTaskStackListener(ITaskStackListener listener); From 92d8c72b21fcc213d26760bdce6a707845635a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 17 Nov 2025 11:07:20 +0800 Subject: [PATCH 120/245] fix: detectHiddenMethod (#1200) --- .../songe/gkd/shizuku/ActivityTaskManager.kt | 26 ++++++++++++++----- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 16 ++++++++++++ .../android/app/IActivityTaskManager.java | 13 +++------- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt index 7d7cdff981..6cbae96274 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt @@ -4,7 +4,6 @@ import android.app.ActivityManager import android.app.IActivityTaskManager import android.view.Display import li.songe.gkd.permission.shizukuGrantedState -import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass object SafeTaskListener { @@ -24,15 +23,28 @@ class SafeActivityTaskManager(private val value: IActivityTaskManager) { )?.let { SafeActivityTaskManager(IActivityTaskManager.Stub.asInterface(it)) } + + private val getTasksType by lazy { + IActivityTaskManager::class.java.detectHiddenMethod( + "getTasks", + 1 to listOf(Int::class.java), + 2 to listOf(Int::class.java, Boolean::class.java, Boolean::class.java), + 3 to listOf( + Int::class.java, + Boolean::class.java, + Boolean::class.java, + Int::class.java + ), + ) + } } fun getTasks(maxNum: Int): List? = safeInvokeMethod { - if (AndroidTarget.TIRAMISU) { - value.getTasks(maxNum, false, false, Display.INVALID_DISPLAY) - } else if (AndroidTarget.S) { - value.getTasks(maxNum, false, false) - } else { - value.getTasks(maxNum) + when (getTasksType) { + 1 -> value.getTasks(maxNum) + 2 -> value.getTasks(maxNum, false, false) + 3 -> value.getTasks(maxNum, false, false, Display.INVALID_DISPLAY) + else -> value.getTasks(maxNum) } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index fccce7ba4a..cde932b74e 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -43,6 +43,22 @@ fun getStubService(name: String, condition: Boolean): ShizukuBinderWrapper? { return ShizukuBinderWrapper(service) } +fun Class<*>.detectHiddenMethod( + methodName: String, + vararg args: Pair>>, +): Int { + val method = declaredMethods.find { + it.name == methodName + } ?: throw NoSuchMethodException() + val types = method.parameterTypes.toList() + args.forEach { (value, argTypes) -> + if (types == argTypes) { + return value + } + } + throw NoSuchMethodException() +} + class ShizukuContext( val serviceWrapper: UserServiceWrapper?, val packageManager: SafePackageManager?, diff --git a/hidden_api/src/main/java/android/app/IActivityTaskManager.java b/hidden_api/src/main/java/android/app/IActivityTaskManager.java index 2fcaf8d626..6499338d9c 100644 --- a/hidden_api/src/main/java/android/app/IActivityTaskManager.java +++ b/hidden_api/src/main/java/android/app/IActivityTaskManager.java @@ -1,34 +1,29 @@ package android.app; import android.os.Binder; -import android.os.Build; import android.os.IBinder; import android.os.IInterface; -import androidx.annotation.DeprecatedSinceApi; -import androidx.annotation.RequiresApi; - import java.util.List; /** * @noinspection unused */ -//@RequiresApi(api = Build.VERSION_CODES.Q) public interface IActivityTaskManager extends IInterface { + // android10+ abstract class Stub extends Binder implements IActivityTaskManager { public static IActivityTaskManager asInterface(IBinder obj) { throw new RuntimeException("Stub!"); } } - @DeprecatedSinceApi(api = Build.VERSION_CODES.R, message = "NoSuchMethodError") + // android10 - android11 List getTasks(int maxNum); - @DeprecatedSinceApi(api = Build.VERSION_CODES.TIRAMISU, message = "NoSuchMethodError") - @RequiresApi(Build.VERSION_CODES.S) + // android12 - android-13.0.0_r15 List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra); - @RequiresApi(Build.VERSION_CODES.TIRAMISU) + // android-13.0.0_r16+ List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); void registerTaskStackListener(ITaskStackListener listener); From f2afb25a6a130f1963700d7b36885e533cacb583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 17 Nov 2025 13:04:03 +0800 Subject: [PATCH 121/245] chore: add log --- app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt index e44f3a9726..440818555a 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -179,11 +179,16 @@ class A11yRuleEngine(val service: A11yService) { if (byEvent == null && service.justStarted) return scope.launchTry(queryDispatcher) { querying = true + val st = System.currentTimeMillis() try { - Log.d("A11yRuleEngine", "startQueryJob start") + Log.d( + "A11yRuleEngine", + "startQueryJob start byEvent=${byEvent != null}, byForced=$byForced, byDelayRule=${byDelayRule != null}" + ) queryAction(byEvent, byForced, byDelayRule) } finally { - Log.d("A11yRuleEngine", "startQueryJob end") + val et = System.currentTimeMillis() - st + Log.d("A11yRuleEngine", "startQueryJob end $et ms") querying = false } } From 6d9a5d9c7fb2f98dc4f89bf1ab4cf3e735a5a38a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 17 Nov 2025 15:17:51 +0800 Subject: [PATCH 122/245] perf: detectHiddenMethod --- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index cde932b74e..e32982ff40 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -47,13 +47,14 @@ fun Class<*>.detectHiddenMethod( methodName: String, vararg args: Pair>>, ): Int { - val method = declaredMethods.find { - it.name == methodName - } ?: throw NoSuchMethodException() - val types = method.parameterTypes.toList() - args.forEach { (value, argTypes) -> - if (types == argTypes) { - return value + declaredMethods.forEach { method -> + if (method.name == methodName) { + val types = method.parameterTypes.toList() + args.forEach { (value, argTypes) -> + if (types == argTypes) { + return value + } + } } } throw NoSuchMethodException() From 370c9e7cce89964a297cbdfadb82fc0bed3f9b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 17 Nov 2025 15:43:59 +0800 Subject: [PATCH 123/245] chore: v1.11.3 --- CHANGELOG.md | 7 ++----- app/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c445b1f4..f8fb078aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,10 @@ -# v1.11.2 +# v1.11.3 以下是本次更新的主要内容 ## 优化和修复 -- 修复某些设备上获取应用列表失败 -- 修复首页最近记录文本过长显示效果 -- 修复执行内置脚本失败 -- 变更应用列表白名单为默认显示 +- 修复部分设备调用接口失败 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1123518d8f..5b2f4a50ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 76 - versionName = "1.11.2" + versionCode = 77 + versionName = "1.11.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From d0ea11e3257975112d1cae08ae2f53fc1d72c267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Wed, 19 Nov 2025 17:24:24 +0800 Subject: [PATCH 124/245] perf: user name trim --- app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt index f237e3f07a..296b004e99 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt @@ -28,6 +28,6 @@ class SafeUserManager(private val value: IUserManager) { value.getUsers(excludePartial, excludeDying, excludePreCreated) } else { value.getUsers(excludeDying) - }.map { UserInfo(id = it.id, name = it.name) } + }.map { UserInfo(id = it.id, name = it.name.trim()) } } ?: emptyList() } From b777a28dad236b3d468d0524a3559a876a21ba85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 20 Nov 2025 17:35:11 +0800 Subject: [PATCH 125/245] feat: appListAuthAbnormal tip --- .../kotlin/li/songe/gkd/ui/component/PerfIcon.kt | 2 ++ .../kotlin/li/songe/gkd/ui/home/AppListPage.kt | 15 +++++++++++++++ .../main/kotlin/li/songe/gkd/util/AppInfoState.kt | 8 ++++++++ .../main/kotlin/li/songe/gkd/util/FolderExt.kt | 1 + 4 files changed, 26 insertions(+) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index b4c621e51f..7658391f23 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -82,6 +82,7 @@ fun PerfIconButton( colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), contentDescription: String? = getDefaultDesc(imageVector), onClickLabel: String? = null, + tint: Color = LocalContentColor.current, ) = IconButton( modifier = modifier.semantics { if (onClickLabel != null) { @@ -95,6 +96,7 @@ fun PerfIconButton( PerfIcon( imageVector = imageVector, contentDescription = contentDescription, + tint = tint, ) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 591a52a35d..a30299369f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -65,6 +65,7 @@ import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.QueryPkgAuthCard import li.songe.gkd.ui.component.autoFocus +import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel @@ -74,6 +75,7 @@ import li.songe.gkd.ui.style.appItemPadding import li.songe.gkd.ui.style.menuPadding import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.SafeR +import li.songe.gkd.util.appListAuthAbnormalFlow import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.switchItem @@ -160,6 +162,19 @@ fun useAppListPage(): ScaffoldExt { } } }, actions = { + if (appListAuthAbnormalFlow.collectAsState().value) { + PerfIconButton( + imageVector = PerfIcon.WarningAmber, + contentDescription = canQueryPkgState.name + "异常", + tint = MaterialTheme.colorScheme.error, + onClick = throttle { + mainVm.dialogFlow.updateDialogOptions( + title = "权限异常", + text = "检测到已授予「${canQueryPkgState.name}」但实际获取用户应用数量稀少\n\n在应用列表下拉刷新可重新获取,若无法解决可尝试关闭权限后重新授予或重启设备" + ) + }, + ) + } PerfIconButton( imageVector = PerfIcon.Block, contentDescription = "切换白名单编辑模式", diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index 30ec34ca01..d95dcab0a9 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -7,6 +7,7 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.graphics.drawable.Drawable import androidx.core.content.ContextCompat +import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -154,6 +155,8 @@ private fun updatePartAppInfo( userAppIconMapFlow.value = newIconMap } +val appListAuthAbnormalFlow = MutableStateFlow(false) + fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO) { val newAppMap = HashMap() val newIconMap = HashMap() @@ -168,7 +171,12 @@ fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO } val mayAuthDenied = newAppMap.count { !it.value.isSystem } <= 4 canQueryPkgState.updateAndGet() + appListAuthAbnormalFlow.value = canQueryPkgState.value && mayAuthDenied if (!canQueryPkgState.value || mayAuthDenied) { + LogUtils.d( + "updateAllAppInfo", + "mayAuthDenied=$mayAuthDenied, newAppMap.size=${newAppMap.size}" + ) val pkgList2 = shizukuContextFlow.value.packageManager?.getInstalledPackages(PKG_FLAGS) if (!pkgList2.isNullOrEmpty()) { pkgList2.forEach { pkgInfo -> diff --git a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index f402d832e4..9ee3249fb9 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -97,6 +97,7 @@ fun buildLogFile(): File { it.writeText(allPermissionStates.joinToString("\n") { state -> state.name + ": " + state.stateFlow.value.toString() }) + it.appendText("\nappListAuthAbnormalFlow: ${appListAuthAbnormalFlow.value}") files.add(it) } tempDir.resolve("gkd-${META.versionCode}-v${META.versionName}.json").also { From 7f4c5cf114d6202882573ab51e748bc45671361c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 20 Nov 2025 19:07:51 +0800 Subject: [PATCH 126/245] fix: IUserManager getUsers --- .../kotlin/li/songe/gkd/shizuku/UserManager.kt | 17 ++++++++++++----- .../src/main/java/android/os/IUserManager.java | 7 ++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt index 296b004e99..0d7f8adba2 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt @@ -3,7 +3,6 @@ package li.songe.gkd.shizuku import android.content.Context import android.os.IUserManager import li.songe.gkd.data.UserInfo -import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.checkExistClass class SafeUserManager(private val value: IUserManager) { @@ -17,6 +16,14 @@ class SafeUserManager(private val value: IUserManager) { )?.let { SafeUserManager(IUserManager.Stub.asInterface(it)) } + + private val getUsersType by lazy { + IUserManager::class.java.detectHiddenMethod( + "getUsers", + 1 to listOf(Boolean::class.java), + 2 to listOf(Boolean::class.java, Boolean::class.java, Boolean::class.java), + ) + } } fun getUsers( @@ -24,10 +31,10 @@ class SafeUserManager(private val value: IUserManager) { excludeDying: Boolean = true, excludePreCreated: Boolean = true ): List = safeInvokeMethod { - if (AndroidTarget.R) { - value.getUsers(excludePartial, excludeDying, excludePreCreated) - } else { - value.getUsers(excludeDying) + when (getUsersType) { + 1 -> value.getUsers(excludeDying) + 2 -> value.getUsers(excludePartial, excludeDying, excludePreCreated) + else -> value.getUsers(excludeDying) }.map { UserInfo(id = it.id, name = it.name.trim()) } } ?: emptyList() } diff --git a/hidden_api/src/main/java/android/os/IUserManager.java b/hidden_api/src/main/java/android/os/IUserManager.java index 2a1e8aa609..4729606593 100644 --- a/hidden_api/src/main/java/android/os/IUserManager.java +++ b/hidden_api/src/main/java/android/os/IUserManager.java @@ -2,9 +2,6 @@ import android.content.pm.UserInfo; -import androidx.annotation.DeprecatedSinceApi; -import androidx.annotation.RequiresApi; - import java.util.List; /** @@ -17,9 +14,9 @@ public static IUserManager asInterface(IBinder obj) { } } - @DeprecatedSinceApi(api = Build.VERSION_CODES.R, message = "NoSuchMethodError") + // android8 - android10, android-16.0.0_r3 List getUsers(boolean excludeDying); - @RequiresApi(Build.VERSION_CODES.R) + // android11 - android-16.0.0_r2 List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); } From 279adbe0ab74a8d293acc78274ff3608ef5a62fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 20 Nov 2025 19:09:29 +0800 Subject: [PATCH 127/245] chore: v1.11.4 --- CHANGELOG.md | 3 ++- app/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8fb078aec..ea1578c198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ -# v1.11.3 +# v1.11.4 以下是本次更新的主要内容 ## 优化和修复 - 修复部分设备调用接口失败 +- 增加读取应用列表权限异常提示 ## 更新方式 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b2f4a50ca..928fac33ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,8 +66,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 77 - versionName = "1.11.3" + versionCode = 78 + versionName = "1.11.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 6d2e477bf7c7435d1eeaa0d2d8e05964d7b4406b Mon Sep 17 00:00:00 2001 From: Vixb Date: Sun, 23 Nov 2025 15:24:06 +0800 Subject: [PATCH 128/245] feat: tooltip for icons (#1214) --- .../li/songe/gkd/ui/component/AnimatedIcon.kt | 24 +++-- .../li/songe/gkd/ui/component/PerfIcon.kt | 94 +++++++------------ .../li/songe/gkd/ui/home/ControlPage.kt | 1 + 3 files changed, 51 insertions(+), 68 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt index b287d6f2e5..ea58d1e280 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt @@ -4,8 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -24,12 +23,21 @@ fun AnimatedIcon( animation, atEnd, ) - Icon( - modifier = modifier, - painter = painter, - contentDescription = contentDescription, - tint = tint, - ) + TooltipBox( + tooltip = { PlainTooltip { Text(text = contentDescription.orEmpty()) } }, + state = rememberTooltipState(), + enableUserInput = !contentDescription.isNullOrEmpty(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Start + ) + ) { + Icon( + modifier = modifier, + painter = painter, + contentDescription = contentDescription, + tint = tint, + ) + } } private fun getIconDesc(@DrawableRes id: Int, atEnd: Boolean): String? = when (id) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index 7658391f23..05dc32243b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -2,56 +2,12 @@ package li.songe.gkd.ui.component import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.FormatListBulleted -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.CenterFocusWeak -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.UnfoldMore -import androidx.compose.material.icons.filled.WarningAmber -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Api -import androidx.compose.material.icons.outlined.ArrowDownward -import androidx.compose.material.icons.outlined.AutoMode -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.DarkMode -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Eco -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Equalizer -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Image -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.LightMode -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.RocketLaunch -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.TextFields -import androidx.compose.material.icons.outlined.Title -import androidx.compose.material.icons.outlined.ToggleOff -import androidx.compose.material.icons.outlined.ToggleOn -import androidx.compose.material.icons.outlined.VerifiedUser -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonColors -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.LocalContentColor +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -66,12 +22,21 @@ fun PerfIcon( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, contentDescription: String? = getDefaultDesc(imageVector), -) = Icon( - imageVector = imageVector, - modifier = modifier, - contentDescription = contentDescription, - tint = tint -) +) = TooltipBox( + tooltip = { PlainTooltip { Text(text = contentDescription.orEmpty()) } }, + state = rememberTooltipState(), + enableUserInput = !contentDescription.isNullOrEmpty(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Start + ) +) { + Icon( + imageVector = imageVector, + modifier = modifier, + contentDescription = contentDescription, + tint = tint + ) +} @Composable fun PerfIconButton( @@ -106,12 +71,21 @@ fun PerfIcon( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, contentDescription: String? = null, -) = Icon( - painter = painterResource(id), - modifier = modifier, - contentDescription = contentDescription, - tint = tint -) +) = TooltipBox( + tooltip = { PlainTooltip { Text(text = contentDescription.orEmpty()) } }, + state = rememberTooltipState(), + enableUserInput = !contentDescription.isNullOrEmpty(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Start + ) +) { + Icon( + painter = painterResource(id), + modifier = modifier, + contentDescription = contentDescription, + tint = tint + ) +} @Composable fun PerfIconButton( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 857bda9dee..f27de0b3c3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -84,6 +84,7 @@ fun useControlPage(): ScaffoldExt { PerfIconButton( imageVector = PerfIcon.RocketLaunch, onClickLabel = "前往无障碍授权页面", + contentDescription = "无障碍授权", onClick = throttle { mainVm.navigatePage(AuthA11YPageDestination) }, From 1eda35f62878ffa6d59728da179d23420912c371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sat, 22 Nov 2025 12:39:09 +0800 Subject: [PATCH 129/245] perf: detectHiddenMethod --- .../kotlin/li/songe/gkd/shizuku/ShizukuApi.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index e32982ff40..2d8f3305df 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -23,6 +23,7 @@ import li.songe.gkd.util.toast import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper +import java.lang.reflect.Method inline fun safeInvokeMethod( block: () -> T @@ -43,11 +44,16 @@ fun getStubService(name: String, condition: Boolean): ShizukuBinderWrapper? { return ShizukuBinderWrapper(service) } +private fun Method.simpleString(): String { + return "${name}(${parameterTypes.joinToString(",") { it.name }}):${returnType.name}" +} + fun Class<*>.detectHiddenMethod( methodName: String, vararg args: Pair>>, ): Int { - declaredMethods.forEach { method -> + val methodsVal = methods + methodsVal.forEach { method -> if (method.name == methodName) { val types = method.parameterTypes.toList() args.forEach { (value, argTypes) -> @@ -57,7 +63,13 @@ fun Class<*>.detectHiddenMethod( } } } - throw NoSuchMethodException() + val result = methodsVal.filter { it.name == methodName } + if (result.isEmpty()) { + throw NoSuchMethodException("${name}::${methodName} not found") + } else { + LogUtils.d("detectHiddenMethod", *result.map { it.simpleString() }.toTypedArray()) + throw NoSuchMethodException("${name}::${methodName} not match") + } } class ShizukuContext( From 0f086857ee6be27c91b8e62f4d9add5a0097c20a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 23 Nov 2025 17:36:40 +0800 Subject: [PATCH 130/245] perf: PerfTooltipBox --- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 2 + .../kotlin/li/songe/gkd/ui/AppConfigPage.kt | 5 +- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 5 +- .../li/songe/gkd/ui/SubsAppGroupListPage.kt | 7 +- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 5 +- .../songe/gkd/ui/SubsGlobalGroupListPage.kt | 25 +++--- .../li/songe/gkd/ui/component/AnimatedIcon.kt | 12 +-- .../AnimationFloatingActionButton.kt | 47 ++++++----- .../li/songe/gkd/ui/component/PerfIcon.kt | 77 ++++++++++++++----- .../songe/gkd/ui/component/PerfTooltipBox.kt | 25 ++++++ .../li/songe/gkd/ui/component/SettingItem.kt | 5 +- .../li/songe/gkd/ui/home/AppListPage.kt | 5 +- .../li/songe/gkd/ui/home/ControlPage.kt | 3 +- .../kotlin/li/songe/gkd/ui/home/HomePage.kt | 1 + .../li/songe/gkd/ui/home/SubsManagePage.kt | 9 +-- .../li/songe/gkd/ui/icon/BackCloseIcon.kt | 21 ++++- 16 files changed, 166 insertions(+), 88 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 1a41542293..86508f2fd2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -331,6 +331,7 @@ fun AdvancedPage() { .iconTextSize(textStyle = MaterialTheme.typography.titleSmall), imageVector = PerfIcon.Api, tint = MaterialTheme.colorScheme.primary, + contentDescription = "Shizuku 状态", ) } val shizukuGranted by shizukuGrantedState.stateFlow.collectAsState() @@ -532,6 +533,7 @@ fun AdvancedPage() { PerfIcon( modifier = Modifier.size(20.dp), id = SafeR.ic_page_info, + contentDescription = "截屏快照设置", ) } }, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt index 08cacfe750..8b488dcafe 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -276,9 +276,8 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { ) ) }, - content = { - PerfIcon(imageVector = PerfIcon.Add) - } + imageVector = PerfIcon.Add, + contentDescription = "添加规则" ) }, ) { contentPadding -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index 9d179709ad..5aa6e7b8f4 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -269,9 +269,8 @@ fun BlockA11yAppListPage() { onClick = { editable = !editable }, - content = { - PerfIcon(imageVector = PerfIcon.Edit) - } + imageVector = PerfIcon.Edit, + contentDescription = "编辑白名单文本" ) }, ) { contentPadding -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt index ebe193c9e0..6412eca4ac 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt @@ -257,11 +257,8 @@ fun SubsAppGroupListPage( ) ) }, - content = { - PerfIcon( - imageVector = PerfIcon.Add, - ) - } + contentDescription = "添加规则", + imageVector = PerfIcon.Add, ) } }) { contentPadding -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index df56611b8e..32f734e762 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -272,9 +272,8 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { onClick = { editable = !editable }, - content = { - PerfIcon(imageVector = PerfIcon.Edit) - } + imageVector = PerfIcon.Edit, + contentDescription = "编辑禁用名单" ) } ) { contentPadding -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt index 9b3cd3325e..0e590c394d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt @@ -220,19 +220,20 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { }, floatingActionButton = { if (editable) { - AnimationFloatingActionButton(visible = !isSelectedMode, onClick = { - mainVm.navigatePage( - UpsertRuleGroupPageDestination( - subsId = subsItemId, - groupKey = null, - appId = null, + AnimationFloatingActionButton( + visible = !isSelectedMode, + onClick = { + mainVm.navigatePage( + UpsertRuleGroupPageDestination( + subsId = subsItemId, + groupKey = null, + appId = null, + ) ) - ) - }) { - PerfIcon( - imageVector = PerfIcon.Add, - ) - } + }, + imageVector = PerfIcon.Add, + contentDescription = "添加规则" + ) } }, ) { paddingValues -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt index ea58d1e280..7c6624988a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt @@ -4,7 +4,8 @@ import androidx.annotation.DrawableRes import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -23,13 +24,8 @@ fun AnimatedIcon( animation, atEnd, ) - TooltipBox( - tooltip = { PlainTooltip { Text(text = contentDescription.orEmpty()) } }, - state = rememberTooltipState(), - enableUserInput = !contentDescription.isNullOrEmpty(), - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Start - ) + PerfTooltipBox( + tooltipText = contentDescription, ) { Icon( modifier = modifier, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt index f520d56856..bebfc4e7ae 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick @@ -24,12 +25,12 @@ private const val elevationDurationMillis = 50 @Composable fun AnimationFloatingActionButton( - modifier: Modifier = Modifier, - onClickLabel: String? = null, - contentDescription: String? = null, visible: Boolean, onClick: () -> Unit, - content: @Composable () -> Unit, + imageVector: ImageVector, + modifier: Modifier = Modifier, + onClickLabel: String? = null, + contentDescription: String? = getIconDefaultDesc(imageVector), ) { val density = LocalDensity.current val maxTranslationX = remember(density.density) { density.run { 24.dp.toPx() } } @@ -63,23 +64,27 @@ fun AnimationFloatingActionButton( } } if (innerVisible) { - FloatingActionButton( - modifier = modifier - .graphicsLayer( - alpha = percent.value, - translationX = (1f - percent.value) * maxTranslationX - ) - .semantics { - if (contentDescription != null) { - this.contentDescription = contentDescription - } - if (onClickLabel != null) { - this.onClick(label = onClickLabel, action = null) - } + PerfTooltipBox(contentDescription) { + FloatingActionButton( + modifier = modifier + .graphicsLayer( + alpha = percent.value, + translationX = (1f - percent.value) * maxTranslationX + ) + .semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = (defaultElevation.value * 6f).dp), + onClick = throttle(onClick), + content = { + PerfIcon(imageVector = imageVector, contentDescription = null) }, - elevation = FloatingActionButtonDefaults.elevation(defaultElevation = (defaultElevation.value * 6f).dp), - onClick = throttle(onClick), - content = content, - ) + ) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index 05dc32243b..6e4b52c92b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -2,12 +2,56 @@ package li.songe.gkd.ui.component import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.* +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CenterFocusWeak +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Api +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.AutoMode +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Eco +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Equalizer +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.material.icons.outlined.Title +import androidx.compose.material.icons.outlined.ToggleOff +import androidx.compose.material.icons.outlined.ToggleOn +import androidx.compose.material.icons.outlined.VerifiedUser +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -21,14 +65,9 @@ fun PerfIcon( imageVector: ImageVector, modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, - contentDescription: String? = getDefaultDesc(imageVector), -) = TooltipBox( - tooltip = { PlainTooltip { Text(text = contentDescription.orEmpty()) } }, - state = rememberTooltipState(), - enableUserInput = !contentDescription.isNullOrEmpty(), - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Start - ) + contentDescription: String? = getIconDefaultDesc(imageVector), +) = PerfTooltipBox( + tooltipText = contentDescription, ) { Icon( imageVector = imageVector, @@ -45,7 +84,7 @@ fun PerfIconButton( modifier: Modifier = Modifier, enabled: Boolean = true, colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), - contentDescription: String? = getDefaultDesc(imageVector), + contentDescription: String? = getIconDefaultDesc(imageVector), onClickLabel: String? = null, tint: Color = LocalContentColor.current, ) = IconButton( @@ -71,13 +110,8 @@ fun PerfIcon( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, contentDescription: String? = null, -) = TooltipBox( - tooltip = { PlainTooltip { Text(text = contentDescription.orEmpty()) } }, - state = rememberTooltipState(), - enableUserInput = !contentDescription.isNullOrEmpty(), - positionProvider = TooltipDefaults.rememberTooltipPositionProvider( - TooltipAnchorPosition.Start - ) +) = PerfTooltipBox( + tooltipText = contentDescription, ) { Icon( painter = painterResource(id), @@ -112,7 +146,7 @@ fun PerfIconButton( ) } -private fun getDefaultDesc(imageVector: ImageVector): String? = when (imageVector) { +fun getIconDefaultDesc(imageVector: ImageVector): String? = when (imageVector) { PerfIcon.Add -> "添加" PerfIcon.Edit -> "编辑" PerfIcon.Save -> "保存" @@ -128,6 +162,7 @@ private fun getDefaultDesc(imageVector: ImageVector): String? = when (imageVecto PerfIcon.Sort -> "排序筛选" PerfIcon.OpenInNew -> "新页面打开" PerfIcon.ContentCopy -> "复制文本" + PerfIcon.MoreVert -> "更多操作" else -> null } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt new file mode 100644 index 0000000000..a88243ac4e --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt @@ -0,0 +1,25 @@ +package li.songe.gkd.ui.component + +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable + +@Composable +fun PerfTooltipBox(tooltipText: String?, content: @Composable () -> Unit) { + if (tooltipText.isNullOrEmpty()) { + content() + } else { + TooltipBox( + tooltip = { PlainTooltip { Text(text = tooltipText) } }, + state = rememberTooltipState(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Start + ), + content = content, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt index 121241ce0d..9a233a5f84 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt @@ -83,7 +83,10 @@ fun SettingItem( } } if (imageVector != null) { - PerfIcon(imageVector = imageVector) + PerfIcon( + imageVector = imageVector, + contentDescription = null, + ) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index a30299369f..56fd74886e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -277,12 +277,11 @@ fun useAppListPage(): ScaffoldExt { floatingActionButton = { AnimationFloatingActionButton( visible = editWhiteListMode, + contentDescription = "编辑白名单", onClick = { mainVm.navigatePage(EditBlockAppListPageDestination) }, - content = { - PerfIcon(imageVector = PerfIcon.Edit, contentDescription = "编辑白名单") - } + imageVector = PerfIcon.Edit, ) } ) { contentPadding -> diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index f27de0b3c3..891a9b6b8b 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -287,7 +287,8 @@ private fun IconTextCard( .background(MaterialTheme.colorScheme.primaryContainer) .padding(8.dp) .size(24.dp), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, ) Spacer(modifier = Modifier.width(itemHorizontalPadding)) content() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt index 8d63739ced..7821e6974f 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt @@ -75,6 +75,7 @@ fun HomePage() { icon = { PerfIcon( imageVector = page.navItem.icon, + contentDescription = null, ) }, label = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 323ba55d8c..dbf3d5b450 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -408,12 +408,9 @@ fun useSubsManagePage(): ScaffoldExt { mainVm.addOrModifySubs(url) } } - } - ) { - PerfIcon( - imageVector = PerfIcon.Add, - ) - } + }, + imageVector = PerfIcon.Add, + ) }, ) { contentPadding -> val lazyListState = rememberLazyListState() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt index 833c63b29c..887ffb06d3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import li.songe.gkd.ui.component.PerfTooltipBox private fun Animatable.calc(start: Float, end: Float): Float { return start + (end - start) * value @@ -33,7 +34,25 @@ private fun Animatable.calc(start: Float, end: Float): fun BackCloseIcon( backOrClose: Boolean, modifier: Modifier = Modifier, + contentDescription: String = if (backOrClose) "返回" else "关闭", tint: Color = LocalContentColor.current +) = PerfTooltipBox( + tooltipText = contentDescription, +) { + InnerBackCloseIcon( + backOrClose = backOrClose, + modifier = modifier, + contentDescription = contentDescription, + tint = tint, + ) +} + +@Composable +fun InnerBackCloseIcon( + backOrClose: Boolean, + modifier: Modifier, + contentDescription: String, + tint: Color, ) { // https://codepen.io/lisonge/pen/WbNEoPR val percent = remember { Animatable(if (backOrClose) 1f else 0f) } @@ -48,7 +67,7 @@ fun BackCloseIcon( modifier = modifier .size(24.dp) .semantics { - this.contentDescription = if (backOrClose) "返回" else "关闭" + this.contentDescription = contentDescription this.role = Role.Image }, ) { From 62a08016d009adb6a81217c958f166b7d8a0724f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Sun, 23 Nov 2025 17:48:58 +0800 Subject: [PATCH 131/245] feat: change ActionLog date format (#1213) --- app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt b/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt index 4c277d830a..bc21b8fcf6 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt @@ -33,7 +33,7 @@ data class ActionLog( val showActivityId by lazy { getShowActivityId(appId, activityId) } - val date by lazy { ctime.format("HH:mm:ss SSS") } + val date by lazy { ctime.format("MM-dd HH:mm:ss SSS") } @DeleteTable.Entries( DeleteTable(tableName = "click_log") From 4633393188b322aa7556cd18b45d42eac606cba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Mon, 24 Nov 2025 21:58:10 +0800 Subject: [PATCH 132/245] chore: add diff link comment for hidden_api --- .../src/main/java/android/app/IActivityTaskManager.java | 4 +--- hidden_api/src/main/java/android/os/IUserManager.java | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/hidden_api/src/main/java/android/app/IActivityTaskManager.java b/hidden_api/src/main/java/android/app/IActivityTaskManager.java index 6499338d9c..efd3a4ffe0 100644 --- a/hidden_api/src/main/java/android/app/IActivityTaskManager.java +++ b/hidden_api/src/main/java/android/app/IActivityTaskManager.java @@ -17,13 +17,11 @@ public static IActivityTaskManager asInterface(IBinder obj) { } } - // android10 - android11 + // https://diff.songe.li/i/IActivityTaskManager/getTasks List getTasks(int maxNum); - // android12 - android-13.0.0_r15 List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra); - // android-13.0.0_r16+ List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); void registerTaskStackListener(ITaskStackListener listener); diff --git a/hidden_api/src/main/java/android/os/IUserManager.java b/hidden_api/src/main/java/android/os/IUserManager.java index 4729606593..520a16fe30 100644 --- a/hidden_api/src/main/java/android/os/IUserManager.java +++ b/hidden_api/src/main/java/android/os/IUserManager.java @@ -14,9 +14,8 @@ public static IUserManager asInterface(IBinder obj) { } } - // android8 - android10, android-16.0.0_r3 + // https://diff.songe.li/i/IUserManager/getUsers List getUsers(boolean excludeDying); - // android11 - android-16.0.0_r2 List getUsers(boolean excludePartial, boolean excludeDying, boolean excludePreCreated); } From 1c3cadfa30e59d10c4dbdedb44c67cd95c1857a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Tue, 25 Nov 2025 18:13:52 +0800 Subject: [PATCH 133/245] perf: TooltipIconButtonBox --- app/src/main/kotlin/li/songe/gkd/App.kt | 19 ++++ .../main/kotlin/li/songe/gkd/a11y/A11yExt.kt | 13 +-- .../kotlin/li/songe/gkd/ui/AdvancedPage.kt | 15 ++- .../li/songe/gkd/ui/BlockA11yAppListPage.kt | 29 +++--- .../kotlin/li/songe/gkd/ui/SubsAppListPage.kt | 30 +++--- .../gkd/ui/SubsGlobalGroupExcludePage.kt | 29 +++--- .../li/songe/gkd/ui/component/AnimatedIcon.kt | 41 ++++++-- .../AnimationFloatingActionButton.kt | 2 +- .../gkd/ui/component/CustomIconButton.kt | 27 +++++- .../li/songe/gkd/ui/component/PerfIcon.kt | 94 +++++++++---------- ...fTooltipBox.kt => TooltipIconButtonBox.kt} | 9 +- .../li/songe/gkd/ui/home/AppListPage.kt | 32 +++---- .../li/songe/gkd/ui/home/SettingsPage.kt | 15 ++- .../li/songe/gkd/ui/icon/BackCloseIcon.kt | 6 +- .../kotlin/li/songe/gkd/ui/share/LocalExt.kt | 5 + .../kotlin/li/songe/gkd/ui/share/StateExt.kt | 41 ++++++++ .../kotlin/li/songe/gkd/ui/style/Theme.kt | 22 ++++- 17 files changed, 274 insertions(+), 155 deletions(-) rename app/src/main/kotlin/li/songe/gkd/ui/component/{PerfTooltipBox.kt => TooltipIconButtonBox.kt} (59%) diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 66d1b317aa..1a87a138a5 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -11,9 +11,12 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.database.ContentObserver +import android.net.Uri import android.os.PowerManager import android.provider.Settings import android.view.WindowManager +import android.view.accessibility.AccessibilityManager import android.view.inputmethod.InputMethodManager import com.blankj.utilcode.util.LogUtils import com.blankj.utilcode.util.Utils @@ -78,6 +81,10 @@ data class AppMeta( val META by lazy { AppMeta() } +fun contentObserver(listener: () -> Unit) = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) = listener() +} + class App : Application() { companion object { const val START_WAIT_TIME = 3000L @@ -94,6 +101,17 @@ class App : Application() { } } + fun registerObserver( + uri: Uri, + observer: ContentObserver + ) { + contentResolver.registerContentObserver(uri, false, observer) + } + + fun unregisterObserver(observer: ContentObserver) { + contentResolver.unregisterContentObserver(observer) + } + fun getSecureString(name: String): String? = Settings.Secure.getString(contentResolver, name) fun putSecureString(name: String, value: String?): Boolean { return Settings.Secure.putString(contentResolver, name, value) @@ -152,6 +170,7 @@ class App : Application() { val keyguardManager by lazy { app.getSystemService(KEYGUARD_SERVICE) as KeyguardManager } val clipboardManager by lazy { app.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } + val a11yManager by lazy { getSystemService(ACCESSIBILITY_SERVICE) as AccessibilityManager } override fun onCreate() { super.onCreate() diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt index d3723fc265..c70300116d 100644 --- a/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -4,7 +4,6 @@ import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityService.ScreenshotResult import android.accessibilityservice.AccessibilityService.TakeScreenshotCallback import android.content.ComponentName -import android.database.ContentObserver import android.graphics.Bitmap import android.provider.Settings import android.view.Display @@ -13,6 +12,7 @@ import android.view.accessibility.AccessibilityNodeInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.app +import li.songe.gkd.contentObserver import li.songe.gkd.service.A11yService import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.OnSimpleLife @@ -25,18 +25,15 @@ import kotlin.coroutines.suspendCoroutine context(context: OnSimpleLife) fun useEnabledA11yServicesFlow(): StateFlow> { val stateFlow = MutableStateFlow(app.getSecureA11yServices()) - val contextObserver = object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - stateFlow.value = app.getSecureA11yServices() - } + val contextObserver = contentObserver { + stateFlow.value = app.getSecureA11yServices() } - app.contentResolver.registerContentObserver( + app.registerObserver( Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES), - false, contextObserver ) context.onDestroyed { - app.contentResolver.unregisterContentObserver(contextObserver) + app.unregisterObserver(contextObserver) } return stateFlow } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt index 86508f2fd2..f7778f3ab1 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt @@ -68,8 +68,8 @@ import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AuthCard -import li.songe.gkd.ui.component.CustomIconButton import li.songe.gkd.ui.component.CustomOutlinedTextField +import li.songe.gkd.ui.component.PerfCustomIconButton import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfSwitch @@ -523,19 +523,16 @@ fun AdvancedPage() { subtitle = "截屏时保存快照", checked = store.captureScreenshot, suffixIcon = { - CustomIconButton( + PerfCustomIconButton( size = 32.dp, + iconSize = 20.dp, onClickLabel = "打开配置截屏快照弹窗", onClick = throttle { showCaptureScreenshotDlg = true }, - ) { - PerfIcon( - modifier = Modifier.size(20.dp), - id = SafeR.ic_page_info, - contentDescription = "截屏快照设置", - ) - } + id = SafeR.ic_page_info, + contentDescription = "截屏快照设置", + ) }, onCheckedChange = { storeFlow.value = store.copy( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt index 5aa6e7b8f4..185055c2b2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -49,7 +49,7 @@ import li.songe.gkd.service.fixRestartService import li.songe.gkd.store.blockA11yAppListFlow import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.component.AnimatedBooleanContent -import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimatedIconButton import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon @@ -199,22 +199,21 @@ fun BlockA11yAppListPage() { var expanded by remember { mutableStateOf(false) } AnimatedVisibility(!store.blockA11yAppListFollowMatch) { Row { - IconButton(onClick = throttle { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - showSearchBar = false + AnimatedIconButton( + onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + showSearchBar = false + } else { + vm.searchStrFlow.value = "" + } } else { - vm.searchStrFlow.value = "" + showSearchBar = true } - } else { - showSearchBar = true - } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - ) - } + }, + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) PerfIconButton(imageVector = PerfIcon.Sort, onClick = { expanded = true }) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt index 7c7ec31395..bf77369b29 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold @@ -39,7 +38,7 @@ import li.songe.gkd.MainActivity import li.songe.gkd.data.AppConfig import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimatedIconButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.PerfIcon @@ -124,22 +123,21 @@ fun SubsAppListPage( ) } }, actions = { - IconButton(onClick = { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - showSearchBar = false + AnimatedIconButton( + onClick = { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + showSearchBar = false + } else { + vm.searchStrFlow.value = "" + } } else { - vm.searchStrFlow.value = "" + showSearchBar = true } - } else { - showSearchBar = true - } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - ) - } + }, + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) PerfIconButton( imageVector = PerfIcon.Sort, onClick = { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt index 32f734e762..66c22e2f70 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -44,7 +44,7 @@ import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.ui.component.AnimatedBooleanContent -import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimatedIconButton import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon @@ -193,22 +193,21 @@ fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { }, contentFalse = { Row { - IconButton(onClick = { - if (showSearchBar) { - if (searchStr.isEmpty()) { - showSearchBar = false + AnimatedIconButton( + onClick = { + if (showSearchBar) { + if (searchStr.isEmpty()) { + showSearchBar = false + } else { + searchStr = "" + } } else { - searchStr = "" + showSearchBar = true } - } else { - showSearchBar = true - } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - ) - } + }, + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + ) var expanded by remember { mutableStateOf(false) } PerfIconButton( imageVector = PerfIcon.Sort, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt index 7c6624988a..cfedb2cfaa 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -12,26 +13,46 @@ import androidx.compose.ui.graphics.Color import li.songe.gkd.util.SafeR @Composable -fun AnimatedIcon( - modifier: Modifier = Modifier, +private fun AnimatedIcon( + modifier: Modifier, @DrawableRes id: Int, - atEnd: Boolean = false, - tint: Color = LocalContentColor.current, - contentDescription: String? = getIconDesc(id, atEnd), + atEnd: Boolean, + tint: Color, + contentDescription: String?, ) { val animation = AnimatedImageVector.animatedVectorResource(id) val painter = rememberAnimatedVectorPainter( animation, atEnd, ) - PerfTooltipBox( - tooltipText = contentDescription, + Icon( + modifier = modifier, + painter = painter, + contentDescription = contentDescription, + tint = tint, + ) +} + +@Composable +fun AnimatedIconButton( + onClick: () -> Unit, + @DrawableRes id: Int, + modifier: Modifier = Modifier, + atEnd: Boolean = false, + tint: Color = LocalContentColor.current, + contentDescription: String? = getIconDesc(id, atEnd), +) = TooltipIconButtonBox( + contentDescription = contentDescription, +) { + IconButton( + onClick = onClick, ) { - Icon( + AnimatedIcon( + id = id, + atEnd = atEnd, modifier = modifier, - painter = painter, - contentDescription = contentDescription, tint = tint, + contentDescription = contentDescription, ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt index bebfc4e7ae..1bdec50f4c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt @@ -64,7 +64,7 @@ fun AnimationFloatingActionButton( } } if (innerVisible) { - PerfTooltipBox(contentDescription) { + TooltipIconButtonBox(contentDescription) { FloatingActionButton( modifier = modifier .graphicsLayer( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt index edbe45eae6..fa7907e0c5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt @@ -1,5 +1,6 @@ package li.songe.gkd.ui.component +import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -20,7 +21,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable -fun CustomIconButton( +private fun CustomIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, onClickLabel: String? = null, @@ -50,3 +51,27 @@ fun CustomIconButton( CompositionLocalProvider(LocalContentColor provides contentColor, content = content) } } + +@Composable +fun PerfCustomIconButton( + onClick: () -> Unit, + size: Dp, + iconSize: Dp, + onClickLabel: String? = null, + @DrawableRes id: Int, + contentDescription: String? = null, +) = TooltipIconButtonBox( + contentDescription = contentDescription, +) { + CustomIconButton( + size = size, + onClickLabel = onClickLabel, + onClick = onClick, + ) { + PerfIcon( + modifier = Modifier.size(iconSize), + id = id, + contentDescription = contentDescription, + ) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt index 6e4b52c92b..4112539c52 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -66,16 +66,12 @@ fun PerfIcon( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, contentDescription: String? = getIconDefaultDesc(imageVector), -) = PerfTooltipBox( - tooltipText = contentDescription, -) { - Icon( - imageVector = imageVector, - modifier = modifier, - contentDescription = contentDescription, - tint = tint - ) -} +) = Icon( + imageVector = imageVector, + modifier = modifier, + contentDescription = contentDescription, + tint = tint +) @Composable fun PerfIconButton( @@ -87,21 +83,25 @@ fun PerfIconButton( contentDescription: String? = getIconDefaultDesc(imageVector), onClickLabel: String? = null, tint: Color = LocalContentColor.current, -) = IconButton( - modifier = modifier.semantics { - if (onClickLabel != null) { - this.onClick(label = onClickLabel, action = null) - } - }, - enabled = enabled, - onClick = onClick, - colors = colors, +) = TooltipIconButtonBox( + contentDescription = contentDescription, ) { - PerfIcon( - imageVector = imageVector, - contentDescription = contentDescription, - tint = tint, - ) + IconButton( + modifier = modifier.semantics { + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, + enabled = enabled, + onClick = onClick, + colors = colors, + ) { + PerfIcon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = tint, + ) + } } @Composable @@ -110,16 +110,12 @@ fun PerfIcon( modifier: Modifier = Modifier, tint: Color = LocalContentColor.current, contentDescription: String? = null, -) = PerfTooltipBox( - tooltipText = contentDescription, -) { - Icon( - painter = painterResource(id), - modifier = modifier, - contentDescription = contentDescription, - tint = tint - ) -} +) = Icon( + painter = painterResource(id), + modifier = modifier, + contentDescription = contentDescription, + tint = tint +) @Composable fun PerfIconButton( @@ -130,20 +126,24 @@ fun PerfIconButton( colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), contentDescription: String? = null, onClickLabel: String? = null, -) = IconButton( - modifier = modifier.semantics { - if (onClickLabel != null) { - this.onClick(label = onClickLabel, action = null) - } - }, - enabled = enabled, - onClick = onClick, - colors = colors, +) = TooltipIconButtonBox( + contentDescription = contentDescription, ) { - PerfIcon( - id = id, - contentDescription = contentDescription, - ) + IconButton( + modifier = modifier.semantics { + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, + enabled = enabled, + onClick = onClick, + colors = colors, + ) { + PerfIcon( + id = id, + contentDescription = contentDescription, + ) + } } fun getIconDefaultDesc(imageVector: ImageVector): String? = when (imageVector) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TooltipIconButtonBox.kt similarity index 59% rename from app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt rename to app/src/main/kotlin/li/songe/gkd/ui/component/TooltipIconButtonBox.kt index a88243ac4e..07eaf16cb9 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTooltipBox.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TooltipIconButtonBox.kt @@ -7,14 +7,17 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import li.songe.gkd.ui.share.LocalIsTalkbackEnabled @Composable -fun PerfTooltipBox(tooltipText: String?, content: @Composable () -> Unit) { - if (tooltipText.isNullOrEmpty()) { +fun TooltipIconButtonBox(contentDescription: String?, content: @Composable () -> Unit) { + // 视障用户使用 TalkBack 朗读 contentDescription,不需要 Tooltip + if (contentDescription.isNullOrEmpty() || LocalIsTalkbackEnabled.current.collectAsState().value) { content() } else { TooltipBox( - tooltip = { PlainTooltip { Text(text = tooltipText) } }, + tooltip = { PlainTooltip { Text(text = contentDescription) } }, state = rememberTooltipState(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider( TooltipAnchorPosition.Start diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt index 56fd74886e..78adcdf382 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -53,7 +52,7 @@ import li.songe.gkd.data.AppInfo import li.songe.gkd.permission.canQueryPkgState import li.songe.gkd.store.blockMatchAppListFlow import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.ui.component.AnimatedIconButton import li.songe.gkd.ui.component.AnimationFloatingActionButton import li.songe.gkd.ui.component.AppBarTextField import li.songe.gkd.ui.component.AppIcon @@ -190,23 +189,22 @@ fun useAppListPage(): ScaffoldExt { vm.editWhiteListModeFlow.update { !it } }, ) - IconButton(onClick = throttle { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - vm.showSearchBarFlow.value = false + AnimatedIconButton( + onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + vm.showSearchBarFlow.value = false + } else { + vm.searchStrFlow.value = "" + } } else { - vm.searchStrFlow.value = "" + vm.showSearchBarFlow.value = true } - } else { - vm.showSearchBarFlow.value = true - } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - contentDescription = if (showSearchBar) "关闭搜索" else "搜索应用列表", - ) - } + }, + id = SafeR.ic_anim_search_close, + atEnd = showSearchBar, + contentDescription = if (showSearchBar) "关闭搜索" else "搜索应用列表", + ) var expanded by remember { mutableStateOf(false) } PerfIconButton( imageVector = PerfIcon.Sort, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 701cdf3a3e..b8311cb085 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -66,9 +66,9 @@ import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.CustomIconButton import li.songe.gkd.ui.component.CustomOutlinedTextField import li.songe.gkd.ui.component.FullscreenDialog +import li.songe.gkd.ui.component.PerfCustomIconButton import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar @@ -347,17 +347,14 @@ fun useSettingsPage(): ScaffoldExt { showToastInputDlg = true }, suffixIcon = { - CustomIconButton( + PerfCustomIconButton( size = 32.dp, + iconSize = 20.dp, onClickLabel = "打开提示设置弹窗", onClick = throttle { showToastSettingsDlg = true }, - ) { - PerfIcon( - modifier = Modifier.size(20.dp), - id = SafeR.ic_page_info, - contentDescription = "提示设置", - ) - } + id = SafeR.ic_page_info, + contentDescription = "提示设置", + ) }, onCheckedChange = { storeFlow.value = store.copy( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt index 887ffb06d3..6d180e328c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import li.songe.gkd.ui.component.PerfTooltipBox +import li.songe.gkd.ui.component.TooltipIconButtonBox private fun Animatable.calc(start: Float, end: Float): Float { return start + (end - start) * value @@ -36,8 +36,8 @@ fun BackCloseIcon( modifier: Modifier = Modifier, contentDescription: String = if (backOrClose) "返回" else "关闭", tint: Color = LocalContentColor.current -) = PerfTooltipBox( - tooltipText = contentDescription, +) = TooltipIconButtonBox( + contentDescription = contentDescription, ) { InnerBackCloseIcon( backOrClose = backOrClose, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt index 4b46fadd14..f8087dd38c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui.share import androidx.compose.runtime.compositionLocalOf import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.MainViewModel @@ -16,3 +17,7 @@ val LocalMainViewModel = compositionLocalOf { val LocalDarkTheme = compositionLocalOf { error("not found LocalDarkTheme") } + +val LocalIsTalkbackEnabled = compositionLocalOf> { + error("not found LocalIsTalkbackEnabled") +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt index 345f1a6b4d..8032f0cb4a 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt @@ -1,13 +1,16 @@ package li.songe.gkd.ui.share import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import li.songe.gkd.util.runMainPost @Composable @@ -60,3 +63,41 @@ fun MutableStateFlow.asMutableStateFlow( override fun tryEmit(value: S) = source.tryEmit(setter(value)) override fun resetReplayCache() = source.resetReplayCache() } + +abstract class UiSharedStateFlow { + protected abstract fun getter(): T + protected abstract fun onVisible() + protected abstract fun onHidden() + + private val stateFlow by lazy { MutableStateFlow(getter()) } + private var count = 0 + private var alive = false + + fun refresh() { + stateFlow.value = getter() + } + + @Composable + fun collectAsState(): State { + DisposableEffect(null) { + if (count == 0 && !alive) { + alive = true + onVisible() + } + count++ + onDispose { + count-- + if (count == 0) { + runMainPost(1000) { + if (count == 0 && alive) { + alive = false + onHidden() + } + } + } + } + } + return stateFlow.collectAsState() + } + +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt index 8370568b3e..766379abc6 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt @@ -1,5 +1,6 @@ package li.songe.gkd.ui.style +import android.view.accessibility.AccessibilityManager import androidx.activity.compose.LocalActivity import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -12,6 +13,7 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -19,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.core.view.WindowInsetsControllerCompat +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map @@ -26,6 +29,7 @@ import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.store.storeFlow import li.songe.gkd.ui.share.LocalDarkTheme +import li.songe.gkd.ui.share.LocalIsTalkbackEnabled import li.songe.gkd.util.AndroidTarget private val LightColorScheme = lightColorScheme() @@ -69,7 +73,23 @@ fun AppTheme( } } } - CompositionLocalProvider(LocalDarkTheme provides darkTheme) { + + val isTalkbackEnabledFlow = remember { + MutableStateFlow(app.a11yManager.isTouchExplorationEnabled) + } + DisposableEffect(null) { + val listener = AccessibilityManager.TouchExplorationStateChangeListener { + isTalkbackEnabledFlow.value = it + } + app.a11yManager.addTouchExplorationStateChangeListener(listener) + onDispose { + app.a11yManager.removeTouchExplorationStateChangeListener(listener) + } + } + CompositionLocalProvider( + LocalDarkTheme provides darkTheme, + LocalIsTalkbackEnabled provides isTalkbackEnabledFlow + ) { MaterialTheme( colorScheme = colorScheme.animation(), content = content, From e645d10ef766d3e11ee81d064b62db78e6260008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 4 Dec 2025 15:42:40 +0800 Subject: [PATCH 134/245] chore: rm unused api --- hidden_api/src/main/java/android/app/AppOpsManagerHidden.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java b/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java index 3bb9622cac..a8ecff547f 100644 --- a/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java +++ b/hidden_api/src/main/java/android/app/AppOpsManagerHidden.java @@ -23,8 +23,4 @@ public class AppOpsManagerHidden { @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) public static int OP_FOREGROUND_SERVICE_SPECIAL_USE; - - public static String opToPublicName(int op) { - throw new RuntimeException("Stub"); - } } From fa9067e7738eff1f1bca7cf804ad45ffcee027bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 4 Dec 2025 16:12:58 +0800 Subject: [PATCH 135/245] chore: update libs --- gradle/libs.versions.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a439c7876..ad960e17d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] kotlin = "2.2.21" -ksp = "2.3.2" +ksp = "2.3.3" agp = "8.13.1" -compose = "1.9.4" -room = "2.8.3" +compose = "1.10.0" +room = "2.8.4" paging = "3.3.6" -ktor = "3.3.2" +ktor = "3.3.3" atomicfu = "0.29.0" destinations = "2.3.0" coil = "3.3.0" @@ -34,12 +34,12 @@ compose_tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "co compose_junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } compose_icons = "androidx.compose.material:material-icons-extended:1.7.8" compose_material3 = "androidx.compose.material3:material3:1.4.0" -compose_activity = "androidx.activity:activity-compose:1.11.0" +compose_activity = "androidx.activity:activity-compose:1.12.1" compose_navigation = "androidx.navigation:navigation-compose:2.9.6" androidx_appcompat = "androidx.appcompat:appcompat:1.7.1" androidx_core_ktx = "androidx.core:core-ktx:1.17.0" -androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.9.4" -androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.9.4" +androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:2.10.0" +androidx_lifecycle_service = "androidx.lifecycle:lifecycle-service:2.10.0" androidx_junit = "androidx.test.ext:junit:1.3.0" androidx_annotation = "androidx.annotation:annotation:1.9.1" androidx_espresso = "androidx.test.espresso:espresso-core:3.7.0" @@ -67,7 +67,7 @@ loc_annotation = { module = "li.songe.loc:loc-annotation", version.ref = "loc" } reorderable = "sh.calvin.reorderable:reorderable:3.0.0" exp4j = "net.objecthunter:exp4j:0.4.8" toaster = "com.github.getActivity:Toaster:13.8" -permissions = "com.github.getActivity:XXPermissions:26.5" +permissions = "com.github.getActivity:XXPermissions:28.0" json5 = "li.songe:json5:0.5.0" utilcodex = "com.blankj:utilcodex:1.31.1" activityResultLauncher = "com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2" From 67659dcd8577d3afddf17994cd90515c09d9b3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E5=88=BA=E8=9E=88?= Date: Thu, 4 Dec 2025 16:15:15 +0800 Subject: [PATCH 136/245] fix: sync activity background to theme --- app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt | 5 +++++ app/src/main/res/values/themes.xml | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt index 766379abc6..061c807fbc 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.core.view.WindowInsetsControllerCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -72,6 +73,10 @@ fun AppTheme( isAppearanceLightStatusBars = !darkTheme } } + val bg = colorScheme.background.toArgb() + LaunchedEffect(darkTheme, bg) { + activity.window.decorView.setBackgroundColor(bg) + } } val isTalkbackEnabledFlow = remember { diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 44830b9880..4ea5cc9574 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,6 +1,8 @@ - + - + + + + +