diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 335aed9974..d166490986 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,16 +11,14 @@ body: 1. 请 **确保** 您已经查阅了 [GKD 官方文档](https://gkd.li) 以及 [常见问题](https://gkd.li/guide/faq) 2. 请 **判断** 是不是第三方规则订阅的问题,如果是,你应该向规则提供者反馈,而不是在这里提交,**此处只接受 GKD 应用本体的问题** 3. 请 **确保** 已有的 [问题](https://github.com/gkd-kit/gkd/issues?q=is%3Aissue) 或 [讨论](https://github.com/orgs/gkd-kit/discussions?discussions_q=) 中没有人提交过相似问题,否则请在该问题下进行讨论 - 4. 请 **不要** 开启重复相关的 issue,这将导致别人搜索 issue 时出现无关的低质量信息,否则你的问题将会被直接关闭甚至删除 - 5. 请 **确保** 你的问题能在 [releases](https://github.com/gkd-kit/gkd/releases/latest) 发布的最新版本(包含测试版本)上复现 (如果不是请先更新到最新版本复现后再提交问题) - 6. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索 + 4. 请 **确保** 你的问题能在 [releases](https://github.com/gkd-kit/gkd/releases/latest) 发布的最新版本(包含测试版本)上复现 (如果不是请先更新到最新版本复现后再提交问题) - type: textarea id: log-file attributes: label: | 日志文件 description: | - 主页-设置-关于-日志,上传日志文件或生成链接并粘贴到下面的输入框\ + 首页-设置-关于-日志,上传日志文件或生成链接并粘贴到下面的输入框\ 任何问题都需要提供日志文件. 否则将直接关闭,请不要纯发文字/截图/视频 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index df799ae562..0ef45e71e3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -11,9 +11,7 @@ body: 1. GKD 默认不提供任何规则,你可以查看 [GKD 官方文档](https://gkd.li) 后自行编写规则或者导入远程订阅,请不要再提出类似想要XXX规则这种问题 2. 请 **判断** 是不是第三方规则订阅的功能请求,如果是,你应该向规则提供者反馈,而不是在这里提交,**此处只接受 GKD 应用本体的功能请求** 3. 请 **确保** 已有的 [问题](https://github.com/gkd-kit/gkd/issues?q=is%3Aissue) 或 [讨论](https://github.com/orgs/gkd-kit/discussions?discussions_q=) 中没有人提交过相似问题,否则请在该问题下进行讨论 - 4. 请 **不要** 开启重复相关的 issue,这将导致别人搜索 issue 时出现无关的低质量信息,否则你的问题将会被直接关闭甚至删除 - 5. 请 **确保** 你想要的功能在 [releases](https://github.com/gkd-kit/gkd/releases/latest) 发布的最新版本(包含测试版本)上没有找到 (如果不是请先更新到最新版本验证后再提交问题) - 6. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索 + 4. 请 **确保** 你想要的功能在 [releases](https://github.com/gkd-kit/gkd/releases/latest) 发布的最新版本(包含测试版本)上没有找到 (如果不是请先更新到最新版本验证后再提交问题) - type: textarea id: feature-description attributes: diff --git a/.github/workflows/Build-Apk.yml b/.github/workflows/Build-Apk.yml index ef37ed6893..3c6152c0fa 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 }} @@ -35,15 +35,17 @@ jobs: echo GKD_KEY_ALIAS='${{ secrets.GKD_KEY_ALIAS }}' >> gradle.properties echo GKD_KEY_PASSWORD='${{ secrets.GKD_KEY_PASSWORD }}' >> gradle.properties + - run: echo GKD_RENAME_APK_FLAG=1 >> gradle.properties + - run: chmod 777 ./gradlew - run: ./gradlew app:assembleGkdRelease - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: - name: release - path: app/build/outputs/apk/gkd/release + archive: false + path: app/build/outputs/apk/gkd/release/*.apk - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: outputs path: app/build/outputs diff --git a/.github/workflows/Build-Release.yml b/.github/workflows/Build-Release.yml index c62e63d799..b8839b5a74 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@v6 - - 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 }} @@ -39,22 +39,22 @@ jobs: - run: chmod 777 ./gradlew - run: ./gradlew app:assembleGkdRelease app:bundlePlayRelease - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release path: app/build/outputs/apk/gkd/release - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: playRelease path: app/build/outputs/bundle/playRelease - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: outputs path: app/build/outputs - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: CHANGELOG.md path: CHANGELOG.md @@ -64,12 +64,17 @@ jobs: permissions: write-all runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 + with: + name: outputs + path: outputs + + - uses: actions/download-artifact@v8 with: name: release path: release - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: CHANGELOG.md @@ -93,3 +98,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/.gitignore b/.gitignore index 9ff2e57481..9da652c2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ local.properties *.jks *.keystore -/_assets +/.cache /.kotlin /gradle/libs.versions.updates.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c92c638d..7374d1603e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,6 @@ -# v1.10.4 +# 更新内容 -以下是本次更新的主要内容 - -## 优化和修复 - -- 修复选择器使用某些字段时查询失败 -- 修复应用打开时长按控制中心图标无法跳转 -- 修复规则有时不参与匹配的问题 -- 优化新增 [resetMatch](https://gkd.li/api/interfaces/RawCommonProps#resetmatch) 字段场景 -- 优化适配 android 16 -- 其他优化和修复 +- 修复某些场景下界面识别更新不及时导致的规则误触 ## 更新方式 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) | ## 订阅 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d5054484fa..33cdc4c470 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.variant.impl.VariantOutputImpl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import kotlin.reflect.full.declaredMemberProperties @@ -17,7 +18,9 @@ data class GitInfo( val commitId: String, val commitTime: String, val tagName: String?, -) +) { + val versionNameSuffix get() = if (tagName == null) ("-" + commitId.take(7)) else null +} val gitInfo = GitInfo( commitId = "git rev-parse HEAD".runCommand(), @@ -47,11 +50,13 @@ val debugSuffixPairList by lazy { 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.remap) + alias(libs.plugins.loc) } android { @@ -64,8 +69,8 @@ android { targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" - versionCode = 65 - versionName = "1.10.4" + versionCode = 90 + versionName = "1.12.0-beta.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -87,13 +92,18 @@ android { buildFeatures { compose = true aidl = true + resValues = true } - val gkdSigningConfig = signingConfigs.create("gkd") { - storeFile = file(project.properties["GKD_STORE_FILE"] as String) - storePassword = project.properties["GKD_STORE_PASSWORD"].toString() - keyAlias = project.properties["GKD_KEY_ALIAS"].toString() - keyPassword = project.properties["GKD_KEY_PASSWORD"].toString() + val gkdSigningConfig = if (project.hasProperty("GKD_STORE_FILE")) { + signingConfigs.create("gkd") { + storeFile = file(project.properties["GKD_STORE_FILE"] as String) + storePassword = project.findProperty("GKD_STORE_PASSWORD")?.toString() + keyAlias = project.findProperty("GKD_KEY_ALIAS")?.toString() + keyPassword = project.findProperty("GKD_KEY_PASSWORD")?.toString() + } + } else { + signingConfigs.getByName("debug") } val playSigningConfig = if (project.hasProperty("PLAY_STORE_FILE")) { @@ -104,14 +114,12 @@ android { keyPassword = project.properties["PLAY_KEY_PASSWORD"].toString() } } else { - null + gkdSigningConfig } buildTypes { all { - if (gitInfo.tagName == null) { - versionNameSuffix = "-${gitInfo.commitId.substring(0, 7)}" - } + versionNameSuffix = gitInfo.versionNameSuffix } release { isMinifyEnabled = true @@ -125,6 +133,7 @@ android { debug { signingConfig = gkdSigningConfig applicationIdSuffix = ".debug" + resValue("color", "better_black", "#FF5D92") debugSuffixPairList.onEach { (key, value) -> resValue("string", key, "$value-debug") } @@ -138,11 +147,11 @@ android { resValue("bool", "is_accessibility_tool", "true") } create("play") { - signingConfig = playSigningConfig ?: gkdSigningConfig + signingConfig = playSigningConfig resValue("bool", "is_accessibility_tool", "false") } all { - dimension = flavorDimensionList.first() + dimension = flavorDimensions.first() manifestPlaceholders["channel"] = name } } @@ -151,7 +160,7 @@ android { targetCompatibility = rootProject.ext["android.javaVersion"] as JavaVersion } dependenciesInfo.includeInApk = false - packagingOptions.resources.excludes += setOf( + packaging.resources.excludes += setOf( // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 "META-INF/**", "**/attach_hotspot_windows.dll", @@ -164,11 +173,21 @@ android { ) } +if (project.hasProperty("GKD_RENAME_APK_FLAG")) { + androidComponents.onVariants { variant -> + variant.outputs.onEach { output -> + output as VariantOutputImpl + output.outputFileName = "gkd-v${output.versionName.get()}.apk" + } + } +} + kotlin { compilerOptions { 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", @@ -177,6 +196,9 @@ kotlin { "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", + "-Xcontext-parameters", + "-Xexplicit-backing-fields", + "-XXLanguage:+MultiDollarInterpolation", ) } } @@ -185,14 +207,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") @@ -201,6 +215,10 @@ composeCompiler { ) } +loc { + template = "{packageName}.{methodName}({fileName}:{lineNumber})" +} + dependencies { implementation(libs.kotlin.stdlib) @@ -209,6 +227,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) @@ -221,7 +240,10 @@ dependencies { implementation(libs.compose.activity) implementation(libs.compose.material3) - implementation(libs.compose.navigation) + + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) @@ -251,14 +273,12 @@ dependencies { implementation(libs.google.accompanist.drawablepainter) + implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) + // https://github.com/Kotlin/kotlinx-atomicfu/issues/145 + implementation(libs.kotlinx.atomicfu) - implementation(libs.utilcodex) implementation(libs.activityResultLauncher) - implementation(libs.floatingBubbleView) - - implementation(libs.destinations.core) - ksp(libs.destinations.ksp) implementation(libs.reorderable) @@ -267,13 +287,16 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.network) implementation(libs.coil.gif) + implementation(libs.telephoto.zoomable) implementation(libs.exp4j) implementation(libs.toaster) implementation(libs.permissions) + implementation(libs.device) implementation(libs.json5) + compileOnly(libs.loc.annotation) implementation(libs.kevinnzouWebview) -} \ No newline at end of file +} 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/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/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/gkd/AndroidManifest.xml b/app/src/gkd/AndroidManifest.xml index d01beaf94f..410b074c73 100644 --- a/app/src/gkd/AndroidManifest.xml +++ b/app/src/gkd/AndroidManifest.xml @@ -1,5 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a9078c9a0b..7c9aeb88a6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,44 +2,40 @@ + - + - - - - - - - + tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" /> - - - - + + + android:theme="@style/AppTheme"> - - - - - - + @@ -99,6 +90,14 @@ + + + + + - + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" + tools:ignore="AccessibilityPolicy"> @@ -133,11 +132,11 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -207,36 +264,51 @@ android:icon="@drawable/ic_flash_on" android:label="@string/rule_match" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + + + - - - - - - - + android:icon="@drawable/ic_layers" + android:label="@string/record_activity" + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + + - + + + + + android:name=".service.EventTileService" + android:exported="true" + android:icon="@drawable/ic_event_list" + android:label="@string/record_a11y_event" + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> + + + + + + + diff --git a/app/src/main/aidl/li/songe/gkd/shizuku/CommandResult.aidl b/app/src/main/aidl/li/songe/gkd/shizuku/CommandResult.aidl new file mode 100644 index 0000000000..d68cd459e6 --- /dev/null +++ b/app/src/main/aidl/li/songe/gkd/shizuku/CommandResult.aidl @@ -0,0 +1,3 @@ +package li.songe.gkd.shizuku; + +parcelable CommandResult; diff --git a/app/src/main/aidl/li/songe/gkd/shizuku/IUserService.aidl b/app/src/main/aidl/li/songe/gkd/shizuku/IUserService.aidl index 5c2fb1dcfe..d269d60d62 100644 --- a/app/src/main/aidl/li/songe/gkd/shizuku/IUserService.aidl +++ b/app/src/main/aidl/li/songe/gkd/shizuku/IUserService.aidl @@ -1,9 +1,15 @@ package li.songe.gkd.shizuku; +import android.graphics.Bitmap; +import android.graphics.Rect; +import li.songe.gkd.shizuku.CommandResult; + interface IUserService { void destroy() = 16777114; // Destroy method defined by Shizuku server - - void exit() = 1; // Exit method defined by user - - String execCommand(String command) = 2; -} \ No newline at end of file + void exit() = 1; + CommandResult execCommand(String command) = 2; + Bitmap takeScreenshot1(int width, int height) = 3; + Bitmap takeScreenshot2(in Rect crop, int rotation) = 4; + Bitmap takeScreenshot3(in Rect crop) = 5; + int killLegacyService() = 6; +} diff --git a/app/src/main/kotlin/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt b/app/src/main/kotlin/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt index 147e23f0bb..b72096efe9 100644 --- a/app/src/main/kotlin/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt +++ b/app/src/main/kotlin/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt @@ -2,6 +2,5 @@ package com.google.android.accessibility.selecttospeak import li.songe.gkd.service.A11yService -// https://github.com/ven-coder/Assists // https://github.com/ven-coder/Assists/issues/12#issuecomment-2684469065 class SelectToSpeakService : A11yService() diff --git a/app/src/main/kotlin/li/songe/gkd/App.kt b/app/src/main/kotlin/li/songe/gkd/App.kt index 2af27401e8..953cf560fe 100644 --- a/app/src/main/kotlin/li/songe/gkd/App.kt +++ b/app/src/main/kotlin/li/songe/gkd/App.kt @@ -3,38 +3,49 @@ 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.ComponentName import android.content.Context -import android.content.Context.ACTIVITY_SERVICE +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.os.Build +import android.hardware.display.DisplayManager +import android.hardware.input.InputManager +import android.net.Uri +import android.os.PowerManager 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 android.util.Log +import android.view.Display +import android.view.WindowManager +import android.view.accessibility.AccessibilityManager +import android.view.inputmethod.InputMethodManager +import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.delay import kotlinx.serialization.Serializable +import li.songe.gkd.a11y.initA11yFeat +import li.songe.gkd.data.CrashData import li.songe.gkd.data.selfAppInfo -import li.songe.gkd.debug.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.service.initA11yWhiteAppList import li.songe.gkd.shizuku.initShizuku import li.songe.gkd.store.initStore -import li.songe.gkd.util.SafeR +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.PKG_FLAGS +import li.songe.gkd.util.deviceInfoDesc import li.songe.gkd.util.initAppState import li.songe.gkd.util.initSubsState +import li.songe.gkd.util.initToast import li.songe.gkd.util.launchTry -import li.songe.gkd.util.setReactiveToastStyle -import li.songe.gkd.util.toJson5String import li.songe.gkd.util.toast import org.lsposed.hiddenapibypass.HiddenApiBypass -import rikka.shizuku.Shizuku +import kotlin.system.exitProcess val appScope by lazy { MainScope() } @@ -54,8 +65,8 @@ 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 } +// 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 data class AppMeta( @@ -67,102 +78,172 @@ data class AppMeta( val versionCode: Int = selfAppInfo.versionCode, val versionName: String = selfAppInfo.versionName!!, val appId: String = app.packageName!!, - val appName: String = app.getString(SafeR.app_name) + val appName: String = app.getString(R.string.app_name) ) { 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() } +fun contentObserver(listener: () -> Unit) = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) = listener() +} + class App : Application() { + companion object { + const val START_WAIT_TIME = 3000L + } + + init { + innerApp = this + } + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (AndroidTarget.P) { HiddenApiBypass.addHiddenApiExemptions("L") } } - val startTime = System.currentTimeMillis() + fun registerObserver( + uri: Uri, + observer: ContentObserver + ) { + contentResolver.registerContentObserver(uri, false, observer) + } - override fun onCreate() { - super.onCreate() - innerApp = this - Utils.init(this) + fun unregisterObserver(observer: ContentObserver) { + contentResolver.unregisterContentObserver(observer) + } - val errorHandler = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { t, e -> - LogUtils.d("UncaughtExceptionHandler", t, e) - errorHandler?.uncaughtException(t, e) - } + fun getSecureString(name: String): String? = Settings.Secure.getString(contentResolver, name) + fun putSecureString(name: String, value: String?): Boolean { + return Settings.Secure.putString(contentResolver, name, value) + } - Toaster.init(this) - setReactiveToastStyle() + fun putSecureInt(name: String, value: Int): Boolean { + return Settings.Secure.putInt(contentResolver, name, value) + } - LogUtils.getConfig().apply { - setConsoleSwitch(META.debuggable) - saveDays = 7 - isLog2FileSwitch = true - } - LogUtils.d( - "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() - } - } + fun getSecureA11yServices(): MutableSet { + val value = getSecureString(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + if (value.isNullOrEmpty()) return mutableSetOf() + return value.split( + ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR + ).mapNotNull { ComponentName.unflattenFromString(it) }.toHashSet() + } + + fun putSecureA11yServices(services: Set) { + putSecureString( + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + services.joinToString(ENABLED_ACCESSIBILITY_SERVICES_SEPARATOR.toString()) { it.flattenToShortString() } ) - 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() + } + + fun resolveAppId(intent: Intent): String? { + 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) { + intent.addCategory(category) } + return resolveAppId(intent) } -} -val a11yServiceEnabledFlow by lazy { MutableStateFlow(getA11yServiceEnabled()) } -private fun getA11yServiceEnabled(): Boolean { - val value = try { - Settings.Secure.getString( - app.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + fun startLaunchActivity() { + val intent = packageManager.getLaunchIntentForPackage(META.appId)!! + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TOP + or Intent.FLAG_ACTIVITY_CLEAR_TASK ) - } catch (_: Exception) { - null + startActivity(intent) } - if (value.isNullOrEmpty()) return false - val colonSplitter = TextUtils.SimpleStringSplitter(':') - colonSplitter.setString(value) - while (colonSplitter.hasNext()) { - if (ComponentName.unflattenFromString(colonSplitter.next()) == A11yService.a11yComponentName) { - return true + + fun checkGrantedPermission(permission: String) = ContextCompat.checkSelfPermission( + this, + permission, + ) == PackageManager.PERMISSION_GRANTED + + val startTime = System.currentTimeMillis() + var justStarted: Boolean = true + get() { + if (field) { + field = System.currentTimeMillis() - startTime < START_WAIT_TIME + } + return field + } + + 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 inputManager by lazy { app.getSystemService(INPUT_SERVICE) as InputManager } + val windowManager by lazy { app.getSystemService(WINDOW_SERVICE) as WindowManager } + val displayManager by lazy { app.getSystemService(DISPLAY_SERVICE) as DisplayManager } + 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 } + + val compatDisplay: Display + get() = if (AndroidTarget.R) { + displayManager.getDisplay(Display.DEFAULT_DISPLAY) + } else { + @Suppress("DEPRECATION") + windowManager.defaultDisplay + } + + override fun onCreate() { + super.onCreate() + LogUtils.d() + Thread.setDefaultUncaughtExceptionHandler { t, e -> + toast(e.message ?: e.toString()) + LogUtils.d("UncaughtExceptionHandler", t, e) + val mtime = System.currentTimeMillis() + appScope.launchTry(Dispatchers.IO) { + CrashData( + id = mtime, + mtime = mtime, + device = deviceInfoDesc, + androidVersionCode = android.os.Build.VERSION.SDK_INT, + androidVersionName = android.os.Build.VERSION.RELEASE, + versionCode = META.versionCode, + versionName = META.versionName, + name = e::class.java.name, + message = e.message, + thread = t.name, + stackTrace = Log.getStackTraceString(e), + ).save() + delay(1500) + if (isActivityVisible) { + startLaunchActivity() + } + android.os.Process.killProcess(android.os.Process.myPid()) + exitProcess(0) + } } + initToast() + initStore() + initChannel() + initAppState() + initA11yFeat() + initShizuku() + initSubsState() + initA11yWhiteAppList() + 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 fbbef05f7e..d5fea100e1 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -1,20 +1,22 @@ package li.songe.gkd import android.app.Activity -import android.app.ActivityManager -import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.compose.animation.core.AnimationConstants +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith 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.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 @@ -23,71 +25,132 @@ 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 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 +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope -import androidx.navigation.compose.rememberNavController +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import com.dylanc.activityresult.launcher.PickContentLauncher import com.dylanc.activityresult.launcher.StartActivityLauncher -import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.generated.NavGraphs -import com.ramcosta.composedestinations.generated.destinations.AuthA11YPageDestination -import com.ramcosta.composedestinations.utils.currentDestinationAsState +import com.dylanc.activityresult.launcher.launchForResult 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 -import li.songe.gkd.debug.FloatingService -import li.songe.gkd.debug.HttpService -import li.songe.gkd.debug.ScreenshotService +import li.songe.gkd.a11y.topActivityFlow +import li.songe.gkd.a11y.updateSystemDefaultAppId +import li.songe.gkd.a11y.updateTopActivity 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.fixRestartService -import li.songe.gkd.service.updateDefaultInputAppId -import li.songe.gkd.service.updateLauncherAppId +import li.songe.gkd.service.StatusService +import li.songe.gkd.service.fixRestartAutomatorService +import li.songe.gkd.service.updateTopTaskAppId +import li.songe.gkd.shizuku.automationRegisteredExceptionFlow +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.A11YScopeAppListRoute +import li.songe.gkd.ui.A11yEventLogPage +import li.songe.gkd.ui.A11yEventLogRoute +import li.songe.gkd.ui.A11yScopeAppListPage +import li.songe.gkd.ui.AboutPage +import li.songe.gkd.ui.AboutRoute +import li.songe.gkd.ui.ActionLogPage +import li.songe.gkd.ui.ActionLogRoute +import li.songe.gkd.ui.ActivityLogPage +import li.songe.gkd.ui.ActivityLogRoute +import li.songe.gkd.ui.AdvancedPage +import li.songe.gkd.ui.AdvancedPageRoute +import li.songe.gkd.ui.AppConfigPage +import li.songe.gkd.ui.AppConfigRoute +import li.songe.gkd.ui.AppOpsAllowPage +import li.songe.gkd.ui.AppOpsAllowRoute +import li.songe.gkd.ui.AuthA11yPage +import li.songe.gkd.ui.AuthA11yRoute +import li.songe.gkd.ui.BlockA11yAppListPage +import li.songe.gkd.ui.BlockA11yAppListRoute +import li.songe.gkd.ui.CrashReportPage +import li.songe.gkd.ui.CrashReportRoute +import li.songe.gkd.ui.EditBlockAppListPage +import li.songe.gkd.ui.EditBlockAppListRoute +import li.songe.gkd.ui.ImagePreviewPage +import li.songe.gkd.ui.ImagePreviewRoute +import li.songe.gkd.ui.SlowGroupPage +import li.songe.gkd.ui.SlowGroupRoute +import li.songe.gkd.ui.SnapshotPage +import li.songe.gkd.ui.SnapshotPageRoute +import li.songe.gkd.ui.SubsAppGroupListPage +import li.songe.gkd.ui.SubsAppGroupListRoute +import li.songe.gkd.ui.SubsAppListPage +import li.songe.gkd.ui.SubsAppListRoute +import li.songe.gkd.ui.SubsCategoryGroupPage +import li.songe.gkd.ui.SubsCategoryGroupRoute +import li.songe.gkd.ui.SubsCategoryPage +import li.songe.gkd.ui.SubsCategoryRoute +import li.songe.gkd.ui.SubsGlobalGroupExcludePage +import li.songe.gkd.ui.SubsGlobalGroupExcludeRoute +import li.songe.gkd.ui.SubsGlobalGroupListPage +import li.songe.gkd.ui.SubsGlobalGroupListRoute +import li.songe.gkd.ui.UpsertRuleGroupPage +import li.songe.gkd.ui.UpsertRuleGroupRoute +import li.songe.gkd.ui.WebViewPage +import li.songe.gkd.ui.WebViewRoute import li.songe.gkd.ui.component.BuildDialog -import li.songe.gkd.ui.component.ShareDataDialog +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.component.ShareLogDlg 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.theme.AppTheme +import li.songe.gkd.ui.component.TextDialog +import li.songe.gkd.ui.home.HomePage +import li.songe.gkd.ui.home.HomeRoute +import li.songe.gkd.ui.share.FixedWindowInsets +import li.songe.gkd.ui.share.LocalMainViewModel +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.LogUtils 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 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 import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -import kotlin.reflect.KClass +import kotlin.concurrent.Volatile import kotlin.reflect.jvm.jvmName class MainActivity : ComponentActivity() { @@ -96,18 +159,89 @@ class MainActivity : ComponentActivity() { val launcher by lazy { StartActivityLauncher(this) } val pickContentLauncher by lazy { PickContentLauncher(this) } + val imeFullHiddenFlow = MutableStateFlow(true) + val imePlayingFlow = MutableStateFlow(false) + + private val imeVisible: Boolean + get() = ViewCompat.getRootWindowInsets(window.decorView) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true // fix #1315 + + var topBarWindowInsets by mutableStateOf(WindowInsets(top = BarUtils.getStatusBarHeight())) + + private fun watchKeyboardVisible() { + if (AndroidTarget.R) { + ViewCompat.setWindowInsetsAnimationCallback( + window.decorView, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + imePlayingFlow.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 } + imePlayingFlow.update { false } + super.onEnd(animation) + } + }) + } else { + KeyboardUtils.registerSoftInputChangedListener(window) { height -> + // onEnd + imeFullHiddenFlow.update { height == 0 } + } + } + } + + suspend fun hideSoftInput(): Boolean { + if (!imeFullHiddenFlow.updateAndGet { !imeVisible }) { + 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 + } + + suspend fun pickFile(contentType: String): Uri? { + val u = launcher.launchForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = contentType + }).data?.data + if (u == null) { + toast("未选择文件") + } + return u + } + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() fixSomeProblems() super.onCreate(savedInstanceState) + LogUtils.d() mainVm launcher pickContentLauncher - ManageService.autoStart() lifecycleScope.launch { - storeFlow.map(lifecycleScope) { s -> s.excludeFromRecents }.collect { - activityManager.appTasks.forEach { task -> + storeFlow.mapState(lifecycleScope) { s -> s.excludeFromRecents }.collect { + app.activityManager.appTasks.forEach { task -> task.setExcludeFromRecents(it) } } @@ -116,21 +250,71 @@ class MainActivity : ComponentActivity() { mainVm.handleIntent(it) intent = null } + watchKeyboardVisible() + StatusService.autoStart() + if (storeFlow.value.enableBlockA11yAppList) { + updateTopTaskAppId(META.appId) + } setContent { - val navController = rememberNavController() - mainVm.navController = navController + val latestInsets = TopAppBarDefaults.windowInsets + val density = LocalDensity.current + if (latestInsets.getTop(density) > topBarWindowInsets.getTop(density)) { + topBarWindowInsets = FixedWindowInsets(latestInsets) + } CompositionLocalProvider( - LocalNavController provides navController, LocalMainViewModel provides mainVm ) { AppTheme { - DestinationsNavHost( - navController = navController, - navGraph = NavGraphs.root + NavDisplay( + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + backStack = mainVm.backStack, + onBack = mainVm::popPage, + entryProvider = entryProvider { + entry { HomePage() } + entry { AuthA11yPage() } + entry { AboutPage() } + entry { BlockA11yAppListPage() } + entry { AdvancedPage() } + entry { SnapshotPage() } + entry { AppOpsAllowPage() } + entry { A11yScopeAppListPage() } + entry { ActivityLogPage() } + entry { A11yEventLogPage() } + entry { EditBlockAppListPage() } + entry { SlowGroupPage() } + entry { SubsAppListPage(it) } + entry { WebViewPage(it) } + entry { SubsCategoryPage(it) } + entry { SubsGlobalGroupListPage(it) } + entry { SubsGlobalGroupExcludePage(it) } + entry { ActionLogPage(it) } + entry { ImagePreviewPage(it) } + entry { UpsertRuleGroupPage(it) } + entry { SubsAppGroupListPage(it) } + entry { AppConfigPage(it) } + entry { CrashReportPage() } + entry { SubsCategoryGroupPage(it) } + }, + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + predictivePopTransitionSpec = { + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, ) if (!mainVm.termsAcceptedFlow.collectAsState().value) { TermsAcceptDialog() } else { + UiAutomationAlreadyRegisteredDlg() AccessRestrictedSettingsDlg() ShizukuErrorDialog(mainVm.shizukuErrorFlow) AuthDialog(mainVm.authReasonFlow) @@ -139,10 +323,10 @@ class MainActivity : ComponentActivity() { EditGithubCookieDlg() mainVm.updateStatus?.UpgradeDialog() SubsSheet(mainVm, mainVm.sheetSubsIdFlow) - ShareDataDialog(mainVm, mainVm.showShareDataIdsFlow) mainVm.inputSubsLinkOption.ContentDialog() mainVm.ruleGroupState.Render() - UrlDetailDialog(mainVm.urlFlow) + TextDialog(mainVm.textFlow) + ShareLogDlg(mainVm.showShareLogDlgFlow) } } } @@ -157,88 +341,69 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() - activityVisibleFlow.update { it + 1 } + LogUtils.d() + activityVisibleState++ + if (topActivityFlow.value.appId != META.appId) { + synchronized(topActivityFlow) { + updateTopActivity( + META.appId, + MainActivity::class.jvmName + ) + } + } } var isFirstResume = true override fun onResume() { super.onResume() + LogUtils.d() if (isFirstResume && startTime - app.startTime < 2000) { isFirstResume = false - return + } else { + syncFixState() } - syncFixState() } override fun onStop() { super.onStop() - activityVisibleFlow.update { it - 1 } + LogUtils.d() + activityVisibleState-- } - private var lastBackPressedTime = 0L - - @Suppress("OVERRIDE_DEPRECATION", "GestureBackNavigation") - override fun onBackPressed() { - // onBackPressedDispatcher.addCallback is not work, it will be covered by compose navigation - val t = System.currentTimeMillis() - if (t - lastBackPressedTime > AnimationConstants.DefaultDurationMillis) { - lastBackPressedTime = t - @Suppress("DEPRECATION") - super.onBackPressed() - } + override fun onDestroy() { + super.onDestroy() + LogUtils.d() } } -private val activityVisibleFlow by lazy { MutableStateFlow(0) } -fun isActivityVisible() = activityVisibleFlow.value > 0 +@Volatile +private var activityVisibleState = 0 +val isActivityVisible get() = 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() } -@Suppress("DEPRECATION") -private fun updateServiceRunning() { - A11yService.isRunning.value = A11yService.instance != null - val list = try { - val manager = app.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - manager.getRunningServices(Int.MAX_VALUE) ?: emptyList() - } catch (_: Exception) { - emptyList() - } - - 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) - ScreenshotService.isRunning.value = checkRunning(ScreenshotService::class) - HttpService.isRunning.value = checkRunning(HttpService::class) -} - private val syncStateMutex = Mutex() fun syncFixState() { appScope.launchTry(Dispatchers.IO) { + if (syncStateMutex.isLocked) { + LogUtils.d("syncFixState isLocked") + } syncStateMutex.withLock { - // 每次切换页面更新记录桌面 appId - updateLauncherAppId() - - updateDefaultInputAppId() - - // 由于某些机型的进程存在 安装缓存/崩溃缓存 导致服务状态可能不正确, 在此保证每次界面切换都能重新刷新状态 - updateServiceRunning() - - // 用户在系统权限设置中切换权限后再切换回应用时能及时更新状态 + updateSystemDefaultAppId() + shizukuContextFlow.value.grantSelf() updatePermissionState() - - // 自动重启无障碍服务 - fixRestartService() + fixRestartAutomatorService() } } } @@ -248,7 +413,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 }, @@ -257,9 +422,9 @@ private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { Column { Text( text = if (installed) { - "Shizuku 授权失败, 请检查是否运行" + "Shizuku 授权失败,请检查是否运行" } else { - "Shizuku 授权失败, 检测到 Shizuku 未安装, 请先下载后安装, 如果你是通过其它方式授权, 请忽略此提示自行查找原因" + "Shizuku 授权失败,检测到 Shizuku 未安装,请先下载后安装,如果你是通过其它方式授权,请忽略此提示自行查找原因" } ) Spacer(modifier = Modifier.height(8.dp)) @@ -282,7 +447,7 @@ private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { style = MaterialTheme.typography.bodySmall, ) } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { @@ -290,8 +455,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), ) } @@ -336,9 +500,7 @@ fun AccessRestrictedSettingsDlg() { } val accessRestrictedSettingsShow by accessRestrictedSettingsShowFlow.collectAsState() val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val currentDestination by navController.currentDestinationAsState() - val isA11yPage = currentDestination?.route == AuthA11YPageDestination.route + val isA11yPage = mainVm.topRoute is AuthA11yRoute LaunchedEffect(isA11yPage, accessRestrictedSettingsShow) { if (isA11yPage && accessRestrictedSettingsShow && !a11yRunning) { toast("请重新授权以解除限制") @@ -359,7 +521,7 @@ fun AccessRestrictedSettingsDlg() { confirmButton = { TextButton({ accessRestrictedSettingsShowFlow.value = false - mainVm.navigatePage(AuthA11YPageDestination) + mainVm.navigateWebPage(ShortUrlSet.URL2) }) { Text(text = "解除") } @@ -374,3 +536,25 @@ fun AccessRestrictedSettingsDlg() { ) } } + +@Composable +fun UiAutomationAlreadyRegisteredDlg() { + if (automationRegisteredExceptionFlow.collectAsState().value != null) { + AlertDialog( + onDismissRequest = { + automationRegisteredExceptionFlow.value = null + }, + title = { Text(text = "启动失败") }, + text = { + Text(text = "自动化服务启动失败,检测到自动化服务已被其他应用占用,请先关闭已有服务后重试\n\n注:自动化服务只能同时运行一个,请确保没有其他应用或测试框架占用后再启动") + }, + confirmButton = { + TextButton(onClick = { + automationRegisteredExceptionFlow.value = null + }) { + Text(text = "我知道了") + } + } + ) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index dfd146eb7f..73bd713a42 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -1,91 +1,129 @@ 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.service.quicksettings.TileService import android.webkit.URLUtil -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.NavHostController -import com.blankj.utilcode.util.LogUtils -import com.ramcosta.composedestinations.generated.destinations.AdvancedPageDestination -import com.ramcosta.composedestinations.generated.destinations.SnapshotPageDestination -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination -import com.ramcosta.composedestinations.spec.Direction +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow 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 +import li.songe.gkd.a11y.useA11yServiceEnabledFlow +import li.songe.gkd.a11y.useEnabledA11yServicesFlow +import li.songe.gkd.data.CrashData 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.permission.AuthReason -import li.songe.gkd.permission.shizukuOkState -import li.songe.gkd.service.MatchTileService -import li.songe.gkd.shizuku.execCommandForResult +import li.songe.gkd.permission.shizukuGrantedState +import li.songe.gkd.service.A11yService +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.shizuku.uiAutomationFlow +import li.songe.gkd.shizuku.updateBinderMutex import li.songe.gkd.store.createTextFlow import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.AdvancedPageRoute +import li.songe.gkd.ui.AppOpsAllowRoute +import li.songe.gkd.ui.CrashReportRoute +import li.songe.gkd.ui.SnapshotPageRoute +import li.songe.gkd.ui.WebViewRoute import li.songe.gkd.ui.component.AlertDialogOptions 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.ui.home.HomeRoute +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.util.AutomatorModeOption +import li.songe.gkd.util.BackupUtils +import li.songe.gkd.util.DefaultSimpleLifeImpl import li.songe.gkd.util.LOCAL_SUBS_ID +import li.songe.gkd.util.LogUtils +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 import li.songe.gkd.util.client -import li.songe.gkd.util.componentName +import li.songe.gkd.util.crashFolder +import li.songe.gkd.util.crashTempFolder +import li.songe.gkd.util.findOption +import li.songe.gkd.util.json 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 import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex import li.songe.gkd.util.updateSubscription +import li.songe.loc.Loc import rikka.shizuku.Shizuku -import java.lang.ref.WeakReference +import java.nio.file.Files +import kotlin.reflect.jvm.jvmName +import kotlin.time.Duration.Companion.days + +class MainViewModel : BaseViewModel(), OnSimpleLife by DefaultSimpleLifeImpl() { + companion object { + private var _instance: MainViewModel? = null + val instance get() = _instance!! + private var tempTermsAccepted = false + } + + init { + LogUtils.d("MainViewModel:init") + _instance = this + addCloseable { + LogUtils.d("MainViewModel:close") + if (_instance == this) { // 可能同时存在 2 个 MainViewModel 实例 + _instance = null + } + } + } -private var tempTermsAccepted = false + override val scope get() = viewModelScope -class MainViewModel : ViewModel() { + val backStack: NavBackStack = NavBackStack(HomeRoute) + val topRoute get() = backStack.last() - private var navControllerRef: WeakReference? = null - var navController: NavHostController - get() = navControllerRef?.get() ?: error("not found navController") - set(value) { - navControllerRef = WeakReference(value) + private val backThrottleTimer = ThrottleTimer() + + fun popPage(@Loc loc: String = "") = runMainPost { + if (backThrottleTimer.expired() && backStack.size > 1) { + val old = backStack.last() + backStack.removeAt(backStack.lastIndex) + LogUtils.d("popPage", "$old -> ${backStack.last()}", loc = loc) } + } - 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 - ) + fun navigatePage( + navKey: NavKey, + replaced: Boolean = false, + @Loc loc: String = "", + ) = runMainPost { + if (navKey != backStack.last()) { + val old = backStack.last() + if (replaced) { + backStack[backStack.lastIndex] = navKey + } else { + backStack.add(navKey) + } + LogUtils.d("navigatePage", "$old -> ${backStack.last()}", loc = loc) + } + } + + fun navigateWebPage(url: String) = navigatePage(WebViewRoute(url)) val dialogFlow = MutableStateFlow(null) val authReasonFlow = MutableStateFlow(null) @@ -102,14 +140,17 @@ class MainViewModel : ViewModel() { val sheetSubsIdFlow = MutableStateFlow(null) - 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, ) = 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() @@ -160,47 +201,42 @@ class MainViewModel : ViewModel() { 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) } } - val appListKeyFlow = MutableStateFlow(0) - val tabFlow = MutableStateFlow(controlNav) + val tabFlow = MutableStateFlow(BottomNavItem.Control.key) + val resetPageScrollEvent = MutableSharedFlow() private var lastClickTabTime = 0L - fun updateTab(navItem: BottomNavItem) { - if (navItem == appListNav && navItem == tabFlow.value) { - // double click - if (System.currentTimeMillis() - lastClickTabTime < 500) { - appListKeyFlow.update { it + 1 } - } + fun handleClickTab(navItem: BottomNavItem) { + val t = System.currentTimeMillis() + // double click + if (navItem.key == tabFlow.value && t - lastClickTabTime < 500) { + viewModelScope.launch { resetPageScrollEvent.emit(navItem) } } - tabFlow.value = navItem - lastClickTabTime = System.currentTimeMillis() - } - - fun navigatePage(direction: Direction) { - if (direction.route == navController.currentDestination?.route) { - return - } - navController.navigate(direction.route) - } - - fun navigateWebPage(url: String) { - navigatePage(WebViewPageDestination(url)) + tabFlow.value = navItem.key + lastClickTabTime = t } fun handleGkdUri(uri: Uri) { val notFoundToast = { toast("未知URI\n${uri}") } when (uri.host) { "page" -> when (uri.path) { - "" -> {} - "/1" -> navigatePage(AdvancedPageDestination) - "/2" -> navigatePage(SnapshotPageDestination()) + "" -> { + val tab = uri.getQueryParameter("tab")?.toIntOrNull() + if (tab != null && BottomNavItem.allSubObjects.any { it.key == tab }) { + tabFlow.value = tab + } + } + + "/1" -> navigatePage(AdvancedPageRoute) + "/2" -> navigatePage(SnapshotPageRoute) + "/3" -> navigatePage(AppOpsAllowRoute) else -> notFoundToast() } @@ -213,37 +249,14 @@ class MainViewModel : ViewModel() { } } - fun handleIntent(intent: Intent) = viewModelScope.launchTry(Dispatchers.Main) { - LogUtils.d("handleIntent", intent) + fun handleIntent(intent: Intent) = viewModelScope.launchTry { + LogUtils.d(intent) val uri = intent.data?.normalizeScheme() + val source = intent.getStringExtra(activityNavSourceName) 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, FloatingTileService::class.componentName -> { - navigatePage(AdvancedPageDestination) - } - - SnapshotTileService::class.componentName -> { - navigatePage(SnapshotPageDestination) - } - - MatchTileService::class.componentName -> { - tabFlow.value = subsNav - } - } + } else if (source == OpenFileActivity::class.jvmName && uri != null) { + withContext(Dispatchers.IO) { BackupUtils.importBackUpData(uri) } } } @@ -275,27 +288,61 @@ class MainViewModel : ViewModel() { ) } - 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 - } + fun switchEnableShizuku(value: Boolean) { + if (updateBinderMutex.mutex.isLocked) { + toast("正在连接中,请稍后") + return + } + storeFlow.update { s -> s.copy(enableShizuku = value) } + } + + fun requestShizuku() { + if (shizukuContextFlow.value.ok) return + if (updateBinderMutex.mutex.isLocked) { + toast("正在连接中,请稍后") + return + } + 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) } + } + if (!shizukuGrantedState.updateAndGet()) { + requestShizuku() + stopCoroutine() } + if (shizukuContextFlow.value.ok) return stopCoroutine() } + private val a11yServicesFlow = useEnabledA11yServicesFlow() + val a11yServiceEnabledFlow = useA11yServiceEnabledFlow(a11yServicesFlow) + + val automatorModeFlow = storeFlow.mapNew { + AutomatorModeOption.objects.findOption(it.automatorMode) + } + + fun updateAutomatorMode(option: AutomatorModeOption) { + if (automatorModeFlow.value == option) return + storeFlow.update { it.copy(automatorMode = option.value, enableAutomator = false) } + A11yService.instance?.shutdown() + uiAutomationFlow.value?.shutdown() + } + + val showShareLogDlgFlow = MutableStateFlow(false) + + var tempCrashDataList = emptyList() + init { + // preload + appIconMapFlow.value viewModelScope.launchTry(Dispatchers.IO) { val subsItems = DbSet.subsItemDao.queryAll() if (!subsItems.any { s -> s.id == LOCAL_SUBS_ID }) { @@ -322,7 +369,7 @@ class MainViewModel : ViewModel() { clearCache() } - if (updateStatus != null && termsAcceptedFlow.value) { + if (termsAcceptedFlow.value && updateStatus?.canRecheck == true) { updateStatus.checkUpdate() } @@ -330,5 +377,34 @@ class MainViewModel : ViewModel() { // preload githubCookieFlow.value } + viewModelScope.launchTry(Dispatchers.IO) { + val list = (crashTempFolder.listFiles() ?: emptyArray()).mapNotNull { + try { + json.decodeFromString(it.readText()) + } catch (e: Exception) { + LogUtils.d("解析崩溃日志失败: ${it.name}", e) + null + } + }.sortedBy { -it.mtime } + crashTempFolder.deleteRecursively() + val t = System.currentTimeMillis() + crashFolder.listFiles()?.filter { + val name = it.name + !list.any { f -> name == f.filename } + }?.forEach { + val mtime = Files.getLastModifiedTime(it.toPath()).toMillis() + if (t - mtime > 30.days.inWholeMilliseconds) { + it.delete() + } + } + tempCrashDataList = list + if (list.isNotEmpty()) { + navigatePage(CrashReportRoute) + } + } + + // for OnSimpleLife + onCreated() + addCloseable { onDestroyed() } } } 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..61c97b8bbf --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt @@ -0,0 +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/a11y/A11yCommonImpl.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yCommonImpl.kt new file mode 100644 index 0000000000..aa2c2d219f --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yCommonImpl.kt @@ -0,0 +1,18 @@ +package li.songe.gkd.a11y + +import android.graphics.Bitmap +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import kotlinx.coroutines.CoroutineScope +import li.songe.gkd.util.AutomatorModeOption + +interface A11yCommonImpl { + suspend fun screenshot(): Bitmap? + val windowNodeInfo: AccessibilityNodeInfo? + val windowInfos: List + val scope: CoroutineScope + var justStarted: Boolean + val mode: AutomatorModeOption + val ruleEngine: A11yRuleEngine + fun shutdown(temp: Boolean = false) +} 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 92% 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..822d3529a0 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yContext.kt +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt @@ -1,11 +1,12 @@ -package li.songe.gkd.service +package li.songe.gkd.a11y -import android.graphics.Rect import android.util.Log import android.util.LruCache import android.view.accessibility.AccessibilityNodeInfo +import kotlinx.atomicfu.atomic import li.songe.gkd.META import li.songe.gkd.data.ResolvedRule +import li.songe.gkd.shizuku.casted import li.songe.gkd.util.InterruptRuleMatchException import li.songe.selector.FastQuery import li.songe.selector.MatchOption @@ -38,13 +39,14 @@ private val AccessibilityNodeInfo?.notExpiredNode: AccessibilityNodeInfo? } class A11yContext( - private val disableInterrupt: Boolean = false + private val a11yEngine: A11yRuleEngine, + private val interruptable: Boolean = true, ) { private var childCache = LruCache, 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 -> @@ -55,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) @@ -65,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( @@ -113,7 +115,7 @@ class A11yContext( private var interruptInnerKey = 0 private fun guardInterrupt() { - if (disableInterrupt) return + if (!interruptable) return if (interruptInnerKey == interruptKey) return interruptInnerKey = interruptKey val rule = currentRule ?: return @@ -128,17 +130,17 @@ class A11yContext( private fun getA11Root(): AccessibilityNodeInfo? { guardInterrupt() - return A11yService.instance?.safeActiveWindow + return a11yEngine.safeActiveWindow } 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( @@ -173,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? { @@ -189,7 +191,7 @@ class A11yContext( if (this != null) { parentCache[node] = this } else { - rootCache = node + rootCache.value = node } } } @@ -250,16 +252,6 @@ class A11yContext( } } - private val tempRect = Rect() - private var tempRectNode: AccessibilityNodeInfo? = null - private fun getTempRect(n: AccessibilityNodeInfo): Rect { - if (n !== tempRectNode) { - n.getBoundsInScreen(tempRect) - tempRectNode = n - } - return tempRect - } - private var tempVid: CharSequence? = null private var tempVidNode: AccessibilityNodeInfo? = null private fun getTempVid(n: AccessibilityNodeInfo): CharSequence? { @@ -287,13 +279,13 @@ class A11yContext( "longClickable" -> node.isLongClickable "visibleToUser" -> node.isVisibleToUser - "left" -> getTempRect(node).left - "top" -> getTempRect(node).top - "right" -> getTempRect(node).right - "bottom" -> getTempRect(node).bottom + "left" -> node.casted.boundsInScreen.left + "top" -> node.casted.boundsInScreen.top + "right" -> node.casted.boundsInScreen.right + "bottom" -> node.casted.boundsInScreen.bottom - "width" -> getTempRect(node).width() - "height" -> getTempRect(node).height() + "width" -> node.casted.boundsInScreen.width() + "height" -> node.casted.boundsInScreen.height() "index" -> getCacheIndex(node) "depth" -> getCacheDepth(node) @@ -346,7 +338,6 @@ class A11yContext( else -> null } - }, getName = { node -> node.className }, getChildren = ::getCacheChildren, @@ -531,7 +522,7 @@ class A11yContext( var resultNode: AccessibilityNodeInfo? = null if (rule.anyMatches.isNotEmpty()) { for (selector in rule.anyMatches) { - resultNode = a11yContext.querySelfOrSelector( + resultNode = querySelfOrSelector( queryNode, selector, rule.matchOption, @@ -541,14 +532,14 @@ class A11yContext( if (resultNode == null) return null } for (selector in rule.matches) { - resultNode = a11yContext.querySelfOrSelector( + resultNode = querySelfOrSelector( queryNode, selector, rule.matchOption, ) ?: return null } for (selector in rule.excludeMatches) { - a11yContext.querySelfOrSelector( + querySelfOrSelector( queryNode, selector, rule.matchOption, @@ -556,7 +547,7 @@ class A11yContext( } if (rule.excludeAllMatches.isNotEmpty()) { val allExclude = rule.excludeAllMatches.all { - a11yContext.querySelfOrSelector( + querySelfOrSelector( queryNode, it, rule.matchOption, @@ -572,5 +563,3 @@ class A11yContext( } } } - -val a11yContext = A11yContext() 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..07918388b9 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt @@ -0,0 +1,141 @@ +package li.songe.gkd.a11y + +import android.content.ComponentName +import android.provider.Settings +import android.view.accessibility.AccessibilityEvent +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 +import li.songe.gkd.util.mapState +import li.songe.selector.initDefaultTypeInfo +import kotlin.contracts.contract + +context(context: OnSimpleLife) +fun useEnabledA11yServicesFlow(): StateFlow> { + val stateFlow = MutableStateFlow(app.getSecureA11yServices()) + val contextObserver = contentObserver { + stateFlow.value = app.getSecureA11yServices() + } + app.registerObserver( + Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES), + contextObserver + ) + context.onDestroyed { + app.unregisterObserver(contextObserver) + } + return stateFlow +} + +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 +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 (AndroidTarget.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 +fun AccessibilityEvent?.isUseful(): Boolean { + contract { + returns(true) implies (this@isUseful != null) + } + return (this != null && packageName != null && className != null && eventType and interestedEvents != 0) +} + +data class A11yEvent( + val type: Int, + val time: Long, + val appId: String, + val name: 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 && name == other.name + } +} + +// 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(), + 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 new file mode 100644 index 0000000000..930243c0f7 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt @@ -0,0 +1,230 @@ +package li.songe.gkd.a11y + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.view.accessibility.AccessibilityEvent +import androidx.core.content.ContextCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import li.songe.gkd.app +import li.songe.gkd.appScope +import li.songe.gkd.permission.shizukuGrantedState +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.LogUtils +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.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 + + +fun onA11yFeatEvent(event: AccessibilityEvent) = event.run { + if (event.eventType == STATE_CHANGED) { + watchCaptureScreenshot() + if (event.packageName == launcherAppId) { + watchCheckShizukuState() + watchAutoUpdateSubs() + } + } +} + +private var lastCheckShizukuTime = 0L +private fun watchCheckShizukuState() { + // 借助无障碍轮询校验 shizuku 权限, 因为 shizuku 可能无故被关闭 + if (storeFlow.value.enableShizuku) { + val t = System.currentTimeMillis() + if (t - lastCheckShizukuTime > 60 * 60_000L) { + lastCheckShizukuTime = t + appScope.launchTry(Dispatchers.IO) { + shizukuGrantedState.updateAndGet() + } + } + } +} + +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 -> + 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 + 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() + } +} + +private var lastUpdateSubsTime = 0L +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 initRuleChangedLog() { + appScope.launch(Dispatchers.Default) { + activityRuleFlow.debounce(300).drop(1).collect { + if (storeFlow.value.enableMatch && it.currentRules.isNotEmpty()) { + LogUtils.d(it.topActivity, *it.currentRules.map { r -> + r.statusText() + }.toTypedArray()) + } + } + } +} + +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 initCaptureVolume() { + var captureVolumeReceiver: BroadcastReceiver? = null + val changeRegister: (Boolean) -> Unit = { + captureVolumeReceiver?.let(app::unregisterReceiver) + captureVolumeReceiver = if (it) { + createVolumeReceiver().apply { + ContextCompat.registerReceiver( + app, + this, + IntentFilter(volumeChangedAction), + ContextCompat.RECEIVER_EXPORTED + ) + } + } else { + null + } + } + appScope.launch(Dispatchers.IO) { + storeFlow.mapState(appScope) { s -> s.captureVolumeChange }.collect(changeRegister) + } +} + +var isInteractive = true + private set +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 + Intent.ACTION_USER_PRESENT -> true + else -> isInteractive + } + if (isInteractive) { + val t = System.currentTimeMillis() + if (t - appChangeTime > 500) { // 37.872(a11y) -> 38.228(onReceive) + A11yRuleEngine.onScreenForcedActive() + } + } + } +} + +private fun initScreenStateReceiver() { + isInteractive = app.powerManager.isInteractive + ContextCompat.registerReceiver( + app, + screenStateReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + }, + ContextCompat.RECEIVER_EXPORTED + ) +} + +fun initA11yFeat() { + initRuleChangedLog() + initCaptureVolume() + initScreenStateReceiver() +} 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..719d10d184 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt @@ -0,0 +1,489 @@ +package li.songe.gkd.a11y + +import android.accessibilityservice.AccessibilityService +import android.graphics.Bitmap +import android.util.Log +import android.view.Display +import android.view.KeyEvent +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.getAndUpdate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import li.songe.gkd.META +import li.songe.gkd.data.ActionPerformer +import li.songe.gkd.data.ActionResult +import li.songe.gkd.data.AppRule +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.isActivityVisible +import li.songe.gkd.service.A11yService +import li.songe.gkd.service.EventService +import li.songe.gkd.service.topAppIdFlow +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.shizuku.uiAutomationFlow +import li.songe.gkd.store.actualBlockA11yAppList +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.AutomatorModeOption +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.runMainPost +import li.songe.gkd.util.showActionToast +import li.songe.gkd.util.systemUiAppId +import li.songe.selector.MatchOption +import li.songe.selector.Selector +import java.util.concurrent.Executors +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + + +private val eventDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() +private val queryDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() +private val actionDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + +private val latestServiceMode = atomic(0) +private val latestServiceTime = atomic(0L) + +class A11yRuleEngine(val service: A11yCommonImpl) { + private val a11yContext = A11yContext(this) + private val effective get() = latestServiceMode.value == service.mode.value + private val hasOthersService = when (service.mode) { + AutomatorModeOption.A11yMode -> uiAutomationFlow.value != null + AutomatorModeOption.AutomationMode -> A11yService.instance != null + } + + fun onA11yConnected() { + val serviceTime = System.currentTimeMillis() + latestServiceMode.value = service.mode.value + latestServiceTime.value = serviceTime + if (storeFlow.value.enableBlockA11yAppList && !actualBlockA11yAppList.contains(topAppIdFlow.value)) { + startQueryJob(byForced = true) + } + runMainPost(1000L) {// 共存 1000ms, 等待另一个服务稳定 + if (latestServiceTime.value == serviceTime) { + when (service.mode) { + AutomatorModeOption.A11yMode -> uiAutomationFlow.value?.shutdown(true) + AutomatorModeOption.AutomationMode -> A11yService.instance?.shutdown(true) + } + } + } + } + + fun onScreenForcedActive() { + // 关闭屏幕 -> Activity::onStop -> 点亮屏幕 -> Activity::onStart -> Activity::onResume + val a = topActivityFlow.value + synchronized(topActivityFlow) { + updateTopActivity( + a.appId, + a.activityId, + scene = ActivityScene.ScreenOn + ) + } + startQueryJob() + } + + 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. + service.windowNodeInfo?.setGeneratedTime() + } catch (_: Throwable) { + null + }.apply { + a11yContext.rootCache.value = this + } + + val safeActiveWindowAppId: String? + get() = safeActiveWindow?.packageName?.toString() + + private val scope get() = service.scope + + @Volatile + private var latestStateEvent: A11yEvent? = null + private var lastContentEventTime = 0L + private var lastEventTime = 0L + private val eventDeque = ArrayDeque() + fun onA11yEvent(event: AccessibilityEvent?) { + if (!effective) return + if (!event.isUseful()) return + // 拒绝副屏无障碍事件 + if (AndroidTarget.TIRAMISU && event.displayId != Display.DEFAULT_DISPLAY) return + onA11yFeatEvent(event) + if (event.eventType == CONTENT_CHANGED) { + if (!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) { + 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 + } + EventService.logEvent(event) + 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 + if (event.eventType == STATE_CHANGED) { + latestStateEvent = a11yEvent + } + synchronized(eventDeque) { eventDeque.addLast(a11yEvent) } + scope.launch(eventDispatcher) { consumeEvent(a11yEvent) } + } + + private val queryEvents = mutableListOf() + private 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.name + val oldAppId = topActivityFlow.value.appId + val rightAppId = if (oldAppId == evAppId) { + evAppId + } else { + getTimeoutAppId() ?: return + } + if (rightAppId == evAppId) { + if (latestEvent.type == STATE_CHANGED) { + synchronized(topActivityFlow) { + // tv.danmaku.bili, com.miui.home, com.miui.home.launcher.Launcher + if (isActivity(evAppId, evActivityId)) { + updateTopActivity(evAppId, evActivityId) + } + } + } + } + if (rightAppId != topActivityFlow.value.appId) { + synchronized(topActivityFlow) { + // 从 锁屏,下拉通知栏 返回等情况, 应用不会发送事件, 但是系统组件会发送事件 + val topCpn = shizukuContextFlow.value.topCpn() + 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) + } + + private var lastGetAppIdTime = 0L + private var lastAppId: String? = null + private 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(Dispatchers.IO) { safeActiveWindowAppId } + } ?: shizukuContextFlow.value.topCpn()?.packageName + lastGetAppIdTime = System.currentTimeMillis() + return lastAppId + } + + // 某些场景耗时 5000 ms + private suspend fun getTimeoutActiveWindow(): AccessibilityNodeInfo? { + return suspendCancellableCoroutine { s -> + val temp = atomic?>(s) + scope.launch(Dispatchers.IO) { + delay(500L) + if (s.isActive) { + temp.getAndUpdate { null }?.resume(null) + } + } + scope.launch(Dispatchers.IO) { + val a = safeActiveWindow + if (s.isActive) { + temp.getAndUpdate { null }?.resume(a) + } + } + } + } + + @Volatile + private var querying = false + + @Synchronized + private fun startQueryJob( + byEvent: A11yEvent? = null, + byForced: Boolean = false, + byDelayRule: ResolvedRule? = null, + ) { + if (!effective) return + if (!storeFlow.value.enableMatch) return + if (activityRuleFlow.value.currentRules.isEmpty()) return + if (querying) return + // 无障碍从零启动时获取 safeActiveWindow 非常耗时 + if (byEvent == null && service.justStarted && !hasOthersService) return checkFutureStartJob() + scope.launchTry(queryDispatcher) { + querying = true + val st = if (META.debuggable) System.currentTimeMillis() else 0L + try { + if (META.debuggable) { + Log.d( + "A11yRuleEngine", + "startQueryJob start byEvent=${byEvent != null}, byForced=$byForced, byDelayRule=${byDelayRule != null}" + ) + } + queryAction(byEvent, byForced, byDelayRule) + } finally { + checkFutureStartJob() + if (META.debuggable) { + val et = System.currentTimeMillis() - st + Log.d("A11yRuleEngine", "startQueryJob end $et ms") + } + querying = false + } + } + } + + private fun checkFutureStartJob() { + val t = System.currentTimeMillis() + if (t - lastTriggerTime < 3000L || t - appChangeTime < 3000L) { + scope.launch(actionDispatcher) { + delay(300) + startQueryJob() + } + } else if (activityRuleFlow.value.hasFeatureAction) { + scope.launch(actionDispatcher) { + delay(300) + startQueryJob(byForced = true) + } + } + } + + private fun fixAppId(rightAppId: String) { + if (topActivityFlow.value.appId == rightAppId) return + synchronized(topActivityFlow) { + val topCpn = shizukuContextFlow.value.topCpn() + if (topCpn?.packageName == rightAppId) { + updateTopActivity(topCpn.packageName, topCpn.className) + } else { + updateTopActivity(rightAppId, null) + } + } + scope.launch(actionDispatcher) { + delay(300) + startQueryJob() + } + } + + private suspend fun queryAction( + byEvent: A11yEvent? = null, + byForced: Boolean = false, + delayRule: ResolvedRule? = null, + ) { + val tempStateEvent = latestStateEvent + val newEvents = if (delayRule != null) {// 延迟规则不消耗事件 + null + } else { + synchronized(queryEvents) { + if (byEvent != null && queryEvents.isEmpty()) { + return + } + (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 = synchronized(topActivityFlow) { 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 + } + 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 (!effective) return + if (checkOutDate(activityRule, tempStateEvent)) 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 ?: getTimeoutActiveWindow()) ?: 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 + } + if (!matchApp) continue + val target = a11yContext.queryRule(rule, nodeVal) ?: continue + 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 + if (checkOutDate(activityRule, tempStateEvent)) 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(rule) + } + addActionLog(rule, topActivity, target, actionResult) + } + } + } + + private fun checkOutDate( + activityRule: ActivityRule, + stateEvent: A11yEvent? + ): Boolean { + if (stateEvent !== latestStateEvent) return true + synchronized(topActivityFlow) { + if (activityRule !== activityRuleFlow.value) return true + } + return false + } + + companion object { + val service: A11yCommonImpl? + get() = uiAutomationFlow.value?.takeIf { + it.mode.value == latestServiceMode.value + } ?: A11yService.instance + val instance: A11yRuleEngine? get() = service?.ruleEngine + + fun compatWindows(): List { + return try { + service?.windowInfos + } catch (_: Throwable) { + null + } ?: emptyList() + } + + fun onScreenForcedActive() { + instance?.onScreenForcedActive() + } + + fun performActionBack(): Boolean { + val r1 = shizukuContextFlow.value.inputManager?.key(KeyEvent.KEYCODE_BACK) + if (r1 != null) return true + return A11yService.instance?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) == true + } + + suspend fun screenshot(): Bitmap? = service?.screenshot() + + suspend fun execAction(gkdAction: GkdAction): ActionResult { + val selector = Selector.parseOrNull(gkdAction.selector) ?: throw RpcError("非法选择器") + runCatching { selector.checkType(typeInfo) }.exceptionOrNull()?.let { + throw RpcError("选择器类型错误:${it.message}") + } + val s = instance ?: throw RpcError("服务未连接") + val a = s.safeActiveWindow ?: throw RpcError("界面没有节点信息") + val targetNode = A11yContext(s, interruptable = false).querySelfOrSelector( + a, selector, MatchOption(fastQuery = gkdAction.fastQuery) + ) ?: throw RpcError("没有查询到节点") + return withContext(Dispatchers.IO) { + ActionPerformer + .getAction(gkdAction.action ?: ActionPerformer.None.action) + .perform(targetNode, gkdAction) + } + } + + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt new file mode 100644 index 0000000000..2d503f14be --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt @@ -0,0 +1,313 @@ +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +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 +import li.songe.gkd.data.ActionResult +import li.songe.gkd.data.ActivityLog +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.service.updateTopTaskAppId +import li.songe.gkd.shizuku.safeInvokeShizuku +import li.songe.gkd.store.actionCountFlow +import li.songe.gkd.store.checkAppBlockMatch +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.LogUtils +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 +import li.songe.loc.Loc + +data class TopActivity( + val appId: String = "", + 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}/${shortActivityId}/${number}" + } + + 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()) +private var lastValidActivity: TopActivity = topActivityFlow.value + set(value) { + if (value.activityId != null) { + field = value + } + } + +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 { + app.packageManager.getActivityInfo( + ComponentName(key.first, key.second), + PKG_FLAGS + ) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } +} + +fun isActivity( + appId: String, + activityId: String, +): Boolean { + return topActivityFlow.value.sameAs(appId, activityId) || ActivityCache.get(appId to activityId) +} + +class ActivityRule( + val topActivity: TopActivity = TopActivity(), + val ruleSummary: RuleSummary = RuleSummary(), +) { + 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) + } + 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() } + val priorityRules: List + get() = if (hasPriorityRule) { + currentRules.sortedBy { if (it.isPriority()) 0 else 1 } + } else { + currentRules + } + val skipMatch: Boolean + get() { + return currentRules.all { r -> !r.status.ok } + } + val skipConsumeEvent: Boolean + 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 = MutableStateFlow(ActivityRule()) + +private var lastAppId = "" + +sealed class ActivityScene { + data object ScreenOn : ActivityScene() + data object A11y : ActivityScene() + data object TaskStack : ActivityScene() +} + +// 外部必须使用 synchronized(topActivityFlow) 来保证更新的原子性 +fun updateTopActivity( + appId: String, + activityId: String?, + scene: ActivityScene = ActivityScene.A11y, + @Loc loc: String = "", +) { + val t = System.currentTimeMillis() + if (scene == ActivityScene.TaskStack) { + updateTopTaskAppId(appId) + } + val oldActivity = topActivityFlow.value + val oldActivityRule = activityRuleFlow.value + val idChanged = (scene == ActivityScene.ScreenOn || appId != oldActivityRule.topActivity.appId) + val isSame = scene != ActivityScene.ScreenOn && oldActivity.sameAs(appId, activityId) + if (scene == ActivityScene.TaskStack) { + lastActivityForceUpdateTime = t + } else if (scene == ActivityScene.A11y) { + if (idChanged && 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 { + 0 + } + topActivityFlow.value = TopActivity( + appId = appId, + activityId = activityId ?: lastValidActivity.takeIf { it.appId == appId }?.activityId, + number = number, + ) + lastValidActivity = oldActivity + lastActivityUpdateTime = t + 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 ruleSummary = ruleSummaryFlow.value + val topChanged = idChanged || oldActivityRule.topActivity != topActivity + val ruleChanged = oldActivityRule.ruleSummary !== ruleSummary + if (topChanged || ruleChanged) { + val newActivityRule = ActivityRule( + ruleSummary = ruleSummary, + topActivity = topActivity, + ) + if (idChanged) { + val oldAppId = lastAppId + lastAppId = appId + appScope.launchTry { + DbSet.appVisitLogDao.insert(oldAppId, appId, t) + } + appChangeTime = t + 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) { + ResetMatchType.App -> { + if (r.isFirstMatchApp) { + r.resetState(t) + } + } + + ResetMatchType.Activity -> r.resetState(t) + ResetMatchType.Match -> { + // is new rule + if (!oldActivityRule.currentRules.contains(r)) { + r.resetState(t) + } + } + } + } + } + activityRuleFlow.value = newActivityRule + LogUtils.d( + "${oldActivity.format()} -> ${topActivityFlow.value.format()} (scene=$scene)", + loc = loc, + tag = "updateTopActivity", + ) + } +} + +@Volatile +var lastTriggerRule: ResolvedRule? = null + +@Volatile +var lastTriggerTime = 0L + +@Volatile +var appChangeTime = 0L + +var imeAppId = "" +var launcherAppId = "" +var systemRecentCn = ComponentName("", "") + +fun updateSystemDefaultAppId() { + 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 { + safeInvokeShizuku { + 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() +fun addActionLog( + rule: ResolvedRule, + topActivity: TopActivity, + target: AccessibilityNodeInfo, + actionResult: ActionResult, +) = appScope.launchTry(Dispatchers.IO) { + val ctime = System.currentTimeMillis() + actionLogMutex.withLock { + val actionLog = ActionLog( + appId = topActivity.appId, + activityId = topActivity.activityId, + subsId = rule.subsItem.id, + subsVersion = rule.rawSubs.version, + groupKey = rule.g.group.key, + groupType = rule.g.group.groupType, + ruleIndex = rule.index, + ruleKey = rule.key, + ctime = ctime, + ) + DbSet.actionLogDao.insert(actionLog) + if (actionCountFlow.value % 100 == 0L) { + DbSet.actionLogDao.deleteKeepLatest() + } + } + LogUtils.d( + rule.statusText(), + AttrInfo.info2data(target, 0, 0), + actionResult + ) +}.let {} 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..f4162421cf --- /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 62202a3e1d..bc21b8fcf6 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 @@ -109,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/AppConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/AppConfig.kt index e47c965681..c474088ca7 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,22 +9,23 @@ 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 { + @Query("SELECT * FROM app_config") + suspend fun queryAll(): List + @Update suspend fun update(vararg objects: AppConfig): Int 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..da21e1c9fa 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -5,65 +5,124 @@ 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 +import li.songe.gkd.shizuku.casted +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( 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 enabled: Boolean, + val userId: Int, +) { + override fun equals(other: Any?): Boolean { + if (other !is AppInfo) return false + 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 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 +private val PackageInfo.compatVersionCode: Int + get() = if (AndroidTarget.P) { + longVersionCode.toInt() } else { - this + @Suppress("DEPRECATION") + versionCode + } + +val ApplicationInfo.isSystem: Boolean + get() = flags and ApplicationInfo.FLAG_SYSTEM != 0 || flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + +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, + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES + ).activities?.isNotEmpty() == true + } catch (_: Throwable) { + // #1195 packageManager.getPackageInfo android.os.DeadSystemRuntimeException + true } } -/** - * 平均单次调用时间 11ms - */ +private fun PackageInfo.getEnabled(userId: Int): Boolean { + val enabled = applicationInfo?.enabled ?: true + if (enabled) return true + 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) { + null, + 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 checkAppHasActivity->271 fun PackageInfo.toAppInfo( - userId: Int? = null, + userId: Int = currentUserId, hidden: Boolean? = null, ): AppInfo { + val isSystem = applicationInfo?.isSystem ?: false return AppInfo( + userId = userId, 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, + isSystem = isSystem, 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), - PackageManager.MATCH_DISABLED_COMPONENTS - ).isEmpty(), + hidden = hidden ?: (isSystem && (casted.overlayTarget != null || !checkHasActivity( + packageName + ))), + enabled = getEnabled(userId), ) } + +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/data/AppVisitLog.kt b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt new file mode 100644 index 0000000000..4a85af0200 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt @@ -0,0 +1,67 @@ +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)), + AppVisitLog(newAppId, fixAppVisitTime(newAppId, mtime)), + ) + if (appLogCount++ % 100 == 0) { + deleteKeepLatest() + } + } + + @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 + ) > 500 + AND mtime <= ( + SELECT mtime + FROM app_visit_log + ORDER BY mtime DESC + LIMIT 1 OFFSET 500 + ) + """ + ) + suspend fun deleteKeepLatest(): Int + } +} + +private fun fixAppVisitTime(appId: String, t: Long): Long = when (appId) { + META.appId -> t - 120_000 + launcherAppId, systemUiAppId -> t - 60_000 + else -> t +} + +private var appLogCount = 0 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..6d4edc506f 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt @@ -1,9 +1,9 @@ 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 +import li.songe.gkd.shizuku.casted @Serializable data class AttrInfo( @@ -35,16 +35,12 @@ data class AttrInfo( val depth: Int, ) { companion object { - /** - * 不要在多线程中使用 - */ - private val rect = Rect() fun info2data( node: AccessibilityNodeInfo, index: Int, depth: Int, ): AttrInfo { - node.getBoundsInScreen(rect) + val rect = node.casted.boundsInScreen val appId = node.packageName?.toString() ?: "" val id: String? = node.viewIdResourceName val idPrefix = "$appId:id/" 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/CategoryConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/CategoryConfig.kt index 9764b95507..fb16613ddd 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/CategoryConfig.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/CategoryConfig.kt @@ -25,6 +25,9 @@ data class CategoryConfig( @Dao interface CategoryConfigDao { + @Query("SELECT * FROM category_config") + suspend fun queryAll(): List + @Update suspend fun update(vararg objects: CategoryConfig): Int @@ -53,7 +56,7 @@ data class CategoryConfig( fun queryConfig(subsItemId: Long): Flow> @Query("SELECT * FROM category_config WHERE subs_id=:subsId AND category_key=:categoryKey") - suspend fun queryCategoryConfig(subsId: Long, categoryKey: Int): CategoryConfig? + fun queryCategoryConfig(subsId: Long, categoryKey: Int): Flow @Query("SELECT * FROM category_config WHERE subs_id IN (:subsItemIds)") suspend fun querySubsItemConfig(subsItemIds: List): List 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..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,19 +1,19 @@ package li.songe.gkd.data import kotlinx.serialization.Serializable -import li.songe.gkd.util.getPkgInfo +import li.songe.gkd.util.appInfoMapFlow @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? = 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/CrashData.kt b/app/src/main/kotlin/li/songe/gkd/data/CrashData.kt new file mode 100644 index 0000000000..bbbbdafb6a --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/data/CrashData.kt @@ -0,0 +1,30 @@ +package li.songe.gkd.data + +import kotlinx.serialization.Serializable +import li.songe.gkd.util.crashFolder +import li.songe.gkd.util.crashTempFolder +import li.songe.gkd.util.format +import li.songe.gkd.util.json + +@Serializable +data class CrashData( + val id: Long, + val mtime: Long, + val device: String, + val androidVersionCode: Int, + val androidVersionName: String, + val versionCode: Int, + val versionName: String, + val name: String, + val message: String?, + val thread: String, + val stackTrace: String, +) { + val filename get() = "gkd_crash-" + mtime.format("yyyyMMdd_HHmmss") + ".json" + fun save() { + val text = json.encodeToString(this) + crashFolder.resolve(filename).writeText(text) + crashTempFolder.resolve(filename).writeText(text) + } + +} 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 fc5de56d75..0af5778349 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt @@ -1,45 +1,47 @@ package li.songe.gkd.data -import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription 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.coroutines.delay import kotlinx.serialization.Serializable -import li.songe.gkd.shizuku.safeLongTap -import li.songe.gkd.shizuku.safeTap +import li.songe.gkd.a11y.A11yRuleEngine +import li.songe.gkd.service.A11yService +import li.songe.gkd.service.TrackService +import li.songe.gkd.shizuku.casted +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.util.ScreenUtils @Serializable data class GkdAction( val selector: String, val fastQuery: Boolean = false, val action: String? = null, - val position: RawSubscription.Position? = null, -) + override val position: RawSubscription.Position? = null, + override val swipeArg: RawSubscription.SwipeArg? = null, +) : RawSubscription.LocationProps @Serializable data class ActionResult( val action: String, val result: Boolean, - val shizuku: Boolean = false, + val shell: Boolean = false, val position: Pair? = null, ) sealed class ActionPerformer(val action: String) { - abstract fun perform( - context: AccessibilityService, + abstract suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult data object ClickNode : ActionPerformer("clickNode") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { + TrackService.addA11yNodePosition(node) return ActionResult( action = action, result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) @@ -48,24 +50,27 @@ sealed class ActionPerformer(val action: String) { } data object ClickCenter : ActionPerformer("clickCenter") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { - val rect = Rect() - node.getBoundsInScreen(rect) - val p = position?.calc(rect) + val rect = node.casted.boundsInScreen + val p = locationProps.position?.calc(rect) val x = p?.first ?: ((rect.right + rect.left) / 2f) val y = p?.second ?: ((rect.bottom + rect.top) / 2f) + if (!ScreenUtils.inScreen(x, y)) { + return ActionResult( + action = action, + result = false, + position = x to y, + ) + } + TrackService.addXyPosition(x, y) 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) { - return ActionResult(action, result, true, position = x to y) - } + result = if (shizukuContextFlow.value.tap(x, y)) { + true + } else { val gestureDescription = GestureDescription.Builder() val path = Path() path.moveTo(x, y) @@ -74,10 +79,9 @@ sealed class ActionPerformer(val action: String) { path, 0, ViewConfiguration.getTapTimeout().toLong() ) ) - context.dispatchGesture(gestureDescription.build(), null, null) - true - } else { - false + A11yService.instance?.dispatchGesture( + gestureDescription.build(), null, null + ) != null }, position = x to y ) @@ -85,69 +89,76 @@ sealed class ActionPerformer(val action: String) { } data object Click : ActionPerformer("click") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { if (node.isClickable) { - val result = ClickNode.perform(context, node, position) + val result = ClickNode.perform(node, locationProps) if (result.result) { return result } } - return ClickCenter.perform(context, node, position) + return ClickCenter.perform(node, locationProps) } } data object LongClickNode : ActionPerformer("longClickNode") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { + TrackService.addA11yNodePosition(node) return ActionResult( action = action, - result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK) + result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK).apply { + if (this) { + delay(LongClickCenter.LONG_DURATION) + } + } ) } } data object LongClickCenter : ActionPerformer("longClickCenter") { - override fun perform( - context: AccessibilityService, + const val LONG_DURATION = 500L + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { - val rect = Rect() - node.getBoundsInScreen(rect) - val p = position?.calc(rect) + val rect = node.casted.boundsInScreen + val p = locationProps.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 + if (!ScreenUtils.inScreen(x, y)) { + return ActionResult( + action = action, + result = false, + position = x to y, + ) + } + TrackService.addXyPosition(x, y) return ActionResult( action = action, - result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { - val result = safeLongTap(x, y, longClickDuration) - if (result != null) { - return ActionResult(action, result, true, position = x to y) - } + result = if (shizukuContextFlow.value.tap(x, y, LONG_DURATION)) { + true + } else { val gestureDescription = GestureDescription.Builder() val path = Path() path.moveTo(x, y) gestureDescription.addStroke( GestureDescription.StrokeDescription( - path, 0, longClickDuration + path, 0, LONG_DURATION ) ) - // TODO 传入处理 callback - context.dispatchGesture(gestureDescription.build(), null, null) - true - } else { - false + (A11yService.instance?.dispatchGesture( + gestureDescription.build(), null, null + ) != null).apply { + if (this) { + delay(LONG_DURATION) + } + } }, position = x to y ) @@ -155,39 +166,36 @@ sealed class ActionPerformer(val action: String) { } data object LongClick : ActionPerformer("longClick") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { if (node.isLongClickable) { - val result = LongClickNode.perform(context, node, position) + val result = LongClickNode.perform(node, locationProps) if (result.result) { return result } } - return LongClickCenter.perform(context, node, position) + return LongClickCenter.perform(node, locationProps) } } data object Back : ActionPerformer("back") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { return ActionResult( action = action, - result = context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) + result = A11yRuleEngine.performActionBack() ) } } data object None : ActionPerformer("none") { - override fun perform( - context: AccessibilityService, + override suspend fun perform( node: AccessibilityNodeInfo, - position: RawSubscription.Position?, + locationProps: RawSubscription.LocationProps, ): ActionResult { return ActionResult( action = action, @@ -196,6 +204,75 @@ sealed class ActionPerformer(val action: String) { } } + data object Swipe : ActionPerformer("swipe") { + override suspend fun perform( + node: AccessibilityNodeInfo, + locationProps: RawSubscription.LocationProps, + ): ActionResult { + val rect = node.casted.boundsInScreen + val swipeArg = locationProps.swipeArg ?: return ActionResult( + action = action, + result = false, + ) + val startP = swipeArg.start.calc(rect) + val endP = swipeArg.end?.calc(rect) ?: startP + if (startP == null || endP == null) { + return ActionResult( + action = action, + result = false, + ) + } + val startX = startP.first + val startY = startP.second + val endX = endP.first + val endY = endP.second + if (!(ScreenUtils.inScreen(startX, startY) && ScreenUtils.inScreen(endX, endY))) { + return ActionResult( + action = action, + result = false, + position = endX to endY, + ) + } + TrackService.addSwipePosition(startX, startY, endX, endY, swipeArg.duration) + return if (shizukuContextFlow.value.swipe( + startX, + startY, + endX, + endY, + swipeArg.duration + ) + ) { + ActionResult( + action = action, + result = true, + shell = true, + position = endX to endY, + ) + } else { + val gestureDescription = GestureDescription.Builder() + val path = Path() + path.moveTo(startX, startY) + path.lineTo(endX, endY) + gestureDescription.addStroke( + GestureDescription.StrokeDescription( + path, 0, swipeArg.duration + ) + ) + ActionResult( + action = action, + result = (A11yService.instance?.dispatchGesture( + gestureDescription.build(), null, null + ) != null).apply { + if (this) { + delay(swipeArg.duration) + } + }, + position = endX to endY, + ) + } + } + } + companion object { private val allSubObjects by lazy { arrayOf( @@ -206,7 +283,8 @@ sealed class ActionPerformer(val action: String) { LongClickCenter, LongClick, Back, - None + None, + Swipe, ) } 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..eae7424ba2 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt @@ -1,10 +1,10 @@ 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.LogUtils import li.songe.gkd.util.toast import kotlin.system.measureTimeMillis @@ -77,8 +77,10 @@ fun info2nodeList(root: AccessibilityNodeInfo?): List { if (times > MAX_KEEP_SIZE) { // https://github.com/gkd-kit/gkd/issues/28 toast("节点数量至多保留$MAX_KEEP_SIZE,丢弃后续节点") - LogUtils.w( - root.packageName, topActivityFlow.value.activityId, "节点数量过多" + LogUtils.d( + "节点数量过多", + root.packageName, + topActivityFlow.value.activityId, ) break } 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..a8423fc3e1 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt @@ -1,7 +1,6 @@ package li.songe.gkd.data import android.graphics.Rect -import com.blankj.utilcode.util.LogUtils import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -15,9 +14,11 @@ 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.LogUtils +import li.songe.gkd.util.ScreenUtils +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,26 +53,39 @@ 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 categoryToGroupsMap by lazy { - val allAppGroups = apps.flatMap { a -> a.groups.map { g -> g to a } } - allAppGroups.groupBy { g -> - categories.find { c -> g.first.name.startsWith(c.name) } - } + val hasRule get() = globalGroups.isNotEmpty() || apps.any { it.groups.isNotEmpty() } + + fun getSafeCategory(key: Int): RawCategory { + return categories.find { it.key == key } ?: RawCategory( + key = key, + name = key.toString(), + enable = false, + desc = null + ) } - val categoryToAppMap by lazy { - val map = mutableMapOf>() + private val categoryAppsMap: Map> by lazy { + val map = mutableMapOf>() + val usedGroups = mutableListOf() categories.forEach { c -> apps.forEach { a -> - if (a.groups.any { g -> g.name.startsWith(c.name) }) { - val list = map[c] + val subGroups = a.groups.filter { g -> + g.name.startsWith(c.name) && !usedGroups.any { g2 -> g2 === g } + } + if (subGroups.isNotEmpty()) { + usedGroups.addAll(subGroups) + val list = map[c.key] + val b = a.copy(groups = subGroups) if (list == null) { - map[c] = mutableListOf(a) + map[c.key] = mutableListOf(b) } else { - list.add(a) + list.add(b) } } } @@ -79,6 +93,20 @@ data class RawSubscription( map } + private val categoryGroupsMap: Map> by lazy { + categoryAppsMap.mapValues { (key, apps) -> + apps.flatMap { it.groups } + } + } + + fun getCategoryApps(categoryKey: Int): List { + return categoryAppsMap[categoryKey] ?: emptyList() + } + + fun getCategory(groupName: String): RawCategory? { + return categories.find { c -> groupName.startsWith(c.name) } + } + fun getAppGroups(appId: String): List { return apps.find { a -> a.id == appId }?.groups ?: emptyList() } @@ -86,21 +114,23 @@ 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() ) } - val groupToCategoryMap by lazy { - val map = mutableMapOf() - categoryToGroupsMap.forEach { (key, value) -> - value.forEach { (g) -> - if (key != null) { - map[g] = key - } - } - } - map + fun getAppByGroup(group: RawAppGroup): RawApp { + return apps.find { a -> a.groups.any { g -> g === group } } + ?: throw IllegalStateException("App not found for group ${group.name}") + } + + fun getCategoryCompatDesc(categoryKey: Int): String? { + val c = getSafeCategory(categoryKey) + if (!c.desc.isNullOrBlank()) return c.desc + val groupSize = categoryGroupsMap[categoryKey]?.size ?: 0 + val appSize = categoryAppsMap[categoryKey]?.size ?: 0 + if (groupSize > 0) return "${appSize}应用/${groupSize}规则" + return null } val appGroups by lazy { @@ -153,7 +183,7 @@ data class RawSubscription( } else { "" } + if (appGroupsSize > 0) { - "${appsSize}应用/${appGroupsSize}规则组" + "${appsSize}应用/${appGroupsSize}规则" } else { "" } @@ -209,20 +239,35 @@ data class RawSubscription( @Serializable - data class RawCategory(val key: Int, val name: String, val enable: Boolean?) + data class RawCategory( + val key: Int, + val name: String, + val enable: Boolean?, + val desc: String?, + ) @Serializable data class Position( - val left: String?, val top: String?, val right: String?, val bottom: String? + val left: String?, + val top: String?, + val right: String?, + val bottom: String?, + val x: String?, + val y: String?, ) { private val leftExp by lazy { getExpression(left) } private val topExp by lazy { getExpression(top) } private val rightExp by lazy { getExpression(right) } private val bottomExp by lazy { getExpression(bottom) } + private val xExp by lazy { getExpression(x) } + private val yExp by lazy { getExpression(y) } + + private val xArr by lazy { arrayOf(leftExp, rightExp, xExp) } + private val yArr by lazy { arrayOf(topExp, bottomExp, yExp) } val isValid by lazy { - ((leftExp != null && (topExp != null || bottomExp != null)) || (rightExp != null && (topExp != null || bottomExp != null))) + xArr.any { it != null } && yArr.any { it != null } } /** @@ -230,37 +275,30 @@ data class RawSubscription( */ fun calc(rect: Rect): Pair? { if (!isValid) return null - arrayOf( - leftExp, topExp, rightExp, bottomExp - ).forEach { exp -> - if (exp != null) { - setVariables(exp, rect) - } - } + xArr.forEach { setVariables(it, rect) } + yArr.forEach { setVariables(it, rect) } try { - if (leftExp != null) { - if (topExp != null) { - return (rect.left + leftExp!!.evaluate() - .toFloat()) to (rect.top + topExp!!.evaluate().toFloat()) - } - if (bottomExp != null) { - return (rect.left + leftExp!!.evaluate() - .toFloat()) to (rect.bottom - bottomExp!!.evaluate().toFloat()) - } - } else if (rightExp != null) { - if (topExp != null) { - return (rect.right - rightExp!!.evaluate() - .toFloat()) to (rect.top + topExp!!.evaluate().toFloat()) - } - if (bottomExp != null) { - return (rect.right - rightExp!!.evaluate() - .toFloat()) to (rect.bottom - bottomExp!!.evaluate().toFloat()) - } + val x0 = xArr.find { it != null }!!.evaluate().toFloat() + val y0 = yArr.find { it != null }!!.evaluate().toFloat() + val x = when { + leftExp != null -> rect.left + x0 + rightExp != null -> rect.right - x0 + xExp != null -> x0 + else -> null + } + val y = when { + topExp != null -> rect.top + y0 + bottomExp != null -> rect.bottom - y0 + yExp != null -> y0 + else -> null + } + if (x != null && y != null) { + return x to y } } catch (e: Exception) { // 可能存在 1/0 导致错误 e.printStackTrace() - LogUtils.d(e) + LogUtils.d("Position.calc", e) toast(e.message ?: e.stackTraceToString()) } return null @@ -288,12 +326,26 @@ data class RawSubscription( val priorityActionMaximum: Int? } - sealed interface RawRuleProps : RawCommonProps { + @Serializable + data class SwipeArg( + val start: Position, + val end: Position?, + val duration: Long, + ) + + interface LocationProps { + // click + val position: Position? + + // swipe + val swipeArg: SwipeArg? + } + + sealed interface RawRuleProps : RawCommonProps, LocationProps { val name: String? val key: Int? val preKeys: List? val action: String? - val position: Position? val matches: List? val anyMatches: List? val excludeMatches: List? @@ -453,6 +505,7 @@ data class RawSubscription( override val preKeys: List?, override val action: String?, override val position: Position?, + override val swipeArg: SwipeArg?, override val matches: List?, override val excludeMatches: List?, override val excludeAllMatches: List?, @@ -513,6 +566,7 @@ data class RawSubscription( override val preKeys: List?, override val action: String?, override val position: Position?, + override val swipeArg: SwipeArg?, override val matches: List?, override val excludeMatches: List?, override val excludeAllMatches: List?, @@ -546,9 +600,9 @@ data class RawSubscription( companion object { private fun RawGroupProps.getErrorDesc(): String? { - val allSelectorStrings = rules.map { r -> + val allSelectorStrings = rules.flatMap { r -> r.getAllSelectorStrings() - }.flatten() + } allSelectorStrings.forEach { source -> try { val selector = Selector.parse(source) @@ -567,17 +621,20 @@ data class RawSubscription( return null } - private val expVars = arrayOf( + private val preFillExpVars = arrayOf( "left", "top", "right", "bottom", "width", "height", - "random" + "random", + "screenWidth", + "screenHeight", ) - private fun setVariables(exp: Expression, rect: Rect) { + private fun setVariables(exp: Expression?, rect: Rect) { + if (exp == null) return exp.setVariable("left", rect.left.toDouble()) exp.setVariable("top", rect.top.toDouble()) exp.setVariable("right", rect.right.toDouble()) @@ -585,13 +642,15 @@ data class RawSubscription( exp.setVariable("width", rect.width().toDouble()) exp.setVariable("height", rect.height().toDouble()) exp.setVariable("random", Math.random()) + exp.setVariable("screenWidth", ScreenUtils.getScreenWidth().toDouble()) + exp.setVariable("screenHeight", ScreenUtils.getScreenHeight().toDouble()) } private fun getExpression(value: String?): Expression? { return if (value != null) { try { - ExpressionBuilder(value).variables(*expVars).build().apply { - expVars.forEach { v -> + ExpressionBuilder(value).variables(*preFillExpVars).build().apply { + preFillExpVars.forEach { v -> // 预填充作 validate setVariable(v, 0.0) } @@ -611,22 +670,37 @@ data class RawSubscription( } } - private fun getPosition(jsonObject: JsonObject?): Position? { - return when (val element = jsonObject?.get("position")) { - JsonNull, null -> null - is JsonObject -> { - Position( - left = element["left"]?.jsonPrimitive?.content, - bottom = element["bottom"]?.jsonPrimitive?.content, - top = element["top"]?.jsonPrimitive?.content, - right = element["right"]?.jsonPrimitive?.content, - ) - } + private fun getPosition(jsonObject: JsonObject?, useSelf: Boolean = false): Position? { + return when (val element = if (useSelf) jsonObject else jsonObject?.get("position")) { + is JsonObject -> Position( + left = element["left"]?.jsonPrimitive?.content, + bottom = element["bottom"]?.jsonPrimitive?.content, + top = element["top"]?.jsonPrimitive?.content, + right = element["right"]?.jsonPrimitive?.content, + x = element["x"]?.jsonPrimitive?.content, + y = element["y"]?.jsonPrimitive?.content, + ) else -> null } } + private fun getSwipeArg( + jsonObject: JsonObject? + ): SwipeArg? = when (val element = jsonObject?.get("swipeArg")) { + is JsonObject -> { + SwipeArg( + start = getPosition(element["start"]?.jsonObject, true) + ?: error("swipe start position is required"), + end = getPosition(element["end"]?.jsonObject, true), + duration = element["duration"]?.jsonPrimitive?.long + ?: error("swipe duration is required"), + ) + } + + else -> null + } + private fun getStringIArray(jsonObject: JsonObject?, name: String): List? { return when (val element = jsonObject?.get(name)) { JsonNull, null -> null @@ -795,6 +869,7 @@ data class RawSubscription( versionCode = getCompatVersionCode(jsonObject), versionName = getCompatVersionName(jsonObject), position = getPosition(jsonObject), + swipeArg = getSwipeArg(jsonObject), forcedTime = getLong(jsonObject, "forcedTime"), priorityTime = getLong(jsonObject, "priorityTime"), priorityActionMaximum = getInt(jsonObject, "priorityActionMaximum"), @@ -912,6 +987,7 @@ data class RawSubscription( order = getInt(jsonObject, "order"), forcedTime = getLong(jsonObject, "forcedTime"), position = getPosition(jsonObject), + swipeArg = getSwipeArg(jsonObject), priorityTime = getLong(jsonObject, "priorityTime"), priorityActionMaximum = getInt(jsonObject, "priorityActionMaximum"), ) @@ -979,6 +1055,7 @@ data class RawSubscription( name = getString(jsonElement.jsonObject, "name") ?: error("miss categories[$index].name"), enable = getBoolean(jsonElement.jsonObject, "enable"), + desc = getString(jsonElement.jsonObject, "desc") ) } ?: emptyList()).filterIfNotAll { it.name.isNotEmpty() } .distinctByIfAny { it.key }, 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..6a9521ec64 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,14 @@ 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 kotlinx.coroutines.flow.updateAndGet +import li.songe.gkd.a11y.appChangeTime +import li.songe.gkd.a11y.lastTriggerRule +import li.songe.gkd.a11y.lastTriggerTime +import li.songe.gkd.store.actionCountFlow import li.songe.selector.MatchOption import li.songe.selector.Selector @@ -67,7 +70,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 } @@ -78,9 +81,9 @@ sealed class ResolvedRule( val selfGroupRules = field[group] ?: emptyList() val othersGroupRules = (group.scopeKeys ?: emptyList()).distinct().filter { k -> k != group.key } - .map { k -> + .flatMap { k -> field.entries.find { e -> e.key.key == k }?.value ?: emptyList() - }.flatten() + } val groupRules = selfGroupRules + othersGroupRules // 共享次数 @@ -107,11 +110,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 +122,25 @@ 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() - lastTriggerTime = actionTriggerTime.value - // 重置延迟点 - actionDelayTriggerTime = 0L - actionCount.value++ + val t = System.currentTimeMillis() + actionTriggerTime.value = t + actionDelayTriggerTime.value = 0L + actionCount.incrementAndGet() + lastTriggerTime = t lastTriggerRule = this + actionCountFlow.updateAndGet { it + 1 } } - 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 +150,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 + } ?: rule.swipeArg?.let { + ActionPerformer.Swipe.action }) - fun performAction( - context: AccessibilityService, - node: AccessibilityNodeInfo - ): ActionResult { - return performer.perform(context, node, rule.position) - } + suspend fun performAction(node: AccessibilityNodeInfo) = performer.perform(node, rule) - var matchDelayJob: Job? = null + val matchDelayJob = atomic(null) val status: RuleStatus get() { @@ -183,17 +178,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 // 处于触发延迟中 } } @@ -240,7 +237,7 @@ fun getFixActivityIds( appId: String, activityIds: List?, ): List { - if (activityIds == null || activityIds.isEmpty()) return emptyList() + if (activityIds.isNullOrEmpty()) return emptyList() return activityIds.map { activityId -> if (activityId.startsWith('.')) { // .a.b.c -> com.x.y.x.a.b.c appId + activityId 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..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( @@ -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/data/SubsConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt index 039beb6b43..a3429b478f 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,29 +11,28 @@ 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.isValidActivityId +import li.songe.gkd.util.isValidAppId 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 @Entity( tableName = "subs_config", ) -@Parcelize data class SubsConfig( @PrimaryKey @ColumnInfo(name = "id") val id: Long = buildUniqueTimeMillisId(), @ColumnInfo(name = "type") val type: Int, @@ -43,7 +41,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 { @@ -54,6 +52,9 @@ data class SubsConfig( @Dao interface SubsConfigDao { + @Query("SELECT * FROM subs_config") + suspend fun queryAll(): List + @Update suspend fun update(vararg objects: SubsConfig): Int @@ -156,7 +157,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) { @@ -164,7 +165,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") } } @@ -201,24 +202,33 @@ 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) { + if (activityId.isValidActivityId()) { + activityIds.add(appId to activityId) + } + } else { + appIds[appId] = true + } } } } @@ -228,8 +238,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 deleted file mode 100644 index b24f47215c..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/data/TransferData.kt +++ /dev/null @@ -1,115 +0,0 @@ -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.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.toast -import li.songe.gkd.util.updateSubscription -import java.io.File - -@Serializable -private data class TransferData( - val type: String = TYPE, - val ctime: Long = System.currentTimeMillis(), - val subsItems: List = emptyList(), - val subsConfigs: List = emptyList(), - val categoryConfigs: List = emptyList(), - val appConfigs: List = emptyList() -) { - companion object { - const val TYPE = "transfer_data" - } -} - -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) } - .mapIndexed { i, s -> - s.copy(order = maxOrder + i) - } - val hasNewSubsItem = - subsItems.any { newSubs -> newSubs.id >= 0 && subsItemsFlow.value.all { oldSubs -> oldSubs.id != newSubs.id } } - DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray()) - DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray()) - DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.toTypedArray()) - DbSet.appConfigDao.insertOrIgnore(*transferData.appConfigs.toTypedArray()) - return hasNewSubsItem -} - -suspend fun exportData(subsIds: Collection): File { - val tempDir = createTempDir() - val dataFile = tempDir.resolve("${TransferData.TYPE}.json") - dataFile.writeText( - json.encodeToString( - TransferData( - subsItems = subsItemsFlow.value.filter { subsIds.contains(it.id) }, - subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsIds.toList()), - categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsIds.toList()), - appConfigs = DbSet.appConfigDao.querySubsItemConfig(subsIds.toList()), - ) - ) - ) - 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 file = sharedDir.resolve("backup-${System.currentTimeMillis()}.zip") - ZipUtils.zipFiles(listOf(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") - if (!transferFile.exists() || !transferFile.isFile) { - toast("导入无数据") - tempDir.deleteRecursively() - return - } - val data = withContext(Dispatchers.Default) { - 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 - } - } - subscriptions.forEach { subscription -> - if (LOCAL_SUBS_IDS.contains(subscription.id)) { - updateSubscription(subscription) - } - } - toast("导入成功") - tempDir.deleteRecursively() - if (hasNewSubsItem) { - delay(1000) - checkSubsUpdate(true) - } -} 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/db/AppDb.kt b/app/src/main/kotlin/li/songe/gkd/db/AppDb.kt index e1ee634774..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,18 +4,26 @@ 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 +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 +import li.songe.gkd.util.dbFolder +import li.songe.gkd.util.json @Database( - version = 11, + version = 14, entities = [ SubsItem::class, Snapshot::class, @@ -24,6 +32,8 @@ import li.songe.gkd.data.SubsItem ActionLog::class, ActivityLog::class, AppConfig::class, + AppVisitLog::class, + A11yEventLog::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -36,8 +46,12 @@ 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), + 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 @@ -46,6 +60,8 @@ abstract class AppDb : RoomDatabase() { abstract fun categoryConfigDao(): CategoryConfig.CategoryConfigDao abstract fun actionLogDao(): ActionLog.ActionLogDao abstract fun activityLogDao(): ActivityLog.ActivityLogDao + abstract fun appVisitLogDao(): AppVisitLog.AppLogDao + abstract fun a11yEventLogDao(): A11yEventLog.A11yEventLogDao } @RenameColumn( @@ -73,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 d4903cd92d..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/db/DbSet.kt +++ /dev/null @@ -1,32 +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() -} \ No newline at end of file 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 669c8c8d73..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt +++ /dev/null @@ -1,108 +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).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/debug/SnapshotActionService.kt b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotActionService.kt deleted file mode 100644 index 1cebe5ab3e..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotActionService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package li.songe.gkd.debug - -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.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(this) - appScope.launchTry { - try { - SnapshotExt.captureSnapshot() - } finally { - stopSelf() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt b/app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt deleted file mode 100644 index e48de6bcf9..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt +++ /dev/null @@ -1,70 +0,0 @@ -package li.songe.gkd.debug - -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.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.shizuku.safeGetTopActivity -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("获取界面信息根节点失败") - - val startTime = System.currentTimeMillis() - fun timeout(): Boolean { - return System.currentTimeMillis() - startTime > 3000L - } - - while (true) { - 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.launch(A11yService.eventThread) { - val topActivity = safeGetTopActivity() ?: TopActivity(appId = latestAppId) - updateTopActivity(topActivity) - getAndUpdateCurrentRules() - appScope.launchTry(Dispatchers.IO) { - captureSnapshot() - } - } - 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/notif/Notif.kt b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt index 8d39b52b91..35bc735a3d 100644 --- a/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt +++ b/app/src/main/kotlin/li/songe/gkd/notif/Notif.kt @@ -6,85 +6,89 @@ 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 import androidx.core.net.toUri +import kotlinx.atomicfu.atomic import li.songe.gkd.META import li.songe.gkd.MainActivity +import li.songe.gkd.R 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.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState -import li.songe.gkd.util.SafeR +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.ScreenshotService +import li.songe.gkd.service.TrackService +import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.componentName import kotlin.reflect.KClass +// 相同的 request code 会导致后续 PendingIntent 失效 +private val pendingIntentReqId = atomic(0) 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, - val text: String, - val ongoing: Boolean, - val autoCancel: Boolean, + val smallIcon: Int = R.drawable.ic_status, + val title: String, + val text: String? = null, + 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 deleteIntent = stopService?.let { - PendingIntent.getBroadcast( - app, - 0, - Intent().apply { - action = StopServiceReceiver.STOP_ACTION - putExtra(StopServiceReceiver.STOP_ACTION, it.componentName.className) - setPackage(META.appId) - }, - 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) .setContentIntent(contextIntent) - .setDeleteIntent(deleteIntent) .setOngoing(ongoing) .setAutoCancel(autoCancel) - .build() - return notification + if (stopService != null) { + val deleteIntent = PendingIntent.getBroadcast( + app, + pendingIntentReqId.incrementAndGet(), + StopServiceReceiver.getIntent(stopService), + PendingIntent.FLAG_IMMUTABLE + ) + notification + .setDeleteIntent(deleteIntent) + .addAction(0, "停止", deleteIntent) + } + return notification.build() } fun notifySelf() { if (!notificationState.updateAndGet()) return + if (!foregroundServiceSpecialUseState.updateAndGet()) return @SuppressLint("MissingPermission") NotificationManagerCompat.from(app).notify(id, toNotification()) } - fun notifyService(context: Service) { + context(service: Service) + fun notifyService() { + if (!notificationState.updateAndGet()) return + if (!foregroundServiceSpecialUseState.updateAndGet()) return ServiceCompat.startForeground( - context, + 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 ) } } @@ -92,60 +96,66 @@ data class Notif( val abNotif by lazy { Notif( id = 100, + title = META.appName, 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, + title = "截屏服务正在运行", + text = "保存快照时截取屏幕", + 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 buttonNotif = Notif( + id = 102, + title = "快照按钮服务正在运行", + text = "点击按钮捕获快照", + uri = "gkd://page/1", + stopService = ButtonService::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, + title = "HTTP服务正在运行", + uri = "gkd://page/1", + stopService = HttpService::class, +) -val snapshotNotif by lazy { - Notif( - id = 104, - text = "快照已保存至记录", - ongoing = false, - autoCancel = true, - uri = "gkd://page/2", - ) -} +val exposeNotif = Notif( + id = 104, + title = "运行外部调用任务中", + text = "任务完成后自动关闭", +) -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 = 105, + title = "快照已保存", + ongoing = false, + autoCancel = true, + uri = "gkd://page/2", +) + +val recordNotif = Notif( + id = 106, + title = "记录服务正在运行", + uri = "gkd://page/1", + stopService = ActivityService::class, +) + +val eventNotif = Notif( + id = 107, + title = "事件服务正在运行", + uri = "gkd://page/1", + stopService = EventService::class, +) + +val trackNotif = Notif( + id = 108, + title = "轨迹服务正在运行", + uri = "gkd://page?tab=3", + stopService = TrackService::class, +) 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..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,39 +3,40 @@ 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 -data class NotifChannel( +sealed class NotifChannel( val id: String, - val name: String, - val desc: String, -) - -val defaultChannel by lazy { - NotifChannel( - id = "default", - name = "GKD", - desc = "默认通知渠道", + val name: String? = null, + val desc: String? = null, +) { + data object Default : NotifChannel( + id = "0", ) -} -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 ?: META.appName, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = it.desc + } + manager.createNotificationChannel(channel) + } } 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..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,29 +7,30 @@ 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.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() } } 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().apply { + action = STOP_ACTION + putExtra(STOP_ACTION, clazz.jvmName) + setPackage(META.appId) + } + + context(service: T) + 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/PermissionDialog.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt index e7c340f28b..6179811222 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt @@ -14,7 +14,6 @@ import li.songe.gkd.util.stopCoroutine data class AuthReason( val text: () -> String, val confirm: ((Activity) -> Unit)? = null, - val renderConfirm: @Composable (() -> ((Activity) -> Unit))? = null, ) @Composable @@ -31,10 +30,9 @@ fun AuthDialog(authReasonFlow: MutableStateFlow) { }, onDismissRequest = { authReasonFlow.value = null }, confirmButton = { - val composeConfirm = authAction.renderConfirm?.invoke() TextButton(onClick = { authReasonFlow.value = null - (composeConfirm ?: authAction.confirm)?.invoke(context) + authAction.confirm?.invoke(context) }) { Text(text = "确认") } @@ -53,31 +51,19 @@ sealed class PermissionResult { data class Denied(val doNotAskAgain: Boolean) : PermissionResult() } -private suspend fun checkOrRequestPermission( - context: MainActivity, - permissionState: PermissionState -): Boolean { - if (!permissionState.updateAndGet()) { - val result = permissionState.request?.invoke(context) - if (result == null) { - context.mainVm.authReasonFlow.value = permissionState.reason - return false - } else if (result is PermissionResult.Denied) { - if (result.doNotAskAgain) { - context.mainVm.authReasonFlow.value = permissionState.reason - } - return false - } - } - return true -} - suspend fun requiredPermission( context: MainActivity, permissionState: PermissionState ) { - val r = checkOrRequestPermission(context, permissionState) - if (!r) { + if (permissionState.updateAndGet()) return + val result = permissionState.request?.invoke(context) + if (result == null) { + context.mainVm.authReasonFlow.value = permissionState.reason + stopCoroutine() + } else if (result is PermissionResult.Denied) { + if (result.doNotAskAgain) { + context.mainVm.authReasonFlow.value = permissionState.reason + } stopCoroutine() } } 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..00076c8870 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -3,38 +3,34 @@ package li.songe.gkd.permission import android.Manifest import android.app.Activity import android.app.AppOpsManager +import android.app.AppOpsManagerHidden 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.Dispatchers +import kotlinx.coroutines.CompletableDeferred 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.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.updateAndGet import li.songe.gkd.MainActivity +import li.songe.gkd.MainViewModel 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 -import li.songe.gkd.ui.local.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.shizuku.SafeAppOpsService +import li.songe.gkd.shizuku.SafePackageManager +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.ui.AppOpsAllowRoute +import li.songe.gkd.util.AndroidTarget 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 name: String, val check: () -> Boolean, val request: (suspend (context: MainActivity) -> PermissionResult)? = null, /** @@ -43,46 +39,27 @@ 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 updateChanged(): Boolean { + return value != updateAndGet() + } + + fun checkOrToast(): Boolean = if (!updateAndGet()) { val r = updateAndGet() if (!r) { reason?.text?.let { toast(it()) } } - return r + r + } else { + true } } -private fun checkSelfPermission(permission: String): Boolean { - return ContextCompat.checkSelfPermission( - app, - permission - ) == 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, @@ -90,99 +67,134 @@ 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") -private fun checkOpNoThrow(op: String): Int { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - try { - return 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 // https://github.com/gkd-kit/gkd/issues/887 val foregroundServiceSpecialUseState by lazy { PermissionState( + name = "特殊用途的前台服务", check = { - checkOpNoThrow("android:foreground_service_special_use") != AppOpsManager.MODE_IGNORED + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + checkAllowedOp(AppOpsManagerHidden.OPSTR_FOREGROUND_SERVICE_SPECIAL_USE) + } else { + true + } }, reason = AuthReason( text = { "当前操作权限「特殊用途的前台服务」已被限制, 请先解除限制" }, - renderConfirm = { - val mainVm = LocalMainViewModel.current - { - mainVm.navigatePage(AppOpsAllowPageDestination) + confirm = { + MainViewModel.instance.navigatePage(AppOpsAllowRoute) + }, + ), + ) +} + +// https://github.com/orgs/gkd-kit/discussions/1234 +val accessA11yState by lazy { + PermissionState( + name = "访问无障碍", + check = { + if (AndroidTarget.Q) { + checkAllowedOp(AppOpsManagerHidden.OPSTR_ACCESS_ACCESSIBILITY) + } else { + true + } + }, + ) +} + +val createA11yOverlayState by lazy { + PermissionState( + name = "创建无障碍悬浮窗", + check = { + if (SafeAppOpsService.supportCreateA11yOverlay) { + checkAllowedOp(AppOpsManagerHidden.OPSTR_CREATE_ACCESSIBILITY_OVERLAY) + } else { + true + } + }, + ) +} + +const val Manifest_permission_GET_APP_OPS_STATS = "android.permission.GET_APP_OPS_STATS" + +val getAppOpsStatsState by lazy { + PermissionState( + name = "获取应用权限状态", + check = { + app.checkGrantedPermission(Manifest_permission_GET_APP_OPS_STATS) + }, + ) +} + +private var canRestrictsRead = true +val accessRestrictedSettingsState by lazy { + PermissionState( + name = "访问受限设置", + check = { + if (canRestrictsRead && AndroidTarget.UPSIDE_DOWN_CAKE && getAppOpsStatsState.updateAndGet()) { + try { + // https://cs.android.com/android/platform/superproject/+/android-14.0.0_r55:frameworks/base/services/core/java/com/android/server/appop/AppOpsService.java;l=4237 + checkAllowedOp(AppOpsManagerHidden.OPSTR_ACCESS_RESTRICTED_SETTINGS) + } catch (_: SecurityException) { + // https://cs.android.com/android/platform/superproject/+/android-14.0.0_r54:frameworks/base/services/core/java/com/android/server/appop/AppOpsService.java;l=4227 + canRestrictsRead = false + true } + } else { + true } - ), + }, ) } +val appOpsRestrictStateList by lazy { + arrayOf( + accessA11yState, + createA11yOverlayState, + accessRestrictedSettingsState, + foregroundServiceSpecialUseState, + ) +} + +val appOpsRestrictedFlow by lazy { + combine( + *appOpsRestrictStateList.map { it.stateFlow }.toTypedArray(), + ) { list -> + list.any { !it } + }.stateIn(appScope, SharingStarted.Eagerly, false) +} + val notificationState by lazy { val permission = PermissionLists.getNotificationServicePermission() PermissionState( + name = "通知权限", check = { XXPermissions.isGrantedPermission(app, permission) }, @@ -198,9 +210,16 @@ val notificationState by lazy { val canQueryPkgState by lazy { val permission = PermissionLists.getGetInstalledAppsPermission() + val supported by lazy { permission.isSupportRequestPermission(app) } PermissionState( + name = "读取应用列表权限", check = { - XXPermissions.isGrantedPermission(app, permission) + if (supported) { + // 此框架内部有两个 printStackTrace 导致每次检测都会打印日志污染控制台 + XXPermissions.isGrantedPermission(app, permission) + } else { + true + } }, request = { asyncRequestPermission(it, permission) @@ -216,17 +235,14 @@ 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) }, reason = AuthReason( text = { - if (isActivityVisible()) { - "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" - } else { - "缺少「悬浮窗权限」请先授权\n或当前应用拒绝显示悬浮窗" - } + "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" }, confirm = { XXPermissions.startPermissionActivity( @@ -240,18 +256,19 @@ val canDrawOverlaysState by lazy { val canWriteExternalStorage by lazy { PermissionState( + name = "写入外部存储权限", check = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { + if (AndroidTarget.Q) { true + } else { + app.checkGrantedPermission(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( @@ -266,33 +283,79 @@ val canWriteExternalStorage by lazy { ) } +val ignoreBatteryOptimizationsState by lazy { + val permission = PermissionLists.getRequestIgnoreBatteryOptimizationsPermission() + PermissionState( + name = "忽略电池优化权限", + 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) }, + name = "写入安全设置权限", + check = { app.checkGrantedPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, ) } -val shizukuOkState by lazy { +private fun shizukuCheckGranted(): Boolean { + if (Shizuku.getBinder()?.isBinderAlive != true) return false + 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( + name = "Shizuku 权限", check = { shizukuCheckGranted() }, ) } -fun updatePermissionState() { - val stateChanged = canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet() - if (!updateAppMutex.mutex.isLocked && (stateChanged || mayQueryPkgNoAccessFlow.value)) { - appScope.launchTry(Dispatchers.IO) { - initOrResetAppInfoCache() - } - } else { - forceUpdateAppList() - } - arrayOf( +val allPermissionStates by lazy { + listOf( notificationState, foregroundServiceSpecialUseState, + accessA11yState, + createA11yOverlayState, + getAppOpsStatsState, + accessRestrictedSettingsState, canDrawOverlaysState, canWriteExternalStorage, + ignoreBatteryOptimizationsState, writeSecureSettingsState, - shizukuOkState, - ).forEach { it.updateAndGet() } + canQueryPkgState, + shizukuGrantedState, + ) +} + +fun updatePermissionState() { + allPermissionStates.forEach { + if (it === canQueryPkgState && !updateAppMutex.mutex.isLocked) { + if (canQueryPkgState.updateChanged()) { + updateAllAppInfo() + } + } else { + it.updateAndGet() + } + } } \ No newline at end of file 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 d899fb0c83..759ffc5b0f 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/A11yService.kt @@ -1,567 +1,177 @@ 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.annotation.SuppressLint +import android.content.Context.WINDOW_SERVICE 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.Gravity 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 android.view.accessibility.AccessibilityWindowInfo 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.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.debug.SnapshotExt -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.UpdateTimeOption -import li.songe.gkd.util.checkSubsUpdate +import kotlinx.coroutines.suspendCancellableCoroutine +import li.songe.gkd.a11y.A11yCommonImpl +import li.songe.gkd.a11y.A11yRuleEngine +import li.songe.gkd.a11y.topActivityFlow +import li.songe.gkd.a11y.updateTopActivity +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.store.updateEnableAutomator +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.AutomatorModeOption +import li.songe.gkd.util.DefaultA11yLifeImpl +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.OnA11yLife import li.songe.gkd.util.componentName -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.map -import li.songe.gkd.util.showActionToast +import li.songe.gkd.util.runMainPost 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 -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() - } - - override val a11yEventCallbacks = mutableListOf<(AccessibilityEvent) -> Unit>() - override fun onAccessibilityEvent(event: AccessibilityEvent?) { - if (event == null || !event.isUseful()) return - onA11yEvent(event) - } - - override fun onInterrupt() {} - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = CoroutineScope(Dispatchers.Default).apply { onDestroyed { cancel() } } - - init { - useLogLifecycle() - useRunningState() - useAliveView() - useCaptureVolume() - onA11yEvent(::handleCaptureScreenshot) - useAutoUpdateSubs() - useRuleChangedLog() - useAutoCheckShizuku() - serviceWrapperFlow - useMatchRule() - onCreated { toast("无障碍已启动") } - onDestroyed { toast("无障碍已停止") } - } - - 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() } - - fun execAction(gkdAction: GkdAction): ActionResult { - val serviceVal = 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( - 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) - } +@SuppressLint("AccessibilityPolicy") +abstract class A11yService : AccessibilityService(), OnA11yLife by DefaultA11yLifeImpl(), + A11yCommonImpl { + override val mode get() = AutomatorModeOption.A11yMode + override val windowNodeInfo: AccessibilityNodeInfo? get() = rootInActiveWindow + override val windowInfos: List get() = windows + override suspend fun screenshot(): Bitmap? = suspendCancellableCoroutine { cont -> + if (AndroidTarget.R) { + takeScreenshot( + Display.DEFAULT_DISPLAY, + application.mainExecutor, + object : TakeScreenshotCallback { + override fun onFailure(errorCode: Int) { + if (cont.isActive) { + cont.resume(null) + } + } - 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 + if (cont.isActive) { + cont.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 - ) - } + ) + } else { + cont.resume(null) } } -} -private fun A11yService.useMatchRule() { - val context = this + override val ruleEngine by lazy { A11yRuleEngine(this) } - var lastTriggerShizukuTime = 0L - var lastContentEventTime = 0L - val queryEvents = mutableListOf() - var queryTaskJob: Job? + override fun onCreate() = onCreated() + override fun onServiceConnected() = onA11yConnected() + override fun onInterrupt() {} + override fun onDestroy() = onDestroyed() + override fun onAccessibilityEvent(event: AccessibilityEvent?) = ruleEngine.onA11yEvent(event) - 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 startTime = System.currentTimeMillis() + override var justStarted: Boolean = true + get() { + if (field) { + field = System.currentTimeMillis() - startTime < 3_000 } + return field } - 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() - } + private var tempShutdownFlag = false - 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 + override fun shutdown(temp: Boolean) { + if (temp) { + tempShutdownFlag = true } - return lastAppId + disableSelf() } - 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() } + private var destroyed = false + private var connected = false + + val wm by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } + + init { + useLogLifecycle() + useAliveFlow(isRunning) + onA11yConnected { instance = this } + onDestroyed { instance = null } + onCreated { + if (currentAppUseA11y) { + updateEnableAutomator(true) + } else { + toast("当前为自动化模式,无障碍将自动关闭", forced = true) + runMainPost(1) { shutdown(true) } } } - 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 - ) - ) - } + onDestroyed { + if (tempShutdownFlag) { + toast("无障碍局部关闭") } 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) + toast("无障碍已关闭") + updateEnableAutomator(false) + } + } + useAliveOverlayView() + onCreated { StatusService.autoStart() } + onDestroyed { + synchronized(topActivityFlow) { + shizukuContextFlow.value.topCpn()?.let { cpn -> + // com.android.systemui + if (!topActivityFlow.value.sameAs(cpn.packageName, cpn.className)) { + updateTopActivity(cpn.packageName, cpn.className) } - 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 + onDestroyed { destroyed = true } + onA11yConnected { + connected = true + toast("无障碍已启动") + if (currentAppUseA11y) { + ruleEngine.onA11yConnected() } } - // 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()) + onCreated { + runMainPost(3000) { + if (!(destroyed || connected)) { + toast("无障碍启动超时,请尝试关闭重启", forced = true) + } } } } -} -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) } - } - ManageService.autoStart() - } - onDestroyed { - if (storeFlow.value.enableService) { - storeFlow.update { it.copy(enableService = false) } - } - A11yService.weakInstance = WeakReference(null) - A11yService.isRunning.value = false - } -} + companion object { + val a11yCn by lazy { SelectToSpeakService::class.componentName } + val isRunning = MutableStateFlow(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() - } - } - } + @Volatile + var instance: A11yService? = null + private set } } -private fun A11yService.useAliveView() { +private fun A11yService.useAliveOverlayView() { val context = this var aliveView: View? = null val wm by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } - suspend fun removeA11View() { + fun removeA11View() { if (aliveView != null) { - withContext(Dispatchers.Main) { - wm.removeView(aliveView) - } + wm.removeView(aliveView) aliveView = null } } - suspend fun addA11View() { + fun addA11View() { removeA11View() val tempView = View(context) val lp = WindowManager.LayoutParams().apply { @@ -569,146 +179,21 @@ private fun A11yService.useAliveView() { 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 } - 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.map(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.map(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) + try { + // 某些设备 android.view.WindowManager$BadTokenException + wm.addView(tempView, lp) + aliveView = tempView + } catch (e: Throwable) { + aliveView = null + LogUtils.d(e) + toast("添加无障碍保活失败\n请尝试重启无障碍") } } + onA11yConnected { addA11View() } + onDestroyed { removeA11View() } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt b/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt deleted file mode 100644 index f60f2628ea..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/A11yState.kt +++ /dev/null @@ -1,251 +0,0 @@ -package li.songe.gkd.service - -import android.content.ComponentName -import android.provider.Settings -import android.view.accessibility.AccessibilityNodeInfo -import com.blankj.utilcode.util.LogUtils -import kotlinx.coroutines.Dispatchers -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 -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.SubsConfig -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 - -data class TopActivity( - val appId: String = "", - val activityId: String? = null, - val number: Int = 0 -) { - fun format(): String { - return "${appId}/${activityId}/${number}" - } - - fun sameAs(other: TopActivity): Boolean { - return appId == other.appId && activityId == other.activityId - } -} - -val topActivityFlow = MutableStateFlow(TopActivity()) -private val activityLogMutex by lazy { Mutex() } - -private var activityLogCount = 0 -private var lastActivityChangeTime = 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() - } - } - } - } - LogUtils.d( - "${topActivityFlow.value.format()} -> ${topActivity.format()}" - ) - topActivityFlow.value = topActivity - lastActivityChangeTime = System.currentTimeMillis() -} - -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 hasPriorityRule = currentRules.size > 1 && currentRules.any { it.priorityEnabled } - val activePriority: Boolean - get() = hasPriorityRule && currentRules.any { it.isPriority() } - val priorityRules: List - get() = if (hasPriorityRule) { - currentRules.sortedBy { if (it.isPriority()) 0 else 1 } - } else { - currentRules - } - val skipMatch: Boolean - get() { - return currentRules.all { r -> !r.status.ok } - } - val skipConsumeEvent: Boolean - get() { - return currentRules.all { r -> !r.status.alive } - } -} - -val activityRuleFlow by lazy { MutableStateFlow(ActivityRule()) } - -private var lastTopActivity: TopActivity = topActivityFlow.value - -private fun getFixTopActivity(): TopActivity { - val top = topActivityFlow.value - if (top.activityId == null) { - if (lastTopActivity.appId == top.appId) { - // 当从通知栏上拉返回应用, 从锁屏返回 等时, activityId 的无障碍事件不会触发, 此时复用上一次获得的 activityId 填充 - updateTopActivity(lastTopActivity) - } - } else { - // 仅保留最近的有 activityId 的单个 TopActivity - lastTopActivity = top - } - return topActivityFlow.value -} - -@Synchronized -fun getAndUpdateCurrentRules(): ActivityRule { - val topActivity = getFixTopActivity() - 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, - 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) { - appChangeTime = t - allRules.globalRules.forEach { it.resetState(t) } - allRules.appIdToRules[oldActivityRule.topActivity.appId]?.forEach { it.resetState(t) } - allAppRules.forEach { it.resetState(t) } - } else { - newActivityRule.currentRules.forEach { r -> - when (r.resetMatchType) { - ResetMatchType.App -> { - if (r.isFirstMatchApp) { - r.resetState(t) - } - } - - ResetMatchType.Activity -> r.resetState(t) - ResetMatchType.Match -> { - // is new rule - if (!oldActivityRule.currentRules.contains(r)) { - r.resetState(t) - } - } - } - } - } - activityRuleFlow.value = newActivityRule - } - return activityRuleFlow.value -} - -var lastTriggerRule: ResolvedRule? = null - -@Volatile -var lastTriggerTime = 0L - -@Volatile -var appChangeTime = 0L - -var launcherActivity = TopActivity("") -val launcherAppId: String - get() = launcherActivity.appId - -fun updateLauncherAppId() { - launcherActivity = app.packageManager.getDefaultLauncherActivity() -} - -var defaultInputAppId = "" -fun updateDefaultInputAppId() { - Settings.Secure.getString(app.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD)?.let { - ComponentName.unflattenFromString(it)?.let { comp -> - defaultInputAppId = comp.packageName - } - } -} - -private val clickLogMutex by lazy { Mutex() } -fun addActionLog( - rule: ResolvedRule, - topActivity: TopActivity, - target: AccessibilityNodeInfo, - actionResult: ActionResult, -) = appScope.launchTry(Dispatchers.IO) { - val ctime = System.currentTimeMillis() - clickLogMutex.withLock { - val actionCount = actionCountFlow.updateAndGet { it + 1 } - val actionLog = ActionLog( - appId = topActivity.appId, - activityId = topActivity.activityId, - subsId = rule.subsItem.id, - subsVersion = rule.rawSubs.version, - groupKey = rule.g.group.key, - groupType = when (rule) { - is AppRule -> SubsConfig.AppGroupType - is GlobalRule -> SubsConfig.GlobalGroupType - }, - ruleIndex = rule.index, - ruleKey = rule.key, - ctime = ctime, - ) - DbSet.actionLogDao.insert(actionLog) - if (actionCount % 100 == 0L) { - DbSet.actionLogDao.deleteKeepLatest() - } - } - LogUtils.d( - rule.statusText(), - AttrInfo.info2data(target, 0, 0), - actionResult - ) -}.let {} diff --git a/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt new file mode 100644 index 0000000000..443a2daece --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt @@ -0,0 +1,154 @@ +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.IntrinsicSize +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 +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 +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.a11y.ActivityScene +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.shizukuContextFlow +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.style.iconTextSize +import li.songe.gkd.util.copyText +import li.songe.gkd.util.startForegroundServiceByClass +import li.songe.gkd.util.stopServiceByClass + + +class ActivityService : OverlayWindowService( + positionKey = "activity" +) { + val activityOkFlow by lazy { + combine(A11yService.isRunning, shizukuContextFlow) { a, b -> + a || b.ok + }.stateIn(scope = lifecycleScope, started = SharingStarted.Eagerly, initialValue = false) + } + + @Composable + override fun ComposeContent() { + val bgColor = MaterialTheme.colorScheme.surface + Column( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(bgColor.copy(alpha = 0.9f)) + .width(IntrinsicSize.Max) + .padding(4.dp) + ) { + CompositionLocalProvider(LocalContentColor provides contentColorFor(bgColor)) { + 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 + ) + } + 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), + ) + } + } + } + } + } + } + + init { + useLogLifecycle() + useAliveFlow(isRunning) + useAliveToast("记录服务") + StopServiceReceiver.autoRegister() + onCreated { recordNotif.notifyService() } + onCreated { + lifecycleScope.launch { + topActivityFlow.collect { + recordNotif.copy(text = it.format()).notifyService() + } + } + if (!A11yService.isRunning.value) { + synchronized(topActivityFlow) { + shizukuContextFlow.value.topCpn()?.let { cpn -> + updateTopActivity( + appId = cpn.packageName, + activityId = cpn.className, + scene = ActivityScene.TaskStack, + ) + } + } + } + } + } + + companion object { + val isRunning = MutableStateFlow(false) + fun start() { + if (!canDrawOverlaysState.checkOrToast()) return + startForegroundServiceByClass(ActivityService::class) + } + + fun stop() = stopServiceByClass(ActivityService::class) + } +} + +@Composable +private fun RowText(text: String?, color: Color = Color.Unspecified) { + Row { + 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/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/BaseTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt new file mode 100644 index 0000000000..f37f5d32ea --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt @@ -0,0 +1,49 @@ +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.DefaultTileLifeImpl +import li.songe.gkd.util.OnTileLife + +abstract class BaseTileService : TileService(), OnTileLife by DefaultTileLifeImpl() { + 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 listeningFlow = MutableStateFlow(false).apply { + onStartListened { value = true } + onStopListened { value = false } + } + + init { + onStartListened { + val t = System.currentTimeMillis() + if (t - lastA11yFixTime > 3_000L) { + lastA11yFixTime = t + fixRestartAutomatorService() + } + } + onTileClicked { StatusService.autoStart() } + 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() + } + } + } + } +} + +private var lastA11yFixTime = 0L 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..7168f10032 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt @@ -0,0 +1,59 @@ +package li.songe.gkd.service + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +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.component.PerfIcon +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( + positionKey = "button" +) { + override fun onClickView() = appScope.launchTry { + SnapshotExt.captureSnapshot() + }.let { } + + override fun onLongClickView() = stopSelf() + + @Composable + override fun ComposeContent() { + val alpha = 0.75f + PerfIcon( + imageVector = PerfIcon.CenterFocusWeak, + 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/EventService.kt b/app/src/main/kotlin/li/songe/gkd/service/EventService.kt new file mode 100644 index 0000000000..229586705d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/EventService.kt @@ -0,0 +1,216 @@ +package li.songe.gkd.service + +import android.view.accessibility.AccessibilityEvent +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.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.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.share.ListPlaceholder +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 = rememberLazyListState() + LaunchedEffect(eventLogs.isEmpty()) { listState.scrollToItem(0) } + 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) + ) { + ClosableTitle( + title = if (A11yService.isRunning.collectAsState().value) "事件服务" else "事件服务(无权限)" + ) + 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 + + fun logEvent(event: AccessibilityEvent) { + val service = instance ?: return + if (event.packageName == META.appId) return + if (logAutoId == 0) return + logAutoId++ + val eventLog = event.toA11yEventLog(logAutoId) + service.eventLogs.add(eventLog) + service.tempEventListFlow.update { it + eventLog } + if (service.eventLogs.size >= 256) { + service.eventLogs.removeRange(0, 64) + } + if (eventLog.id % 100 == 0) { + appScope.launchTry { DbSet.a11yEventLogDao.deleteKeepLatest() } + } + } + + 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/ExposeService.kt b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt new file mode 100644 index 0000000000..0bf35bdfd3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt @@ -0,0 +1,84 @@ +package li.songe.gkd.service + +import android.app.Service +import android.content.Intent +import android.os.Binder +import li.songe.gkd.app +import li.songe.gkd.appScope +import li.songe.gkd.notif.exposeNotif +import li.songe.gkd.syncFixState +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.SnapshotExt +import li.songe.gkd.util.componentName +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.runMainPost +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 { + runMainPost(1000) { 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) { + -1 -> StatusService.autoStart() + 0 -> SnapshotExt.captureSnapshot() + 1 -> { + toast("执行成功", forced = true) + syncFixState() + } + + else -> { + toast("未知调用: expose=$expose data=$data", forced = true) + } + } + } + + override fun onCreate() { + super.onCreate() + exposeNotif.notifyService() + } + + companion object { + fun initCommandFile() { + val commandText = template + .replace("{service}", ExposeService::class.componentName.flattenToShortString()) + shFolder.resolve("expose.sh").writeText(commandText) + } + + fun exposeIntent(expose: Int, data: String? = null): Intent { + return Intent(app, ExposeService::class.java).apply { + putExtra("expose", expose) + if (data != null) { + putExtra("data", data) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + } +} + +private const val template = $$"""set -euo pipefail +echo '> start expose.sh' +p='' +if [ -n "${1:-}" ]; then + p+=" --ei expose $1" +fi +if [ -n "${2:-}" ]; then + p+=" --es data $2" +fi +am start-foreground-service -n {service} $p +echo '> expose.sh end' +""" \ 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..1aa605bd5e 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -1,171 +1,235 @@ 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.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex 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.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.AutomationService +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.shizuku.uiAutomationFlow +import li.songe.gkd.store.actualA11yScopeAppList +import li.songe.gkd.store.actualBlockA11yAppList 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.mapState +import li.songe.gkd.util.runMainPost import li.songe.gkd.util.toast -import li.songe.gkd.util.useLogLifecycle -class GkdTileService : TileService(), OnDestroy, OnChangeListen, OnTileClick { - override fun onStartListening() { - super.onStartListening() - onStartListened() - } +class GkdTileService : BaseTileService() { + override val activeFlow = combine(A11yService.isRunning, uiAutomationFlow) { a11y, automator -> + a11y || automator != null + }.stateIn(scope, SharingStarted.Eagerly, false) - override fun onClick() { - super.onClick() - onTileClicked() + init { + onTileClicked { switchAutomatorService() } } +} - override fun onStopListening() { - super.onStopListening() - onStopListened() +private val modifyA11yMutex = Mutex() +private const val A11Y_AWAIT_START_TIME = 2000L +private const val A11Y_AWAIT_FIX_TIME = 1000L + +private fun modifyA11yRun(block: suspend () -> Unit) { + if (modifyA11yMutex.isLocked) return + appScope.launchTry(Dispatchers.IO) { + if (modifyA11yMutex.isLocked) return@launchTry + modifyA11yMutex.withLock { block() } } +} - override fun onDestroy() { - super.onDestroy() - onDestroyed() +private suspend fun switchA11yService() { + if (A11yService.isRunning.value) { + A11yService.instance?.disableSelf() + } else { + if (!writeSecureSettingsState.updateAndGet()) { + if (!writeSecureSettingsState.value) { + toast("请先授予「写入安全设置权限」") + return + } + } + val names = app.getSecureA11yServices() + app.putSecureInt(Settings.Secure.ACCESSIBILITY_ENABLED, 1) + if (names.contains(A11yService.a11yCn)) { // 当前无障碍异常, 重启服务 + names.remove(A11yService.a11yCn) + app.putSecureA11yServices(names) + delay(A11Y_AWAIT_FIX_TIME) + } + names.add(A11yService.a11yCn) + 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 + } } +} - val scope = MainScope().also { scope -> - onDestroyed { scope.cancel() } +private fun switchAutomationService() { + val newEnabled = uiAutomationFlow.value == null + uiAutomationFlow.value?.shutdown() + if (newEnabled && shizukuContextFlow.value.ok) { + AutomationService.tryConnect() } +} - private val listeningFlow = MutableStateFlow(false).also { listeningFlow -> - onStartListened { listeningFlow.value = true } - onStopListened { listeningFlow.value = false } +fun switchAutomatorService() = modifyA11yRun { + if (currentAppUseA11y) { + switchA11yService() + } else { + switchAutomationService() } +} - 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() +private fun skipBlockApp(): Boolean { + if (storeFlow.value.enableBlockA11yAppList) { + val topAppId = if (isActivityVisible || app.justStarted) { + META.appId + } else { + shizukuContextFlow.value.topCpn()?.packageName } - onTileClicked { - switchA11yService() + if (topAppId != null && topAppId in actualBlockA11yAppList) { + return true } } + return false } -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 suspend fun fixA11yService() { + if (!A11yService.isRunning.value && writeSecureSettingsState.updateAndGet()) { + if (skipBlockApp()) return + val names = app.getSecureA11yServices() + val a11yBroken = names.contains(A11yService.a11yCn) + if (a11yBroken) { + // 无障碍出现故障, 重启服务 + names.remove(A11yService.a11yCn) + app.putSecureA11yServices(names) + // 必须等待一段时间, 否则概率不会触发系统重启无障碍 + delay(A11Y_AWAIT_FIX_TIME) + if (!currentAppUseA11y) return + } + names.add(A11yService.a11yCn) + app.putSecureA11yServices(names) + delay(A11Y_AWAIT_START_TIME) + if (currentAppUseA11y && !A11yService.isRunning.value) { + toast("重启无障碍失败") + accessRestrictedSettingsShowFlow.value = true + } + } } -private fun updateServiceNames(names: List) { - Settings.Secure.putString( - app.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, - names.joinToString(":") - ) +private fun fixAutomationService() { + if (uiAutomationFlow.value == null && shizukuContextFlow.value.ok) { + if (skipBlockApp()) return + if (currentAppUseA11y) return + AutomationService.tryConnect(true) + } } -private fun enableA11yService() { - Settings.Secure.putInt( - app.contentResolver, - Settings.Secure.ACCESSIBILITY_ENABLED, - 1 - ) +fun fixRestartAutomatorService() = modifyA11yRun { + if (storeFlow.value.enableAutomator) { + if (currentAppUseA11y) { + fixA11yService() + } else { + fixAutomationService() + } + } } -private val modifyA11yMutex by lazy { Mutex() } -private const val A11Y_AWAIT_START_TIME = 1000L -private const val A11Y_AWAIT_FIX_TIME = 500L +val currentAppUseA11y + get() = storeFlow.value.useA11y || topAppIdFlow.value in actualA11yScopeAppList + +val currentAppBlocked + get() = storeFlow.value.enableBlockA11yAppList && topAppIdFlow.value in actualBlockA11yAppList -fun switchA11yService() = appScope.launchTry(Dispatchers.IO) { - modifyA11yMutex.withLock { - val newEnableService = !A11yService.isRunning.value +private fun innerForcedUpdateA11yService(disabled: Boolean) { + if (!storeFlow.value.enableAutomator) { + return + } + if (disabled) { + A11yService.instance?.shutdown(true) + uiAutomationFlow.value?.shutdown(true) + return + } + if (currentAppUseA11y) { if (A11yService.isRunning.value) { - A11yService.instance?.disableSelf() - } else { - if (!writeSecureSettingsState.updateAndGet()) { - toast("请先授予「写入安全设置权限」") - return@launchTry - } - val names = getServiceNames() - enableA11yService() - if (names.contains(A11yService.a11yClsName)) { // 当前无障碍异常, 重启服务 - names.remove(A11yService.a11yClsName) - updateServiceNames(names) - delay(A11Y_AWAIT_FIX_TIME) - } - names.add(A11yService.a11yClsName) - updateServiceNames(names) - delay(A11Y_AWAIT_START_TIME) - // https://github.com/orgs/gkd-kit/discussions/799 - if (!A11yService.isRunning.value) { - toast("开启无障碍失败") - accessRestrictedSettingsShowFlow.value = true - return@launchTry - } + return } - storeFlow.update { it.copy(enableService = newEnableService) } + if (!writeSecureSettingsState.stateFlow.value) { + return + } + val names = app.getSecureA11yServices() + names.add(A11yService.a11yCn) + app.putSecureA11yServices(names) + } else { + AutomationService.tryConnect(true) } } -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 = getServiceNames() - val a11yBroken = names.contains(A11yService.a11yClsName) - if (a11yBroken) { - // 无障碍出现故障, 重启服务 - names.remove(A11yService.a11yClsName) - updateServiceNames(names) - // 必须等待一段时间, 否则概率不会触发系统重启无障碍服务 - delay(A11Y_AWAIT_FIX_TIME) +private fun forcedUpdateA11yService(disabled: Boolean) = modifyA11yRun { + innerForcedUpdateA11yService(disabled) +} + +const val A11Y_WHITE_APP_AWAIT_TIME = 3000L + +@Volatile +private var lastAppIdChangeTime = 0L +val topAppIdFlow = MutableStateFlow("") +val a11yPartDisabledFlow by lazy { + topAppIdFlow.mapState(appScope) { + actualBlockA11yAppList.contains(it) + } +} + +fun updateTopTaskAppId(value: String) { + if (storeFlow.value.enableBlockA11yAppList || actualA11yScopeAppList.isNotEmpty()) { + topAppIdFlow.value = value + } +} + +fun initA11yWhiteAppList() { + val actualFlow = topAppIdFlow.drop(1) + appScope.launch(Dispatchers.Main) { + actualFlow.collect { + lastAppIdChangeTime = System.currentTimeMillis() + if (!currentAppBlocked) { + if (topActivityFlow.value.sameAs(systemRecentCn) && currentAppUseA11y) { + // 切换无障碍会造成卡顿,在最近任务界面时,延迟这个卡顿 + val tempTime = lastAppIdChangeTime + runMainPost(A11Y_WHITE_APP_AWAIT_TIME) { + if (tempTime == lastAppIdChangeTime) { + forcedUpdateA11yService(false) + } + } + } else { + // 切换自动化不会卡顿,直接启动 + forcedUpdateA11yService(false) + } } - names.add(A11yService.a11yClsName) - updateServiceNames(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 { + if (currentAppBlocked) { + forcedUpdateA11yService(true) } } } } - 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 57% 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 0d71c44533..e130ab8a4d 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,26 @@ -package li.songe.gkd.debug +package li.songe.gkd.service import android.app.Service import android.content.Intent -import com.blankj.utilcode.util.LogUtils +import android.util.Log 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,12 +30,11 @@ 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 import kotlinx.serialization.Serializable +import li.songe.gkd.a11y.A11yRuleEngine import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo import li.songe.gkd.data.DeviceInfo @@ -39,67 +46,63 @@ 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.DefaultSimpleLifeImpl 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.LogUtils +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 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 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(), OnSimpleLife by DefaultSimpleLifeImpl() { override fun onBind(intent: Intent?) = null + override fun onCreate() = onCreated() + override fun onDestroy() = onDestroyed() - override fun onCreate() { - super.onCreate() - onCreated() - } - - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - - val scope = MainScope().apply { onDestroyed { cancel() } } - - private val httpServerPortFlow = storeFlow.map(scope) { s -> s.httpServerPort } + val httpServerPortFlow = storeFlow.mapState(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) { + httpServerPortFlow.collect { + localNetworkIpsFlow.value = getIpAddressInLocalNetwork() + } + } + } onDestroyed { if (storeFlow.value.autoClearMemorySubs) { deleteSubscription(LOCAL_HTTP_SUBS_ID) } httpServerFlow.value = null } - StopServiceReceiver.autoRegister(this) onCreated { - httpNotif.notifyService(this) + 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 } @@ -112,9 +115,9 @@ class HttpService : Service(), OnCreate, OnDestroy { } if (httpServerFlow.value == null) { stopSelf() - return@collect + } else if (isReboot) { + toast("HTTP服务重启成功") } - httpNotif.copy(text = "HTTP服务-$port").notifyService(this@HttpService) } } } @@ -144,14 +147,14 @@ data class ReqId( @Serializable data class ServerInfo( - val device: DeviceInfo = DeviceInfo.instance, + val device: DeviceInfo = DeviceInfo(), val gkdAppInfo: AppInfo = selfAppInfo ) 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 +162,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) { "" } } @@ -181,7 +182,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() @@ -195,7 +196,26 @@ 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("/deleteSnapshot") { + val data = call.receive() + val allSnapshots = DbSet.snapshotDao.query().first() + val snapshot = allSnapshots.find { it.id == data.id } + if (snapshot != null) { + SnapshotExt.removeSnapshot(data.id) + DbSet.snapshotDao.delete(snapshot) + call.respond(RpcOk("快照删除成功")) + } else { + throw RpcError("快照不存在或已被删除") + } } post("/updateSubscription") { val subscription = @@ -216,7 +236,54 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { throw RpcError("无障碍没有运行") } val gkdAction = call.receive() - call.respond(A11yService.execAction(gkdAction)) + call.respond(A11yRuleEngine.execAction(gkdAction)) + } + } + } +} + +private fun getKtorCorsPlugin() = createApplicationPlugin(name = "KtorCorsPlugin") { + 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") + } + } +} + +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 d1ea03c728..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt +++ /dev/null @@ -1,92 +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.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) - onCreated { toast("常驻通知已启动") } - onDestroyed { toast("常驻通知已停止") } - useNotif() - } - - 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..279faec2ea 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 +import li.songe.gkd.util.mapState -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.mapState(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/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 new file mode 100644 index 0000000000..edc8315a42 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt @@ -0,0 +1,328 @@ +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 +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 +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 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.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.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.DefaultSimpleLifeImpl +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.runMainPost +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast +import kotlin.math.abs + +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( + val positionKey: String, +) : LifecycleService(), SavedStateRegistryOwner, OnSimpleLife by DefaultSimpleLifeImpl() { + companion object { + private var aliveSize = 0 + val isAnyAlive: Boolean + get() = aliveSize > 0 + } + + override fun onCreate() { + super.onCreate() + onCreated() + } + + override val scope get() = lifecycleScope + + private val resizeFlow = MutableSharedFlow() + + override fun onConfigurationChanged(newConfig: Configuration) { + lifecycleScope.launch { resizeFlow.emit(Unit) } + } + + override fun onDestroy() { + super.onDestroy() + onDestroyed() + } + + val registryController = SavedStateRegistryController.create(this).apply { + performAttach() + performRestore(null) + } + override val savedStateRegistry = registryController.savedStateRegistry + + private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } + + @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() {} + + open fun onLongClickView() {} + + val view by lazy { + ComposeView(this).apply { + setViewTreeSavedStateRegistryOwner(this@OverlayWindowService) + setViewTreeLifecycleOwner(this@OverlayWindowService) + setContent { + AppTheme(invertedTheme = true) { + ComposeContent() + } + } + } + } + + private val minMargin get() = 10.dp.px.toInt() + private val defaultPosition get() = listOf(minMargin, BarUtils.getStatusBarHeight()) + + private val shareContext = useShareContext() + + private val positionFlow = MutableStateFlow( + shareContext.positionMapFlow.value[positionKey].let { + if (it != null && it.size >= 2) { + it + } else { + defaultPosition + } + } + ) + + init { + aliveSize++ + onDestroyed { + runMainPost(1000) { aliveSize-- } + } + lifecycleScope.launch { + positionFlow.drop(1).debounce(300).collect { pos -> + shareContext.positionMapFlow.update { + it.toMutableMap().apply { + set(positionKey, pos) + } + } + } + } + onCreated { + val marginX = minMargin + val marginY = minMargin + 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.last() + } + var screenWidth = ScreenUtils.getScreenWidth() + var screenHeight = ScreenUtils.getScreenHeight() + 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, + screenHeight - view.height - marginY + ) + if (x != layoutParams.x || y != layoutParams.y) { + positionFlow.value = listOf(x, y) + val startX = layoutParams.x + val startY = layoutParams.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 + (x - startX) * fraction).toInt() + layoutParams.y = (startY + (y - startY) * fraction).toInt() + windowManager.updateViewLayout(view, layoutParams) + } else { + pause() + } + } + doOnEnd { + if (tempFlag == fixMoveFlag) { + fixMoveFlag = 0 + } + } + }.start() + } + } + lifecycleScope.launch { + view.viewTreeObserver.addOnGlobalLayoutListener { launch { resizeFlow.emit(Unit) } } + resizeFlow.debounce(100).collect { fixLimitXy() } + } + var downXy: Pair? = null + var longClickJob: kotlinx.coroutines.Job? = 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 + longClickJob = null + longClickJob = scope.launch { + delay(500) + longClickJob = null + if (downXy != null) { + onLongClickView() + } + } + true + } + + MotionEvent.ACTION_MOVE -> { + downXy?.let { downEvent -> + val dx = (event.rawX - downEvent.first).toInt() + val dy = (event.rawY - downEvent.second).toInt() + val x = dx + paramsXy.first + val y = dy + paramsXy.second + layoutParams.x = x.coerceIn(marginX, screenWidth - view.width - marginX) + layoutParams.y = y.coerceIn( + marginY, + screenHeight - view.height - marginY + ) + positionFlow.value = listOf(layoutParams.x, layoutParams.y) + windowManager.updateViewLayout(view, layoutParams) + longClickJob?.let { + val maxBreakLongOffset = 10 + if (abs(dx) > maxBreakLongOffset || abs(dy) > maxBreakLongOffset) { + longClickJob?.cancel() + longClickJob = null + } + } + } + true + } + + MotionEvent.ACTION_UP -> { + val gapTime = event.eventTime - event.downTime + if (gapTime <= ViewConfiguration.getTapTimeout()) { + onClickView() + } + downXy = null + longClickJob = 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/debug/ScreenshotService.kt b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt similarity index 57% 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..600c5e6216 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/ScreenshotService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt @@ -1,32 +1,24 @@ -package li.songe.gkd.debug +package li.songe.gkd.service import android.app.Service import android.content.Intent import coil3.Bitmap -import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.flow.MutableStateFlow 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.DefaultSimpleLifeImpl +import li.songe.gkd.util.LogUtils +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 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(), OnSimpleLife by DefaultSimpleLifeImpl() { 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 { @@ -40,32 +32,28 @@ class ScreenshotService : Service(), OnCreate, OnDestroy { } } - override fun onDestroy() { - super.onDestroy() - onDestroyed() - } - private var screenshotUtil: ScreenshotUtil? = null init { useLogLifecycle() useAliveFlow(isRunning) - onCreated { toast("截屏服务已启动") } - onDestroyed { toast("截屏服务已停止") } - onCreated { screenshotNotif.notifyService(this) } - onCreated { instance = WeakReference(this) } - onDestroyed { instance = WeakReference(null) } - onDestroyed { screenshotUtil?.destroy() } - StopServiceReceiver.autoRegister(this) + useAliveToast("截屏服务") + StopServiceReceiver.autoRegister() + onCreated { screenshotNotif.notifyService() } + 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() + return withTimeoutOrNull(5_000) { + instance?.screenshotUtil?.execute() } } diff --git a/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt new file mode 100644 index 0000000000..ca51935949 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt @@ -0,0 +1,72 @@ +package li.songe.gkd.service + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive +import li.songe.gkd.a11y.A11yRuleEngine +import li.songe.gkd.appScope +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.SnapshotExt +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.toast + +class SnapshotTileService() : BaseTileService() { + override val activeFlow = MutableStateFlow(false) + + init { + onTileClicked { execSnapshot() } + } +} + +private fun execSnapshot() { + LogUtils.d("SnapshotTileService::onClick") + val service = A11yRuleEngine.instance + if (service == null) { + A11yRuleEngine.performActionBack() + toast("服务未连接", forced = true) + return + } + appScope.launchTry(Dispatchers.IO) { + val oldAppId = service.safeActiveWindowAppId + + if (oldAppId == null) { + A11yRuleEngine.performActionBack() + toast("获取信息根节点失败", forced = true) + return@launchTry + } + + val startTime = System.currentTimeMillis() + fun timeout(): Boolean { + return System.currentTimeMillis() - startTime > 3000L + } + + var ok = false + while (isActive) { + val latestAppId = service.safeActiveWindowAppId + if (latestAppId == null) { + // https://github.com/gkd-kit/gkd/issues/713 + delay(250) + if (timeout()) { + toast("当前应用没有无障碍信息,捕获失败", forced = true) + break + } + } else if (latestAppId != oldAppId) { + ok = true + LogUtils.d("SnapshotTileService::eventExecutor.execute") + appScope.launchTry { SnapshotExt.captureSnapshot(forcedCropStatusBar = true) } + break + } else { + A11yRuleEngine.performActionBack() + delay(500) + if (timeout()) { + toast("未检测到界面切换,捕获失败", forced = true) + break + } + } + } + if (!ok) { + A11yRuleEngine.performActionBack() + } + } +} 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..de7f83d165 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/StatusService.kt @@ -0,0 +1,180 @@ +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.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.appOpsRestrictedFlow +import li.songe.gkd.permission.foregroundServiceSpecialUseState +import li.songe.gkd.permission.notificationState +import li.songe.gkd.permission.requiredPermission +import li.songe.gkd.permission.shizukuGrantedState +import li.songe.gkd.permission.writeSecureSettingsState +import li.songe.gkd.shizuku.uiAutomationFlow +import li.songe.gkd.store.actionCountFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.DefaultSimpleLifeImpl +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 +import li.songe.gkd.util.stopServiceByClass + +class StatusService : Service(), OnSimpleLife by DefaultSimpleLifeImpl() { + override fun onBind(intent: Intent?) = null + override fun onCreate() = onCreated() + override fun onDestroy() = onDestroyed() + + val shizukuWarnFlow = combine( + shizukuGrantedState.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 automationRunning = uiAutomationFlow.value != null + 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 (appOpsRestrictedFlow.value) { + Triple(title, "权限受限,请解除限制", "gkd://page/3") + } else if (shizukuWarn) { + Triple(title, "Shizuku 未连接,请授权或关闭优化", "gkd://page/1") + } else if (!automationRunning && !abRunning) { + if (currentAppUseA11y) { + val text = if (a11yServiceEnabledFlow.value) { + "无障碍发生故障" + } else if (writeSecureSettingsState.updateAndGet()) { + if (store.enableAutomator && store.enableBlockA11yAppList && a11yPartDisabledFlow.value) { + val name = + appInfoMapFlow.value[topAppIdFlow.value]?.name ?: topAppIdFlow.value + "局部关闭 · $name" + } else { + "无障碍已关闭" + } + } else { + "无障碍未授权" + } + Triple(title, text, abNotif.uri) + } else { + val text = + if (store.enableAutomator && store.enableBlockA11yAppList && a11yPartDisabledFlow.value) { + val name = + appInfoMapFlow.value[topAppIdFlow.value]?.name ?: topAppIdFlow.value + "局部关闭 · $name" + } else { + "自动化已关闭" + } + Triple(title, text, abNotif.uri) + } + } else if (!store.enableMatch) { + Triple(title, "暂停规则匹配", "gkd://page?tab=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( + name = "常驻通知", + delayMillis = if (app.justStarted) 1000 else 0, + ) + onCreated { + abNotif.notifyService() + scope.launch { + combine( + A11yService.isRunning, + uiAutomationFlow, + storeFlow, + ruleSummaryFlow, + shizukuWarnFlow, + a11yServiceEnabledFlow, + writeSecureSettingsState.stateFlow, + appOpsRestrictedFlow, + topAppIdFlow, + actionCountFlow.debounce(1000L), + ) { + statusTriple() + } + .stateIn( + scope, + SharingStarted.Eagerly, + Triple(abNotif.title, abNotif.text, abNotif.uri) + ) + .collect { + abNotif.copy( + title = it.first, + text = it.second, + uri = it.third, + ).notifyService() + } + } + } + } + + companion object { + val isRunning = MutableStateFlow(false) + val needRestart + get() = storeFlow.value.enableStatusService + && !isRunning.value + && notificationState.updateAndGet() + && foregroundServiceSpecialUseState.updateAndGet() + + 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() { + if (System.currentTimeMillis() - lastAutoStart < 1000) return + // 重启自动打开通知栏状态服务 + // 需要已有服务或前台才能自主启动,否则报错 startForegroundService() not allowed due to mAllowStartForeground false + if (needRestart) { + start() + lastAutoStart = System.currentTimeMillis() + } + } + } +} + +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/service/TrackService.kt b/app/src/main/kotlin/li/songe/gkd/service/TrackService.kt new file mode 100644 index 0000000000..df3881736d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/TrackService.kt @@ -0,0 +1,405 @@ +package li.songe.gkd.service + +import android.animation.ValueAnimator +import android.content.res.Configuration +import android.graphics.PixelFormat +import android.graphics.Rect +import android.view.Gravity +import android.view.Surface +import android.view.WindowManager +import android.view.accessibility.AccessibilityNodeInfo +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +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 kotlinx.atomicfu.atomic +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import li.songe.gkd.app +import li.songe.gkd.notif.StopServiceReceiver +import li.songe.gkd.notif.trackNotif +import li.songe.gkd.shizuku.casted +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.DefaultSimpleLifeImpl +import li.songe.gkd.util.OnSimpleLife +import li.songe.gkd.util.ScreenUtils +import li.songe.gkd.util.startForegroundServiceByClass +import li.songe.gkd.util.stopServiceByClass +import kotlin.math.min +import kotlin.math.pow + +class TrackService : LifecycleService(), SavedStateRegistryOwner, + OnSimpleLife by DefaultSimpleLifeImpl() { + 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 + override val scope get() = lifecycleScope + + private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } + private val resizeFlow = MutableSharedFlow() + override fun onConfigurationChanged(newConfig: Configuration) { + lifecycleScope.launch { resizeFlow.emit(Unit) } + } + + val strokeWidth = 2f + val pointSize = ScreenUtils.getScreenSize().let { min(it.width, it.height) } * 0.1f + val pointRadius = pointSize / 2 + + private fun DrawScope.drawTrackPoint(center: Offset) { + drawLine( + color = Color.Yellow, + start = Offset(center.x, center.y - pointRadius), + end = Offset(center.x, center.y + pointRadius), + strokeWidth = strokeWidth, + ) + drawLine( + color = Color.Yellow, + start = Offset(center.x - pointRadius, center.y), + end = Offset(center.x + pointRadius, center.y), + strokeWidth = strokeWidth, + ) + val ringSize = 3 + repeat(ringSize) { i -> + drawCircle( + color = Color.Red, + radius = pointRadius * 0.8f / ringSize * (i + 1), + center = center, + style = Stroke(strokeWidth) + ) + } + } + + private abstract inner class FloatLayer { + private val view = ComposeView(this@TrackService).apply { + setViewTreeSavedStateRegistryOwner(this@TrackService) + setViewTreeLifecycleOwner(this@TrackService) + setContent(::ComposeContent) + } + private val layoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.START or Gravity.TOP + if (AndroidTarget.S) { + alpha = app.inputManager.maximumObscuringOpacityForTouch + } + } + private val subScope = MainScope() + private val boundsRect = Rect() + protected var connected = false + protected var removed = false + + @Composable + abstract fun ComposeContent() + abstract fun syncRotation() + + fun removeView() { + subScope.cancel() + windowManager.removeView(view) + removed = true + } + + fun getRect(): Rect? { + if (!connected || removed) return null + return boundsRect + } + + fun setAlpha(alpha: Float) { + if (!connected || removed) return + if (layoutParams.alpha == alpha) return + layoutParams.alpha = alpha + windowManager.updateViewLayout(view, layoutParams) + } + + fun updateViewLayout( + x: Number, + y: Number, + width: Number = layoutParams.width, + height: Number = layoutParams.height, + ) { + layoutParams.x = x.toInt() + layoutParams.y = y.toInt() + layoutParams.width = width.toInt() + layoutParams.height = height.toInt() + boundsRect.set( + layoutParams.x, + layoutParams.y, + layoutParams.x + layoutParams.width, + layoutParams.y + layoutParams.height, + ) + if (!connected) { + connected = true + windowManager.addView(view, layoutParams) + subScope.launch { resizeFlow.collect { syncRotation() } } + } else { + windowManager.updateViewLayout(view, layoutParams) + recalcOverlappingAlpha() + } + } + } + + private inner class PointFloatLayer(val point: TrackPoint) : FloatLayer() { + @Composable + override fun ComposeContent() = Canvas(modifier = Modifier.fillMaxSize()) { + drawTrackPoint(Offset(pointRadius, pointRadius)) + } + + override fun syncRotation() { + val (x, y) = point.getCurCenter() - Offset(pointRadius, pointRadius) + updateViewLayout(x, y) + } + + init { + updateViewLayout(point.x - pointRadius, point.y - pointRadius, pointSize, pointSize) + } + } + + private inner class SwipePointFloatLayer(val swipePoint: SwipeTrackPoint) : FloatLayer() { + @Composable + override fun ComposeContent() = Canvas(modifier = Modifier.fillMaxSize()) { + val sc = swipePoint.start.getCurCenter() + val ec = swipePoint.end.getCurCenter() + val start = Offset( + if (sc.x <= ec.x) pointRadius else size.width - pointRadius, + if (sc.y <= ec.y) pointRadius else size.height - pointRadius + ) + val end = Offset( + if (sc.x <= ec.x) size.width - pointRadius else pointRadius, + if (sc.y <= ec.y) size.height - pointRadius else pointRadius + ) + drawTrackPoint(start) + drawTrackPoint(end) + drawLine( + color = Color.Blue, + start = start, + end = end, + strokeWidth = strokeWidth, + ) + } + + override fun syncRotation() { + val f = animator.animatedValue as Float + val sc = swipePoint.start.getCurCenter() + val ec = swipePoint.end.getCurCenter() + val cur = Offset(sc.x + (ec.x - sc.x) * f, sc.y + (ec.y - sc.y) * f) + updateViewLayout( + minOf(sc.x, cur.x) - pointRadius, + minOf(sc.y, cur.y) - pointRadius, + maxOf(sc.x, cur.x) - minOf(sc.x, cur.x) + pointSize, + maxOf(sc.y, cur.y) - minOf(sc.y, cur.y) + pointSize + ) + } + + private val animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = swipePoint.duration + addUpdateListener { + if (removed) { + cancel() + } else { + val f = it.animatedValue as Float + val sc = swipePoint.start.getCurCenter() + val ec = swipePoint.end.getCurCenter() + val cur = Offset(sc.x + (ec.x - sc.x) * f, sc.y + (ec.y - sc.y) * f) + updateViewLayout( + minOf(sc.x, cur.x) - pointRadius, + minOf(sc.y, cur.y) - pointRadius, + maxOf(sc.x, cur.x) - minOf(sc.x, cur.x) + pointSize, + maxOf(sc.y, cur.y) - minOf(sc.y, cur.y) + pointSize + ) + } + } + } + + init { + val sc = swipePoint.start.getCurCenter() + updateViewLayout(sc.x - pointRadius, sc.y - pointRadius, pointSize, pointSize) + animator.start() + } + } + + private val layerMap = hashMapOf().apply { + onDestroyed { + forEach { it.value.removeView() } + clear() + } + } + + private fun recalcOverlappingAlpha() { + if (!AndroidTarget.S) return + val maxOpacity = app.inputManager.maximumObscuringOpacityForTouch + val entries = layerMap.values.mapNotNull { layer -> + layer.getRect()?.let { rect -> layer to rect } + } + for ((layer, rect) in entries) { + var overlapCount = 1 + for ((other, otherRect) in entries) { + if (other !== layer && Rect.intersects(rect, otherRect)) { + overlapCount++ + } + } + val safeAlpha = if (overlapCount > 1) { + 1f - (1f - maxOpacity).toDouble().pow(1.0 / overlapCount).toFloat() + } else { + maxOpacity + } + layer.setAlpha(safeAlpha) + } + } + + val tapDelay = 100L + val missDelay = 7500L + + private fun addPoint(point: TrackPoint) { + runScopePost(tapDelay) { + layerMap[point.id] = PointFloatLayer(point) + recalcOverlappingAlpha() + } + runScopePost(missDelay) { + layerMap.remove(point.id)?.removeView() + recalcOverlappingAlpha() + } + } + + private fun addSwipePoint(swipePoint: SwipeTrackPoint) { + runScopePost(tapDelay) { + layerMap[swipePoint.id] = SwipePointFloatLayer(swipePoint) + recalcOverlappingAlpha() + } + runScopePost(missDelay + swipePoint.duration) { + layerMap.remove(swipePoint.id)?.removeView() + recalcOverlappingAlpha() + } + } + + init { + useLogLifecycle() + onCreated { service = this } + onDestroyed { service = null } + useAliveFlow(isRunning) + useAliveToast("轨迹提示") + StopServiceReceiver.autoRegister() + onCreated { trackNotif.notifyService() } + } + + companion object { + @Volatile + private var service: TrackService? = null + val isRunning: StateFlow + field = MutableStateFlow(false) + + fun start() = startForegroundServiceByClass(TrackService::class) + fun stop() = stopServiceByClass(TrackService::class) + fun addA11yNodePosition(node: AccessibilityNodeInfo) { + service?.addPoint( + TrackPoint( + node.casted.boundsInScreen.centerX().toFloat(), + node.casted.boundsInScreen.centerY().toFloat(), + ) + ) + } + + fun addXyPosition(x: Float, y: Float) { + service?.addPoint(TrackPoint(x, y)) + } + + fun addSwipePosition( + startX: Float, + startY: Float, + endX: Float, + endY: Float, + duration: Long + ) { + service?.addSwipePoint( + SwipeTrackPoint( + TrackPoint(startX, startY), + TrackPoint(endX, endY), + duration + ) + ) + } + } +} + +private val autoIncreaseId = atomic(0) + +private data class TrackPoint( + val x: Float, + val y: Float, +) { + val id = autoIncreaseId.incrementAndGet() + val screenSize = ScreenUtils.getScreenSize() + val rotation = app.compatDisplay.rotation + + fun getCurCenter(): Offset { + val curSize = ScreenUtils.getScreenSize() + val curRotation = app.compatDisplay.rotation + val (physX, physY) = screenToPhysical(x, y, screenSize.width, screenSize.height, rotation) + return physicalToScreen(physX, physY, curSize.width, curSize.height, curRotation) + } + + private fun screenToPhysical( + sx: Float, sy: Float, + sw: Int, sh: Int, + rot: Int, + ): Offset = when (rot) { + Surface.ROTATION_0 -> Offset(sx, sy) + Surface.ROTATION_90 -> Offset(sh - sy, sx) + Surface.ROTATION_180 -> Offset(sw - sx, sh - sy) + Surface.ROTATION_270 -> Offset(sy, sw - sx) + else -> Offset(sx, sy) + } + + private fun physicalToScreen( + px: Float, py: Float, + sw: Int, sh: Int, + rot: Int, + ): Offset = when (rot) { + Surface.ROTATION_0 -> Offset(px, py) + Surface.ROTATION_90 -> Offset(py, sh - px) + Surface.ROTATION_180 -> Offset(sw - px, sh - py) + Surface.ROTATION_270 -> Offset(sw - py, px) + else -> Offset(px, py) + } +} + +private data class SwipeTrackPoint( + val start: TrackPoint, + val end: TrackPoint, + val duration: Long, +) { + val id = autoIncreaseId.incrementAndGet() +} diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/AccessibilityManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AccessibilityManager.kt new file mode 100644 index 0000000000..83539925bd --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/AccessibilityManager.kt @@ -0,0 +1,13 @@ +package li.songe.gkd.shizuku + +import android.content.Context +import android.view.accessibility.IAccessibilityManager + +class SafeAccessibilityManager(val value: IAccessibilityManager) { + companion object { + fun newBinder() = getShizukuService(Context.ACCESSIBILITY_SERVICE)?.let { + SafeAccessibilityManager(IAccessibilityManager.Stub.asInterface(it)) + } + } + +} \ 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..0d2ec80895 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt @@ -0,0 +1,63 @@ +package li.songe.gkd.shizuku + +import android.app.ActivityManager +import android.app.IActivityManager +import android.content.Context +import android.content.Intent +import li.songe.gkd.util.AndroidTarget + +class SafeActivityManager(private val value: IActivityManager) { + companion object { + fun newBinder() = getShizukuService(Context.ACTIVITY_SERVICE)?.let { + SafeActivityManager(IActivityManager.Stub.asInterface(it)) + } + } + + fun getTasks(maxNum: Int = 1): List = safeInvokeShizuku { + if (AndroidTarget.P) { + value.getTasks(maxNum) + } else { + value.getTasks(maxNum, 0) + } + } ?: emptyList() + + fun startForegroundService(intent: Intent) { + // 被启动的服务必须设置 android:exported="true" + // https://github.com/android-cs/16/blob/main/services/core/java/com/android/server/am/ActivityManagerShellCommand.java#L982 + val requireForeground = true + val callingPackage = "com.android.shell" + val callingFeatureId: String? = null + if (AndroidTarget.R) { + value.startService( + null, + intent, + intent.type, + requireForeground, + callingPackage, + callingFeatureId, + currentUserId + ) + } else { + value.startService( + null, + intent, + intent.type, + requireForeground, + callingPackage, + currentUserId + ) + } + } + + fun registerDefault() { + safeInvokeShizuku { + value.registerTaskStackListener(FixedTaskStackListener) + } + } + + fun unregisterDefault() { + safeInvokeShizuku { + value.unregisterTaskStackListener(FixedTaskStackListener) + } + } +} \ 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 a05ce62b71..75cb777871 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,39 @@ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.IActivityTaskManager +import android.content.ContextHidden 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.typeOf +import li.songe.gkd.util.AndroidTarget -/** - * -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获取方法签名错误") +class SafeActivityTaskManager(private val value: IActivityTaskManager) { + companion object { + fun newBinder() = if (AndroidTarget.Q) { + getShizukuService(ContextHidden.ACTIVITY_TASK_SERVICE)?.let { + SafeActivityTaskManager(IActivityTaskManager.Stub.asInterface(it)) } + } else { + null } } - return try { - // https://bugly.qq.com/v2/crash-reporting/crashes/d0ce46b353/106137?pid=1 - // binder haven't been received - when (getTasksFcType) { - 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) - 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) - } - - override fun unregisterTaskStackListener(listener: TaskListener) { - manager.unregisterTaskStackListener(listener) + fun getTasks(maxNum: Int = 1): List? = safeInvokeShizuku { + when (HiddenApiType.getTasks) { + 1 -> value.getTasks(maxNum) + 2 -> value.getTasks(maxNum, false, false) + 3 -> value.getTasks(maxNum, false, false, Display.DEFAULT_DISPLAY) + else -> value.getTasks(maxNum) } } -} -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) + fun registerDefault() { + safeInvokeShizuku { + value.registerTaskStackListener(FixedTaskStackListener) } } - 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 unregisterDefault() { + safeInvokeShizuku { + value.unregisterTaskStackListener(FixedTaskStackListener) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt new file mode 100644 index 0000000000..dafd210802 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt @@ -0,0 +1,66 @@ +package li.songe.gkd.shizuku + +import android.app.AppOpsManager +import android.app.AppOpsManagerHidden +import android.content.Context +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import com.android.internal.app.IAppOpsService +import li.songe.gkd.META +import li.songe.gkd.util.AndroidTarget + +class SafeAppOpsService( + private val value: IAppOpsService +) { + companion object { + + fun newBinder() = getShizukuService(Context.APP_OPS_SERVICE)?.let { + SafeAppOpsService(IAppOpsService.Stub.asInterface(it)) + } + + // https://diff.songe.li/i/AppOpsManager.OP_CREATE_ACCESSIBILITY_OVERLAY + private val a11yOverlayOk by lazy { + AndroidTarget.UPSIDE_DOWN_CAKE && try { + AppOpsManager::class.java.getField("OP_CREATE_ACCESSIBILITY_OVERLAY") + } catch (_: NoSuchFieldException) { + null + } != null + } + + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + val supportCreateA11yOverlay get() = a11yOverlayOk + } + + fun checkOperation(code: Int): Int? = safeInvokeShizuku { + value.checkOperation(code, android.os.Process.myUid(), META.appId) + } + + fun setMode(code: Int, mode: Int) = safeInvokeShizuku { + value.setMode(code, android.os.Process.myUid(), META.appId, mode) + } + + private fun setAllowSelfMode(code: Int) { + val m = checkOperation(code = code) ?: return + if (m == AppOpsManager.MODE_ALLOWED) { + return + } + setMode(code = code, mode = AppOpsManager.MODE_ALLOWED) + } + + 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) + } + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + setAllowSelfMode(AppOpsManagerHidden.OP_FOREGROUND_SERVICE_SPECIAL_USE) + } + if (supportCreateA11yOverlay) { + setAllowSelfMode(AppOpsManagerHidden.OP_CREATE_ACCESSIBILITY_OVERLAY) + } + } +} \ No newline at end of file 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/AutomationService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/AutomationService.kt new file mode 100644 index 0000000000..e7264237c5 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/AutomationService.kt @@ -0,0 +1,147 @@ +package li.songe.gkd.shizuku + +import android.app.UiAutomation +import android.app.UiAutomationHidden +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.HandlerThread +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import li.songe.gkd.a11y.A11yCommonImpl +import li.songe.gkd.a11y.A11yRuleEngine +import li.songe.gkd.store.updateEnableAutomator +import li.songe.gkd.util.AutomatorModeOption +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.createGkdTempDir +import li.songe.gkd.util.toast + +class AutomationService private constructor() : A11yCommonImpl { + override val mode get() = AutomatorModeOption.AutomationMode + private val handlerThread = HandlerThread("UiAutomatorHandlerThread") + private val uiAutomation by lazy { + UiAutomationHidden( + handlerThread.looper, + ProxyUiAutomationConnection(), + ).castedHidden + } + + override val scope = MainScope() + + override val ruleEngine by lazy { A11yRuleEngine(this) } + + private val listener = UiAutomation.OnAccessibilityEventListener { + ruleEngine.onA11yEvent(it) + } + + override suspend fun screenshot(): Bitmap? = withContext(Dispatchers.IO) { + try { + uiAutomation.takeScreenshot() + } catch (e: Throwable) { + LogUtils.d("takeScreenshot failed, rollback to screencapFile", e) + val tempDir = createGkdTempDir() + val fp = tempDir.resolve("screenshot.png") + val ok = shizukuContextFlow.value.serviceWrapper?.screencapFile(fp.absolutePath) + if (ok == true && fp.exists()) { + BitmapFactory.decodeFile(fp.absolutePath).apply { + tempDir.deleteRecursively() + } + } else { + null + } + } + } + + override val windowNodeInfo: AccessibilityNodeInfo? get() = uiAutomation.rootInActiveWindow + override val windowInfos: List get() = uiAutomation.windows + private val startTime = System.currentTimeMillis() + override var justStarted: Boolean = true + get() { + if (field) { + field = System.currentTimeMillis() - startTime < 3_000 + } + return field + } + + private var connected = false + + // https://github.com/android-cs/16/blob/main/cmds/uiautomator/library/testrunner-src/com/android/uiautomator/core/UiAutomationShellWrapper.java#L25 + private fun connect() { + handlerThread.start() + uiAutomation.casted.connect(UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) + uiAutomation.setOnAccessibilityEventListener(listener) + connected = true + toast("自动化已启动") + updateEnableAutomator(true) + ruleEngine.onA11yConnected() + } + + private fun disconnect() { + scope.cancel() + handlerThread.quit() + if (!connected) return + uiAutomation.setOnAccessibilityEventListener(null) + safeInvokeShizuku { + uiAutomation.casted.disconnect() + } + if (tempShutdownFlag) { + toast("自动化局部关闭") + } else { + toast("自动化已关闭") + updateEnableAutomator(false) + } + } + + private var tempShutdownFlag = false + override fun shutdown(temp: Boolean) { + if (temp) { + tempShutdownFlag = true + } + disconnect() + uiAutomationFlow.value = null + } + + companion object { + private val loading = atomic(false) + fun tryConnect(silent: Boolean = false) { + if (loading.value) return + loading.value = true + try { + automationRegisteredExceptionFlow.value = null + if (uiAutomationFlow.value?.connected == true) { + return + } + uiAutomationFlow.value?.shutdown() + val instance = AutomationService() + try { + instance.connect() + uiAutomationFlow.value = instance + } catch (e: Exception) { + instance.disconnect() + uiAutomationFlow.value = null + // https://github.com/android-cs/16/blob/main/services/accessibility/java/com/android/server/accessibility/UiAutomationManager.java#L110 + if (e is IllegalStateException && e.message?.contains("already registered") == true) { + toast("自动化启动失败,被其他应用占用") + if (!silent) { + automationRegisteredExceptionFlow.value = e + } + LogUtils.d(e.message) + } else { + toast("自动化启动失败:${e.message}") + LogUtils.d(e) + } + } + } finally { + loading.value = false + } + } + } +} + +val uiAutomationFlow = MutableStateFlow(null) +val automationRegisteredExceptionFlow = MutableStateFlow(null) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt index 901d5c9057..548b783b9e 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt @@ -1,10 +1,14 @@ package li.songe.gkd.shizuku -import kotlinx.serialization.Serializable +import android.os.Parcelable +import kotlinx.parcelize.Parcelize -@Serializable +@Parcelize data class CommandResult( - val code: Int, + val code: Int?, val result: String, val error: String? -) +) : Parcelable { + val ok: Boolean + get() = code == 0 +} diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/HiddenApiType.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/HiddenApiType.kt new file mode 100644 index 0000000000..cc6293e6a7 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/HiddenApiType.kt @@ -0,0 +1,80 @@ +package li.songe.gkd.shizuku + +import android.app.IActivityTaskManager +import android.os.IUserManager +import android.view.IWindowManager +import li.songe.gkd.util.LogUtils +import java.lang.reflect.Method + +object HiddenApiType { + // https://diff.songe.li/i/IUserManager.getUsers + val getUsers 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), + ) + } + + // https://diff.songe.li/i/IActivityTaskManager.getTasks + val getTasks 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 + ), + ) + } + + // https://diff.songe.li/i/IWindowManager.thawRotation + val thawRotation by lazy { + IWindowManager::class.java.detectHiddenMethod( + "thawRotation", + 1 to emptyList(), + 2 to listOf(String::class.java), + ) + } + + // https://diff.songe.li/i/IWindowManager.freezeRotation + val freezeRotation by lazy { + IWindowManager::class.java.detectHiddenMethod( + "freezeRotation", + 1 to listOf(Int::class.java), + 2 to listOf(Int::class.java, String::class.java), + ) + } +} + + +private fun Class<*>.detectHiddenMethod( + methodName: String, + vararg args: Pair>>, +): Int { + val methodsVal = methods + methodsVal.forEach { method -> + if (method.name == methodName) { + val types = method.parameterTypes.toList() + args.forEach { (value, argTypes) -> + if (types == argTypes) { + return value + } + } + } + } + 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") + } +} + +private fun Method.simpleString(): String { + return "${name}(${parameterTypes.joinToString(",") { it.name }}):${returnType.name}" +} diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/HiddenCast.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/HiddenCast.kt new file mode 100644 index 0000000000..248c3e5439 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/HiddenCast.kt @@ -0,0 +1,35 @@ +@file:Suppress("CAST_NEVER_SUCCEEDS") + +package li.songe.gkd.shizuku + +import android.accessibilityservice.AccessibilityServiceInfo +import android.accessibilityservice.AccessibilityServiceInfoHidden +import android.app.ActivityManager +import android.app.TaskInfoHidden +import android.app.UiAutomation +import android.app.UiAutomationHidden +import android.content.pm.PackageInfo +import android.content.pm.PackageInfoHidden +import android.view.KeyEvent +import android.view.KeyEventHidden +import android.view.MotionEvent +import android.view.MotionEventHidden +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeInfoHidden + +// Ignoring an implementation of the method `a.getCasted(b)` because it has multiple definitions + +inline val UiAutomationHidden.castedHidden get() = this as UiAutomation +inline val UiAutomation.casted get() = this as UiAutomationHidden + +inline val AccessibilityNodeInfo.casted get() = this as AccessibilityNodeInfoHidden + +inline val AccessibilityServiceInfo.casted get() = this as AccessibilityServiceInfoHidden + +inline val KeyEvent.casted get() = this as KeyEventHidden + +inline val MotionEvent.casted get() = this as MotionEventHidden + +inline val PackageInfo.casted get() = this as PackageInfoHidden + +inline val ActivityManager.RunningTaskInfo.casted get() = this as TaskInfoHidden 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..ed4181da8f --- /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 + + +class SafeInputManager(private val value: IInputManager) { + companion object { + fun newBinder() = getShizukuService(Context.INPUT_SERVICE)?.let { + SafeInputManager(IInputManager.Stub.asInterface(it)) + } + } + + private val command = InputShellCommand(this) + + fun compatInjectInputEvent( + ev: InputEvent, + mode: Int, + ) = safeInvokeShizuku { + 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) + } + } + + @WorkerThread + fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, duration: Long) { + command.runSwipe(x1, y1, x2, y2, duration) + } + + 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 new file mode 100644 index 0000000000..714c232ef4 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt @@ -0,0 +1,270 @@ +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.KeyCharacterMap +import android.view.KeyEvent +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 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 +@Suppress("SameParameterValue") +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, + ) + } + + 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 + ) + } + + 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 + } + event.casted.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 + } + + 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) { + event.casted.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/PackageManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt index 019fdd942a..1ea9b2bf4e 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt @@ -1,74 +1,94 @@ package li.songe.gkd.shizuku -import android.content.IntentFilter +import android.Manifest 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.typeOf +import li.songe.gkd.META +import li.songe.gkd.app +import li.songe.gkd.permission.Manifest_permission_GET_APP_OPS_STATS +import li.songe.gkd.permission.canQueryPkgState +import li.songe.gkd.util.AndroidTarget -private var packageFlagsParamsLongType: 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() - } - return if (packageFlagsParamsLongType == true) { - getInstalledPackages(flags, userId).list - } else { - getInstalledPackages(flags.toInt(), userId).list +class SafePackageManager(private val value: IPackageManager) { + companion object { + + fun newBinder() = getShizukuService("package")?.let { + SafePackageManager(IPackageManager.Stub.asInterface(it)) + } + + private var canUseGetInstalledApps = true } -} -interface SafePackageManager { - fun compatGetInstalledPackages(flags: Long, userId: Int): List - fun getAllIntentFilters(packageName: String): List -} + val isSafeMode get() = safeInvokeShizuku { value.isSafeMode } + + fun getInstalledPackages( + flags: Int, + userId: Int = currentUserId, + ): List = safeInvokeShizuku { + if (AndroidTarget.CINNAMON_BUN) { + value.getInstalledPackagesV17(flags.toLong(), userId).list + } else if (AndroidTarget.TIRAMISU) { + value.getInstalledPackages(flags.toLong(), userId).list + } else { + value.getInstalledPackages(flags, userId).list + } + } ?: emptyList() -fun newPackageManager(): SafePackageManager? { - val service = SystemServiceHelper.getSystemService("package") - if (service == null) { - LogUtils.d("shizuku 无法获取 package") - return null + @Suppress("unused") + fun getPackageInfo( + packageName: String, + flags: Int, + userId: Int, + ): PackageInfo? = safeInvokeShizuku { + if (AndroidTarget.TIRAMISU) { + value.getPackageInfo(packageName, flags.toLong(), userId) + } else { + value.getPackageInfo(packageName, flags, userId) + } } - 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 + fun getApplicationEnabledSetting( + packageName: String, + userId: Int, + ): Int = safeInvokeShizuku { + value.getApplicationEnabledSetting(packageName, userId) + } ?: 0 + + private fun grantRuntimePermission( + packageName: String, + permissionName: String, + userId: Int = currentUserId, + ) = safeInvokeShizuku { + value.grantRuntimePermission( + packageName, + permissionName, + userId + ) } -} -val shizukuWorkProfileUsedFlow by lazy { - combine(shizukuOkState.stateFlow, shizukuStoreFlow) { shizukuOk, store -> - shizukuOk && store.enableWorkProfile - }.stateIn(appScope, SharingStarted.Eagerly, false) -} + private fun grantSelfPermission(name: String, skipCheck: Boolean = false) { + if (!skipCheck) { + if (app.checkGrantedPermission(name)) return + } + grantRuntimePermission( + packageName = META.appId, + permissionName = name, + ) + } -val packageManagerFlow by lazy> { - val stateFlow = MutableStateFlow(null) - appScope.launch(Dispatchers.IO) { - shizukuWorkProfileUsedFlow.collect { - stateFlow.value = if (it) newPackageManager() else null + fun allowAllSelfPermission() { + if (canUseGetInstalledApps && !canQueryPkgState.value) { + try { + grantSelfPermission("com.android.permission.GET_INSTALLED_APPS", skipCheck = true) + } catch (_: IllegalArgumentException) { + canUseGetInstalledApps = false + } + } + grantSelfPermission(Manifest_permission_GET_APP_OPS_STATS) + grantSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) + if (AndroidTarget.TIRAMISU) { + grantSelfPermission(Manifest.permission.POST_NOTIFICATIONS) } } - stateFlow } diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ProxyUiAutomationConnection.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ProxyUiAutomationConnection.kt new file mode 100644 index 0000000000..fcea5766af --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ProxyUiAutomationConnection.kt @@ -0,0 +1,287 @@ +package li.songe.gkd.shizuku + +import android.accessibilityservice.AccessibilityServiceInfo +import android.accessibilityservice.AccessibilityServiceInfoHidden +import android.accessibilityservice.IAccessibilityServiceClient +import android.app.IUiAutomationConnection +import android.graphics.Bitmap +import android.graphics.Rect +import android.os.Binder +import android.os.Build +import android.os.RemoteException +import android.view.Display.DEFAULT_DISPLAY +import android.view.accessibility.AccessibilityEvent +import android.window.ScreenCapture +import androidx.annotation.RequiresApi +import li.songe.gkd.util.AndroidTarget +import rikka.shizuku.Shizuku + + +// https://diff.songe.li/i/UiAutomationConnection +class ProxyUiAutomationConnection : IUiAutomationConnection.Stub() { + companion object { + private const val INITIAL_FROZEN_ROTATION_UNSPECIFIED = -1 + } + + private val mLock = Any() + private val mToken = Binder() + private var mClient: IAccessibilityServiceClient? = null + private var mInitialFrozenRotation = INITIAL_FROZEN_ROTATION_UNSPECIFIED + private var mIsShutdown = false + private var mOwningUid = 0 + private val mWindowManager + get() = shizukuContextFlow.value.wmManager?.value ?: throw ShizukuOffException() + private val manager + get() = shizukuContextFlow.value.a11yManager?.value ?: throw ShizukuOffException() + + override fun connect( + client: IAccessibilityServiceClient?, + flags: Int, + ) { + if (client == null) { + throw IllegalArgumentException("Client cannot be null!") + } + synchronized(mLock) { + throwIfShutdownLocked() + if (isConnectedLocked()) { + throw IllegalStateException("Already connected.") + } + mOwningUid = Shizuku.getUid() // Binder.getCallingUid() + registerUiTestAutomationServiceLocked(client, currentUserId, flags) + storeRotationStateLocked() + } + } + + + override fun disconnect() { + synchronized(mLock) { + throwIfCalledByNotTrustedUidLocked() + throwIfShutdownLocked() + if (!isConnectedLocked()) { + throw IllegalStateException("Already disconnected.") + } + mOwningUid = -1 + unregisterUiTestAutomationServiceLocked() + restoreRotationStateLocked() + } + } + + override fun shutdown() { + synchronized(mLock) { + if (isConnectedLocked()) { + throwIfCalledByNotTrustedUidLocked() + } + throwIfShutdownLocked() + mIsShutdown = true + if (isConnectedLocked()) { + disconnect() + } + } + } + + // https://diff.songe.li/i/UiAutomationConnection.takeScreenshot + override fun takeScreenshot(width: Int, height: Int): Bitmap? { + synchronized(mLock) { + throwIfCalledByNotTrustedUidLocked() + throwIfShutdownLocked() + throwIfNotConnectedLocked() + } + val identity = clearCallingIdentity() + try { + return shizukuContextFlow.value.serviceWrapper?.run { + userService.takeScreenshot1(width, height) + } + } finally { + restoreCallingIdentity(identity) + } + } + + override fun takeScreenshot( + crop: Rect, + rotation: Int, + ): Bitmap? { + synchronized(mLock) { + throwIfCalledByNotTrustedUidLocked() + throwIfShutdownLocked() + throwIfNotConnectedLocked() + } + val identity = clearCallingIdentity() + try { + return shizukuContextFlow.value.serviceWrapper?.run { + userService.takeScreenshot2(crop, rotation) + } + } finally { + restoreCallingIdentity(identity) + } + } + + override fun takeScreenshot(crop: Rect): Bitmap? { + synchronized(mLock) { + throwIfCalledByNotTrustedUidLocked() + throwIfShutdownLocked() + throwIfNotConnectedLocked() + } + val identity = clearCallingIdentity() + try { + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + val captureArgs = ScreenCapture.CaptureArgs.Builder() + .setSourceCrop(crop) + .build() + val syncScreenCapture = ScreenCapture.createSyncCaptureListener() + mWindowManager.captureDisplay(DEFAULT_DISPLAY, captureArgs, syncScreenCapture) + val screenshotBuffer = syncScreenCapture.buffer + return screenshotBuffer?.asBitmap() + } else { + return shizukuContextFlow.value.serviceWrapper?.run { + userService.takeScreenshot3(crop) + } + } + } catch (re: RemoteException) { + throw re.rethrowAsRuntimeException() + } finally { + restoreCallingIdentity(identity) + } + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun takeScreenshot( + crop: Rect, + listener: ScreenCapture.ScreenCaptureListener, + ): Boolean { + synchronized(mLock) { + throwIfCalledByNotTrustedUidLocked() + throwIfShutdownLocked() + throwIfNotConnectedLocked() + } + val identity = clearCallingIdentity() + try { + val captureArgs = ScreenCapture.CaptureArgs.Builder() + .setSourceCrop(crop) + .build() + mWindowManager.captureDisplay(DEFAULT_DISPLAY, captureArgs, listener) + } catch (re: RemoteException) { + throw re.rethrowAsRuntimeException() + } finally { + restoreCallingIdentity(identity) + } + return true + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun takeScreenshot( + crop: Rect, + listener: ScreenCapture.ScreenCaptureListener, + displayId: Int, + ): Boolean { + synchronized(mLock) { + throwIfCalledByNotTrustedUidLocked() + throwIfShutdownLocked() + throwIfNotConnectedLocked() + } + val identity = clearCallingIdentity() + try { + val captureArgs = ScreenCapture.CaptureArgs.Builder() + .setSourceCrop(crop) + .build() + mWindowManager.captureDisplay(displayId, captureArgs, listener) + } catch (re: RemoteException) { + throw re.rethrowAsRuntimeException() + } finally { + restoreCallingIdentity(identity) + } + return true + } + + private fun registerUiTestAutomationServiceLocked( + client: IAccessibilityServiceClient, + userId: Int, + flags: Int, + ) { + // see app/src/main/res/xml/ab_desc.xml + val info = AccessibilityServiceInfo().apply { + eventTypes = + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED or AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED + feedbackType = AccessibilityServiceInfo.FEEDBACK_ALL_MASK + this.flags = (this.flags or + AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or + AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or + AccessibilityServiceInfo.DEFAULT or + AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or + AccessibilityServiceInfoHidden.FLAG_FORCE_DIRECT_BOOT_AWARE) + } + info.casted.apply { + setCapabilities(AccessibilityServiceInfo.CAPABILITY_CAN_RETRIEVE_WINDOW_CONTENT) + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + setAccessibilityTool(true) + } + } + try { + if (AndroidTarget.UPSIDE_DOWN_CAKE) { + manager.registerUiTestAutomationService(mToken, client, info, userId, flags) + } else { + manager.registerUiTestAutomationService(mToken, client, info, flags) + } + mClient = client + } catch (re: RemoteException) { + throw IllegalStateException( + "Error while registering UiTestAutomationService for " + + "user " + userId + ".", re + ) + } + } + + private fun unregisterUiTestAutomationServiceLocked() { + manager.unregisterUiTestAutomationService(mClient) + mClient = null + } + + private fun storeRotationStateLocked() { + try { + if (mWindowManager.isRotationFrozen()) { + mInitialFrozenRotation = mWindowManager.getDefaultDisplayRotation() + } + } catch (_: RemoteException) { + } + } + + private fun restoreRotationStateLocked() { + try { + if (mInitialFrozenRotation != INITIAL_FROZEN_ROTATION_UNSPECIFIED) { + when (HiddenApiType.freezeRotation) { + 1 -> mWindowManager.freezeRotation(mInitialFrozenRotation) + 2 -> mWindowManager.freezeRotation( + mInitialFrozenRotation, + "UiAutomationConnection#restoreRotationStateLocked" + ) + } + } else { + when (HiddenApiType.thawRotation) { + 1 -> mWindowManager.thawRotation() + 2 -> mWindowManager.thawRotation("UiAutomationConnection#restoreRotationStateLocked") + } + } + } catch (_: RemoteException) { + } + } + + private fun throwIfShutdownLocked() { + if (mIsShutdown) { + throw IllegalStateException("Connection shutdown!") + } + } + + private fun isConnectedLocked(): Boolean = mClient != null + + private fun throwIfCalledByNotTrustedUidLocked() { + val callingUid = Shizuku.getUid() + if (callingUid != mOwningUid && mOwningUid != android.os.Process.SYSTEM_UID && callingUid != 0) { + throw SecurityException("Calling from not trusted UID!") + } + } + + private fun throwIfNotConnectedLocked() { + if (!isConnectedLocked()) { + throw IllegalStateException("Not connected!") + } + } +} 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..e53768c846 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -1,108 +1,263 @@ package li.songe.gkd.shizuku -import android.content.Intent +import android.app.ActivityManager +import android.content.ComponentName import android.content.pm.PackageManager +import androidx.annotation.WorkerThread 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.update -import li.songe.gkd.META +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.store.shizukuStoreFlow -import li.songe.gkd.util.allPackageInfoMapFlow +import li.songe.gkd.isActivityVisible +import li.songe.gkd.permission.shizukuGrantedState +import li.songe.gkd.permission.updatePermissionState +import li.songe.gkd.service.ExposeService +import li.songe.gkd.service.StatusService +import li.songe.gkd.service.currentAppBlocked +import li.songe.gkd.service.currentAppUseA11y +import li.songe.gkd.service.updateTopTaskAppId +import li.songe.gkd.store.storeFlow +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.MutexState import li.songe.gkd.util.launchTry -import li.songe.gkd.util.otherUserAppInfoMapFlow -import li.songe.gkd.util.userAppInfoMapFlow +import li.songe.gkd.util.toast import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper +import kotlin.system.exitProcess -fun shizukuCheckGranted(): Boolean { - val granted = try { - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - } catch (_: Exception) { - false +inline fun safeInvokeShizuku( + block: () -> T +): T? = try { + block() +} catch (_: ShizukuOffException) { + 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 } - if (!granted) return false - if (shizukuStoreFlow.value.enableActivity) { - return safeGetTopActivity() != null || shizukuCheckActivity() +} + +class ShizukuOffException : IllegalStateException("Shizuku is off") + +fun getShizukuService(name: String): ShizukuBinderWrapper? { + return SystemServiceHelper.getSystemService(name)?.let(::ShizukuBinderWrapper) +} + +// https://github.com/android-cs/16/blob/main/packages/Shell/AndroidManifest.xml +private fun checkRemotePermission(permission: String): Boolean { + return Shizuku.checkRemotePermission(permission) == PackageManager.PERMISSION_GRANTED +} + +private val isAdbRestricted: Boolean + get() { + if (!checkRemotePermission("android.permission.GRANT_RUNTIME_PERMISSIONS")) { + return true + } + if (AndroidTarget.P && !checkRemotePermission("android.permission.MANAGE_APP_OPS_MODES")) { + return true + } + return false + } + +class ShizukuContext( + val serviceWrapper: UserServiceWrapper?, + val packageManager: SafePackageManager?, + val userManager: SafeUserManager?, + val activityManager: SafeActivityManager?, + val activityTaskManager: SafeActivityTaskManager?, + val appOpsService: SafeAppOpsService?, + val inputManager: SafeInputManager?, + val a11yManager: SafeAccessibilityManager?, + val wmManager: SafeWindowManager?, +) { + val ok get() = this !== defaultShizukuContext + fun destroy() { + serviceWrapper?.destroy() + if (activityTaskManager != null) { + activityTaskManager.unregisterDefault() + } else { + activityManager?.unregisterDefault() + } + } + + val states = listOf( + "IUserService" to serviceWrapper, + "IActivityManager" to activityManager, + "IActivityTaskManager" to activityTaskManager, + "IAppOpsService" to appOpsService, + "IInputManager" to inputManager, + "IPackageManager" to packageManager, + "IUserManager" to userManager, + "IAccessibilityManager" to a11yManager, + "IWindowManager" to wmManager, + ) + + fun grantSelf() { + packageManager ?: return + appOpsService ?: return + if (isAdbRestricted) return + 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) + } + + fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, duration: Long): Boolean { + return serviceWrapper?.swipe(x1, y1, x2, y2, duration) ?: (inputManager?.swipe( + x1, + y1, + x2, + y2, + duration + ) != null) + } + + fun getTasks(maxNum: Int = 1): List { + return activityTaskManager?.getTasks(maxNum) + ?: activityManager?.getTasks(maxNum) + ?: emptyList() + } + + fun topCpn(): ComponentName? = getTasks().firstOrNull()?.topActivity + + init { + if (activityTaskManager != null) { + activityTaskManager.registerDefault() + } else { + activityManager?.registerDefault() + } + grantSelf() + // 某些情况下存在残留进程 + val size = serviceWrapper?.userService?.killLegacyService() + if (size != null && size > 0) { + LogUtils.d("killLegacyService $size") + } } - return true } -fun shizukuCheckWorkProfile(): Boolean { - return (try { - arrayOf( - newPackageManager()?.getAllIntentFilters(META.appId)?.isNotEmpty() == true, - newUserManager()?.compatGetUsers()?.isNotEmpty() == true - ).all { it } - } catch (e: Throwable) { - e.printStackTrace() - false - }) +private val defaultShizukuContext by lazy { + ShizukuContext( + serviceWrapper = null, + packageManager = null, + userManager = null, + activityManager = null, + activityTaskManager = null, + appOpsService = null, + inputManager = null, + a11yManager = null, + wmManager = null, + ) } -fun initShizuku() { - 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 } +val currentUserId by lazy { android.os.Process.myUserHandle().hashCode() } + +val shizukuContextFlow by lazy { MutableStateFlow(defaultShizukuContext) } + +val shizukuUsedFlow by lazy { + combine( + shizukuGrantedState.stateFlow, + storeFlow.map { it.enableShizuku }, + ) { a, b -> + a && b + }.stateIn(appScope, SharingStarted.Eagerly, false) +} + +val updateBinderMutex = MutexState() +private fun updateShizukuBinder() = updateBinderMutex.launchTry(appScope, Dispatchers.IO) { + if (shizukuUsedFlow.value) { + if (!app.justStarted) { + toast("正在连接 Shizuku 服务...") + } + val shizukuContext = ShizukuContext( + serviceWrapper = buildServiceWrapper(), + packageManager = SafePackageManager.newBinder(), + userManager = SafeUserManager.newBinder(), + activityManager = SafeActivityManager.newBinder(), + activityTaskManager = SafeActivityTaskManager.newBinder(), + appOpsService = SafeAppOpsService.newBinder(), + inputManager = SafeInputManager.newBinder(), + a11yManager = SafeAccessibilityManager.newBinder(), + wmManager = SafeWindowManager.newBinder(), + ) + shizukuContextFlow.value = shizukuContext + shizukuContext.topCpn()?.let { cpn -> + updateTopTaskAppId(cpn.packageName) + } + if ( + storeFlow.value.useAutomation && + !currentAppBlocked && + !currentAppUseA11y + ) { + AutomationService.tryConnect(true) + } + updatePermissionState() + if (StatusService.needRestart) { + // + shizukuContext.activityManager?.startForegroundService(ExposeService.exposeIntent(expose = -1)) + } + val delayMillis = if (app.justStarted) 1200L else 0L + if (shizukuContext.serviceWrapper == null) { + if (shizukuContext.packageManager != null) { + toast("Shizuku 服务连接部分失败", delayMillis = delayMillis) } else { - otherUserMapFlow.value = emptyMap() - allPackageInfoMapFlow.value = emptyMap() + toast("Shizuku 服务连接失败", delayMillis = delayMillis) } + } else { + toast("Shizuku 服务连接成功", delayMillis = delayMillis) + } + } else if (shizukuContextFlow.value.ok) { + val willRelaunch = uiAutomationFlow.value != null && !shizukuGrantedState.updateAndGet() + if (willRelaunch) { + // 需要重启应用让系统释放 UiAutomation + killRelaunchApp() + } else { + uiAutomationFlow.value?.shutdown(true) + shizukuContextFlow.value.destroy() + shizukuContextFlow.value = defaultShizukuContext + toast("Shizuku 服务已断开") } } - appScope.launchTry(Dispatchers.IO) { - combine( - packageManagerFlow, - 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() - } - } - } +} + +private suspend fun killRelaunchApp() { + if (isActivityVisible) { + toast("Shizuku 断开,重启应用以释放自动化服务", forced = true) + delay(1500) + app.startLaunchActivity() + } else { + toast("Shizuku 断开,结束应用以释放自动化服务", forced = true) + delay(1500) + } + android.os.Process.killProcess(android.os.Process.myPid()) + exitProcess(0) +} + +fun initShizuku() { + Shizuku.addBinderReceivedListener { + LogUtils.d("Shizuku.addBinderReceivedListener") + appScope.launchTry(Dispatchers.IO) { + shizukuGrantedState.updateAndGet() + } + } + Shizuku.addBinderDeadListener { + LogUtils.d("Shizuku.addBinderDeadListener") + shizukuGrantedState.stateFlow.value = false + } + appScope.launchTry { + shizukuUsedFlow.collect { updateShizukuBinder() } } } 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..887058d2b5 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt @@ -0,0 +1,64 @@ +package li.songe.gkd.shizuku + +import android.app.ActivityManager +import android.app.ITaskStackListener +import android.content.ComponentName +import android.os.Parcel +import android.view.Display +import li.songe.gkd.a11y.ActivityScene +import li.songe.gkd.a11y.topActivityFlow +import li.songe.gkd.a11y.updateTopActivity +import li.songe.gkd.util.AndroidTarget + +object 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(): Unit = synchronized(topActivityFlow) { + val cpn = shizukuContextFlow.value.topCpn() ?: return + if (lastFront.first > 0 && lastFront.second == cpn && System.currentTimeMillis() - lastFront.first > 200) { + lastFront = defaultFront + return + } + updateTopActivity( + appId = cpn.packageName, + activityId = cpn.className, + scene = ActivityScene.TaskStack, + ) + } + + private val defaultFront = 0L to ComponentName("", "") + private var lastFront = defaultFront + private fun onTaskMovedToFrontCompat( + cpn: ComponentName? = null + ): Unit = synchronized(topActivityFlow) { + val cpn = cpn ?: shizukuContextFlow.value.topCpn() ?: return + lastFront = System.currentTimeMillis() to cpn + updateTopActivity( + appId = cpn.packageName, + activityId = cpn.className, + scene = ActivityScene.TaskStack, + ) + } + + override fun onTaskMovedToFront(taskId: Int) { + val taskInfo = shizukuContextFlow.value.getTasks().firstOrNull() ?: return + @Suppress("DEPRECATION") + if (taskInfo.id != taskId) { + return + } + onTaskMovedToFrontCompat(taskInfo.topActivity) + } + + override fun onTaskMovedToFront(taskInfo: ActivityManager.RunningTaskInfo) { + if (AndroidTarget.Q && taskInfo.casted.displayId != Display.DEFAULT_DISPLAY) { + return + } + 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 d7e5141e57..8152290e29 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,26 @@ 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 +class SafeUserManager(private val value: IUserManager) { + companion object { -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) + fun newBinder() = getShizukuService(Context.USER_SERVICE)?.let { + SafeUserManager(IUserManager.Stub.asInterface(it)) } - }).map { - li.songe.gkd.data.UserInfo( - id = it.id, - name = it.name.trim(), - ) } -} - -interface SafeUserManager { - 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 + fun getUsers( + excludePartial: Boolean = true, + excludeDying: Boolean = true, + excludePreCreated: Boolean = true + ): List = safeInvokeShizuku { + when (HiddenApiType.getUsers) { + 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/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt index 30e700093d..97310f1412 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt @@ -3,52 +3,38 @@ package li.songe.gkd.shizuku import android.content.ComponentName import android.content.Context import android.content.ServiceConnection +import android.graphics.Bitmap +import android.graphics.Rect import android.os.IBinder -import android.os.RemoteException import android.util.Log +import android.view.SurfaceControlHidden 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.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull 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.permission.shizukuGrantedState +import li.songe.gkd.util.LogUtils 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 java.io.File import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlin.system.exitProcess -@Suppress("unused") -class UserService : IUserService.Stub { - /** - * Constructor is required. - */ - constructor() { - Log.i("UserService", "constructor") - } +// https://github.com/RikkaApps/Shizuku/issues/1171#issuecomment-2952442340 +@Keep +class UserService(val context: Context) : IUserService.Stub() { - @Keep - constructor(context: Context) { - Log.i("UserService", "constructor with Context: context=$context") + init { + Log.d( + "UserService", + "constructor(context=${context.packageName},pid=${android.os.Process.myPid()},uid=${android.os.Process.myUid()})" + ) } override fun destroy() { - Log.i("UserService", "destroy") + Log.d("UserService", "destroy") exitProcess(0) } @@ -56,8 +42,8 @@ class UserService : IUserService.Stub { destroy() } - @Throws(RemoteException::class) - override fun execCommand(command: String): String { + override fun execCommand(command: String): CommandResult { + Log.d("UserService", "execCommand(command=$command)") val process = Runtime.getRuntime().exec("sh") val outputStream = DataOutputStream(process.outputStream) val commandResult = try { @@ -96,34 +82,63 @@ class UserService : IUserService.Stub { process.outputStream.close() process.destroy() } - return json.encodeToString(commandResult) + return commandResult } -} -private fun IUserService.execCommandForResult(command: String): Boolean? { - return try { - val result = execCommand(command) - if (result != null) { - json.decodeFromString(result).code == 0 - } else { - null + override fun takeScreenshot1(width: Int, height: Int): Bitmap? { + return SurfaceControlHidden.screenshot(width, height) + } + + override fun takeScreenshot2( + crop: Rect, + rotation: Int + ): Bitmap? { + val width = crop.width() + val height = crop.height() + return SurfaceControlHidden.screenshot(crop, width, height, rotation) + } + + override fun takeScreenshot3(crop: Rect): Bitmap? { + val width = crop.width() + val height = crop.height() + val displayToken = SurfaceControlHidden.getInternalDisplayToken() + val captureArgs = SurfaceControlHidden.DisplayCaptureArgs.Builder(displayToken) + .setSourceCrop(crop) + .setSize(width, height) + .build() + val screenshotBuffer = SurfaceControlHidden.captureDisplay(captureArgs) + return screenshotBuffer?.asBitmap() + } + + override fun killLegacyService(): Int { + val pid = android.os.Process.myPid() + val idReg = "\\d+".toRegex() + val legacyPids = execCommand("ps | grep '${context.packageName}:$shizukuPsSuffix'") + .result.lineSequence() + .mapNotNull { idReg.find(it)?.value?.toInt() } + .filter { it != pid }.toList() + if (legacyPids.isNotEmpty()) { + execCommand(legacyPids.joinToString(";") { "kill $it" }) } - } catch (e: Exception) { - e.printStackTrace() - null + return legacyPids.size } } +private const val shizukuPsSuffix = "shizuku-user-service" -private fun unbindUserService(serviceArgs: Shizuku.UserServiceArgs, connection: ServiceConnection) { - if (!shizukuOkState.stateFlow.value) return - LogUtils.d("unbindUserService", serviceArgs) +private fun unbindUserService( + serviceArgs: Shizuku.UserServiceArgs, + connection: ServiceConnection, + reason: String? = null, +) { + if (!shizukuGrantedState.stateFlow.value) return + LogUtils.d(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) Shizuku.unbindUserService(serviceArgs, connection, true) } catch (e: Exception) { - LogUtils.d(e) + e.printStackTrace() } } @@ -132,25 +147,46 @@ data class UserServiceWrapper( val connection: ServiceConnection, val serviceArgs: Shizuku.UserServiceArgs ) { - fun destroy() { - unbindUserService(serviceArgs, connection) + fun destroy() = unbindUserService(serviceArgs, connection) + + fun execCommandForResult(command: String): CommandResult = try { + userService.execCommand(command) + } catch (e: Throwable) { + e.printStackTrace() + CommandResult(code = null, result = "", error = e.message) + } + + 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" + } + return execCommandForResult(command).ok + } + + fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, duration: Long): Boolean { + val command = "input swipe $x1 $y1 $x2 $y2 $duration" + return execCommandForResult(command).ok } - fun execCommandForResult(command: String): Boolean? { - return userService.execCommandForResult(command) + fun screencapFile(filePath: String): Boolean { + val tempPath = "/data/local/tmp/screencap_${System.currentTimeMillis()}.png" + val command = "screencap -p $tempPath" + val r = execCommandForResult(command) + if (r.ok) { + File(tempPath).copyTo(File(filePath), overwrite = true) + execCommandForResult("rm $tempPath") + } + return r.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) - .processNameSuffix("shizuku-user-service") + .processNameSuffix(shizukuPsSuffix) .debuggable(META.debuggable) .version(META.versionCode) .tag("default") @@ -178,64 +214,19 @@ suspend fun buildServiceWrapper(): UserServiceWrapper? { LogUtils.d("onServiceDisconnected", componentName) } } - bindServiceMutex.withLock { - return withTimeoutOrNull(3000) { - suspendCoroutine { continuation -> - resumeCallback = { continuation.resume(it) } + return withTimeoutOrNull(3000) { + suspendCancellableCoroutine { continuation -> + resumeCallback = { continuation.resume(it) } + try { Shizuku.bindUserService(serviceArgs, connection) - } - }.apply { - if (this == null) { - toast("获取 Shizuku 服务超时失败") - unbindUserService(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, "connect timeout") } } - stateFlow -} - -suspend fun shizukuCheckUserService(): Boolean { - return try { - execCommandForResult("input tap 0 0") - } catch (e: Throwable) { - e.printStackTrace() - false - } -} - -suspend fun execCommandForResult(command: String): Boolean { - return serviceWrapperFlow.updateAndGet { - it ?: buildServiceWrapper() - }?.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") -} - -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/shizuku/WindowManager.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/WindowManager.kt new file mode 100644 index 0000000000..96fbf6245d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/WindowManager.kt @@ -0,0 +1,12 @@ +package li.songe.gkd.shizuku + +import android.content.Context +import android.view.IWindowManager + +class SafeWindowManager(val value: IWindowManager) { + companion object { + fun newBinder() = getShizukuService(Context.WINDOW_SERVICE)?.let { + SafeWindowManager(IWindowManager.Stub.asInterface(it)) + } + } +} \ No newline at end of file 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..5bc6f45165 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt @@ -2,44 +2,63 @@ package li.songe.gkd.store import kotlinx.serialization.Serializable import li.songe.gkd.META +import li.songe.gkd.util.AppGroupOption +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.AutomatorModeOption import li.songe.gkd.util.RuleSortOption -import li.songe.gkd.util.SortTypeOption import li.songe.gkd.util.UpdateChannelOption import li.songe.gkd.util.UpdateTimeOption @Serializable data class SettingsStore( - val enableService: Boolean = true, + val enableAutomator: Boolean = false, + val automatorMode: Int = AutomatorModeOption.A11yMode.value, + val enableShizuku: Boolean = false, val enableMatch: Boolean = true, - val enableStatusService: Boolean = true, + 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, val toastWhenClick: Boolean = true, - val clickToast: String = "GKD", - val autoClearMemorySubs: Boolean = true, + val actionToast: String = META.appName, + val autoClearMemorySubs: Boolean = false, 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, - 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 showSystemApp: Boolean = true, - val showHiddenApp: Boolean = false, - val appRuleSortType: Int = RuleSortOption.Default.value, - val appShowInnerDisable: Boolean = false, - val subsAppSortType: Int = SortTypeOption.SortByName.value, - val subsAppShowUninstallApp: Boolean = false, - val subsExcludeSortType: Int = SortTypeOption.SortByName.value, - val subsExcludeShowSystemApp: Boolean = true, - val subsExcludeShowHiddenApp: Boolean = false, - val subsExcludeShowDisabledApp: Boolean = false, + val customNotifTitle: String = META.appName, + 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 = true, + val appRuleSort: Int = RuleSortOption.ByDefault.value, + val subsAppSort: Int = AppSortOption.ByUsedTime.value, + val subsCategorySort: Int = AppSortOption.ByUsedTime.value, + val subsAppShowUninstall: Boolean = false, + val subsAppGroupType: Int = AppGroupOption.UserGroup.value or AppGroupOption.SystemGroup.value, + val subsCategoryGroupType: Int = AppGroupOption.UserGroup.value or AppGroupOption.SystemGroup.value, + val subsAppShowBlock: Boolean = false, + val subsCategoryShowBlock: Boolean = false, + val subsExcludeSort: Int = AppSortOption.ByUsedTime.value, + val subsExcludeShowBlockApp: Boolean = true, + val subsExcludeShowInnerDisabledApp: Boolean = true, val subsPowerWarn: Boolean = true, -) + val enableBlockA11yAppList: Boolean = false, + val blockA11yAppListFollowMatch: Boolean = true, + val a11yAppSort: Int = AppSortOption.ByUsedTime.value, + val a11yScopeAppSort: Int = AppSortOption.ByUsedTime.value, + val appGroupType: Int = (1 shl AppGroupOption.normalObjects.size) - 1, + val a11yAppGroupType: Int = appGroupType, + val a11yScopeAppGroupType: Int = appGroupType, + val subsExcludeAppGroupType: Int = appGroupType, + val showDisabledRule: Boolean = true, +) { + val useA11y get() = automatorMode == AutomatorModeOption.A11yMode.value + val useAutomation get() = automatorMode == AutomatorModeOption.AutomationMode.value +} \ No newline at end of file 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/StorageExt.kt b/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt index d46ffc88c7..b94f79a737 100644 --- a/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt @@ -2,7 +2,10 @@ package li.songe.gkd.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi 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 @@ -11,15 +14,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 +28,31 @@ 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 + ) +} + +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +class MutableStoreStateFlow( + val filename: String, + val decode: (String?) -> T, + val encode: (T) -> String, + private val stateFlow: MutableStateFlow, +) : MutableStateFlow by stateFlow { + fun encodeSelf(): String = encode(value) + fun updateByDecode(text: String?) { + value = decode(text) + } } fun createTextFlow( @@ -37,19 +61,26 @@ fun createTextFlow( encode: (T) -> String, private: Boolean = false, scope: CoroutineScope = appScope, -): MutableStateFlow { - val name = if (key.contains('.')) key else "$key.txt" - val initText = readStoreText(name, private) + debounceMillis: Long = 0, +): MutableStoreStateFlow { + val filename = if (key.contains('.')) key else "$key.txt" + val file = (if (private) privateStoreFolder else storeFolder).resolve(filename) + val initText = readStoreText(file) val initValue = decode(initText) val stateFlow = MutableStateFlow(initValue) scope.launch { - stateFlow.drop(1).collect { + stateFlow.drop(1).conflate().debounce(debounceMillis).collect { withContext(Dispatchers.IO) { - writeStoreText(name, encode(it), private) + writeStoreText(file, encode(it)) } } } - return stateFlow + return MutableStoreStateFlow( + filename = filename, + decode = decode, + encode = encode, + stateFlow = stateFlow, + ) } inline fun createAnyFlow( @@ -58,7 +89,8 @@ inline fun createAnyFlow( crossinline initialize: (T) -> T = { it }, private: Boolean = false, scope: CoroutineScope = appScope, -): MutableStateFlow { + debounceMillis: Long = 0, +): MutableStoreStateFlow { return createTextFlow( key = "$key.json", decode = { @@ -72,5 +104,6 @@ inline fun createAnyFlow( }, private = private, scope = scope, + debounceMillis = debounceMillis, ) } 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..ec4694e368 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,12 @@ 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.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 val storeFlow by lazy { @@ -11,20 +16,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", @@ -33,11 +24,63 @@ val actionCountFlow by lazy { ) } -fun initStore() { +val blockMatchAppListFlow by lazy { + createTextFlow( + key = "block_match_app_list", + decode = { it?.let(AppListString::decode) ?: AppListString.getDefaultBlockList() }, + encode = AppListString::encode, + ) +} + +val blockA11yAppListFlow by lazy { + createTextFlow( + key = "block_a11y_app_list", + decode = { it?.let(AppListString::decode) ?: emptySet() }, + encode = AppListString::encode, + ) +} + +val actualBlockA11yAppList: Set + get() = if (storeFlow.value.blockA11yAppListFollowMatch) { + blockMatchAppListFlow.value + } else { + blockA11yAppListFlow.value + } + +val a11yScopeAppListFlow by lazy { + createTextFlow( + key = "a11y_scope_app_list", + decode = { it?.let(AppListString::decode) ?: setOf("com.tencent.mm") }, + encode = AppListString::encode, + ) +} + +val actualA11yScopeAppList: Set + get() = if (storeFlow.value.useAutomation) { + a11yScopeAppListFlow.value + } else { + emptySet() + } + +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 - shizukuStoreFlow.value actionCountFlow.value + blockMatchAppListFlow.value + blockA11yAppListFlow.value + a11yScopeAppListFlow.value + gkdStartCommandText + ExposeService.initCommandFile() } fun switchStoreEnableMatch() { @@ -48,3 +91,8 @@ fun switchStoreEnableMatch() { } storeFlow.update { it.copy(enableMatch = !it.enableMatch) } } + +fun updateEnableAutomator(value: Boolean) { + if (value == storeFlow.value.enableAutomator) return + storeFlow.update { it.copy(enableAutomator = 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..6ce4a2d0c9 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogPage.kt @@ -0,0 +1,364 @@ +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.navigation3.runtime.NavKey +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import kotlinx.serialization.Serializable +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.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 + +@Serializable +data object A11yEventLogRoute : NavKey + +@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.popPage() + }) + }, + 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/A11yScopeAppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListPage.kt new file mode 100644 index 0000000000..9483cbc2bb --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListPage.kt @@ -0,0 +1,272 @@ +package li.songe.gkd.ui + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Box +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.DropdownMenu +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import li.songe.gkd.MainActivity +import li.songe.gkd.R +import li.songe.gkd.store.a11yScopeAppListFlow +import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.component.AnimatedBooleanContent +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.AppCheckBoxCard +import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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.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.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.scaffoldPadding +import li.songe.gkd.util.AppGroupOption +import li.songe.gkd.util.AppListString +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.switchItem +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast + +@Serializable +data object A11YScopeAppListRoute : NavKey + +@Composable +fun A11yScopeAppListPage() { + val store by storeFlow.collectAsState() + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + val vm = viewModel() + val appInfos by vm.appInfosFlow.collectAsState() + val searchStr by vm.searchStrFlow.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 { + context.justHideSoftInput() + if (vm.textChanged) { + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + editable = false + }) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + canScroll = !editable && !store.blockA11yAppListFollowMatch, + navigationIcon = { + IconButton( + onClick = throttle(vm.viewModelScope.launchAsFn { + if (editable) { + if (vm.textChanged) { + context.justHideSoftInput() + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + editable = !editable + } else { + context.hideSoftInput() + mainVm.popPage() + } + }) + ) { + BackCloseIcon(backOrClose = !editable) + } + }, + title = { + val firstShowSearchBar = remember { showSearchBar } + if (showSearchBar) { + BackHandler { + if (!context.justHideSoftInput()) { + showSearchBar = 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) { + a11yScopeAppListFlow.value = + AppListString.decode(vm.textFlow.value) + toast("更新成功") + } else { + toast("未修改") + } + context.justHideSoftInput() + editable = false + }, + ) + }, + contentFalse = { + Row { + var expanded by remember { mutableStateOf(false) } + AnimatedIconButton( + onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + showSearchBar = false + } else { + vm.searchStrFlow.value = "" + } + } else { + showSearchBar = true + } + }, + id = R.drawable.ic_anim_search_close, + atEnd = showSearchBar, + ) + PerfIconButton(imageVector = PerfIcon.Sort, onClick = { + expanded = true + }) + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + MenuGroupCard(inTop = true, title = "排序") { + var sortType by vm.sortTypeFlow.asMutableState() + AppSortOption.objects.forEach { option -> + MenuItemRadioButton( + text = option.label, + selected = sortType == option, + onClick = { sortType = option }, + ) + } + } + MenuGroupCard(inTop = true, title = "筛选") { + var appGroupType by vm.appGroupTypeFlow.asMutableState() + AppGroupOption.normalObjects.forEach { option -> + val newValue = option.invert(appGroupType) + MenuItemCheckbox( + enabled = newValue != 0, + text = option.label, + checked = option.include(appGroupType), + onClick = { appGroupType = newValue }, + ) + } + } + } + } + } + }, + ) + }) + }, + floatingActionButton = { + AnimationFloatingActionButton( + visible = !editable && scrollBehavior.isFullVisible, + onClickLabel = "进入文本编辑模式", + onClick = { + editable = !editable + }, + imageVector = PerfIcon.Edit, + contentDescription = "编辑文本" + ) + }, + ) { contentPadding -> + if (editable) { + MultiTextField( + modifier = Modifier.scaffoldPadding(contentPadding), + textFlow = vm.textFlow, + immediateFocus = true, + placeholderText = "请输入应用ID列表\n示例:\ncom.android.systemui\ncom.android.settings", + indicatorSize = vm.indicatorSizeFlow.collectAsState().value, + ) + } else { + val a11yScopeAppList by a11yScopeAppListFlow.collectAsState() + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + ) { + items(appInfos, { it.id }) { appInfo -> + val checked = a11yScopeAppList.contains(appInfo.id) + AppCheckBoxCard( + appInfo = appInfo, + checked = checked, + onCheckedChange = { + a11yScopeAppListFlow.update { + it.switchItem(appInfo.id) + } + }, + ) + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (appInfos.isEmpty() && searchStr.isNotEmpty()) { + EmptyText(text = "暂无搜索结果") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListVm.kt new file mode 100644 index 0000000000..2cfc13370b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListVm.kt @@ -0,0 +1,64 @@ +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.a11yScopeAppListFlow +import li.songe.gkd.store.storeFlow +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.AppListString +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.findOption + +class A11yScopeAppListVm : BaseViewModel() { + val sortTypeFlow = storeFlow.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.a11yScopeAppSort) }, + setter = { + storeFlow.value.copy(a11yScopeAppSort = it.value) + } + ) + val appGroupTypeFlow = storeFlow.asMutableStateFlow( + getter = { it.a11yScopeAppGroupType }, + setter = { + storeFlow.value.copy(a11yScopeAppGroupType = it) + } + ) + val appFilter = useAppFilter( + appGroupTypeFlow = appGroupTypeFlow, + sortTypeFlow = sortTypeFlow, + ) + 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() = a11yScopeAppListFlow.value != AppListString.decode(textFlow.value) + + val indicatorSizeFlow = textFlow.debounce(500).map { + AppListString.decode(it).size + }.stateInit(0) + + init { + showSearchBarFlow.launchCollect { + if (!it) { + searchStrFlow.value = "" + } + } + editableFlow.launchOnChange { + if (it) { + showSearchBarFlow.value = false + textFlow.value = AppListString.encode(a11yScopeAppListFlow.value, append = true) + } + } + appInfosFlow.launchOnChange { + resetKey.intValue++ + } + } +} \ No newline at end of file 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..f4b9c714cb 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 @@ -16,21 +15,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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 @@ -44,61 +36,69 @@ 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 import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.MainActivity -import li.songe.gkd.app +import li.songe.gkd.R 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.TextListDialog 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.local.LocalNavController +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 import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.ISSUES_URL import li.songe.gkd.util.PLAY_STORE_URL import li.songe.gkd.util.REPOSITORY_URL -import li.songe.gkd.util.SafeR import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.UpdateChannelOption -import li.songe.gkd.util.buildLogFile import li.songe.gkd.util.findOption import li.songe.gkd.util.format +import li.songe.gkd.util.getShareApkFile import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry import li.songe.gkd.util.openUri import li.songe.gkd.util.saveFileToDownloads import li.songe.gkd.util.shareFile -import li.songe.gkd.util.sharedDir import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -import java.io.File -@Destination(style = ProfileTransitions::class) +@Serializable +data object AboutRoute : NavKey + @Composable fun AboutPage() { - val navController = LocalNavController.current 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 }, @@ -143,34 +143,29 @@ fun AboutPage() { }, ) } - var showShareLogDlg by remember { mutableStateOf(false) } - var showShareAppDlg by remember { mutableStateOf(false) } + var showShareAppDlg by vm.showShareAppDlgFlow.asMutableState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { + mainVm.popPage() + }, + ) }, title = { Text(text = "关于") }, actions = { - IconButton(onClick = { - showShareAppDlg = true - }) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Share, + onClick = { + showShareAppDlg = true + }, + ) } ) } @@ -255,18 +250,23 @@ 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(",可点击下方继续反馈") }) }, - confirmText = "继续反馈", + confirmText = "继续", dismissRequest = true, ) mainVm.openUrl(ISSUES_URL) @@ -281,9 +281,9 @@ fun AboutPage() { } SettingItem( title = "导出日志", - imageVector = Icons.Default.Share, + imageVector = PerfIcon.Share, onClick = { - showShareLogDlg = true + mainVm.showShareLogDlgFlow.value = true } ) if (mainVm.updateStatus != null) { @@ -295,7 +295,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) { @@ -333,99 +333,56 @@ fun AboutPage() { } } - if (showShareLogDlg) { - Dialog(onDismissRequest = { showShareLogDlg = false }) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - ) { - val modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - Text( - text = "分享到其他应用", modifier = Modifier - .clickable(onClick = throttle { - showShareLogDlg = false - mainVm.viewModelScope.launchTry(Dispatchers.IO) { - val logZipFile = buildLogFile() - context.shareFile(logZipFile, "分享日志文件") - } - }) - .then(modifier) - ) - Text( - text = "保存到下载", modifier = Modifier - .clickable(onClick = throttle { - showShareLogDlg = false - mainVm.viewModelScope.launchTry(Dispatchers.IO) { - val logZipFile = buildLogFile() - context.saveFileToDownloads(logZipFile) - } - }) - .then(modifier) - ) - Text( - text = "生成链接(需科学上网)", - modifier = Modifier - .clickable(onClick = throttle { - showShareLogDlg = false - mainVm.uploadOptions.startTask( - getFile = { buildLogFile() } - ) - }) - .then(modifier) - ) - } - } + if (showShareAppDlg) { + TextListDialog( + onDismiss = { showShareAppDlg = false }, + textList = listOf( + "分享到其他应用" to mainVm.viewModelScope.launchAsFn(Dispatchers.IO) { + if (!META.isGkdChannel) { + mainVm.dialogFlow.waitResult( + title = "分享提示", + textContent = { Text(text = exportPlayTipTemplate()) }, + confirmText = "继续", + ) + } + context.shareFile(getShareApkFile(), "分享安装文件") + }, + "保存到下载" to mainVm.viewModelScope.launchAsFn(Dispatchers.IO) { + if (!META.isGkdChannel) { + mainVm.dialogFlow.waitResult( + title = "保存提示", + textContent = { Text(text = exportPlayTipTemplate()) }, + confirmText = "继续", + ) + } + context.saveFileToDownloads(getShareApkFile()) + }, + "Google Play" to { + mainVm.openUrl(PLAY_STORE_URL) + }, + ) + ) } +} - if (showShareAppDlg) { - Dialog(onDismissRequest = { showShareAppDlg = false }) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - ) { - val modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - Text( - text = "分享到其他应用", modifier = Modifier - .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, "分享安装文件") - } - }) - .then(modifier) - ) - Text( - text = "保存到下载", modifier = Modifier - .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) - } - }) - .then(modifier) - ) - Text( - text = "Google Play", modifier = Modifier - .clickable(onClick = throttle { - showShareAppDlg = false - mainVm.openUrl(PLAY_STORE_URL) - }) - .then(modifier) +@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(",或点击下方继续操作") } } @@ -433,22 +390,20 @@ fun AboutPage() { private fun AnimatedLogoIcon( modifier: Modifier = Modifier ) { - val mainVm = LocalMainViewModel.current - val enableDarkTheme by mainVm.enableDarkThemeFlow.collectAsState() - val darkTheme = enableDarkTheme ?: isSystemInDarkTheme() + val darkTheme = LocalDarkTheme.current + val colorRid = if (darkTheme) R.color.better_white else R.color.better_black var atEnd by remember { mutableStateOf(false) } - val animation = AnimatedImageVector.animatedVectorResource(id = SafeR.ic_anim_logo) + val animation = AnimatedImageVector.animatedVectorResource(id = R.drawable.ic_anim_logo) val painter = rememberAnimatedVectorPainter( animation, atEnd ) LaunchedEffect(Unit) { - while (true) { + while (isActive) { atEnd = !atEnd 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/AboutVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt new file mode 100644 index 0000000000..bdbd4dd9e8 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt @@ -0,0 +1,9 @@ +package li.songe.gkd.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow + +class AboutVm : ViewModel() { + val showInfoDlgFlow = MutableStateFlow(false) + val showShareAppDlgFlow = MutableStateFlow(false) +} \ 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 be9689c760..4b2578a4ab 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,46 +11,45 @@ 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.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.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.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey 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 com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination -import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.Serializable import li.songe.gkd.data.ActionLog import li.songe.gkd.data.ExcludeData import li.songe.gkd.data.RawSubscription @@ -60,99 +60,108 @@ 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 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.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.iconTextSize 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.subsIdToRawFlow -import li.songe.gkd.util.subsItemsFlow +import li.songe.gkd.util.mapState +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -@Destination(style = ProfileTransitions::class) +@Serializable +data class ActionLogRoute( + val subsId: Long? = null, + val appId: String? = null, +) : NavKey + @Composable -fun ActionLogPage( - subsId: Long? = null, - appId: String? = null, -) { +fun ActionLogPage(route: ActionLogRoute) { + val subsId = route.subsId + val appId = route.appId val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val vm = viewModel() - - val actionDataItems = vm.pagingDataFlow.collectAsLazyPagingItems() - val (scrollBehavior, listState) = useListScrollState(actionDataItems.itemCount > 0) + val vm = viewModel { ActionLogVm(route) } + 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 { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { + mainVm.popPage() + }, + ) }, 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 -> @@ -164,13 +173,13 @@ 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), + modifier = Modifier.animateListItem(), i = i, item = item, lastItem = lastItem, @@ -181,10 +190,10 @@ 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 = "暂无记录") + if (list.itemCount == 0 && list.loadState.refresh !is LoadState.Loading) { + EmptyText(text = "暂无数据") } } } @@ -218,25 +227,57 @@ 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 .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( + AppConfigRoute( + 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)) @@ -261,7 +302,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, @@ -273,16 +313,28 @@ private fun ActionLogCard( ) } if (subsId == null) { - Text( - text = subscription?.name ?: "id=${actionLog.subsId}", - modifier = Modifier.clickable(onClick = throttle { - if (subsItemsFlow.value.any { it.id == actionLog.subsId }) { - mainVm.sheetSubsIdFlow.value = actionLog.subsId - } else { - toast("订阅不存在") - } - }) - ) + Row { + 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() @@ -346,21 +398,21 @@ private fun ActionLogDialog( shape = RoundedCornerShape(16.dp), ) { ItemText( - text = "查看规则组", + text = "查看规则", onClick = { onDismissRequest() if (actionLog.groupType == SubsConfig.AppGroupType) { mainVm.navigatePage( - SubsAppGroupListPageDestination( - actionLog.subsId, actionLog.appId, actionLog.groupKey - ) + SubsAppGroupListRoute( + actionLog.subsId, actionLog.appId, actionLog.groupKey ) + ) } else if (actionLog.groupType == SubsConfig.GlobalGroupType) { mainVm.navigatePage( - SubsGlobalGroupListPageDestination( - actionLog.subsId, actionLog.groupKey - ) + SubsGlobalGroupListRoute( + actionLog.subsId, actionLog.groupKey ) + ) } } ) @@ -368,7 +420,7 @@ private fun ActionLogDialog( if (actionLog.groupType == SubsConfig.GlobalGroupType) { val subs = remember(actionLog.subsId) { - subsIdToRawFlow.map(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) { @@ -401,7 +453,7 @@ private fun ActionLogDialog( .stringify() ) DbSet.subsConfigDao.insert(newSubsConfig) - toast("更新禁用") + toast("更新成功") } ) HorizontalDivider() @@ -437,7 +489,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..e7c7333723 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt @@ -1,40 +1,38 @@ package li.songe.gkd.ui -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map -import com.ramcosta.composedestinations.generated.destinations.ActionLogPageDestination import kotlinx.coroutines.flow.MutableStateFlow 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) +class ActionLogVm(val route: ActionLogRoute) : ViewModel() { val pagingDataFlow = Pager(PagingConfig(pageSize = 100)) { - if (args.subsId != null) { - DbSet.actionLogDao.pagingSubsSource(subsId = args.subsId) - } else if (args.appId != null) { - DbSet.actionLogDao.pagingAppSource(appId = args.appId) + if (route.subsId != null) { + DbSet.actionLogDao.pagingSubsSource(subsId = route.subsId) + } else if (route.appId != null) { + DbSet.actionLogDao.pagingAppSource(appId = route.appId) } else { DbSet.actionLogDao.pagingSource() } } .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 +44,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 28d2da06fe..a48b8638b9 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,34 +12,34 @@ 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.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.foundation.shape.CircleShape 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.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 import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey 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.Serializable import li.songe.gkd.MainActivity import li.songe.gkd.data.ActivityLog import li.songe.gkd.db.DbSet @@ -46,67 +47,70 @@ 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.local.LocalNavController +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.iconTextSize 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.appInfoMapFlow import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -@Destination(style = ProfileTransitions::class) +@Serializable +data object ActivityLogRoute : NavKey + @Composable 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() - 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 = throttle { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popPage() + }) }, 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), @@ -121,13 +125,13 @@ fun ActivityLogPage() { CompositionLocalProvider( LocalNumberCharWidth provides timeTextWidth ) { - ActivityLogCard(i = i, actionLog = actionLog, lastActionLog = lastActionLog) + ActivityLogCard(i = i, activityLog = actionLog, lastActivityLog = 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 = "暂无记录") + EmptyText(text = "暂无数据") } } } @@ -137,27 +141,68 @@ fun ActivityLogPage() { @Composable private fun ActivityLogCard( i: Int, - actionLog: ActivityLog, - lastActionLog: ActivityLog?, + activityLog: ActivityLog, + lastActivityLog: ActivityLog?, ) { - val isDiffApp = actionLog.appId != lastActionLog?.appId + val mainVm = LocalMainViewModel.current + val isDiffApp = activityLog.appId != lastActivityLog?.appId val verticalPadding = if (i == 0) 0.dp else if (isDiffApp) 12.dp else 8.dp + 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( + AppConfigRoute( + 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 + .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( @@ -171,20 +216,14 @@ private fun ActivityLogCard( modifier = Modifier.weight(1f) ) { FixedTimeText( - text = actionLog.date, + text = activityLog.date, style = MaterialTheme.typography.bodySmall, 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 10402cf501..e3125b25c5 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 @@ -15,25 +14,20 @@ 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.filled.WarningAmber -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.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor 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.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -41,9 +35,11 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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 +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -52,66 +48,62 @@ 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 androidx.navigation3.runtime.NavKey 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 kotlinx.serialization.Serializable 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.R 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.shizuku.shizukuCheckActivity -import li.songe.gkd.shizuku.shizukuCheckUserService -import li.songe.gkd.shizuku.shizukuCheckWorkProfile -import li.songe.gkd.store.shizukuStoreFlow +import li.songe.gkd.permission.shizukuGrantedState +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.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.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.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.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +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 import li.songe.gkd.util.ShortUrlSet -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.appInfoMapFlow 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 -import rikka.shizuku.Shizuku +import li.songe.selector.Selector + +@Serializable +data object AdvancedPageRoute : NavKey -@Destination(style = ProfileTransitions::class) @Composable 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 { - mutableStateOf(false) - } + var showEditPortDlg by vm.showEditPortDlgFlow.asMutableState() if (showEditPortDlg) { + val portRange = remember { 1000 to 65535 } + val placeholderText = remember { "请输入 ${portRange.first}-${portRange.second} 的整数" } var value by remember { mutableStateOf(store.httpServerPort.toString()) } @@ -122,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) @@ -149,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("更新成功") } } ) { @@ -179,20 +169,139 @@ 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 = "我知道了") + } + }, + ) + } + + 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 = "取消", + ) + } + }) + } + var showHttpSettingDlg by rememberSaveable { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, title = { Text(text = "高级设置") }) + PerfTopAppBar( + scrollBehavior = scrollBehavior, + navigationIcon = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popPage() + }) + }, + title = { Text(text = "高级设置") }, + ) } ) { contentPadding -> Column( @@ -201,107 +310,154 @@ fun AdvancedPage() { .verticalScroll(rememberScrollState()) .padding(contentPadding), ) { - ShizukuTitleCard() - val shizukuOk by shizukuOkState.stateFlow.collectAsState() - AnimatedVisibility(!shizukuOk) { + Row( + modifier = Modifier + .fillMaxWidth() + .titleItemPadding(showTop = false), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier, + text = "Shizuku", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + PerfIcon( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClickLabel = "打开 Shizuku 状态弹窗", onClick = throttle { + showShizukuState = true + }) + .iconTextSize(textStyle = MaterialTheme.typography.titleSmall), + imageVector = PerfIcon.Api, + tint = MaterialTheme.colorScheme.primary, + contentDescription = "Shizuku 状态", + ) + } + val shizukuGranted by shizukuGrantedState.stateFlow.collectAsState() + AnimatedVisibility(store.enableShizuku && !shizukuGranted) { 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() + } + ) } - ShizukuFragment(vm, shizukuOk) + TextSwitch( + title = "启用优化", + subtitle = "提升权限优化体验", + suffix = "了解更多", + suffixUnderline = true, + onSuffixClick = { mainVm.navigateWebPage(ShortUrlSet.URL14) }, + checked = store.enableShizuku, + suffixIcon = { + if (updateBinderMutex.state.collectAsState().value) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp), + ) + } + }, + onCheckedChange = { + mainVm.switchEnableShizuku(it) + }, + onClick = null, + ) val server by HttpService.httpServerFlow.collectAsState() val httpServerRunning = server != null val localNetworkIps by HttpService.localNetworkIpsFlow.collectAsState() Text( - text = "HTTP服务", + text = "HTTP", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) - Row( - modifier = Modifier.itemPadding(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "HTTP服务", - style = MaterialTheme.typography.bodyLarge, + TextSwitch( + title = "HTTP服务", + subtitle = "在浏览器下连接调试", + suffixIcon = { + PerfCustomIconButton( + size = 32.dp, + iconSize = 20.dp, + onClickLabel = "打开HTTP设置弹窗", + onClick = { showHttpSettingDlg = !showHttpSettingDlg }, + id = R.drawable.ic_page_info, + contentDescription = "HTTP设置", + tint = if (showHttpSettingDlg) MaterialTheme.colorScheme.primary else LocalContentColor.current, ) - CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.bodyMedium + }, + checked = httpServerRunning, + onCheckedChange = throttle(fn = vm.viewModelScope.launchAsFn { + if (it) { + requiredPermission(context, foregroundServiceSpecialUseState) + requiredPermission(context, notificationState) + HttpService.start() + } else { + HttpService.stop() + } + }) + ) + AnimatedVisibility(visible = httpServerRunning) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodyMedium + ) { + Column( + modifier = Modifier.itemPadding() ) { - Text(text = if (httpServerRunning) "点击链接打开即可自动连接" else "在浏览器下连接调试工具") - AnimatedVisibility(httpServerRunning) { - Column { - Row { - val localUrl = "http://127.0.0.1:${store.httpServerPort}" - Text( - text = localUrl, - color = MaterialTheme.colorScheme.primary, - style = LocalTextStyle.current.copy(textDecoration = TextDecoration.Underline), - modifier = Modifier.clickable(onClick = throttle { - mainVm.openUrl(localUrl) - }), - ) - Spacer(modifier = Modifier.width(2.dp)) - Text(text = "仅本设备可访问") - } - localNetworkIps.forEach { host -> - val lanUrl = "http://${host}:${store.httpServerPort}" - Text( - text = lanUrl, - color = MaterialTheme.colorScheme.primary, - style = LocalTextStyle.current.copy(textDecoration = TextDecoration.Underline), - modifier = Modifier.clickable(onClick = throttle { - mainVm.openUrl(lanUrl) - }) - ) - } - } + Text(text = "点击下方链接即可连接") + Row { + val localUrl = "http://127.0.0.1:${store.httpServerPort}" + Text( + text = localUrl, + color = MaterialTheme.colorScheme.primary, + style = LocalTextStyle.current.copy(textDecoration = TextDecoration.Underline), + modifier = Modifier.clickable(onClick = throttle { + mainVm.openUrl(localUrl) + }), + ) + Spacer(modifier = Modifier.width(2.dp)) + Text(text = "仅本设备访问") + } + localNetworkIps.forEach { host -> + val lanUrl = "http://${host}:${store.httpServerPort}" + Text( + text = lanUrl, + color = MaterialTheme.colorScheme.primary, + style = LocalTextStyle.current.copy(textDecoration = TextDecoration.Underline), + modifier = Modifier.clickable(onClick = throttle { + mainVm.openUrl(lanUrl) + }) + ) } } } - Switch( - checked = httpServerRunning, - onCheckedChange = throttle(fn = vm.viewModelScope.launchAsFn { - if (it) { - requiredPermission(context, foregroundServiceSpecialUseState) - requiredPermission(context, notificationState) - HttpService.start() - } else { - HttpService.stop() - } - }) - ) } - - SettingItem( - title = "服务端口", - subtitle = store.httpServerPort.toString(), - imageVector = Icons.Outlined.Edit, - onClick = { - showEditPortDlg = true + AnimatedVisibility(visible = showHttpSettingDlg) { + Column { + SettingItem( + title = "服务端口", + subtitle = store.httpServerPort.toString(), + imageVector = PerfIcon.Edit, + onClickLabel = "编辑服务端口", + onClick = { + showHttpSettingDlg = false + showEditPortDlg = true + } + ) + TextSwitch( + title = "清除订阅", + subtitle = "关闭服务时删除内存订阅", + checked = store.autoClearMemorySubs, + onCheckedChange = { + storeFlow.update { + it.copy(autoClearMemorySubs = !it.autoClearMemorySubs) + } + } + ) } - ) - - TextSwitch( - title = "清除订阅", - subtitle = "服务关闭时,删除内存订阅", - checked = store.autoClearMemorySubs - ) { - storeFlow.value = store.copy( - autoClearMemorySubs = it - ) } Text( @@ -315,12 +471,11 @@ fun AdvancedPage() { title = "快照记录", subtitle = "应用界面节点信息及截图", onClick = { - mainVm.navigatePage(SnapshotPageDestination) + mainVm.navigatePage(SnapshotPageRoute) } ) - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (!AndroidTarget.R) { val screenshotRunning by ScreenshotService.isRunning.collectAsState() TextSwitch( title = "截屏服务", @@ -343,254 +498,147 @@ 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() } - } + }, ) TextSwitch( title = "音量快照", subtitle = "音量变化时保存快照", - checked = store.captureVolumeChange - ) { - storeFlow.value = store.copy( - captureVolumeChange = it - ) - } + checked = store.captureVolumeChange, + onCheckedChange = { + storeFlow.value = store.copy( + captureVolumeChange = it + ) + }, + ) TextSwitch( title = "截屏快照", - subtitle = "触发截屏时保存快照", - suffix = "查看限制", - onSuffixClick = { - mainVm.dialogFlow.updateDialogOptions( - title = "限制说明", - text = "仅支持部分小米设备截屏触发\n\n只保存节点信息不保存图片, 用户需要在快照记录里替换截图", + subtitle = "截屏时保存快照", + checked = store.captureScreenshot, + suffixIcon = { + PerfCustomIconButton( + size = 32.dp, + iconSize = 20.dp, + onClickLabel = "打开配置截屏快照弹窗", + onClick = throttle { + showCaptureScreenshotDlg = true + }, + id = R.drawable.ic_page_info, + contentDescription = "截屏快照设置", ) }, - checked = store.captureScreenshot - ) { - storeFlow.value = store.copy( - captureScreenshot = it - ) - } + 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 - ) - } + subtitle = "隐藏快照截图状态栏", + checked = store.hideSnapshotStatusBar, + onCheckedChange = { + storeFlow.value = store.copy( + hideSnapshotStatusBar = it + ) + } + ) TextSwitch( title = "保存提示", - subtitle = "保存时提示\"正在保存快照\"", - checked = store.showSaveSnapshotToast - ) { - storeFlow.value = store.copy( - showSaveSnapshotToast = it - ) - } + subtitle = "提示「正在保存快照」", + checked = store.showSaveSnapshotToast, + onCheckedChange = { + storeFlow.value = store.copy( + showSaveSnapshotToast = it + ) + } + ) SettingItem( title = "Github Cookie", subtitle = "生成快照/日志链接", suffix = "获取教程", + suffixUnderline = true, onSuffixClick = { - mainVm.navigatePage(WebViewPageDestination(initUrl = (ShortUrlSet.URL1))) + 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, ) - - TextSwitch( - title = "记录界面", - subtitle = "记录打开的应用及界面", - checked = store.enableActivityLog - ) { - storeFlow.value = store.copy( - enableActivityLog = it - ) - } SettingItem( - title = "界面记录", + title = "界面日志", + subtitle = "界面切换日志", onClick = { - mainVm.navigatePage(ActivityLogPageDestination) + mainVm.navigatePage(ActivityLogRoute) } ) - - Text( - text = "其他", - modifier = Modifier.titleItemPadding(), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, + TextSwitch( + 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(A11yEventLogRoute) + } ) - TextSwitch( - title = "前台悬浮窗", - subtitle = "添加透明悬浮窗", - suffix = "查看作用", - onSuffixClick = { - mainVm.dialogFlow.updateDialogOptions( - title = "悬浮窗作用", - text = "1.提高 GKD 前台优先级, 降低被系统杀死概率\n2.提高点击响应速度, 关闭后可能导致点击缓慢或不点击", - ) - }, - checked = store.enableAbFloatWindow, - onCheckedChange = { - storeFlow.value = store.copy( - enableAbFloatWindow = it - ) - }) + title = "事件服务", + subtitle = "显示无障碍事件", + checked = EventService.isRunning.collectAsState().value, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + requiredPermission(context, foregroundServiceSpecialUseState) + requiredPermission(context, notificationState) + requiredPermission(context, canDrawOverlaysState) + EventService.start() + } else { + EventService.stop() + } + } + ) 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) } - }) - -} - -@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/AdvancedVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt index 3f5db66f2d..2f46f8ea36 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,11 @@ package li.songe.gkd.ui import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow -class AdvancedVm : ViewModel() +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 4787fe18e0..525fbd3881 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -1,103 +1,102 @@ 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.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 -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 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 import androidx.lifecycle.viewmodel.compose.viewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.ActionLogPageDestination -import com.ramcosta.composedestinations.generated.destinations.SubsAppGroupListPageDestination -import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination +import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable 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.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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 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.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.iconTextSize 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 +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 import li.songe.gkd.util.toJson5String -@Suppress("unused") -@Destination(style = ProfileTransitions::class) +@Serializable +data class AppConfigRoute( + val appId: String, + val focusLog: ActionLog? = null, +) : NavKey + @Composable -fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { +fun AppConfigPage(route: AppConfigRoute) { + val appId = route.appId + val focusLog = route.focusLog val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val vm = viewModel() + val vm = viewModel { AppConfigVm(route) } val ruleSortType by vm.ruleSortTypeFlow.collectAsState() val groupSize by vm.groupSizeFlow.collectAsState() - val firstLoading by vm.linkLoad.firstLoadingFlow.collectAsState() - val (scrollBehavior, listState) = useListScrollState(groupSize > 0, ruleSortType.value) + val firstLoading by vm.firstLoadingFlow.collectAsState() + val resetKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState( + resetKey, + groupSize > 0, + firstLoading, + ) if (focusLog != null && groupSize > 0) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { @@ -138,36 +137,40 @@ 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 } else { - navController.popBackStack() + mainVm.popPage() } }) { 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() @@ -180,50 +183,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 { - mainVm.navigatePage(ActionLogPageDestination(appId = appId)) - }) { - Icon( - imageVector = Icons.Default.History, - contentDescription = null, - ) - } - IconButton(onClick = { + }) + } + }, + contentFalse = { + Row { + PerfIconButton(imageVector = PerfIcon.History, onClick = throttle { + mainVm.navigatePage(ActionLogRoute(appId = appId)) + }) + 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( @@ -250,53 +234,26 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { } ) } else { - Text( - text = "排序", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - RuleSortOption.allSubObject.forEach { s -> - DropdownMenuItem( - text = { - Text(s.label) - }, - trailingIcon = { - RadioButton( - selected = ruleSortType == s, - onClick = throttle { - storeFlow.update { it.copy(appRuleSortType = s.value) } - } - ) - }, - onClick = throttle { - storeFlow.update { it.copy(appRuleSortType = s.value) } - }, + MenuGroupCard(inTop = true, title = "排序") { + val handleItem: (RuleSortOption) -> Unit = throttle { v -> + storeFlow.update { s -> s.copy(appRuleSort = v.value) } + } + RuleSortOption.objects.forEach { s -> + MenuItemRadioButton( + text = s.label, + selected = ruleSortType == s, + onClick = { + handleItem(s) + }, + ) + } + } + MenuGroupCard(title = "筛选") { + MenuItemCheckbox( + text = "未启用", + stateFlow = vm.showDisabledRuleFlow, ) } - 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) } - }, - ) } } } @@ -306,21 +263,17 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { floatingActionButton = { AnimationFloatingActionButton( visible = !isSelectedMode, - onClick = throttle { + onClick = { mainVm.navigatePage( - UpsertRuleGroupPageDestination( + UpsertRuleGroupRoute( subsId = LOCAL_SUBS_ID, groupKey = null, appId = appId ) ) }, - content = { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - ) - } + imageVector = PerfIcon.Add, + contentDescription = "添加规则" ) }, ) { contentPadding -> @@ -331,6 +284,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 @@ -338,18 +292,20 @@ 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( - SubsAppGroupListPageDestination( + SubsAppGroupListRoute( subsItemId = subsId, appId = appId, ) ) }) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.weight(1f), @@ -360,20 +316,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() - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(start = 4.dp) - .width(fontSizeDp) - .height(lineHeightDp) + modifier = Modifier.iconTextSize() ) } } @@ -381,13 +327,13 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { val subsConfig = when (group) { is RawSubscription.RawAppGroup -> appSubsConfigs is RawSubscription.RawGlobalGroup -> globalSubsConfigs - }.find { it.subsId == entry.subsItem.id && it.groupKey == group.key } + }?.find { it.subsId == entry.subsItem.id && it.groupKey == group.key } val category = when (group) { - is RawSubscription.RawAppGroup -> entry.subscription.groupToCategoryMap[group] + is RawSubscription.RawAppGroup -> entry.subscription.getCategory(group.name) is RawSubscription.RawGlobalGroup -> null } val categoryConfig = if (category != null) { - categoryConfigs.find { it.subsId == subsId && it.categoryKey == category.key } + categoryConfigs?.find { it.subsId == subsId && it.categoryKey == category.key } } else { null } @@ -415,14 +361,12 @@ fun AppConfigPage(appId: String, focusLog: ActionLog? = null) { ) } RuleGroupCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), subs = entry.subscription, appId = appId, group = group, subsConfig = subsConfig, - category = category, categoryConfig = categoryConfig, - showBottom = true, onLongClick = onLongClick, isSelectedMode = isSelectedMode, isSelected = isSelected, @@ -431,13 +375,10 @@ 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 = "暂无规则") - } else { - // 避免被 floatingActionButton 遮挡 - Spacer(modifier = Modifier.height(EmptyHeight)) + EmptyText(text = if (vm.showDisabledRuleFlow.collectAsState().value) "暂无数据" else "暂无数据,或修改筛选") } } } 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..e0cf07a7e8 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt @@ -1,49 +1,47 @@ package li.songe.gkd.ui -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.LogUtils -import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flattenConcat import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +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.ui.component.ShowGroupState +import li.songe.gkd.ui.component.getActualGroupChecked import li.songe.gkd.ui.component.toGroupState -import li.songe.gkd.util.LinkLoad +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.ui.share.asMutableStateFlow +import li.songe.gkd.util.LogUtils import li.songe.gkd.util.RuleSortOption -import li.songe.gkd.util.ViewModelExt +import li.songe.gkd.util.UsedSubsEntry import li.songe.gkd.util.collator import li.songe.gkd.util.findOption -import li.songe.gkd.util.map import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.usedSubsEntriesFlow -class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { - private val args = AppConfigPageDestination.argsFrom(stateHandle) - - val linkLoad = LinkLoad(viewModelScope) - - val ruleSortTypeFlow = storeFlow.map(viewModelScope) { - RuleSortOption.allSubObject.findOption(it.appRuleSortType) - } - - val appShowInnerDisableFlow = storeFlow.map(viewModelScope) { - it.appShowInnerDisable +class AppConfigVm(val route: AppConfigRoute) : BaseViewModel() { + val ruleSortTypeFlow = storeFlow.mapNew { + RuleSortOption.objects.findOption(it.appRuleSort) } + val showDisabledRuleFlow = storeFlow.asMutableStateFlow( + getter = { it.showDisabledRule }, + setter = { + storeFlow.value.copy(showDisabledRule = it) + } + ) - private val usedSubsIdsFlow = subsItemsFlow.map(viewModelScope) { list -> + private val usedSubsIdsFlow = subsItemsFlow.mapNew { 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(route.appId).attachLoad() private val appUsedSubsIdsFlow = combine(usedSubsIdsFlow, appConfigsFlow) { ids, configs -> ids.filter { @@ -52,49 +50,37 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { }.stateInit(usedSubsIdsFlow.value) private val latestLogsFlow = ruleSortTypeFlow.map { - if (it == RuleSortOption.ByTime) { - DbSet.actionLogDao.queryLatestByAppId(args.appId) + if (it == RuleSortOption.ByActionTime) { + DbSet.actionLogDao.queryLatestByAppId(route.appId) } else { flowOf(emptyList()) } - }.flattenConcat().let(linkLoad::invoke).stateInit(emptyList()) + }.flattenConcat().attachLoad().stateInit(emptyList()) - val globalSubsConfigsFlow = DbSet.subsConfigDao.queryUsedGlobalConfig().let(linkLoad::invoke) - .stateInit(emptyList()) + val globalSubsConfigsFlow = DbSet.subsConfigDao.queryUsedGlobalConfig().attachLoad() + .stateInit(null) val appSubsConfigsFlow = appUsedSubsIdsFlow.map { - DbSet.subsConfigDao.queryAppConfig(it, args.appId) - }.flattenConcat().let(linkLoad::invoke) - .stateInit(emptyList()) + DbSet.subsConfigDao.queryAppConfig(it, route.appId) + }.flattenConcat().attachLoad() + .stateInit(null) val categoryConfigsFlow = appUsedSubsIdsFlow.map { DbSet.categoryConfigDao.queryBySubsIds(it) - }.flattenConcat().let(linkLoad::invoke) - .stateInit(emptyList()) + }.flattenConcat().attachLoad() + .stateInit(null) private val temp1ListFlow = combine( appUsedSubsIdsFlow, usedSubsEntriesFlow, - appShowInnerDisableFlow, globalSubsConfigsFlow, - ) { usedSubsIds, list, show, configs -> + ) { usedSubsIds, list, configs -> + if (configs == null) return@combine emptyList() 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) + e.subscription.getAppGroups(route.appId) } else { emptyList() } @@ -102,14 +88,92 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { }.filter { it.second.isNotEmpty() } }.stateInit(emptyList()) - val subsPairsFlow = combine( + private val checkedGroupSetFlow = combine( + temp1ListFlow, + globalSubsConfigsFlow, + categoryConfigsFlow, + appSubsConfigsFlow, + ) { list, globalSubsConfigs, categoryConfigs, appSubsConfigs -> + if (globalSubsConfigs == null || categoryConfigs == null || appSubsConfigs == null) { + return@combine emptySet() + } + val checkedSet = mutableSetOf>() + list.forEach { (entry, groups) -> + groups.forEach { group -> + val subsConfig = when (group) { + is RawSubscription.RawAppGroup -> appSubsConfigs + is RawSubscription.RawGlobalGroup -> globalSubsConfigs + }.find { it.subsId == entry.subsItem.id && it.groupKey == group.key } + val category = when (group) { + is RawSubscription.RawAppGroup -> entry.subscription.getCategory(group.name) + is RawSubscription.RawGlobalGroup -> null + } + val categoryConfig = if (category != null) { + categoryConfigs.find { it.subsId == entry.subsItem.id && it.categoryKey == category.key } + } else { + null + } + val checked = getActualGroupChecked( + subs = entry.subscription, + group = group, + appId = route.appId, + subsConfig = subsConfig, + categoryConfig = categoryConfig, + ) && (group !is RawSubscription.RawGlobalGroup || subsConfig?.enable != false) + if (checked) { + checkedSet.add(Triple(entry.subsItem.id, group.groupType, group.key)) + } + } + } + checkedSet + }.stateInit(emptySet()) + + private val actualCheckedGroupSetFlow = + MutableStateFlow>>(emptySet()) + + init { + showDisabledRuleFlow.launchOnChange { + actualCheckedGroupSetFlow.value = checkedGroupSetFlow.value + } + viewModelScope.launch { + combine(checkedGroupSetFlow, firstLoadingFlow) { a, first -> first to a } + .collect { (first, a) -> + if (!first) { + actualCheckedGroupSetFlow.update { b -> a + b } + } + } + } + } + + private val temp2ListFlow = combine( temp1ListFlow, + showDisabledRuleFlow, + actualCheckedGroupSetFlow, + ) { list, showDisabledRule, checkedSet -> + if (showDisabledRule) { + list + } else { + val newList = mutableListOf>>() + list.forEach { (entry, groups) -> + val newGroups = groups.filter { g -> + checkedSet.contains(Triple(entry.subsItem.id, g.groupType, g.key)) + } + if (newGroups.isNotEmpty()) { + newList.add(entry to newGroups) + } + } + newList + } + }.stateInit(emptyList()) + + val subsPairsFlow = combine( + temp2ListFlow, latestLogsFlow, 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, @@ -118,7 +182,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } } - 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 @@ -126,7 +190,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } } } - }.combine(linkLoad.firstLoadingFlow) { list, firstLoading -> + }.combine(firstLoadingFlow) { list, firstLoading -> if (firstLoading) { emptyList() } else { @@ -134,18 +198,18 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } }.stateInit(emptyList()) - val groupSizeFlow = subsPairsFlow.map(viewModelScope) { list -> + val groupSizeFlow = subsPairsFlow.mapNew { list -> list.sumOf { it.second.size } } val isSelectedModeFlow = MutableStateFlow(false) val selectedDataSetFlow = MutableStateFlow(emptySet()) - private fun getAllSelectedDataSet() = subsPairsFlow.value.map { e -> + private fun getAllSelectedDataSet() = subsPairsFlow.value.flatMap { e -> e.second.map { g -> - g.toGroupState(subsId = e.first.subsItem.id, appId = args.appId) + g.toGroupState(subsId = e.first.subsItem.id, appId = route.appId) } - }.flatten().toSet() + }.toSet() fun selectAll() { selectedDataSetFlow.value = getAllSelectedDataSet() @@ -155,7 +219,7 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { selectedDataSetFlow.value = getAllSelectedDataSet() - selectedDataSetFlow.value } - val focusGroupFlow = args.focusLog?.let { + val focusGroupFlow = route.focusLog?.let { MutableStateFlow?>( Triple( it.subsId, @@ -174,4 +238,3 @@ class AppConfigVm(stateHandle: SavedStateHandle) : ViewModelExt() { } } - 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..4da6fe26b5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt @@ -1,70 +1,72 @@ package li.songe.gkd.ui -import androidx.compose.animation.AnimatedVisibility +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +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.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.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.LocalTextStyle 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 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.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph +import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers -import li.songe.gkd.META -import li.songe.gkd.permission.foregroundServiceSpecialUseState +import kotlinx.serialization.Serializable +import li.songe.gkd.MainActivity +import li.songe.gkd.permission.PermissionState +import li.songe.gkd.permission.appOpsRestrictStateList +import li.songe.gkd.permission.appOpsRestrictedFlow 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.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 -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.getShareApkFile import li.songe.gkd.util.launchAsFn -import li.songe.gkd.util.runCommandByRoot +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.saveFileToDownloads import li.songe.gkd.util.toast -@Destination(style = ProfileTransitions::class) +@Serializable +data object AppOpsAllowRoute : NavKey + @Composable fun AppOpsAllowPage() { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current + val context = LocalActivity.current as MainActivity val vm = viewModel() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - val foregroundServiceSpecialUse by foregroundServiceSpecialUseState.stateFlow.collectAsStateWithLifecycle() - val restrictedCount = arrayOf(foregroundServiceSpecialUse).count { !it } + val appOpsRestricted by appOpsRestrictedFlow.collectAsState() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popPage() + }) }, title = { Text(text = "解除限制") }) @@ -75,45 +77,53 @@ fun AppOpsAllowPage() { .verticalScroll(rememberScrollState()) .padding(contentPadding) ) { - AnimatedVisibility(visible = !foregroundServiceSpecialUse) { - Card( + if (appOpsRestricted) { + Column( modifier = Modifier .padding(itemHorizontalPadding, 0.dp) .fillMaxWidth(), - onClick = { }, - colors = surfaceCardColors, ) { - Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), + text = "下列权限应默认授予,但可能因某些操作如系统升级,备份迁移等被限制", style = MaterialTheme.typography.bodyMedium, - text = "权限「特殊用途的前台服务」已被限制" - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), - text = "此权限应默认授予, 但您可能执行某些操作导致被限制", - style = MaterialTheme.typography.bodySmall, ) + Spacer(modifier = Modifier.height(24.dp)) + Column( + modifier = Modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + appOpsRestrictStateList.forEach { RestrictItem(it) } + } + Spacer(modifier = Modifier.height(16.dp)) AuthButtonGroup( - onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.grantPermissionByShizuku(appOpsCommand) - toast("授权成功") - }, - onClickManual = { - vm.showCopyDlgFlow.value = true - }, - onClickRoot = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - runCommandByRoot(appOpsCommand) - toast("授权成功") - } + modifier = Modifier.fillMaxWidth(), + buttons = listOf( + "Shizuku 授权" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.guardShizukuContext() + toast("授权成功") + }, + "命令授权" to { + vm.showCopyDlgFlow.value = true + }, + "卸载重装" to { + mainVm.dialogFlow.updateDialogOptions( + title = "卸载重装", + text = "卸载后重新安装可让应用权限回归初始状态解除限制,先点击下方「导出应用」可将应用提前保存至下载,然后卸载应用,到文件管理中重新安装即可\n\n注意:卸载会删除所有数据,请自行备份数据", + dismissText = "导出应用", + dismissAction = { + mainVm.viewModelScope.launchTry(Dispatchers.IO) { + context.saveFileToDownloads(getShareApkFile()) + } + }, + confirmText = "关闭", + ) + } + ) ) - Spacer(modifier = Modifier.height(12.dp)) } } - Spacer(modifier = Modifier.height(EmptyHeight)) - AnimatedVisibility(visible = restrictedCount == 0) { + if (!appOpsRestricted) { Spacer(modifier = Modifier.height(EmptyHeight)) EmptyText(text = "状态正常, 无需操作") } @@ -122,7 +132,7 @@ fun AppOpsAllowPage() { val showCopyDlg by vm.showCopyDlgFlow.collectAsState() ManualAuthDialog( - commandText = appOpsCommand, + commandText = gkdStartCommandText, show = showCopyDlg, onUpdateShow = { vm.showCopyDlgFlow.value = it @@ -130,7 +140,24 @@ fun AppOpsAllowPage() { ) } -private val appOpsCommand by lazy { - "appops set ${META.appId} FOREGROUND_SERVICE_SPECIAL_USE allow" +@Composable +private fun RestrictItem(state: PermissionState) { + if (!state.stateFlow.collectAsState().value) { + Row { + val lineHeightDp = LocalDensity.current.run { LocalTextStyle.current.lineHeight.toDp() } + val size = 5.dp + Spacer( + modifier = Modifier + .padding(vertical = (lineHeightDp - size) / 2) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + .size(size) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + style = MaterialTheme.typography.titleMedium, + text = state.name, + ) + } + } } - 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 58a029fadb..3df857d893 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt @@ -1,85 +1,93 @@ package li.songe.gkd.ui -import android.os.Build +import android.Manifest +import android.app.AppOpsManagerHidden +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 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.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.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton 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 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.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 androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable import li.songe.gkd.META +import li.songe.gkd.permission.Manifest_permission_GET_APP_OPS_STATS import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService -import li.songe.gkd.service.fixRestartService -import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.AuthButtonGroup +import li.songe.gkd.service.fixRestartAutomatorService +import li.songe.gkd.shizuku.SafeAppOpsService +import li.songe.gkd.shizuku.shizukuUsedFlow +import li.songe.gkd.store.updateEnableAutomator +import li.songe.gkd.ui.component.AnimatedBooleanContent 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.local.LocalMainViewModel -import li.songe.gkd.ui.local.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.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.AutomatorModeOption 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.shFolder import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -@Destination(style = ProfileTransitions::class) +@Serializable +data object AuthA11yRoute : NavKey + @Composable fun AuthA11yPage() { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val vm = viewModel() val showCopyDlg by vm.showCopyDlgFlow.collectAsState() val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() val a11yRunning by A11yService.isRunning.collectAsState() - + val automatorMode by mainVm.automatorModeFlow.collectAsState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { + mainVm.popPage() + }) }, title = { - Text(text = "授权状态") + Text(text = "工作模式") }) }) { contentPadding -> Column( @@ -88,157 +96,222 @@ fun AuthA11yPage() { .verticalScroll(rememberScrollState()) .padding(contentPadding) ) { - Text( - text = "选择一个授权模式进行操作", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(itemHorizontalPadding) - ) Card( modifier = Modifier - .padding(itemHorizontalPadding, 0.dp) + .padding(horizontal = itemHorizontalPadding) .fillMaxWidth(), - onClick = { }, + onClick = throttle { mainVm.updateAutomatorMode(AutomatorModeOption.A11yMode) }, colors = surfaceCardColors, ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = automatorMode == AutomatorModeOption.A11yMode, + onClick = null, + ) + Text( + modifier = Modifier.padding(start = 12.dp), + text = AutomatorModeOption.A11yMode.label, + style = MaterialTheme.typography.titleMedium, + ) + } Text( - modifier = Modifier.padding(cardHorizontalPadding, 8.dp), - text = "普通授权(简单)", - style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 4.dp), + text = "基础", + style = MaterialTheme.typography.titleSmall ) - Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), + TextListItem( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 8.dp, top = 4.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 = "已持有「无障碍权限」, 可继续使用", - style = MaterialTheme.typography.bodySmall, - ) - Spacer(modifier = Modifier.height(12.dp)) - } else { - Row( - modifier = Modifier - .padding(4.dp, 0.dp) - .fillMaxWidth(), - ) { - TextButton(onClick = throttle { openA11ySettings() }) { + AnimatedBooleanContent( + targetState = writeSecureSettings || a11yRunning, + contentTrue = { + Text( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 8.dp, top = 4.dp), + text = "已持有「无障碍权限」可继续使用", + style = MaterialTheme.typography.bodySmall, + ) + }, + contentFalse = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = cardHorizontalPadding), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton( + onClick = throttle { openA11ySettings() }, + ) { + Text( + text = "手动授权", + style = MaterialTheme.typography.bodyLarge, + ) + } Text( - text = "手动授权", - style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .padding(bottom = 12.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + mainVm.navigateWebPage(ShortUrlSet.URL2) + }) + .padding(horizontal = 4.dp), + text = "无法开启无障碍?", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, ) } } + ) + Text( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 4.dp, top = 8.dp), + text = "增强", + style = MaterialTheme.typography.titleSmall, + ) + TextListItem( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 8.dp, top = 4.dp), + style = MaterialTheme.typography.bodyMedium, + list = listOf( + "授予「写入安全设置权限」", + "应用可自行控制开关无障碍", + ), + ) + AnimatedBooleanContent( + targetState = writeSecureSettings, + contentTrue = { + Text( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 8.dp, top = 4.dp), + text = "已持有「写入安全设置权限」 优先使用此项", + style = MaterialTheme.typography.bodySmall, + ) + }, + contentFalse = { + Row( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ShizukuAuthButton() + TextButton(onClick = { vm.showCopyDlgFlow.value = true }) { + Text( + text = "命令授权", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + ) + TextButton( + modifier = Modifier + .padding(horizontal = cardHorizontalPadding), + onClick = throttle { + if (!writeSecureSettings) { + toast("请先授予「${writeSecureSettingsState.name}」") + } + mainVm.dialogFlow.updateDialogOptions( + title = "无感保活", + text = "添加通知栏快捷开关\n\n1. 下拉通知栏至「快捷开关」标界面\n2. 找到名称为 ${META.appName} 的快捷开关\n3. 添加此开关到通知面板 \n\n只要此快捷开关在通知面板可见\n无论是系统杀后台还是自身崩溃\n简单下拉打开通知即可重启" + ) + } + ) { Text( - modifier = Modifier - .padding(cardHorizontalPadding, 0.dp) - .clickable { - mainVm.navigatePage(WebViewPageDestination(initUrl = (ShortUrlSet.URL2))) - }, - text = "无法开启无障碍?", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + text = "无感保活", + style = MaterialTheme.typography.bodyLarge, ) - Spacer(modifier = Modifier.height(12.dp)) } + Spacer(modifier = Modifier.height(12.dp)) } Spacer(modifier = Modifier.height(12.dp)) Card( modifier = Modifier - .padding(itemHorizontalPadding, 0.dp) + .padding(horizontal = itemHorizontalPadding) .fillMaxWidth(), - onClick = { }, + onClick = throttle { mainVm.updateAutomatorMode(AutomatorModeOption.AutomationMode) }, colors = surfaceCardColors, ) { - Text( - modifier = Modifier.padding(cardHorizontalPadding, 8.dp), - text = "高级授权(推荐)", - style = MaterialTheme.typography.bodyLarge, - ) - Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), - style = MaterialTheme.typography.bodyMedium, - text = "1. 授予「写入安全设置权限」\n2. 授权永久有效, 包含「无障碍权限」\n3. 应用可自行控制开关无障碍服务\n4. 在通知栏快捷开关可快捷重启, 无感保活" - ) - if (!writeSecureSettings) { - A11yAuthButtonGroup() - } else { - Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = automatorMode == AutomatorModeOption.AutomationMode, + onClick = null, + ) Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), - text = "已持有「写入安全设置权限」 优先使用此权限", - style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 12.dp), + text = AutomatorModeOption.AutomationMode.label, + style = MaterialTheme.typography.titleMedium, ) } - Row( + TextListItem( modifier = Modifier - .padding(4.dp, 0.dp) - .fillMaxWidth(), - ) { - TextButton(onClick = throttle { - if (!writeSecureSettings) { - toast("请先授权") - } - mainVm.dialogFlow.updateDialogOptions( - title = "无感保活", - text = "添加通知栏快捷开关\n\n1. 下拉通知栏至「快捷开关」标界面\n2. 找到名称为 ${META.appName} 的快捷开关\n3. 添加此开关到通知面板 \n\n只要此快捷开关在通知面板可见\n无论是系统杀后台还是自身BUG崩溃\n简单下拉打开通知即可重启" - ) - }) { + .padding(horizontal = cardHorizontalPadding) + .padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + list = listOf( + "自动化驱动的无障碍", + "不会导致界面显示异常", + "不会被其它应用检测为无障碍", + "部分应用仍需切换至无障碍模式", + ), + ) + AnimatedBooleanContent( + targetState = shizukuUsedFlow.collectAsState().value, + contentTrue = { Text( - text = "无感保活", - style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .padding(horizontal = cardHorizontalPadding) + .padding(start = 8.dp, top = 8.dp), + text = "已连接 Shizuku 服务,可继续使用", + style = MaterialTheme.typography.bodySmall, + ) + }, + contentFalse = { + ShizukuAuthButton( + modifier = Modifier.padding( + start = cardHorizontalPadding + ) ) } - } - Spacer(modifier = Modifier.height(4.dp)) - } - if (writeSecureSettings) { - Spacer(modifier = Modifier.height(12.dp)) - Card( - modifier = Modifier - .padding(itemHorizontalPadding, 0.dp) - .fillMaxWidth(), - onClick = { }, - colors = surfaceCardColors, + ) + TextButton( + modifier = Modifier.padding(start = cardHorizontalPadding), + onClick = throttle { + mainVm.navigatePage(A11YScopeAppListRoute) + }, ) { Text( - modifier = Modifier.padding(cardHorizontalPadding, 8.dp), - text = "解除可能受到的无障碍限制", + text = "局部无障碍", style = MaterialTheme.typography.bodyLarge, ) - Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), - style = MaterialTheme.typography.bodyMedium, - text = "1. 某些系统有更严格的无障碍限制\n2. 在 GKD 更新后会限制其开关无障碍\n3. 重新授权可解决此问题" - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - modifier = Modifier.padding(cardHorizontalPadding, 0.dp), - text = "若能正常开关无障碍请忽略此项", - 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)) } + Spacer(modifier = Modifier.height(12.dp)) } Spacer(modifier = Modifier.height(EmptyHeight)) } } ManualAuthDialog( - commandText = a11yCommandText, + commandText = gkdStartCommandText, show = showCopyDlg, onUpdateShow = { vm.showCopyDlgFlow.value = it @@ -246,40 +319,77 @@ fun AuthA11yPage() { ) } -private val a11yCommandText by lazy { - arrayOf( - "pm grant ${META.appId} android.permission.WRITE_SECURE_SETTINGS", - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - "appops set ${META.appId} ACCESS_RESTRICTED_SETTINGS allow" - } else { - null - }, - ).filterNotNull().joinToString("; ").trimEnd() +@Composable +private fun ShizukuAuthButton( + modifier: Modifier = Modifier, +) { + val mainVm = LocalMainViewModel.current + val vm = viewModel() + TextButton( + modifier = modifier, + onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.guardShizukuContext() + if (writeSecureSettingsState.value) { + toast("授权成功") + updateEnableAutomator(true) + fixRestartAutomatorService() + } + }) + ) { + Text( + text = "Shizuku 授权", + style = MaterialTheme.typography.bodyLarge, + ) + } } -private fun successAuthExec() { - if (writeSecureSettingsState.updateAndGet()) { - toast("授权成功") - storeFlow.update { it.copy(enableService = true) } - fixRestartService() - } +private val Int.appopsAllow get() = "appops set ${META.appId} ${AppOpsManagerHidden.opToName(this)} allow" +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, + Manifest_permission_GET_APP_OPS_STATS.pmGrant, + if (AndroidTarget.TIRAMISU) Manifest.permission.POST_NOTIFICATIONS.pmGrant else null, + AppOpsManagerHidden.OP_POST_NOTIFICATION.appopsAllow, + AppOpsManagerHidden.OP_SYSTEM_ALERT_WINDOW.appopsAllow, + if (AndroidTarget.Q) AppOpsManagerHidden.OP_ACCESS_ACCESSIBILITY.appopsAllow else null, + if (AndroidTarget.TIRAMISU) AppOpsManagerHidden.OP_ACCESS_RESTRICTED_SETTINGS.appopsAllow else null, + if (AndroidTarget.UPSIDE_DOWN_CAKE) AppOpsManagerHidden.OP_FOREGROUND_SERVICE_SPECIAL_USE.appopsAllow else null, + if (SafeAppOpsService.supportCreateA11yOverlay) AppOpsManagerHidden.OP_CREATE_ACCESSIBILITY_OVERLAY.appopsAllow else null, + "sh ${shFolder.absolutePath}/expose.sh 1", + "echo '> start.sh end'", + ).joinToString("\n") + val file = shFolder.resolve("start.sh") + file.writeText(commandText) + "adb shell sh ${file.absolutePath}" } @Composable -private fun A11yAuthButtonGroup() { - val mainVm = LocalMainViewModel.current - val vm = viewModel() - AuthButtonGroup( - onClickShizuku = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - mainVm.grantPermissionByShizuku(a11yCommandText) - successAuthExec() - }, - onClickManual = { - vm.showCopyDlgFlow.value = true - }, - onClickRoot = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - runCommandByRoot(a11yCommandText) - toast("授权成功") +private fun TextListItem( + list: List, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, +) { + val lineHeightDp = LocalDensity.current.run { style.lineHeight.toDp() } + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + 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) + } } - ) -} \ 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 new file mode 100644 index 0000000000..8e45c476f6 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt @@ -0,0 +1,305 @@ +package li.songe.gkd.ui + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedVisibility +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.DropdownMenu +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.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.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import li.songe.gkd.MainActivity +import li.songe.gkd.R +import li.songe.gkd.service.fixRestartAutomatorService +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.AnimatedIconButton +import li.songe.gkd.ui.component.AnimationFloatingActionButton +import li.songe.gkd.ui.component.AppBarTextField +import li.songe.gkd.ui.component.AppCheckBoxCard +import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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.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 +import li.songe.gkd.ui.share.noRippleClickable +import li.songe.gkd.ui.style.EmptyHeight +import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.AppGroupOption +import li.songe.gkd.util.AppListString +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.switchItem +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast + +@Serializable +data object BlockA11yAppListRoute : NavKey + +@Composable +fun BlockA11yAppListPage() { + val store by storeFlow.collectAsState() + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + val vm = viewModel() + val appInfos by vm.appInfosFlow.collectAsState() + val searchStr by vm.searchStrFlow.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 { + context.justHideSoftInput() + if (vm.textChanged) { + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + editable = false + }) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + canScroll = !editable && !store.blockA11yAppListFollowMatch, + navigationIcon = { + IconButton( + onClick = throttle(vm.viewModelScope.launchAsFn { + if (editable) { + if (vm.textChanged) { + context.justHideSoftInput() + mainVm.dialogFlow.waitResult( + title = "提示", + text = "当前内容未保存,是否放弃编辑?", + ) + } + editable = !editable + } else { + context.hideSoftInput() + mainVm.popPage() + } + }) + ) { + BackCloseIcon(backOrClose = !editable) + } + }, + title = { + val firstShowSearchBar = remember { showSearchBar } + if (showSearchBar) { + BackHandler { + if (!context.justHideSoftInput()) { + showSearchBar = 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() + editable = false + }, + ) + }, + contentFalse = { + 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) } + fixRestartAutomatorService() + } + ) + + var expanded by remember { mutableStateOf(false) } + AnimatedVisibility(!store.blockA11yAppListFollowMatch) { + Row { + AnimatedIconButton( + onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + showSearchBar = false + } else { + vm.searchStrFlow.value = "" + } + } else { + showSearchBar = true + } + }, + id = R.drawable.ic_anim_search_close, + atEnd = showSearchBar, + ) + PerfIconButton(imageVector = PerfIcon.Sort, onClick = { + expanded = true + }) + } + } + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + MenuGroupCard(inTop = true, title = "排序") { + var sortType by vm.sortTypeFlow.asMutableState() + AppSortOption.objects.forEach { option -> + MenuItemRadioButton( + text = option.label, + selected = sortType == option, + onClick = { sortType = option }, + ) + } + } + MenuGroupCard(inTop = true, title = "筛选") { + var appGroupType by vm.appGroupTypeFlow.asMutableState() + AppGroupOption.normalObjects.forEach { option -> + val newValue = option.invert(appGroupType) + MenuItemCheckbox( + enabled = newValue != 0, + text = option.label, + checked = option.include(appGroupType), + onClick = { appGroupType = newValue }, + ) + } + } + } + } + } + }, + ) + }) + }, + floatingActionButton = { + AnimationFloatingActionButton( + visible = !editable && scrollBehavior.isFullVisible && !store.blockA11yAppListFollowMatch, + onClickLabel = "进入白名单文本编辑模式", + onClick = { + editable = !editable + }, + imageVector = PerfIcon.Edit, + contentDescription = "编辑白名单文本" + ) + }, + ) { contentPadding -> + 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, + immediateFocus = true, + placeholderText = "请输入应用ID列表\n示例:\ncom.android.systemui\ncom.android.settings", + indicatorSize = vm.indicatorSizeFlow.collectAsState().value, + ) + } else { + val blockA11yAppList by blockA11yAppListFlow.collectAsState() + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + ) { + items(appInfos, { it.id }) { appInfo -> + AppCheckBoxCard( + appInfo = appInfo, + checked = blockA11yAppList.contains(appInfo.id), + onCheckedChange = { + blockA11yAppListFlow.update { + it.switchItem(appInfo.id) + } + }, + ) + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (appInfos.isEmpty() && searchStr.isNotEmpty()) { + EmptyText(text = "暂无搜索结果") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) + } + } + } + } + } +} 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..9c818d482c --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt @@ -0,0 +1,64 @@ +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.asMutableStateFlow +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.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.a11yAppSort) }, + setter = { + storeFlow.value.copy(a11yAppSort = it.value) + } + ) + val appGroupTypeFlow = storeFlow.asMutableStateFlow( + getter = { it.a11yAppGroupType }, + setter = { + storeFlow.value.copy(a11yAppGroupType = it) + } + ) + val appFilter = useAppFilter( + appGroupTypeFlow = appGroupTypeFlow, + sortTypeFlow = sortTypeFlow, + ) + 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 indicatorSizeFlow = textFlow.debounce(500).map { + AppListString.decode(it).size + }.stateInit(0) + + 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/CrashReportPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportPage.kt new file mode 100644 index 0000000000..9329a79379 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportPage.kt @@ -0,0 +1,108 @@ +package li.songe.gkd.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.verticalScroll +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import li.songe.gkd.ui.component.CopyTextCard +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.useScrollBehaviorState +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.itemHorizontalPadding +import li.songe.gkd.ui.style.itemVerticalPadding +import li.songe.gkd.util.ISSUES_URL +import li.songe.gkd.util.throttle + + +@Serializable +data object CrashReportRoute : NavKey + +@Composable +fun CrashReportPage() { + val mainVm = LocalMainViewModel.current + val vm = viewModel() + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, scrollState) = useScrollBehaviorState(scrollKey) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = mainVm::popPage, + ) + }, + title = { + Text( + text = "崩溃记录", + modifier = Modifier.noRippleClickable(onClick = throttle { scrollKey.intValue++ }) + ) + }, + ) + }, + bottomBar = { + if (vm.crashDataList.isNotEmpty()) { + BottomAppBar { + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = throttle { mainVm.openUrl(ISSUES_URL) }, + ) { + Text(text = "问题反馈") + } + Spacer(modifier = Modifier.width(itemHorizontalPadding)) + TextButton( + onClick = { mainVm.showShareLogDlgFlow.value = true }, + ) { + Text(text = "导出日志") + } + Spacer(modifier = Modifier.width(itemHorizontalPadding)) + } + } + }, + ) { contentPadding -> + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxSize() + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(itemVerticalPadding) + ) { + if (vm.crashDataList.isNotEmpty()) { + vm.crashDataList.forEach { crashData -> + CopyTextCard( + text = crashData.stackTrace, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } else { + Spacer(modifier = Modifier.height(EmptyHeight)) + EmptyText() + } + Spacer(modifier = Modifier.height(EmptyHeight)) + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt new file mode 100644 index 0000000000..d46728c347 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/CrashReportVm.kt @@ -0,0 +1,12 @@ +package li.songe.gkd.ui + +import li.songe.gkd.MainViewModel +import li.songe.gkd.ui.share.BaseViewModel + +class CrashReportVm : BaseViewModel() { + val crashDataList = MainViewModel.instance.run { + val v = tempCrashDataList + tempCrashDataList = emptyList() + v + } +} \ 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..9d3cb3d6f1 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt @@ -0,0 +1,83 @@ +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 +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 androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +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.scaffoldPadding +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast + +@Serializable +data object EditBlockAppListRoute : NavKey + +@Composable +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.popPage() + }) + BackHandler(onBack = onBack) + Scaffold(modifier = Modifier, topBar = { + PerfTopAppBar( + modifier = Modifier.fillMaxWidth(), + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = onBack, + ) + }, + 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.popPage() + }) + ) + } + ) + }) { contentPadding -> + MultiTextField( + modifier = Modifier.scaffoldPadding(contentPadding), + textFlow = vm.textFlow, + 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 new file mode 100644 index 0000000000..6d3d4d566b --- /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 indicatorSizeFlow = textFlow.debounce(500).map { + AppListString.decode(it).size + }.stateInit(0) + + 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 019d1addd5..699a6f7e5e 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt @@ -1,129 +1,341 @@ package li.songe.gkd.ui import android.webkit.URLUtil +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +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.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.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults 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.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.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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 androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation3.runtime.NavKey +import coil3.EventListener +import coil3.ImageLoader import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter +import coil3.decode.Decoder +import coil3.disk.DiskCache +import coil3.fetch.Fetcher +import coil3.gif.AnimatedImageDecoder +import coil3.gif.GifDecoder +import coil3.imageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.CachePolicy +import coil3.request.ErrorResult import coil3.request.ImageRequest +import coil3.request.Options +import coil3.request.SuccessResult 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.style.ProfileTransitions -import li.songe.gkd.util.imageLoader +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.Serializable +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.util.AndroidTarget +import li.songe.gkd.util.coilCacheDir import li.songe.gkd.util.throttle +import me.saket.telephoto.zoomable.ZoomableContentLocation +import me.saket.telephoto.zoomable.rememberZoomableState +import me.saket.telephoto.zoomable.zoomable +import okhttp3.OkHttpClient +import okio.Path.Companion.toOkioPath +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +@Serializable +data class ImagePreviewItem( + val uri: String, + val title: String? = null, + val titles: List = emptyList(), +) + +@Serializable +data class ImagePreviewRoute( + val title: String? = null, + val uri: String? = null, + val uris: List = emptyList(), + val items: List = emptyList(), +) : NavKey + +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() +} -@Destination(style = ProfileTransitions::class) @Composable -fun ImagePreviewPage( - title: String? = null, - uri: String? = null, - uris: Array = emptyArray(), -) { +fun ImagePreviewPage(route: ImagePreviewRoute) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current + val context = LocalActivity.current as MainActivity + var showBars by remember { mutableStateOf(true) } + + // 路由同时兼容旧的 uri/uris 和新的 items,预览页内部统一按图片项处理。 + val previewItems = remember(route) { + when { + route.items.isNotEmpty() -> route.items + route.uris.isNotEmpty() -> route.uris.map { ImagePreviewItem(it) } + route.uri != null -> listOf(ImagePreviewItem(uri = route.uri)) + else -> emptyList() + } + } + val previewUris = remember(previewItems) { previewItems.map { it.uri } } + val singleItem = previewItems.singleOrNull() + val pagerState = rememberPagerState(pageCount = { previewItems.size.coerceAtLeast(1) }) + + val controller = remember { + WindowCompat.getInsetsController(context.window, context.window.decorView) + } + DisposableEffect(null) { + val oldBehavior = controller.systemBarsBehavior + val oldLight = controller.isAppearanceLightStatusBars + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.isAppearanceLightStatusBars = false + onDispose { + controller.systemBarsBehavior = oldBehavior + controller.isAppearanceLightStatusBars = oldLight + controller.show(WindowInsetsCompat.Type.statusBars()) + } + } + LaunchedEffect(showBars) { + if (showBars) { + controller.show(WindowInsetsCompat.Type.statusBars()) + } else { + controller.hide(WindowInsetsCompat.Type.statusBars()) + } + } + + // 规则组示例图会连续横滑,但预取并发限制在 2,避免与首图显示请求抢带宽。 + LaunchedEffect(previewUris) { + if (previewUris.size <= 1) return@LaunchedEffect + previewUris + .drop(1) + .filter(URLUtil::isNetworkUrl) + .chunked(2) + .forEach { uriBatch -> + uriBatch.map { preloadUri -> + async { + imageLoader.execute( + buildPreviewImageRequest( + context = context, + uri = preloadUri, + ) + ) + } + }.awaitAll() + } + } + Box( modifier = Modifier - .background(MaterialTheme.colorScheme.background) + .background(Color.Black) .fillMaxSize() ) { - val showUri = uri ?: if (uris.size == 1) uris.first() else null - val state = rememberPagerState { uris.size } - TopAppBar( - modifier = Modifier - .zIndex(1f) - .fillMaxWidth(), - navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, - title = { - if (title != null) { - Text(text = title) - } - }, - actions = { - val currentUri = showUri ?: uris.getOrNull(state.currentPage) - if (currentUri != null && URLUtil.isNetworkUrl(currentUri)) { - IconButton(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) - ) - ) - if (showUri != null) { - UriImage(showUri) - } else if (uris.isNotEmpty()) { - Box( - modifier = Modifier.fillMaxSize() - ) { + when { + singleItem != null -> { + UriImage( + uri = singleItem.uri, + onToggleBars = { showBars = !showBars }, + ) + } + + previewItems.isNotEmpty() -> { HorizontalPager( modifier = Modifier.fillMaxSize(), - state = state, - pageContent = { - UriImage(uris[it]) + state = pagerState, + pageContent = { index -> + UriImage( + uri = previewItems[index].uri, + onToggleBars = { showBars = !showBars }, + ) } ) - Box( - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(bottom = 150.dp), - contentAlignment = Alignment.BottomCenter - ) { - Text( - text = "${state.currentPage + 1}/${uris.size}", - style = MaterialTheme.typography.titleLarge + } + } + + AnimatedVisibility( + visible = showBars, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .zIndex(1f) + .fillMaxWidth() + ) { + Column { + val currentPreviewItem = + singleItem ?: previewItems.getOrNull(pagerState.currentPage) + val currentUri = currentPreviewItem?.uri + PerfTopAppBar( + modifier = Modifier.background(Color.Black.copy(alpha = 0.5f)), + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { mainVm.popPage() }, + colors = IconButtonDefaults.iconButtonColors(contentColor = Color.White) + ) + }, + title = { + val baseTitle = route.title?.takeIf { it.isNotBlank() } + val itemTitle = currentPreviewItem + ?.let(::buildPreviewSubtitle) + ?.takeIf { it.isNotBlank() && it != baseTitle } + when { + baseTitle != null && itemTitle != null -> { + Column { + Text( + text = baseTitle, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + style = MaterialTheme.typography.titleLarge.copy( + color = Color.White, + fontWeight = FontWeight.Medium + ) + ) + Text( + text = itemTitle, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + style = MaterialTheme.typography.titleSmall.copy( + color = Color.White.copy(alpha = 0.8f), + fontWeight = FontWeight.Normal + ) + ) + } + } + + baseTitle != null -> { + Text( + text = baseTitle, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + style = MaterialTheme.typography.titleLarge.copy( + color = Color.White, + fontWeight = FontWeight.Medium + ) + ) + } + + itemTitle != null -> { + Text( + text = itemTitle, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + style = MaterialTheme.typography.titleLarge.copy( + color = Color.White, + fontWeight = FontWeight.Medium + ) + ) + } + } + }, + actions = { + if (currentUri != null && URLUtil.isNetworkUrl(currentUri)) { + PerfIconButton( + imageVector = PerfIcon.OpenInNew, + onClick = throttle(fn = { mainVm.openUrl(currentUri) }), + colors = IconButtonDefaults.iconButtonColors(contentColor = Color.White) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + navigationIconContentColor = Color.White, + titleContentColor = Color.White, + actionIconContentColor = Color.White ) + ) + + if (previewItems.size > 1) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "${pagerState.currentPage + 1} / ${previewItems.size}", + modifier = Modifier + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + .padding(horizontal = 12.dp, vertical = 4.dp), + color = Color.White, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + } } } } @@ -131,59 +343,202 @@ fun ImagePreviewPage( } @Composable -private fun UriImage(uri: String) { +private fun UriImage( + uri: String, + onToggleBars: () -> Unit = {}, +) { val context = LocalContext.current + val imageLoader = context.imageLoader + val isNetworkImage = remember(uri) { URLUtil.isNetworkUrl(uri) } + val phaseTextFlow = remember(uri) { MutableStateFlow(null) } + val phaseText by phaseTextFlow.collectAsState() + + // 手势层切至 Telephoto,loading / error 还是使用 AsyncImagePainter.State 统一驱动。 val model = remember(uri) { - ImageRequest.Builder(context).data(uri) - .crossfade(DefaultDurationMillis) - .build().apply { - imageLoader.enqueue(this) + buildPreviewImageRequest( + context = context, + uri = uri, + listener = object : EventListener() { + override fun onStart(request: ImageRequest) { + phaseTextFlow.value = "请求中" + } + + override fun fetchStart( + request: ImageRequest, + fetcher: Fetcher, + options: Options, + ) { + phaseTextFlow.value = if (isNetworkImage) "下载中" else "读取中" + } + + override fun decodeStart( + request: ImageRequest, + decoder: Decoder, + options: Options, + ) { + phaseTextFlow.value = "解码中" + } + + override fun onSuccess(request: ImageRequest, result: SuccessResult) { + phaseTextFlow.value = null + } + + override fun onError(request: ImageRequest, result: ErrorResult) { + phaseTextFlow.value = null + } + + override fun onCancel(request: ImageRequest) { + phaseTextFlow.value = null + } } + ) } - val painter = rememberAsyncImagePainter(model) + val painter = rememberAsyncImagePainter( + model = model, + imageLoader = imageLoader, + ) val state by painter.state.collectAsState() - when (state) { - AsyncImagePainter.State.Empty -> {} - is AsyncImagePainter.State.Loading -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(40.dp)) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (val stateVal = state) { + AsyncImagePainter.State.Empty -> Unit + + is AsyncImagePainter.State.Loading -> { + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(uri) { + detectTapGestures(onTap = { onToggleBars() }) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(40.dp)) + phaseText?.let { text -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = text, + color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.bodySmall, + ) + } + } } - } - is AsyncImagePainter.State.Success -> { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Center, - ) { - Image( + is AsyncImagePainter.State.Success -> { + ZoomableImageContent( + uri = uri, painter = painter, - contentDescription = null, - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.FillWidth, - alignment = Alignment.Center, + onToggleBars = onToggleBars, ) } - } - is AsyncImagePainter.State.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - modifier = Modifier.clickable(onClick = throttle { painter.restart() }), - text = "加载失败, 点击重试", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) + is AsyncImagePainter.State.Error -> { + val reload = throttle { painter.restart() } + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(uri) { + detectTapGestures(onTap = { onToggleBars() }) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.pointerInput(uri) { + detectTapGestures(onTap = { reload() }) + }, + text = "加载失败, 点击重试", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + stateVal.result.throwable.message?.let { msg -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = msg, + color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 16.dp), + textAlign = TextAlign.Center + ) + } + } } } } } + +@Composable +private fun ZoomableImageContent( + uri: String, + painter: Painter, + onToggleBars: () -> Unit, +) { + // 每个 pager page 都独立持有一个 ZoomableState,避免翻页后复用缩放位置。 + val zoomableState = rememberZoomableState() + val intrinsicSize = painter.intrinsicSize + + // Image() 的绘制区域和实际图片内容边界并不总是完全一致。 + // 把内容位置告诉 Telephoto 后,边缘检测和与 pager 的手势协同会更稳定。 + LaunchedEffect(uri, intrinsicSize) { + if (intrinsicSize != Size.Unspecified && intrinsicSize.width > 0f && intrinsicSize.height > 0f) { + zoomableState.setContentLocation( + ZoomableContentLocation.scaledInsideAndCenterAligned(intrinsicSize) + ) + } + } + + // 限制图片成功状态下的深色画布背景,防止非必要全局黑色背景不跟随主题 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Image( + painter = painter, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .zoomable( + state = zoomableState, + onClick = { onToggleBars() }, + ), + contentScale = ContentScale.Inside, + alignment = Alignment.Center, + ) + } +} + +private fun buildPreviewImageRequest( + context: android.content.Context, + uri: String, + listener: EventListener? = null, +): ImageRequest { + return ImageRequest.Builder(context) + .data(uri) + .crossfade(DefaultDurationMillis) + .listener(listener) + .run { + if (URLUtil.isNetworkUrl(uri)) { + this + } else { + diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.DISABLED) + } + } + .build() +} + +private fun buildPreviewSubtitle(item: ImagePreviewItem): String? { + val titles = buildList { + item.title?.takeIf { it.isNotBlank() }?.let(::add) + item.titles + .mapNotNull { it.takeIf(String::isNotBlank) } + .forEach(::add) + }.distinct() + return titles.takeIf { it.isNotEmpty() }?.joinToString(" / ") +} 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..05e4a80df0 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 @@ -26,50 +19,45 @@ 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 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 androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable 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.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +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.appInfoMapFlow import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle -@Destination(style = ProfileTransitions::class) +@Serializable +data object SlowGroupRoute : NavKey + @Composable fun SlowGroupPage() { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.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 = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popPage() + }) }, title = { Text(text = "缓慢查询") }, actions = { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Info, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "缓慢查询", text = arrayOf( @@ -78,9 +66,7 @@ fun SlowGroupPage() { "缓慢查询可能导致触发缓慢或更多耗电, 一些可能优化的建议操作\n1. 降低选择器获取新节点次数\n2. 降低或限制规则查询时间或次数" ).joinToString("\n\n"), ) - }) { - Icon(Icons.Outlined.Info, contentDescription = null) - } + }) } ) } @@ -96,11 +82,11 @@ fun SlowGroupPage() { modifier = Modifier .clickable(onClick = throttle { mainVm.navigatePage( - SubsGlobalGroupListPageDestination( - rule.subsItem.id, - group.key - ) + SubsGlobalGroupListRoute( + rule.subsItem.id, + group.key ) + ) }) .itemPadding(), title = group.name, @@ -115,19 +101,19 @@ fun SlowGroupPage() { modifier = Modifier .clickable(onClick = throttle { mainVm.navigatePage( - SubsAppGroupListPageDestination( - rule.subsItem.id, - rule.app.id, - group.key - ) + SubsAppGroupListRoute( + rule.subsItem.id, + rule.app.id, + group.key ) + ) }) .itemPadding(), title = group.name, 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 = "暂无规则") @@ -161,9 +147,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 bf5f7ee81b..4ef34195e7 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,36 +39,37 @@ 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 +import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable 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.permission.canWriteExternalStorage 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.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +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.LIST_PLACEHOLDER_KEY -import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.ImageUtils +import li.songe.gkd.util.SnapshotExt +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 @@ -80,53 +77,57 @@ import li.songe.gkd.util.shareFile import li.songe.gkd.util.throttle import li.songe.gkd.util.toast -@Destination(style = ProfileTransitions::class) +@Serializable +data object SnapshotPageRoute : NavKey + @Composable fun SnapshotPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current 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) + 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 = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { + mainVm.popPage() + }) + }, + 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 -> @@ -139,17 +140,17 @@ fun SnapshotPage() { ) { items(snapshots, { it.id }) { snapshot -> SnapshotCard( - modifier = Modifier.animateListItem(this), + modifier = Modifier.animateListItem(), snapshot = snapshot, onClick = { selectedSnapshot = snapshot } ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (snapshots.isEmpty() && !firstLoading) { - EmptyText(text = "暂无记录") + EmptyText(text = "暂无数据") } } } @@ -170,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 + ImagePreviewRoute( + title = appInfoMapFlow.value[snapshotVal.appId]?.name ?: snapshotVal.appId, uri = snapshotVal.screenshotFile.absolutePath, ) ) - selectedSnapshot = null })) .then(modifier) ) @@ -200,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, @@ -242,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) @@ -260,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) { @@ -328,10 +329,10 @@ 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.toString(), + text = showAppName, overflow = TextOverflow.Ellipsis, maxLines = 1, softWrap = false, @@ -342,10 +343,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/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..f810ea175c 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 @@ -9,38 +10,36 @@ 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 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 -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination +import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.Serializable 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 @@ -48,12 +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.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +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 -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 @@ -63,23 +61,26 @@ import li.songe.gkd.util.toJson5String import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription -@Destination(style = ProfileTransitions::class) +@Serializable +data class SubsAppGroupListRoute( + val subsItemId: Long, + val appId: String, + val focusGroupKey: Int? = null, // 背景/边框高亮一下 +) : NavKey + @Composable -fun SubsAppGroupListPage( - subsItemId: Long, - appId: String, - @Suppress("unused") focusGroupKey: Int? = null, // 背景/边框高亮一下 -) { +fun SubsAppGroupListPage(route: SubsAppGroupListRoute) { + val subsItemId = route.subsItemId + val appId = route.appId + val focusGroupKey = route.focusGroupKey + val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val vm = viewModel() - val subs = vm.subsRawFlow.collectAsState().value + val vm = viewModel { SubsAppGroupListVm(route) } + 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 editable = subsItemId < 0 val isSelectedMode = vm.isSelectedModeFlow.collectAsState().value val selectedDataSet = vm.selectedDataSetFlow.collectAsState().value @@ -96,7 +97,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) { @@ -108,26 +110,30 @@ 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 } else { - navController.popBackStack() + mainVm.popPage() } }) { 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, + appFallbackName = app.name, ) } }, actions = { @@ -139,73 +145,63 @@ fun SubsAppGroupListPage( ) { if (it) { Row { - IconButton(onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { - val copyGroups = app.groups.filter { g -> - selectedDataSet.any { it.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 { it.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 { it.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 + }) } } } @@ -255,43 +251,38 @@ fun SubsAppGroupListPage( if (editable) { AnimationFloatingActionButton( visible = !isSelectedMode, - onClick = throttle { + onClick = { mainVm.navigatePage( - UpsertRuleGroupPageDestination( + UpsertRuleGroupRoute( subsId = subsItemId, groupKey = null, appId = appId ) ) }, - content = { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - ) - } + contentDescription = "添加规则", + imageVector = PerfIcon.Add, ) } }) { contentPadding -> LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(app.groups, { it.key }) { group -> - val category = groupToCategoryMap[group] + val category = subs.getCategory(group.name) val subsConfig = subsConfigs.find { it.groupKey == group.key } val categoryConfig = categoryConfigs.find { it.categoryKey == category?.key } RuleGroupCard( - modifier = Modifier.animateListItem(this), - subs = subs!!, + modifier = Modifier.animateListItem(), + 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 }, @@ -313,12 +304,10 @@ fun SubsAppGroupListPage( } ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { 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 924e2faa37..032ed405bf 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt @@ -1,43 +1,31 @@ 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.map -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.ui.share.BaseViewModel -class SubsAppGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { - private val args = SubsAppGroupListPageDestination.argsFrom(stateHandle) +class SubsAppGroupListVm(val route: SubsAppGroupListRoute) : BaseViewModel() { - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } + val subsFlow = mapSafeSubs(route.subsItemId) + val subsAppFlow = subsFlow.mapNew { it.getApp(route.appId) } - val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(args.subsItemId, args.appId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(route.subsItemId, route.appId) + .stateInit(emptyList()) - val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(route.subsItemId) + .stateInit(emptyList()) - val subsAppFlow = subsIdToRawFlow.map(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()) - val focusGroupFlow = args.focusGroupKey?.let { + val focusGroupFlow = route.focusGroupKey?.let { MutableStateFlow?>( Triple( - args.subsItemId, - args.appId, - args.focusGroupKey + route.subsItemId, + route.appId, + route.focusGroupKey ) ) } 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..18fd454699 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,9 @@ 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,94 +22,81 @@ 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 androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import li.songe.gkd.MainActivity +import li.songe.gkd.R 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.QueryPkgAuthCard +import li.songe.gkd.ui.component.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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.SubsAppCard 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.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.menuPadding import li.songe.gkd.ui.style.scaffoldPadding -import li.songe.gkd.util.LIST_PLACEHOLDER_KEY +import li.songe.gkd.util.AppGroupOption +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 +@Serializable +data class SubsAppListRoute(val subsItemId: Long) : NavKey -@Destination(style = ProfileTransitions::class) @Composable -fun SubsAppListPage( - subsItemId: Long, -) { +fun SubsAppListPage(route: SubsAppListRoute) { + val subsItemId = route.subsItemId + val mainVm = LocalMainViewModel.current - val context = LocalActivity.current!! - val navController = LocalNavController.current + val context = LocalActivity.current as MainActivity + val vm = viewModel { SubsAppListVm(route) } - 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() - } - navController.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.popPage() + }), + ) }, title = { val firstShowSearchBar = remember { showSearchBar } if (showSearchBar) { BackHandler { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } else { + if (!context.justHideSoftInput()) { showSearchBar = false } } @@ -135,76 +110,65 @@ fun SubsAppListPage( TowLineText( title = useSubs(subsItemId)?.name ?: subsItemId.toString(), subtitle = "应用规则", + modifier = Modifier.noRippleClickable { + vm.resetKey.intValue++ + } ) } }, 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, - ) - } - IconButton(onClick = { expanded = true }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = null - ) - } + }, + id = R.drawable.ic_anim_search_close, + atEnd = showSearchBar, + ) + 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, - ) - SortTypeOption.allSubObject.forEach { sortOption -> - DropdownMenuItem( - text = { - Text(sortOption.label) - }, - trailingIcon = { - RadioButton( - selected = sortType == sortOption, - onClick = { - storeFlow.update { s -> s.copy(subsAppSortType = sortOption.value) } - }) - }, - onClick = { - storeFlow.update { s -> s.copy(subsAppSortType = sortOption.value) } - }, + MenuGroupCard(inTop = true, title = "排序") { + var sortType by vm.sortTypeFlow.asMutableState() + AppSortOption.objects.forEach { option -> + MenuItemRadioButton( + text = option.label, + selected = sortType == option, + onClick = { sortType = option }, + ) + } + } + MenuGroupCard(title = "分组") { + var appGroupType by vm.appGroupTypeFlow.asMutableState() + AppGroupOption.allObjects.forEach { option -> + val newValue = option.invert(appGroupType) + MenuItemCheckbox( + enabled = newValue != 0, + text = option.label, + checked = option.include(appGroupType), + onClick = { appGroupType = newValue }, + ) + } + } + MenuGroupCard(title = "筛选") { + MenuItemCheckbox( + text = "白名单", + stateFlow = vm.showBlockAppFlow, ) } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - DropdownMenuItem( - text = { - Text("显示未安装应用") - }, - trailingIcon = { - Checkbox(checked = showUninstallApp, onCheckedChange = { - storeFlow.update { s -> s.copy(subsAppShowUninstallApp = it) } - }) - }, - onClick = { - storeFlow.update { s -> s.copy(subsAppShowUninstallApp = !showUninstallApp) } - }, - ) } } }) @@ -213,7 +177,7 @@ fun SubsAppListPage( if (LOCAL_SUBS_IDS.contains(subsItemId)) { FloatingActionButton(onClick = throttle { mainVm.navigatePage( - UpsertRuleGroupPageDestination( + UpsertRuleGroupRoute( subsId = subsItemId, groupKey = null, appId = "", @@ -221,9 +185,8 @@ fun SubsAppListPage( ) ) }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Add, ) } } @@ -233,44 +196,38 @@ 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(SubsAppGroupListRoute(subsItemId, a.id)) }, - onValueChange = throttle(fn = vm.viewModelScope.launchAsFn { enable -> - val newItem = appConfig?.copy( + onValueChange = vm.viewModelScope.launchAsFn { enable -> + val newItem = a.appConfig?.copy( enable = enable ) ?: AppConfig( enable = enable, subsId = subsItemId, - appId = appRaw.id, + appId = a.id, ) DbSet.appConfigDao.insert(newItem) - }), + }, ) } - item(LIST_PLACEHOLDER_KEY) { + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) - val firstLoading by vm.linkLoad.firstLoadingFlow.collectAsState() - if (appAndConfigs.isEmpty() && !firstLoading) { + val firstLoading by vm.firstLoadingFlow.collectAsState() + if (appTripleList.isEmpty() && !firstLoading) { EmptyText( text = if (searchStr.isNotEmpty()) { - if (showUninstallApp) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件" + if (vm.showAllAppFlow.collectAsState().value) "暂无搜索结果" else "暂无搜索结果,或修改筛选" } else { "暂无规则" } ) + 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 40dc155610..90b5b61cd5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt @@ -1,162 +1,147 @@ package li.songe.gkd.ui -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.destinations.SubsAppListPageDestination +import androidx.compose.runtime.mutableIntStateOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.map 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.util.LinkLoad -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.ui.share.BaseViewModel +import li.songe.gkd.ui.share.asMutableStateFlow +import li.songe.gkd.ui.share.useSubsAppFilter +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.findOption import li.songe.gkd.util.getGroupEnable -import li.songe.gkd.util.map -import li.songe.gkd.util.subsIdToRawFlow -class SubsAppListVm(stateHandle: SavedStateHandle) : ViewModelExt() { - private val args = SubsAppListPageDestination.argsFrom(stateHandle) - val linkLoad = LinkLoad(viewModelScope) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } +class SubsAppListVm(val route: SubsAppListRoute) : BaseViewModel() { - private val appConfigsFlow = DbSet.appConfigDao.queryAppTypeConfig(args.subsItemId) - .let(linkLoad::invoke).stateInit(emptyList()) + val subsFlow = mapSafeSubs(route.subsItemId) - private val groupSubsConfigsFlow = DbSet.subsConfigDao.querySubsGroupTypeConfig(args.subsItemId) - .let(linkLoad::invoke).stateInit(emptyList()) + private val appConfigsFlow = DbSet.appConfigDao.queryAppTypeConfig(route.subsItemId) + .attachLoad().stateInit(emptyList()) - private val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .let(linkLoad::invoke).stateInit(emptyList()) + private val groupSubsConfigsFlow = + DbSet.subsConfigDao.querySubsGroupTypeConfig(route.subsItemId) + .attachLoad().stateInit(emptyList()) - private val appIdToOrderFlow = - DbSet.actionLogDao.queryLatestUniqueAppIds(args.subsItemId).let(linkLoad::invoke) - .map { appIds -> - appIds.mapIndexed { index, appId -> appId to index }.toMap() - } - val sortTypeFlow = - storeFlow.map(viewModelScope) { SortTypeOption.allSubObject.findOption(it.subsAppSortType) } + private val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(route.subsItemId) + .attachLoad().stateInit(emptyList()) - val showUninstallAppFlow = storeFlow.map(viewModelScope) { it.subsAppShowUninstallApp } - private val rawAppsFlow = subsRawFlow.map(viewModelScope) { - (it?.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 -> - // 顺序: 已安装(有名字->无名字)->未安装(有名字(来自订阅)->无名字) - 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)) - } - } - private val temp1ListFlow = combine( - temp0ListFlow, - appInfoCacheFlow, - showUninstallAppFlow - ) { apps, appInfoCache, showUninstallApp -> - if (showUninstallApp) { - apps - } else { - apps.filter { a -> appInfoCache.containsKey(a.id) } + + val sortTypeFlow = storeFlow.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.subsAppSort) }, + setter = { + storeFlow.value.copy(subsAppSort = it.value) } - } - private val sortAppsFlow = combine( - temp1ListFlow, - appInfoCacheFlow, - appIdToOrderFlow, - sortTypeFlow - ) { apps, appInfoCache, appIdToOrder, sortType -> - when (sortType) { - SortTypeOption.SortByAppMtime -> { - apps.sortedBy { a -> -(appInfoCache[a.id]?.mtime ?: 0) } - } + ) + val appGroupTypeFlow = storeFlow.asMutableStateFlow( + getter = { it.subsAppGroupType }, + setter = { storeFlow.value.copy(subsAppGroupType = it) }, + ) + val showBlockAppFlow = storeFlow.asMutableStateFlow( + getter = { it.subsAppShowBlock }, + setter = { storeFlow.value.copy(subsAppShowBlock = it) }, + ) - SortTypeOption.SortByTriggerTime -> { - apps.sortedBy { a -> appIdToOrder[a.id] ?: Int.MAX_VALUE } - } + private val temp1ListFlow = useSubsAppFilter( + subsId = route.subsItemId, + appsFlow = subsFlow.mapNew { it.apps }, + sortTypeFlow = sortTypeFlow, + appGroupTypeFlow = appGroupTypeFlow, + showBlockAppFlow = showBlockAppFlow, + ) - SortTypeOption.SortByName -> { - apps - } - } - }.stateInit(emptyList()) + val showAllAppFlow = combine(subsFlow, temp1ListFlow) { subs, list -> + subs.apps.size == list.size + }.stateInit(false) 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 } - ) - } - Triple(app, appSubsConfigs.find { s -> s.appId == app.id }, enableSize) - } - }.stateInit(emptyList()) - - val filterAppAndConfigsFlow = combine( - appAndConfigsFlow, debounceSearchStr, appInfoCacheFlow - ) { appAndConfigs, searchStr, appInfoCache -> + val temp3ListFlow = combine( + temp1ListFlow, + appInfoMapFlow, + debounceSearchStr, + ) { list, appMap, searchStr -> + val apps = list.map { it to appMap[it.id] } 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, + ) { subs, apps, categoryConfigs, appConfigs, groupSubsConfigs -> + apps.map { + val appGroupSubsConfigs = groupSubsConfigs.filter { s -> s.appId == it.first.id } + val enableSize = it.first.groups.count { g -> + val category = subs.getCategory(g.name) + getGroupEnable( + g, + appGroupSubsConfigs.find { c -> c.groupKey == g.key }, + category, + categoryConfigs.find { c -> c.categoryKey == category?.key } + ) + } + SubsAppInfoItem( + rawApp = it.first, + appInfo = it.second, + appConfig = appConfigs.find { s -> s.appId == it.first.id }, + enableSize = 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 enableSize: Int, +) { + val id get() = rawApp.id +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryGroupPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryGroupPage.kt new file mode 100644 index 0000000000..d31cfde9de --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryGroupPage.kt @@ -0,0 +1,326 @@ +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.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.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavKey +import kotlinx.coroutines.delay +import kotlinx.serialization.Serializable +import li.songe.gkd.data.CategoryConfig +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.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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.useListScrollState +import li.songe.gkd.ui.component.waitResult +import li.songe.gkd.ui.icon.ResetSettings +import li.songe.gkd.ui.icon.ToggleMid +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.iconTextSize +import li.songe.gkd.ui.style.scaffoldPadding +import li.songe.gkd.util.AppGroupOption +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.EnableGroupOption +import li.songe.gkd.util.findOption +import li.songe.gkd.util.getCategoryEnable +import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.throttle +import li.songe.gkd.util.toast +import li.songe.gkd.util.updateSubscription + +@Serializable +data class SubsCategoryGroupRoute(val subsId: Long, val categoryKey: Int) : NavKey + +@Composable +fun SubsCategoryGroupPage(route: SubsCategoryGroupRoute) { + val mainVm = LocalMainViewModel.current + val vm = viewModel { SubsCategoryGroupVm(route) } + val subs = vm.subsFlow.collectAsState().value + val apps = vm.appsFlow.collectAsState().value + val category = vm.categoryFlow.collectAsState().value + val subsConfigs = vm.subsConfigsFlow.collectAsState().value + val categoryConfig = vm.categoryConfigFlow.collectAsState().value + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val groupSize = apps.sumOf { it.groups.size } + val (scrollBehavior, listState) = useListScrollState(scrollKey, groupSize) + Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = mainVm::popPage, + ) + }, title = { + val modifier = Modifier.noRippleClickable(onClick = { scrollKey.intValue++ }) + val desc = category.desc + if (desc != null) { + TowLineText( + title = category.name, + subtitle = desc, + modifier = modifier, + ) + } else { + TowLineText( + title = subs.name, + subtitle = category.name, + modifier = modifier, + ) + } + }, actions = { + PerfIconButton( + imageVector = when (getCategoryEnable(category, categoryConfig)) { + false -> PerfIcon.ToggleOff + null -> ToggleMid + true -> PerfIcon.ToggleOn + }, + onClick = throttle( + vm.viewModelScope.launchAsFn { + val newValue = when (getCategoryEnable(category, categoryConfig)) { + false -> null + null -> true + true -> false + } + val option = EnableGroupOption.objects.findOption(newValue) + DbSet.categoryConfigDao.insert( + (categoryConfig ?: CategoryConfig( + enable = option.value, + subsId = subs.id, + categoryKey = category.key + )).copy(enable = option.value) + ) + toast(option.label) + }, + ), + ) + val resetAll = suspend { + mainVm.dialogFlow.waitResult( + title = "重置开关", + text = "重置当前类别下所有规则开关为默认值?\n重置后规则可由类别批量控制开关", + ) + val updatedList = DbSet.subsConfigDao.batchResetAppGroupEnable( + subs.id, + apps.flatMap { a -> a.groups } + .map { g -> g to subs.getAppByGroup(g) }, + ) + if (updatedList.isNotEmpty()) { + toast("重置 ${updatedList.size} 规则") + } else { + toast("无可重置规则") + } + } + if (subs.isLocal) { + var expanded by remember { mutableStateOf(false) } + PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { expanded = true }) + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + if (groupSize > 0) { + DropdownMenuItem( + leadingIcon = { PerfIcon(imageVector = ResetSettings) }, + text = { Text(text = "重置") }, + onClick = throttle(vm.viewModelScope.launchAsFn { + expanded = false + resetAll() + }) + ) + } + DropdownMenuItem( + leadingIcon = { PerfIcon(imageVector = PerfIcon.Edit) }, + text = { Text(text = "编辑") }, + onClick = { + expanded = false + vm.showEditCategoryFlow.value = true + } + ) + DropdownMenuItem( + leadingIcon = { PerfIcon(imageVector = PerfIcon.Delete) }, + text = { Text(text = "删除") }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error, + ), + onClick = throttle(mainVm.viewModelScope.launchAsFn { + expanded = false + mainVm.dialogFlow.waitResult( + title = "删除类别", + text = "确定删除 ${category.name} ?", + error = true, + ) + mainVm.popPage() + delay(500) + updateSubscription( + subs.copy(categories = subs.categories.toMutableList().apply { + removeIf { it.key == category.key } + }) + ) + DbSet.categoryConfigDao.deleteByCategoryKey( + subs.id, + category.key + ) + toast("删除成功") + }) + ) + } + } + } else if (!subs.isLocal && groupSize > 0) { + PerfIconButton( + imageVector = ResetSettings, + onClick = throttle(vm.viewModelScope.launchAsFn { resetAll() }), + ) + } + var sortExpanded by remember { mutableStateOf(false) } + PerfIconButton( + imageVector = PerfIcon.Sort, + onClick = { + sortExpanded = true + }, + ) + Box( + modifier = Modifier.wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu(expanded = sortExpanded, onDismissRequest = { sortExpanded = false }) { + MenuGroupCard(inTop = true, title = "排序") { + var sortType by vm.sortTypeFlow.asMutableState() + AppSortOption.objects.forEach { option -> + MenuItemRadioButton( + text = option.label, + selected = sortType == option, + onClick = { sortType = option }, + ) + } + } + MenuGroupCard(title = "分组") { + var appGroupType by vm.appGroupTypeFlow.asMutableState() + AppGroupOption.allObjects.forEach { option -> + val newValue = option.invert(appGroupType) + MenuItemCheckbox( + enabled = newValue != 0, + text = option.label, + checked = option.include(appGroupType), + onClick = { appGroupType = newValue }, + ) + } + } + MenuGroupCard(title = "筛选") { + MenuItemCheckbox( + text = "白名单", + stateFlow = vm.showBlockAppFlow, + ) + } + } + } + }) + }) { contentPadding -> + LazyColumn( + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + apps.forEach { app -> + stickyHeader(app.id) { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 8.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = throttle { + mainVm.navigatePage( + SubsAppGroupListRoute( + subsItemId = subs.id, + appId = app.id, + ) + ) + }) + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppNameText( + modifier = Modifier.weight(1f), + appId = app.id, + fallbackName = app.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.iconTextSize(), + ) + } + } + items(app.groups, { app.id to it.key }) { group -> + RuleGroupCard( + subs = subs, + appId = app.id, + group = group, + subsConfig = subsConfigs.find { c -> c.appId == app.id && c.groupKey == group.key }, + categoryConfig = categoryConfig, + ) + } + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (apps.isEmpty()) { + EmptyText(text = if (vm.showAllAppFlow.collectAsState().value) "暂无数据" else "暂无数据,或修改筛选") + Spacer(modifier = Modifier.height(EmptyHeight)) + } + } + } + } + + if (vm.showEditCategoryFlow.collectAsState().value) { + UpsertCategoryDialog( + subs = subs, + category = category, + ) { + vm.showEditCategoryFlow.value = false + } + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryGroupVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryGroupVm.kt new file mode 100644 index 0000000000..729ad63636 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryGroupVm.kt @@ -0,0 +1,53 @@ +package li.songe.gkd.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import li.songe.gkd.db.DbSet +import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.share.BaseViewModel +import li.songe.gkd.ui.share.asMutableStateFlow +import li.songe.gkd.ui.share.useSubsAppFilter +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.findOption + + +class SubsCategoryGroupVm(val route: SubsCategoryGroupRoute) : BaseViewModel() { + val subsFlow = mapSafeSubs(route.subsId) + val categoryFlow = subsFlow.mapNew { it.getSafeCategory(route.categoryKey) } + val subsConfigsFlow = DbSet.subsConfigDao.querySubsGroupTypeConfig(route.subsId) + .stateInit(emptyList()) + val categoryConfigFlow = + DbSet.categoryConfigDao.queryCategoryConfig(route.subsId, route.categoryKey).stateInit(null) + val showEditCategoryFlow = MutableStateFlow(false) + + val sortTypeFlow = storeFlow.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.subsCategorySort) }, + setter = { + storeFlow.value.copy(subsCategorySort = it.value) + } + ) + val appGroupTypeFlow = storeFlow.asMutableStateFlow( + getter = { it.subsCategoryGroupType }, + setter = { storeFlow.value.copy(subsCategoryGroupType = it) }, + ) + val showBlockAppFlow = storeFlow.asMutableStateFlow( + getter = { it.subsCategoryShowBlock }, + setter = { storeFlow.value.copy(subsCategoryShowBlock = it) }, + ) + + private val rawAppsFlow = subsFlow.mapNew { it.getCategoryApps(route.categoryKey) } + + val appsFlow = useSubsAppFilter( + subsId = route.subsId, + appsFlow = subsFlow.mapNew { it.getCategoryApps(route.categoryKey) }, + sortTypeFlow = sortTypeFlow, + appGroupTypeFlow = appGroupTypeFlow, + showBlockAppFlow = showBlockAppFlow, + ) + + val showAllAppFlow = combine(rawAppsFlow, appsFlow) { list1, list2 -> + list1.size == list2.size + }.stateInit(false) + + +} 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..cce58beeee 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt @@ -1,165 +1,134 @@ package li.songe.gkd.ui -import androidx.compose.foundation.layout.Box +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.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 -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 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 import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable import li.songe.gkd.appScope 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.FullscreenDialog +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.PerfTriStateSwitch import li.songe.gkd.ui.component.TowLineText 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.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.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 import li.songe.gkd.util.throttle -import li.songe.gkd.util.toToggleableState import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription -@Destination(style = ProfileTransitions::class) +@Serializable +data class SubsCategoryRoute(val subsItemId: Long) : NavKey + @Composable -fun SubsCategoryPage(subsItemId: Long) { +fun SubsCategoryPage(@Suppress("unused") route: SubsCategoryRoute) { val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val vm = viewModel() + val vm = viewModel { SubsCategoryVm(route) } 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() + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState(scrollKey) Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = { - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = mainVm::popPage, + ) }, title = { TowLineText( - title = subs?.name ?: subsItemId.toString(), - subtitle = "规则类别" + title = subs.name, + subtitle = "规则类别", + modifier = Modifier.noRippleClickable(onClick = { scrollKey.intValue++ }) ) }, actions = { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Info, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "类别说明", text = arrayOf( - "类别会捕获以当前类别开头的所有应用规则组, 因此可调整类别开关(分类手动配置)来批量开关规则组", - "规则组开关优先级为:\n规则手动配置 > 分类手动配置 > 分类默认 > 规则默认", - "因此如果手动开关了规则组(规则手动配置), 则该规则组不会被批量开关, 可通过点击类别-重置规则组开关, 来移除类别下所有规则手动配置", + "类别会捕获以当前类别开头的所有应用规则, 因此可调整类别开关(分类手动配置)来批量开关规则", + "规则开关优先级为:\n规则手动配置 > 分类手动配置 > 分类默认 > 规则默认", + "因此如果手动开关了规则(规则手动配置), 则该规则不会被批量开关, 可通过点击类别-重置规则开关, 来移除类别下所有规则手动配置", ).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, ) } } }) { contentPadding -> LazyColumn( - modifier = Modifier.scaffoldPadding(contentPadding) + modifier = Modifier.scaffoldPadding(contentPadding), + state = listState, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { items(categories, { it.key }) { category -> CategoryItemCard( - vm = vm, - subs = subs!!, + subs = subs, category = category, categoryConfig = categoryConfigMap[category.key], - showBottom = categories.last() != category ) } - item(LIST_PLACEHOLDER_KEY) { + 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) { - AddOrEditCategoryDialog( - subs = subs, - category = editCategory, - ) { - vm.editCategoryFlow.value = null - } - } - val showAddCategory by vm.showAddCategoryFlow.collectAsState() - if (subs != null && showAddCategory) { - AddOrEditCategoryDialog( + if (vm.showAddCategoryFlow.collectAsState().value) { + UpsertCategoryDialog( subs = subs, category = null, ) { @@ -170,44 +139,43 @@ fun SubsCategoryPage(subsItemId: Long) { @Composable private fun CategoryItemCard( - vm: SubsCategoryVm, subs: RawSubscription, category: RawSubscription.RawCategory, categoryConfig: CategoryConfig?, - showBottom: Boolean, ) { - val groups = subs.categoryToGroupsMap[category] ?: emptyList() - var expanded by remember { mutableStateOf(false) } - val onClick = { - if (groups.isNotEmpty() || subs.isLocal) { - expanded = true - } - } + val mainVm = LocalMainViewModel.current Card( - onClick = onClick, + onClick = { + mainVm.navigatePage( + SubsCategoryGroupRoute( + subsId = subs.id, + categoryKey = category.key + ) + ) + }, shape = MaterialTheme.shapes.extraSmall, modifier = Modifier.padding( - start = 8.dp, - end = 8.dp, - bottom = if (showBottom) 4.dp else 0.dp + horizontal = 8.dp, ), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .padding(8.dp) + .weight(1f) + ) { Text( text = category.name, style = MaterialTheme.typography.bodyLarge, ) - if (groups.isNotEmpty()) { - val appSize = subs.categoryToAppMap[category]?.size ?: 0 + val desc = subs.getCategoryCompatDesc(category.key) + if (desc != null) { Text( - text = "${appSize}应用/${groups.size}规则组", + text = desc, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -219,23 +187,13 @@ private fun CategoryItemCard( ) } } - CategoryMenu( - vm = vm, - subs = subs, - category = category, - expanded = expanded, - onCheckedChange = { expanded = it } - ) - Spacer(modifier = Modifier.width(8.dp)) - val enable = getCategoryEnable(category, categoryConfig) - TriStateCheckbox( - state = EnableGroupOption.allSubObject.findOption(enable).toToggleableState(), - onClick = throttle(appScope.launchAsFn { - val option = when (enable) { - false -> EnableGroupOption.FollowSubs - null -> EnableGroupOption.AllEnable - true -> EnableGroupOption.AllDisable - } + PerfTriStateSwitch( + modifier = Modifier + .noRippleClickable(onClick = {}) + .padding(8.dp), + checked = getCategoryEnable(category, categoryConfig), + onCheckedChange = throttle(appScope.launchAsFn { + val option = EnableGroupOption.objects.findOption(it) DbSet.categoryConfigDao.insert( (categoryConfig ?: CategoryConfig( enable = option.value, @@ -251,154 +209,98 @@ private fun CategoryItemCard( } @Composable -private fun CategoryMenu( - vm: SubsCategoryVm, - subs: RawSubscription, - category: RawSubscription.RawCategory, - expanded: Boolean, - onCheckedChange: ((Boolean) -> Unit), -) { - val mainVm = LocalMainViewModel.current - val groups = subs.categoryToGroupsMap[category] ?: emptyList() - Box( - modifier = Modifier.wrapContentSize(Alignment.TopStart) - ) { - DropdownMenu( - expanded = expanded, - onDismissRequest = { onCheckedChange(false) } - ) { - if (groups.isNotEmpty()) { - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = ResetSettings, - contentDescription = null - ) - }, - text = { Text(text = "重置规则组开关") }, - onClick = throttle(vm.viewModelScope.launchAsFn { - onCheckedChange(false) - val updatedList = DbSet.subsConfigDao.batchResetAppGroupEnable( - subs.id, - subs.categoryToGroupsMap[category] ?: emptyList(), - ) - if (updatedList.isNotEmpty()) { - toast("成功重置 ${updatedList.size} 规则组开关") - } else { - toast("无可重置规则组") - } - }) - ) - } - if (subs.isLocal) { - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = null - ) - }, - text = { Text(text = "编辑") }, - onClick = { - onCheckedChange(false) - vm.editCategoryFlow.value = category - } - ) - DropdownMenuItem( - text = { Text(text = "删除", color = MaterialTheme.colorScheme.error) }, - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - }, - onClick = throttle(vm.viewModelScope.launchAsFn { - onCheckedChange(false) - mainVm.dialogFlow.waitResult( - title = "删除类别", - text = "确定删除 ${category.name} ?", - error = true, - ) - updateSubscription( - subs.copy(categories = subs.categories.toMutableList().apply { - removeIf { it.key == category.key } - }) - ) - DbSet.categoryConfigDao.deleteByCategoryKey( - subs.id, - category.key - ) - toast("删除成功") - }) - ) - } - } - } -} - -@Composable -private fun AddOrEditCategoryDialog( +fun UpsertCategoryDialog( subs: RawSubscription, category: RawSubscription.RawCategory?, onDismissRequest: () -> Unit, ) { - var value by remember { - mutableStateOf(category?.name ?: "") - } + var nameValue by remember { mutableStateOf(category?.name ?: "") } + var descValue by remember { mutableStateOf(category?.desc ?: "") } val onClick = appScope.launchAsFn { if (category != null) { + if (subs.categories.any { c -> c.key != category.key && c.name == nameValue }) { + error("不可添加同名类别") + } onDismissRequest() - updateSubscription( - subs.copy(categories = subs.categories.toMutableList().apply { - set( - indexOfFirst { c -> c.key == category.key }, - category.copy(name = value) - ) - }) - ) - toast("更新成功") + val changed = category.name != nameValue || (category.desc ?: "") != descValue + if (changed) { + updateSubscription( + subs.copy(categories = subs.categories.toMutableList().apply { + set( + indexOfFirst { c -> c.key == category.key }, + category.copy(name = nameValue, desc = descValue) + ) + }) + ) + toast("更新成功") + } else { + toast("未修改") + } } else { - if (subs.categories.any { c -> c.name == value }) { + if (subs.categories.any { c -> c.name == nameValue }) { error("不可添加同名类别") } onDismissRequest() updateSubscription( subs.copy(categories = subs.categories.toMutableList().apply { - add(RawSubscription.RawCategory(key = (subs.categories.maxOfOrNull { c -> c.key } - ?: -1) + 1, name = value, enable = null)) + val c = RawSubscription.RawCategory( + key = (subs.categories.maxOfOrNull { c -> c.key } ?: -1) + 1, + enable = null, + name = nameValue, + desc = descValue, + ) + add(c) }) ) toast("添加成功") } } - AlertDialog( - properties = DialogProperties(dismissOnClickOutside = false), - title = { Text(text = if (category == null) "添加类别" else "编辑类别") }, - text = { - OutlinedTextField( - value = value, - onValueChange = { value = it.trim() }, + FullscreenDialog(onDismissRequest = onDismissRequest) { + Scaffold( + topBar = { + PerfTopAppBar( + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.Close, + onClick = throttle(onDismissRequest), + ) + }, + title = { Text(text = if (category == null) "添加类别" else "编辑类别") }, + actions = { + PerfIconButton( + imageVector = PerfIcon.Save, + enabled = nameValue.isNotEmpty(), + onClick = throttle(onClick), + ) + } + ) + }, + ) { paddingValues -> + Column( modifier = Modifier - .fillMaxWidth() - .autoFocus(), - placeholder = { Text(text = "请输入类别名称") }, - singleLine = true - ) - }, - onDismissRequest = {}, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(text = "取消") - } - }, - confirmButton = { - TextButton( - enabled = value.isNotEmpty(), - onClick = throttle(onClick) + .padding(paddingValues) + .padding(horizontal = 16.dp), ) { - Text(text = "确认") + OutlinedTextField( + label = { Text("类别名称") }, + value = nameValue, + onValueChange = { nameValue = it.trim() }, + modifier = Modifier + .fillMaxWidth() + .autoFocus(), + placeholder = { Text(text = "请输入类别名称") }, + singleLine = true, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + label = { Text("类别描述") }, + value = descValue, + onValueChange = { descValue = it.trim() }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(text = "请输入类别描述") }, + singleLine = true, + ) } } - ) - + } } 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..50d5e36f38 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt @@ -1,29 +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.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.map -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.ui.share.BaseViewModel -class SubsCategoryVm(stateHandle: SavedStateHandle) : ViewModel() { - private val args = SubsCategoryPageDestination.argsFrom(stateHandle) +class SubsCategoryVm(val route: SubsCategoryRoute) : BaseViewModel() { + val subsRawFlow = mapSafeSubs(route.subsItemId) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { m -> m[args.subsItemId] } + val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(route.subsItemId) + .stateInit(emptyList()) - val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val categoryConfigMapFlow = categoryConfigsFlow.map { it.associateBy { c -> c.categoryKey } } + .stateInit(emptyMap()) - val categoryConfigMapFlow = categoryConfigsFlow.map { it.associateBy { it.categoryKey } } - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) - - val editCategoryFlow = MutableStateFlow(null) val showAddCategoryFlow = MutableStateFlow(false) } \ No newline at end of file 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..17bfd13051 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt @@ -9,32 +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.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 @@ -42,366 +30,348 @@ 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 androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import li.songe.gkd.MainActivity +import li.songe.gkd.R +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.store.blockMatchAppListFlow +import li.songe.gkd.ui.component.AnimatedBooleanContent +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 import li.songe.gkd.ui.component.AppNameText -import li.songe.gkd.ui.component.CardFlagBar import li.songe.gkd.ui.component.EmptyText import li.songe.gkd.ui.component.InnerDisableSwitch -import li.songe.gkd.ui.component.QueryPkgAuthCard +import li.songe.gkd.ui.component.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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.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.local.LocalNavController +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 +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.menuPadding +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.SafeR -import li.songe.gkd.util.SortTypeOption +import li.songe.gkd.util.AppGroupOption +import li.songe.gkd.util.AppSortOption 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 -@Destination(style = ProfileTransitions::class) +@Serializable +data class SubsGlobalGroupExcludeRoute( + val subsItemId: Long, + val groupKey: Int, +) : NavKey + @Composable -fun SubsGlobalGroupExcludePage(subsItemId: Long, groupKey: Int) { - val context = LocalActivity.current!! - val navController = LocalNavController.current - val vm = viewModel() - val rawSubs = vm.rawSubsFlow.collectAsState().value - val group = vm.groupFlow.collectAsState().value +fun SubsGlobalGroupExcludePage(route: SubsGlobalGroupExcludeRoute) { + val subsItemId = route.subsItemId + val groupKey = route.groupKey + + val mainVm = LocalMainViewModel.current + val context = LocalActivity.current as MainActivity + val vm = viewModel { SubsGlobalGroupExcludeVm(route) } + 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() - val showSystemApp by vm.showSystemAppFlow.collectAsState() - 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) - } + var searchStr by vm.searchStrFlow.asMutableState() + var editable by vm.editableFlow.asMutableState() + 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 - Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { - IconButton(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } - navController.popBackStack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - }, title = { - val firstShowSearchBar = remember { showSearchBar } - if (showSearchBar) { - BackHandler { - if (KeyboardUtils.isSoftInputVisible(context)) { - softwareKeyboardController?.hide() - } else { - showSearchBar = false - } - } - AppBarTextField( - value = searchStr, - onValueChange = { newValue -> vm.searchStrFlow.value = newValue.trim() }, - hint = "请输入应用名称/ID", - modifier = if (firstShowSearchBar) Modifier else Modifier.autoFocus(), - ) - } else { - TowLineText( - title = "编辑禁用", - subtitle = "${rawSubs?.name ?: subsItemId}/${group?.name ?: groupKey}" - ) - } - }, actions = { - IconButton(onClick = { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - showSearchBar = false - } else { - vm.searchStrFlow.value = "" + val (scrollBehavior, listState) = useListScrollState( + vm.resetKey, + canScroll = { !editable } + ) + + 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 = { + PerfTopAppBar( + scrollBehavior = scrollBehavior, + canScroll = !editable, + navigationIcon = { + IconButton(onClick = throttle(vm.viewModelScope.launchAsFn { + if (vm.editableFlow.value) { + editable = false + context.justHideSoftInput() + } else { + context.hideSoftInput() + mainVm.popPage() + } + })) { + BackCloseIcon(backOrClose = !editable) } - } 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) } - } - ) - }, - onClick = { - storeFlow.update { it.copy(subsExcludeSortType = sortOption.value) } + }, + 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++ } ) } - 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) } - }) + }, + 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 + }), + ) }, - onClick = { - storeFlow.update { it.copy(subsExcludeShowHiddenApp = !it.subsExcludeShowHiddenApp) } + contentFalse = { + Row { + AnimatedIconButton( + onClick = { + if (showSearchBar) { + if (searchStr.isEmpty()) { + showSearchBar = false + } else { + searchStr = "" + } + } else { + showSearchBar = true + } + }, + id = R.drawable.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 } + ) { + MenuGroupCard(inTop = true, title = "排序") { + var sortType by vm.sortTypeFlow.asMutableState() + AppSortOption.objects.forEach { option -> + MenuItemRadioButton( + text = option.label, + selected = sortType == option, + onClick = { sortType = option } + ) + } + } + MenuGroupCard(title = "分组") { + var appGroupType by vm.appGroupTypeFlow.asMutableState() + AppGroupOption.normalObjects.forEach { option -> + val newValue = option.invert(appGroupType) + MenuItemCheckbox( + enabled = newValue != 0, + text = option.label, + checked = option.include(appGroupType), + onClick = { appGroupType = newValue }, + ) + } + } + MenuGroupCard(title = "筛选") { + MenuItemCheckbox( + text = "内置禁用", + stateFlow = vm.showInnerDisabledAppFlow, + ) + MenuItemCheckbox( + text = "白名单", + stateFlow = vm.showBlockAppFlow, + ) + } + } + } + } }, ) - 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) } - }, - ) - } - } - } - - }) - }, 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(appInfo = appInfo) - Spacer(modifier = Modifier.width(12.dp)) + }) + }, + floatingActionButton = { + AnimationFloatingActionButton( + visible = !editable && scrollBehavior.isFullVisible, + onClick = { + editable = !editable + }, + imageVector = PerfIcon.Edit, + contentDescription = "编辑禁用名单" + ) + } + ) { 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 -> - Column( + Row( modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.SpaceBetween + .fillMaxWidth() + .itemPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - 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, - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - if (group != null) { + 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( - rawSubs!!, + subs, excludeData, group, appInfo.id ) if (checked != null) { - key(appInfo.id) { - Switch( - 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 { + 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) - }), - ) - } + .stringify() + ) + DbSet.subsConfigDao.insert(subsConfig) + }, + thumbContent = if (excludeData.appIds.contains(appInfo.id)) ({ + PerfIcon( + imageVector = ResetSettings, + modifier = Modifier.size(8.dp) + ) + }) else null, + ) } else { InnerDisableSwitch() } } - CardFlagBar(visible = excludeData.appIds.containsKey(appInfo.id)) } - } - item(LIST_PLACEHOLDER_KEY) { - 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()) { + EmptyText(text = if (vm.appFilter.showAllAppFlow.collectAsState().value) "暂无搜索结果" else "暂无搜索结果,或修改筛选") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) + } } - 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) { - toast("禁用项无变动") - 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 16f31ed243..fa4c33ee18 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt @@ -1,108 +1,95 @@ package li.songe.gkd.ui -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupExcludePageDestination +import androidx.compose.runtime.mutableIntStateOf 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.map -import li.songe.gkd.util.orderedAppInfosFlow -import li.songe.gkd.util.subsIdToRawFlow -class SubsGlobalGroupExcludeVm(stateHandle: SavedStateHandle) : ViewModel() { - private val args = SubsGlobalGroupExcludePageDestination.argsFrom(stateHandle) +class SubsGlobalGroupExcludeVm(val route: SubsGlobalGroupExcludeRoute) : BaseViewModel() { - val rawSubsFlow = subsIdToRawFlow.map(viewModelScope) { it[args.subsItemId] } + val subsFlow = mapSafeSubs(route.subsItemId) + val groupFlow = subsFlow.mapNew { r -> r.globalGroups.find { g -> g.key == route.groupKey } } + val subsConfigFlow = DbSet.subsConfigDao + .queryGlobalGroupTypeConfig(route.subsItemId, route.groupKey) + .stateInit(null) + val excludeDataFlow = subsConfigFlow.mapNew { s -> ExcludeData.parse(s?.exclude) } - val groupFlow = - rawSubsFlow.map(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.map(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } - - val searchStrFlow = MutableStateFlow("") - private val debounceSearchStrFlow = searchStrFlow.debounce(200) - .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) + val sortTypeFlow = storeFlow.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.subsExcludeSort) }, + setter = { + storeFlow.value.copy(subsExcludeSort = it.value) + } + ) + 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 appGroupTypeFlow = storeFlow.asMutableStateFlow( + getter = { it.subsExcludeAppGroupType }, + setter = { + storeFlow.value.copy(subsExcludeAppGroupType = it) + } + ) + val appFilter = useAppFilter( + appGroupTypeFlow = appGroupTypeFlow, + appOrderListFlow = DbSet.actionLogDao.queryLatestUniqueAppIds( + route.subsItemId, + route.groupKey + ).stateInit(emptyList()), + sortTypeFlow = sortTypeFlow, + 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.map(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 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 } + val resetKey = mutableIntStateOf(0) + val excludeTextFlow = MutableStateFlow("") + val editableFlow = MutableStateFlow(false).apply { + launchOnChange { + if (it) { + excludeTextFlow.value = excludeDataFlow.value.stringify() } - }.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 - } - } - } - }.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, - disabledAppSetFlow - ) { apps, showDisabledApp, disabledAppSet -> - if (showDisabledApp || disabledAppSet.isEmpty()) { - apps - } else { - apps.filter { a -> !disabledAppSet.contains(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 c65d240dd0..e210e5a917 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 @@ -9,37 +10,36 @@ 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 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 -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination +import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.Serializable 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 @@ -47,12 +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.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController +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 -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 @@ -60,18 +59,22 @@ import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription -@Suppress("unused") -@Destination(style = ProfileTransitions::class) + +@Serializable +data class SubsGlobalGroupListRoute(val subsItemId: Long, val focusGroupKey: Int? = null) : NavKey + @Composable -fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { +fun SubsGlobalGroupListPage(route: SubsGlobalGroupListRoute) { + val subsItemId = route.subsItemId + val focusGroupKey = route.focusGroupKey + val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current - val vm = viewModel() + val vm = viewModel { SubsGlobalGroupListVm(route) } 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 @@ -89,7 +92,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) { @@ -103,24 +107,27 @@ 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 } else { - navController.popBackStack() + mainVm.popPage() } }) { 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 = "全局规则" ) } @@ -135,25 +142,25 @@ 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 ) { - subs mainVm.dialogFlow.waitResult( - title = "删除规则组", - text = "删除当前所选规则组?", + 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) } ) ) @@ -163,21 +170,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 + }) } } } @@ -224,39 +223,38 @@ fun SubsGlobalGroupListPage(subsItemId: Long, focusGroupKey: Int? = null) { }, floatingActionButton = { if (editable) { - AnimationFloatingActionButton(visible = !isSelectedMode, onClick = throttle { - mainVm.navigatePage( - UpsertRuleGroupPageDestination( - subsId = subsItemId, - groupKey = null, - appId = null, + AnimationFloatingActionButton( + visible = !isSelectedMode, + onClick = { + mainVm.navigatePage( + UpsertRuleGroupRoute( + subsId = subsItemId, + groupKey = null, + appId = null, + ) ) - ) - }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "add", - ) - } + }, + imageVector = PerfIcon.Add, + contentDescription = "添加规则" + ) } }, ) { paddingValues -> 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 } RuleGroupCard( - modifier = Modifier.animateListItem(this), - subs = subs!!, + modifier = Modifier.animateListItem(), + 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 = { @@ -274,12 +272,10 @@ 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 = "暂无规则") - } 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 27a56a3a91..fa0d5c00c3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt @@ -1,32 +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.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.map -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.ui.share.BaseViewModel -class SubsGlobalGroupListVm(stateHandle: SavedStateHandle) : ViewModel() { - private val args = SubsGlobalGroupListPageDestination.argsFrom(stateHandle) - val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } +class SubsGlobalGroupListVm(val route: SubsGlobalGroupListRoute) : BaseViewModel() { + val subsRawFlow = mapSafeSubs(route.subsItemId) - val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId) - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(route.subsItemId) + .stateInit(emptyList()) val isSelectedModeFlow = MutableStateFlow(false) val selectedDataSetFlow = MutableStateFlow(emptySet()) - val focusGroupFlow = args.focusGroupKey?.let { + val focusGroupFlow = route.focusGroupKey?.let { MutableStateFlow?>( Triple( - args.subsItemId, + route.subsItemId, null, - args.focusGroupKey + route.focusGroupKey ) ) } 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..7604d33384 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt @@ -8,21 +8,14 @@ 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.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -33,117 +26,93 @@ 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 androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable 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.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.share.LocalDarkTheme +import li.songe.gkd.ui.share.LocalMainViewModel 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") -@Destination(style = ProfileTransitions::class) +@Serializable +data class UpsertRuleGroupRoute( + val subsId: Long, + val groupKey: Int? = null, + val appId: String? = null, + val forward: Boolean = false, +) : NavKey + @Composable -fun UpsertRuleGroupPage( - subsId: Long, - groupKey: Int? = null, - appId: String? = null, - forward: Boolean = false, -) { +fun UpsertRuleGroupPage(route: UpsertRuleGroupRoute) { + val subsId = route.subsId + val appId = route.appId + val forward = route.forward + val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity - val navController = LocalNavController.current - val vm = viewModel() + val vm = viewModel { UpsertRuleGroupVm(route) } 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()) { + context.justHideSoftInput() mainVm.dialogFlow.waitResult( - title = "放弃编辑", + title = "提示", text = "当前内容未保存,是否放弃编辑?", ) + } else { + context.hideSoftInput() } - withContext(Dispatchers.Main) { mainVm.navController.popBackStack() } - }.let { } + mainVm.popPage() + }) - 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( + SubsGlobalGroupListRoute(subsItemId = subsId), + replaced = true + ) } else { - navController.popBackStack() + mainVm.navigatePage( + SubsAppGroupListRoute( + subsItemId = subsId, + vm.addAppId ?: appId + ), + replaced = true + ) } + } else { + mainVm.popPage() } }) - BackHandler(true) { - if (KeyboardUtils.isSoftInputVisible(context)) { - KeyboardUtils.hideSoftInput(context) - return@BackHandler - } - checkIfSaveText() - } - DisposableEffect(null) { - onDispose { - clearJson5TransformationCache() - } - } + BackHandler(true, checkIfSaveText) Scaffold(modifier = Modifier, topBar = { - TopAppBar( + PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { - IconButton(onClick = throttle { - if (KeyboardUtils.isSoftInputVisible(context)) { - KeyboardUtils.hideSoftInput(context) - } - checkIfSaveText() - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = checkIfSaveText) }, title = { - Text(text = if (vm.isEdit) "编辑规则组" else "添加规则组") + 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 -> @@ -159,19 +128,26 @@ fun UpsertRuleGroupPage( .fillMaxSize(), ) { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { - // need compose 1.9.0 + val imeShowing by context.imePlayingFlow.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), placeholder = { - Text(text = if (vm.isApp) "请输入应用规则组\n" else "请输入全局规则组\n") + Text(text = if (vm.isApp) "请输入应用规则\n" else "请输入全局规则\n") }, ) } 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..c5c65d9d80 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt @@ -1,37 +1,35 @@ package li.songe.gkd.ui -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import com.blankj.utilcode.util.LogUtils -import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination import kotlinx.coroutines.flow.MutableStateFlow 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.util.subsIdToRawFlow +import li.songe.gkd.ui.style.clearJson5TransformationCache +import li.songe.gkd.util.LogUtils +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription import li.songe.json5.Json5 -class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { - val args = UpsertRuleGroupPageDestination.argsFrom(stateHandle) - val groupKey = args.groupKey - val appId = args.appId +class UpsertRuleGroupVm(val route: UpsertRuleGroupRoute) : ViewModel() { + val groupKey = route.groupKey + val appId = route.appId val isEdit = groupKey != null val isApp = appId != null val isAddAnyApp = appId == "" private val initialGroup: RawSubscription.RawGroupProps? = run { - val subs = subsIdToRawFlow.value[args.subsId] + val subs = subsMapFlow.value[route.subsId] subs ?: return@run null if (groupKey != null) { if (appId != null) { subs.getAppGroups(appId) } else { subs.globalGroups - }.find { it.key == args.groupKey } + }.find { it.key == route.groupKey } } else { null } @@ -40,18 +38,18 @@ 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 fun saveRule() { - val subs = subsIdToRawFlow.value[args.subsId] ?: error("订阅不存在") + val subs = subsMapFlow.value[route.subsId] ?: error("订阅不存在") val text = textFlow.value if (text.isBlank()) { error("规则不能为空") @@ -60,7 +58,7 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { toast("规则无变动") return } - val jsonObject = runCatching { Json5.parseToJson5Element(text) }.run { + var jsonObject = runCatching { Json5.parseToJson5Element(text) }.run { if (isFailure) { error("非法格式\n${exceptionOrNull()?.message}") } @@ -69,12 +67,28 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { if (jsonObject !is JsonObject) { error("规则应为对象格式") } + // 自动填充 key + if (jsonObject["name"] != null && jsonObject["key"] == null) { + jsonObject = JsonObject(jsonObject + mapOf("key" to JsonPrimitive(groupKey ?: 0))) + } + if (jsonObject["id"] is JsonPrimitive && jsonObject["groups"] is JsonArray) { + val groups = jsonObject["groups"] as JsonArray + val newGroups = groups.map { + if (it is JsonObject && it["name"] != null && it["key"] == null) { + JsonObject(it + mapOf("key" to JsonPrimitive(groupKey ?: 0))) + } else { + it + } + } + jsonObject = JsonObject(mapOf("groups" to JsonArray(newGroups)) + jsonObject) + } + if (jsonObject == initialGroup?.cacheJsonObject) { toast("规则无变动") return } if (groupKey != null) { - val newGroup = try { + var newGroup = try { if (appId != null) { if (jsonObject["groups"] is JsonArray) { val id = jsonObject["id"] ?: error("缺少id") @@ -83,7 +97,7 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { } RawSubscription.parseApp(jsonObject).let { newApp -> if (newApp.groups.isEmpty()) { - error("至少输入一个规则组") + error("至少输入一个规则") } newApp.groups.first() } @@ -99,7 +113,15 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { } newGroup.errorDesc?.let(::error) if (newGroup.key != groupKey) { - error("不能更改规则组的key") + // 自动修正 key 与原来一致 + newGroup = when (newGroup) { + is RawSubscription.RawAppGroup -> newGroup.copy(key = groupKey) + is RawSubscription.RawGlobalGroup -> newGroup.copy(key = groupKey) + } + } + if (newGroup == initialGroup) { + toast("规则无变动") + return } val newSubs = if (appId != null) { newGroup as RawSubscription.RawAppGroup @@ -124,10 +146,10 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { updateSubscription(newSubs) } else { if (isAddAnyApp) { - val newApp = try { + var newApp = try { RawSubscription.parseApp(jsonObject).apply { if (groups.isEmpty()) { - error("至少输入一个规则组") + error("至少输入一个规则") } } } catch (e: Exception) { @@ -139,6 +161,17 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { newApp.groups.forEach { g -> checkGroupKeyName(oldApp.groups, g) } + // 自动修正 key 与原来不重复 + val usedKeys = oldApp.groups.map { it.key }.toHashSet() + newApp = newApp.copy(groups = newApp.groups.map { g -> + if (g.key in usedKeys) { + g.copy(key = usedKeys.max() + 1).also { + usedKeys.add(it.key) + } + } else { + g + } + }) } val newSubs = subs.copy(apps = subs.apps.toMutableList().apply { val i = indexOfFirst { a -> a.id == newApp.id } @@ -155,7 +188,7 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { updateSubscription(newSubs) } else if (appId != null) { // add specified app group - val newGroups = try { + var newGroups = try { if (jsonObject["groups"] is JsonArray) { val id = jsonObject["id"] ?: error("缺少id") if (!(id is JsonPrimitive && id.isString && id.content == appId)) { @@ -163,7 +196,7 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { } RawSubscription.parseApp(jsonObject).apply { if (groups.isEmpty()) { - error("至少输入一个规则组") + error("至少输入一个规则") } }.groups } else { @@ -178,6 +211,17 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { checkGroupKeyName(oldApp.groups, g) g.errorDesc?.let { error(it) } } + // 自动修正 key 与原来不重复 + val usedKeys = oldApp.groups.map { it.key }.toHashSet() + newGroups = newGroups.map { g -> + if (g.key in usedKeys) { + g.copy(key = usedKeys.max() + 1).also { + usedKeys.add(it.key) + } + } else { + g + } + } val newSubs = subs.copy(apps = subs.apps.toMutableList().apply { val newApp = oldApp.copy(groups = oldApp.groups + newGroups) val i = indexOfFirst { a -> a.id == newApp.id } @@ -193,13 +237,16 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { updateSubscription(newSubs) } else { // add global group - val newGroup = try { + var newGroup = try { RawSubscription.parseGlobalGroup(jsonObject) } catch (e: Exception) { LogUtils.d(e) error("非法规则\n${e.message}") } checkGroupKeyName(subs.globalGroups, newGroup) + if (subs.globalGroups.any { it.key == newGroup.key }) { + newGroup = newGroup.copy(key = subs.globalGroups.maxOf { it.key } + 1) + } updateSubscription( subs.copy( globalGroups = subs.globalGroups + newGroup @@ -213,6 +260,10 @@ class UpsertRuleGroupVm(stateHandle: SavedStateHandle) : ViewModel() { toast("添加成功") } } + + init { + addCloseable { clearJson5TransformationCache() } + } } private fun checkGroupKeyName( @@ -220,9 +271,6 @@ private fun checkGroupKeyName( newGroup: RawSubscription.RawGroupProps ) { if (groups.any { it.name == newGroup.name }) { - error("已存在同名「${newGroup.name}」规则组") - } - if (groups.any { it.key == newGroup.key }) { - error("已存在同 key=${newGroup.key} 规则组") + error("已存在同名「${newGroup.name}」规则") } } 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..268f39388e 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 @@ -10,23 +9,12 @@ 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.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 @@ -34,15 +22,12 @@ 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 androidx.navigation3.runtime.NavKey import com.kevinnzou.web.AccompanistWebViewClient import com.kevinnzou.web.LoadingState import com.kevinnzou.web.WebView import com.kevinnzou.web.rememberWebViewState -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph import io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.Dispatchers @@ -51,51 +36,44 @@ 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.local.LocalMainViewModel -import li.songe.gkd.ui.local.LocalNavController -import li.songe.gkd.ui.style.ProfileTransitions +import li.songe.gkd.ui.share.LocalMainViewModel +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.LogUtils import li.songe.gkd.util.client import li.songe.gkd.util.copyText import li.songe.gkd.util.openUri import li.songe.gkd.util.throttle +@Serializable +data class WebViewRoute(val initUrl: String) : NavKey -@Destination(style = ProfileTransitions::class) @Composable -fun WebViewPage( - initUrl: String, -) { +fun WebViewPage(route: WebViewRoute) { + val initUrl = route.initUrl val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current val webViewState = rememberWebViewState(url = initUrl) val webViewClient = remember { GkdWebViewClient() } val webView = remember { Value(null) } Scaffold(modifier = Modifier, topBar = { - TopAppBar( + PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { - IconButton(onClick = throttle { navController.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.ArrowBack, + onClick = { mainVm.popPage() }, + ) }, 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( @@ -108,24 +86,16 @@ fun WebViewPage( } }, actions = { - if (chromeVersion > 0 && chromeVersion < MINI_CHROME_VERSION) { - IconButton(onClick = throttle { + if (chromeVersion in 1..= Build.VERSION_CODES.TIRAMISU) { + if (AndroidTarget.TIRAMISU) { setAlgorithmicDarkeningAllowed(false) } } @@ -191,10 +161,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 @@ -203,10 +176,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() 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/AnimatedIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt index 63795aed5b..124d605587 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,17 +5,20 @@ 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 import androidx.compose.ui.graphics.Color +import li.songe.gkd.R @Composable -fun AnimatedIcon( - modifier: Modifier = Modifier, +private fun AnimatedIcon( + modifier: Modifier, @DrawableRes id: Int, - atEnd: Boolean = false, - tint: Color = LocalContentColor.current, + atEnd: Boolean, + tint: Color, + contentDescription: String?, ) { val animation = AnimatedImageVector.animatedVectorResource(id) val painter = rememberAnimatedVectorPainter( @@ -25,7 +28,36 @@ fun AnimatedIcon( Icon( modifier = modifier, painter = painter, - contentDescription = null, + contentDescription = contentDescription, tint = tint, ) -} \ No newline at end of file +} + +@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, + ) { + AnimatedIcon( + id = id, + atEnd = atEnd, + modifier = modifier, + tint = tint, + contentDescription = contentDescription, + ) + } +} + +private fun getIconDesc(@DrawableRes id: Int, atEnd: Boolean): String? = when (id) { + R.drawable.ic_anim_search_close -> if (atEnd) "关闭搜索" else "打开搜索" + else -> null +} 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/AnimationFloatingActionButton.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt index 958079c736..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 @@ -13,17 +13,24 @@ 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 +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import li.songe.gkd.util.throttle private const val elevationDurationMillis = 50 @Composable fun AnimationFloatingActionButton( - modifier: Modifier = Modifier, 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() } } @@ -57,14 +64,27 @@ fun AnimationFloatingActionButton( } } if (innerVisible) { - FloatingActionButton( - modifier = modifier.graphicsLayer( - alpha = percent.value, - translationX = (1f - percent.value) * maxTranslationX - ), - elevation = FloatingActionButtonDefaults.elevation(defaultElevation = (defaultElevation.value * 6f).dp), - onClick = onClick, - content = content, - ) + TooltipIconButtonBox(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) + }, + ) + } } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppCheckBoxCard.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppCheckBoxCard.kt new file mode 100644 index 0000000000..51ef34f599 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppCheckBoxCard.kt @@ -0,0 +1,64 @@ +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.Row +import androidx.compose.material3.MaterialTheme +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.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 li.songe.gkd.data.AppInfo +import li.songe.gkd.ui.style.appItemPadding +import li.songe.gkd.util.throttle + +@Composable +fun AppCheckBoxCard( + appInfo: AppInfo, + checked: Boolean, + onCheckedChange: (() -> Unit), +) { + Row( + modifier = Modifier + .clickable(onClick = throttle(onCheckedChange)) + .clearAndSetSemantics { + contentDescription = "应用:${appInfo.name}" + stateDescription = if (checked) "已加入名单" else "未加入名单" + onClick( + label = if (checked) "从名单中移除" else "加入名单", + action = null + ) + } + .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 = checked, + ) + } +} \ No newline at end of file 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..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,30 +2,19 @@ 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 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( @@ -34,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 507bb30652..e5bf9a6ed3 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,68 +1,120 @@ 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.material.icons.Icons -import androidx.compose.material.icons.outlined.VerifiedUser -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +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.Color +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 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 -import li.songe.gkd.util.throttle -import li.songe.gkd.util.toast +import li.songe.gkd.shizuku.currentUserId +import li.songe.gkd.util.appInfoMapFlow @Composable fun AppNameText( + modifier: Modifier = Modifier, appId: String? = null, appInfo: AppInfo? = null, fallbackName: String? = null, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, ) { - val info = appInfo ?: appInfoCacheFlow.collectAsState().value[appId] - Row { - if (info?.isSystem == true) { - val fontSizeDp = LocalDensity.current.run { - LocalTextStyle.current.fontSize.toDp() + 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 { 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, + overflow = TextOverflow.Ellipsis, + textDecoration = textDecoration, + style = style, + color = color, + ) + } 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 contentColor = style.color.takeOrElse { LocalContentColor.current } + remember(style, contentColor) { + mapOf( + "icon" to InlineTextContent( + placeholder = Placeholder( + width = style.fontSize, + height = style.lineHeight, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + PerfIcon( + imageVector = PerfIcon.VerifiedUser, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .fillMaxSize(), + 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"), + modifier = modifier, + text = annotatedString, + inlineContent = inlineContent, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, + textDecoration = textDecoration, + style = style, + color = color, ) - 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 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..4514f5a1d4 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,45 +1,28 @@ package li.songe.gkd.ui.component -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.FlowRow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import li.songe.gkd.util.throttle @Composable fun AuthButtonGroup( - onClickShizuku: ()-> Unit, - onClickManual: ()-> Unit, - onClickRoot: ()-> Unit, - -){ - Row( - modifier = Modifier - .padding(4.dp, 0.dp) - .fillMaxWidth(), + buttons: List Unit>>, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier, ) { - 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/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..fe72d1e16f --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt @@ -0,0 +1,80 @@ +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 +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.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +private fun CustomIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + onClickLabel: String? = null, + 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, + onClickLabel = onClickLabel, + 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) + } +} + +@Composable +fun PerfCustomIconButton( + onClick: () -> Unit, + size: Dp, + iconSize: Dp, + onClickLabel: String? = null, + @DrawableRes id: Int, + contentDescription: String? = null, + tint: Color = LocalContentColor.current, +) = TooltipIconButtonBox( + contentDescription = contentDescription, +) { + CustomIconButton( + size = size, + onClickLabel = onClickLabel, + onClick = onClick, + ) { + PerfIcon( + modifier = Modifier.size(iconSize), + id = id, + contentDescription = contentDescription, + tint = tint, + ) + } +} 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/component/DialogOptions.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/DialogOptions.kt index 38c9c24bf3..5e72e5c755 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/DialogOptions.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/DialogOptions.kt @@ -10,10 +10,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine import li.songe.gkd.util.stopCoroutine import li.songe.gkd.util.throttle import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine data class AlertDialogOptions( val title: @Composable (() -> Unit)? = null, @@ -111,9 +111,11 @@ suspend fun MutableStateFlow.getResult( dismissText: String = DEFAULT_DISMISS_TEXT, error: Boolean = false, ): Boolean { - return suspendCoroutine { s -> + return suspendCancellableCoroutine { s -> val dismiss = { - s.resume(false) + if (s.isActive) { + s.resume(false) + } this.value = null } updateDialogOptions( @@ -123,7 +125,9 @@ suspend fun MutableStateFlow.getResult( onDismissRequest = if (dismissRequest) dismiss else ({}), confirmText = confirmText, confirmAction = { - s.resume(true) + if (s.isActive) { + s.resume(true) + } this.value = null }, dismissText = dismissText, 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 20485377fc..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.local.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/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/FullscreenDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt new file mode 100644 index 0000000000..7878e52593 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt @@ -0,0 +1,59 @@ +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, + windowTitle = "全局弹窗", + ) +) { + 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..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,10 +1,8 @@ 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 -import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text @@ -19,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( @@ -33,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) { @@ -46,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( @@ -55,14 +50,9 @@ fun GroupNameText( placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { - Icon( + PerfIcon( imageVector = SportsBasketball, - modifier = Modifier - .runIf(!clickDisabled) { - clickable(onClick = throttle { toast("当前是全局规则组") }) - } - .fillMaxSize(), - contentDescription = null, + modifier = Modifier.fillMaxSize(), tint = textColor ) } @@ -80,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/Hooks.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt index b14f26a8cd..e4a4cf5dbb 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 @@ -1,19 +1,22 @@ package li.songe.gkd.ui.component import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.key +import androidx.compose.runtime.derivedStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -21,14 +24,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.map -import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.mapState +import li.songe.gkd.util.subsMapFlow @Composable fun useSubs(subsId: Long?): RawSubscription? { val scope = rememberCoroutineScope() - return remember(subsId) { subsIdToRawFlow.map(scope) { it[subsId] } }.collectAsState().value + return remember(subsId) { subsMapFlow.mapState(scope) { it[subsId] } }.collectAsState().value } @Composable @@ -51,27 +55,95 @@ 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 +private fun getCompatStateValue(v: Any?): Any? = when (v) { + is StateFlow<*> -> v.collectAsState().value + is androidx.compose.runtime.State<*> -> v.value + else -> v +} + +@Composable +fun useListScrollState( + v1: Any?, + v2: Any? = null, + v3: Any? = null, + canScroll: () -> Boolean = { true }, +): Pair { + val x1 = getCompatStateValue(v1) + val x2 = getCompatStateValue(v2) + val x3 = getCompatStateValue(v3) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( + state = rememberSaveable(x1, x2, x3, saver = TopAppBarState.Saver) { + TopAppBarState(-Float.MAX_VALUE, 0f, 0f) + }, + canScroll = canScroll + ) + val scrollState = rememberSaveable(x1, x2, x3, saver = LazyListState.Saver) { + LazyListState(0, 0) + } + return scrollBehavior to scrollState +} + +@Composable +fun usePinnedScrollBehaviorState(v1: Any?): Pair { + val x1 = getCompatStateValue(v1) + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + state = rememberSaveable(x1, saver = TopAppBarState.Saver) { + TopAppBarState(-Float.MAX_VALUE, 0f, 0f) + }, + ) + val scrollState = rememberSaveable(x1, saver = LazyListState.Saver) { + LazyListState(0, 0) + } + return scrollBehavior to scrollState } @Composable -fun Modifier.autoFocus() = focusRequester(useAutoFocus()) +fun useScrollBehaviorState(v1: Any?): Pair { + val x1 = getCompatStateValue(v1) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( + state = rememberSaveable(x1, saver = TopAppBarState.Saver) { + TopAppBarState(-Float.MAX_VALUE, 0f, 0f) + }, + ) + val scrollState = rememberSaveable(x1, saver = ScrollState.Saver) { ScrollState(initial = 0) } + return scrollBehavior to scrollState +} @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 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/InnerDisableSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt index fb4609e1fd..e4cfe27a71 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,13 +2,14 @@ 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 li.songe.gkd.ui.local.LocalMainViewModel +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 @Composable @@ -22,7 +23,7 @@ fun InnerDisableSwitch( if (valid) { mainVm.dialogFlow.updateDialogOptions( title = "内置禁用", - text = "此规则组已经在内部配置对当前应用的禁用, 因此无法手动开启规则组\n\n提示: 这种情况一般在此全局规则无法适配/跳过适配/单独适配当前应用时出现", + text = "此规则已经在内部配置对当前应用的禁用,就算强制开启规则也是无意义或不生效的\n\n提示: 这种情况一般在此全局规则无法适配/跳过适配/单独适配当前应用时出现", ) } else { mainVm.dialogFlow.updateDialogOptions( @@ -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 7e2bf2bf43..54b54765f1 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 @@ -18,29 +14,31 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.window.DialogProperties -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.flow.MutableStateFlow -import li.songe.gkd.ui.local.LocalMainViewModel +import kotlinx.coroutines.suspendCancellableCoroutine +import li.songe.gkd.ui.WebViewRoute +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 import li.songe.gkd.util.toast -import kotlin.coroutines.Continuation import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class InputSubsLinkOption { private val showFlow = MutableStateFlow(false) private val valueFlow = MutableStateFlow("") private val initValueFlow = MutableStateFlow("") - private var continuation: Continuation? = null + private var continuation: CancellableContinuation? = null private fun resume(value: String?) { showFlow.value = false valueFlow.value = "" initValueFlow.value = "" - continuation?.resume(value) + if (continuation?.isActive == true) { + continuation?.resume(value) + } continuation = null } @@ -69,7 +67,7 @@ class InputSubsLinkOption { initValueFlow.value = initValue valueFlow.value = initValue showFlow.value = true - return suspendCoroutine { + return suspendCancellableCoroutine { continuation = it } } @@ -90,15 +88,13 @@ 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, + contentDescription = "订阅帮助", + onClick = throttle { + cancel() + mainVm.navigatePage(WebViewRoute(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 3ac2c50d7f..17cfa4348c 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,21 +10,17 @@ 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 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 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.WebViewRoute +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 @@ -37,12 +33,9 @@ fun ManualAuthDialog( ) { if (show) { val mainVm = LocalMainViewModel.current - val adbCommandText = remember(commandText) { - "adb shell \"$commandText\"" - } 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 中运行如下命令") @@ -56,7 +49,7 @@ fun ManualAuthDialog( .fillMaxWidth() ) { Text( - text = adbCommandText, + text = commandText, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) @@ -64,16 +57,15 @@ fun ManualAuthDialog( style = MaterialTheme.typography.bodySmall, ) } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { - copyText(adbCommandText) + copyText(commandText) }) .padding(4.dp) .size(20.dp), - imageVector = Icons.Outlined.ContentCopy, - contentDescription = null, + imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } @@ -82,7 +74,7 @@ fun ManualAuthDialog( modifier = Modifier .clickable(onClick = throttle { onUpdateShow(false) - mainVm.navigatePage(WebViewPageDestination(initUrl = ShortUrlSet.URL3)) + mainVm.navigatePage(WebViewRoute(initUrl = ShortUrlSet.URL3)) }), text = "运行后授权失败?", style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/MenuExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/MenuExt.kt new file mode 100644 index 0000000000..f09d4a4c18 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/MenuExt.kt @@ -0,0 +1,87 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import li.songe.gkd.util.throttle + +@Composable +inline fun MenuGroupCard(inTop: Boolean = false, title: String, content: @Composable () -> Unit) { + Text( + text = title, + modifier = Modifier + .padding(MenuDefaults.DropdownMenuItemContentPadding) + .padding(top = if (inTop) 0.dp else 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + content() +} + +@Composable +fun MenuItemCheckbox( + text: String, + checked: Boolean, + onClick: () -> Unit, + enabled: Boolean = true, +) { + val actualOnClick = throttle(onClick) + DropdownMenuItem( + text = { Text(text = text) }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = { actualOnClick() }, + enabled = enabled, + ) + }, + onClick = actualOnClick, + enabled = enabled, + ) +} + +@Composable +fun MenuItemCheckbox( + text: String, + stateFlow: MutableStateFlow, + enabled: Boolean = true, +) = MenuItemCheckbox( + text = text, + checked = stateFlow.collectAsState().value, + onClick = { stateFlow.update { !it } }, + enabled = enabled, +) + +@Composable +fun MenuItemRadioButton( + text: String, + selected: Boolean, + onClick: () -> Unit, + enabled: Boolean = true, +) { + val actualOnClick = throttle(onClick) + DropdownMenuItem( + text = { + Text(text = text) + }, + trailingIcon = { + RadioButton( + selected = selected, + onClick = actualOnClick, + enabled = enabled, + ) + }, + onClick = actualOnClick, + enabled = enabled, + ) +} \ No newline at end of file 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..850e84a082 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt @@ -0,0 +1,83 @@ +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, + indicatorSize: Int? = 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, + ) + } + val actualSize = indicatorSize ?: text.length + if (actualSize > 0 && text.isNotEmpty()) { + Text( + text = actualSize.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..202fb9fe53 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt @@ -0,0 +1,213 @@ +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.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 +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, + contentDescription: String? = getIconDefaultDesc(imageVector), +) = Icon( + imageVector = imageVector, + modifier = modifier, + contentDescription = contentDescription, + tint = tint +) + +@Composable +fun PerfIconButton( + imageVector: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + contentDescription: String? = getIconDefaultDesc(imageVector), + onClickLabel: String? = null, +) = TooltipIconButtonBox( + contentDescription = contentDescription, +) { + IconButton( + modifier = modifier.semantics { + if (onClickLabel != null) { + this.onClick(label = onClickLabel, action = null) + } + }, + enabled = enabled, + onClick = onClick, + colors = colors, + ) { + PerfIcon( + imageVector = imageVector, + contentDescription = contentDescription, + ) + } +} + +@Composable +fun PerfIcon( + @DrawableRes id: Int, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + contentDescription: String? = null, +) = Icon( + painter = painterResource(id), + modifier = modifier, + contentDescription = contentDescription, + tint = tint +) + +@Composable +fun PerfIconButton( + @DrawableRes id: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + contentDescription: String? = null, + onClickLabel: String? = null, +) = TooltipIconButtonBox( + 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) { + 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 -> "复制文本" + PerfIcon.MoreVert -> "更多操作" + else -> null +} + +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 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 + 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/component/PerfSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt new file mode 100644 index 0000000000..f00aec0832 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt @@ -0,0 +1,35 @@ +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 androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +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.semantics { + stateDescription = if (checked) "已开启" else "已关闭" + }, + 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..60cde2ed69 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt @@ -0,0 +1,53 @@ +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.runtime.remember +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, + 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) { + TopAppBar( + title = title, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + expandedHeight = expandedHeight, + windowInsets = (LocalActivity.current as MainActivity).topBarWindowInsets, + colors = colors, + scrollBehavior = actualScrollBehavior, + ) + } +} \ 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 5aa878706f..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,16 +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.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 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 @@ -24,57 +19,40 @@ 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.style.EmptyHeight +import li.songe.gkd.ui.share.LocalMainViewModel 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() { - 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, +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) + }) ) { - Spacer(modifier = Modifier.height(EmptyHeight / 2)) - CircularProgressIndicator() - } - } else if (!canQueryPkg || mayQueryPkgNoAccess) { - val mainVm = LocalMainViewModel.current - val context = LocalActivity.current as MainActivity - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = Icons.Default.WarningAmber, - contentDescription = null, - 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 / 2)) - + Text(text = "申请权限") } } } \ 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 887749bbe6..7427d15e15 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 @@ -3,30 +3,22 @@ package li.songe.gkd.ui.component 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.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.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +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.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 @@ -49,11 +41,13 @@ 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.ui.share.noRippleClickable 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 @@ -63,9 +57,7 @@ fun RuleGroupCard( appId: String?, group: RawSubscription.RawGroupProps, subsConfig: SubsConfig?, - category: RawSubscription.RawCategory?, categoryConfig: CategoryConfig?, - showBottom: Boolean, focusGroupFlow: MutableStateFlow?>? = null, isSelectedMode: Boolean = false, isSelected: Boolean = false, @@ -73,6 +65,7 @@ fun RuleGroupCard( onSelectedChange: () -> Unit = {}, ) { val mainVm = LocalMainViewModel.current + val category = subs.getCategory(group.name) val inGlobalAppPage = appId != null && group is RawSubscription.RawGlobalGroup @@ -99,19 +92,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) { @@ -162,8 +159,6 @@ fun RuleGroupCard( pageAppId = appId, ) }) - val horizontal = 8.dp - val vertical = 8.dp val containerColor = animateColorAsState( if (isSelected || highlighted) { MaterialTheme.colorScheme.primaryContainer @@ -174,67 +169,75 @@ fun RuleGroupCard( ) Card( modifier = modifier - .padding( - start = 8.dp, - end = 8.dp, - bottom = if (showBottom) 4.dp else 0.dp - ) - .combinedClickable(onClick = onClick, onLongClick = onLongClick), + .padding(horizontal = 8.dp) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onClickLabel = "打开规则详情弹窗", + onLongClickLabel = "进入多选模式" + ), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( containerColor = containerColor.value ), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = horizontal, top = vertical, bottom = vertical), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + val canRest = if (inGlobalAppPage) { + excludeData.appIds.contains(appId) + } else { + subsConfig?.enable != null + } + val hasExcludeActivity = if (inGlobalAppPage) { + checked != null && excludeData.activityIds.any { it.first == appId } + } else { + excludeData.activityIds.isNotEmpty() + } + Box { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - 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 + .padding(8.dp) + .weight(1f), + ) { + GroupNameText( + modifier = Modifier.fillMaxWidth(), + text = group.name, + style = MaterialTheme.typography.bodyLarge, + isGlobal = group is RawSubscription.RawGlobalGroup, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + 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, - ) + val switchModifier = Modifier + .noRippleClickable(onClick = {}) + .padding(8.dp) + .graphicsLayer( + alpha = 0.5f + (1 - 0.5f) * percent.value, + ) if (!group.valid) { InnerDisableSwitch( modifier = switchModifier, @@ -242,10 +245,17 @@ 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) + onCheckedChange = if (isSelectedMode) null else onCheckedChange, + thumbContent = if (canRest) ({ + PerfIcon( + imageVector = ResetSettings, + modifier = Modifier.size(8.dp) + ) + }) else null, ) } else { InnerDisableSwitch( @@ -253,70 +263,102 @@ fun RuleGroupCard( isSelectedMode = isSelectedMode, ) } - val visible = if (inGlobalAppPage) { - excludeData != null && excludeData.appIds.contains(appId) - } else { - subsConfig?.enable != null - } - CardFlagBar(visible = visible, width = horizontal) + } + if (hasExcludeActivity) { + PerfIcon( + imageVector = PerfIcon.Block, + contentDescription = "此规则已排除部分页面", + 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) + ) } } } } +fun getActualGroupChecked( + subs: RawSubscription, + group: RawSubscription.RawGroupProps, + appId: String?, + subsConfig: SubsConfig?, + categoryConfig: CategoryConfig?, +): Boolean { + if (!group.valid) return false + val inGlobalAppPage = appId != null && group is RawSubscription.RawGlobalGroup + return if (inGlobalAppPage) { + getGlobalGroupChecked( + subs, + ExcludeData.parse(subsConfig?.exclude), + group, + appId, + ) + } else { + getGroupEnable( + group, + subsConfig, + subs.getCategory(group.name), + categoryConfig, + ) + } ?: false +} + @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, + contentDescription = "批量关闭规则", + 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, + contentDescription = "批量打开规则", + 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, + contentDescription = "批量重置规则开关", + 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 66f736b049..3ea700152d 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,37 +13,28 @@ 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 import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 -import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination -import com.ramcosta.composedestinations.utils.currentDestinationAsState import kotlinx.coroutines.delay import li.songe.gkd.data.RawSubscription +import li.songe.gkd.ui.ImagePreviewItem +import li.songe.gkd.ui.ImagePreviewRoute +import li.songe.gkd.ui.SubsAppGroupListRoute +import li.songe.gkd.ui.SubsGlobalGroupListRoute 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.style.getJson5AnnotatedString import li.songe.gkd.util.copyText import li.songe.gkd.util.throttle @@ -59,17 +50,15 @@ fun RuleGroupDialog( onClickResetSwitch: (() -> Unit)?, onClickDelete: () -> Unit = {} ) { - val mainVm = LocalMainViewModel.current - val navController = LocalNavController.current + val mainVm = LocalMainViewModel.current AlertDialog( onDismissRequest = onDismissRequest, - title = { Text(text = "规则组详情") }, + title = { Text(text = "规则详情") }, text = { Box( modifier = Modifier.fillMaxWidth() ) { val maxHeight = 300.dp -// val showMaxLine = ceil(maxHeight.value / textStyle.lineHeight.value).toInt() Column( modifier = Modifier .align(Alignment.TopStart) @@ -78,6 +67,9 @@ fun RuleGroupDialog( .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) .verticalScroll(rememberScrollState()) + .clearAndSetSemantics { + contentDescription = "规则内容" + } ) { SelectionContainer { val textState = remember { @@ -108,7 +100,7 @@ fun RuleGroupDialog( ) } } - Icon( + PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { @@ -116,8 +108,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( @@ -135,75 +126,104 @@ fun RuleGroupDialog( }, confirmButton = { Row { - val currentDestination by navController.currentDestinationAsState() - val (direction, destination) = remember(subs.id, appId, group.key) { + val currentRoute = mainVm.topRoute + val targetRoute = remember(subs.id, appId, group.key) { if (group is RawSubscription.RawGlobalGroup) { - SubsGlobalGroupListPageDestination( + SubsGlobalGroupListRoute( subsItemId = subs.id, focusGroupKey = group.key - ) to SubsGlobalGroupListPageDestination + ) } else { - SubsAppGroupListPageDestination( + SubsAppGroupListRoute( subsItemId = subs.id, appId = appId.toString(), focusGroupKey = group.key - ) to SubsAppGroupListPageDestination + ) } } - if (currentDestination?.baseRoute != destination.baseRoute) { - IconButton(onClick = throttle { + if (targetRoute::class != currentRoute::class) { + PerfIconButton(imageVector = PerfIcon.ArrowForward, onClick = throttle { onDismissRequest() - navController.navigate(direction.route) - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null - ) - } + mainVm.navigatePage(targetRoute) + }) } if (group.allExampleUrls.isNotEmpty()) { - IconButton(onClick = throttle { + PerfIconButton(imageVector = PerfIcon.Image, onClick = throttle { onDismissRequest() mainVm.navigatePage( - ImagePreviewPageDestination( + ImagePreviewRoute( title = group.name, - uris = group.allExampleUrls.toTypedArray() + items = buildRuleGroupPreviewItems(group), ) ) - }) { - 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.Block, + onClickLabel = "编辑规则排除名单", + onClick = throttle(onClickEditExclude), + ) AnimatedVisibility( visible = onClickResetSwitch != null, ) { - IconButton(onClick = throttle(onClickResetSwitch ?: {})) { - Icon( - imageVector = ResetSettings, - contentDescription = null - ) - } + PerfIconButton( + imageVector = ResetSettings, + onClickLabel = "重置开关状态至默认值", + onClick = throttle(onClickResetSwitch ?: {}), + ) } if (subs.isLocal) { - IconButton(onClick = throttle(onClickDelete)) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.Delete, + onClick = throttle(onClickDelete), + ) } } }, ) } + +// 规则组示例图需要保留“图片属于哪个子规则”的上下文,预览页才能显示更具体的标题。 +private fun buildRuleGroupPreviewItems(group: RawSubscription.RawGroupProps): List { + val uriTitlesMap = linkedMapOf>() + + fun addPreviewItem(uri: String, title: String?) { + val titles = uriTitlesMap.getOrPut(uri) { linkedSetOf() } + title?.takeIf { it.isNotBlank() }?.let(titles::add) + } + + group.exampleUrls.orEmpty().forEach { uri -> + addPreviewItem( + uri = uri, + title = group.name, + ) + } + group.rules.forEach { rule -> + val ruleTitle = buildRulePreviewTitle(rule) + rule.exampleUrls.orEmpty().forEach { uri -> + addPreviewItem( + uri = uri, + title = ruleTitle, + ) + } + } + + return uriTitlesMap.map { (uri, titles) -> + ImagePreviewItem( + uri = uri, + titles = titles.toList(), + ) + } +} + +private fun buildRulePreviewTitle(rule: RawSubscription.RawRuleProps): String? { + return when { + !rule.name.isNullOrBlank() -> rule.name + rule.key != null -> "key=${rule.key}" + !rule.preKeys.isNullOrEmpty() -> "preKeys=${(rule.preKeys as Iterable).joinToString(",")}" + else -> null + } +} 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..597045563f 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,16 @@ package li.songe.gkd.ui.component +import androidx.activity.compose.BackHandler +import androidx.compose.material3.Scaffold 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 @@ -19,11 +22,15 @@ 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.SubsGlobalGroupExcludeRoute +import li.songe.gkd.ui.UpsertRuleGroupRoute import li.songe.gkd.ui.getGlobalGroupChecked -import li.songe.gkd.ui.local.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,14 +60,14 @@ 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 { subs.globalGroups }?.find { it.key == groupKey } ?: error("require group") - val category = subs.groupToCategoryMap[group] ?: return null - return DbSet.categoryConfigDao.queryCategoryConfig(subsId, category.key) + val category = subs.getCategory(group.name) ?: return null + return DbSet.categoryConfigDao.queryCategoryConfig(subsId, category.key).first() } } @@ -88,7 +95,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 } @@ -109,7 +116,7 @@ suspend fun batchUpdateGroupEnable( val oldEnable = getGroupEnable( targetGroup, subsConfig, - subscription.groupToCategoryMap[targetGroup], + subscription.getCategory(targetGroup.name), categoryConfig ) // app rule @@ -123,7 +130,7 @@ suspend fun batchUpdateGroupEnable( val newEnable = getGroupEnable( targetGroup, newSubsConfig, - subscription.groupToCategoryMap[targetGroup], + subscription.getCategory(targetGroup.name), categoryConfig ) if (enable == newEnable && oldEnable == newEnable) { @@ -198,29 +205,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,11 +268,11 @@ class RuleGroupState( subs = showSubs, group = showGroup, appId = showGroupState.appId, - onDismissRequest = dismissShow, + onDismissRequest = dismissGroupShow, onClickEdit = { - dismissShow() - vm.navigatePage( - UpsertRuleGroupPageDestination( + dismissGroupShow() + mainVm.navigatePage( + UpsertRuleGroupRoute( subsId = showGroupState.subsId, groupKey = showGroupState.groupKey, appId = showGroupState.appId, @@ -245,10 +280,10 @@ class RuleGroupState( ) }, onClickEditExclude = { - dismissShow() + dismissGroupShow() if (showGroupState.appId == null) { mainVm.navigatePage( - SubsGlobalGroupExcludePageDestination( + SubsGlobalGroupExcludeRoute( showGroupState.subsId, showGroupState.groupKey ) @@ -261,7 +296,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( @@ -269,32 +304,32 @@ class RuleGroupState( ).stringify() ) ) - toast("已重置局部开关至初始状态") + toast("已重置局部开关至默认值") } } else { null } } else { subsConfig.enable?.let { - vm.viewModelScope.launchAsFn { + mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update(subsConfig.copy(enable = null)) - toast("已重置开关至初始状态") + toast("已重置开关至默认值") } } } } else { subsConfig.enable?.let { - vm.viewModelScope.launchAsFn { + mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update(subsConfig.copy(enable = null)) - toast("已重置开关至初始状态") + toast("已重置开关至默认值") } } } }, - onClickDelete = vm.viewModelScope.launchAsFn { - dismissShow() - val r = vm.dialogFlow.getResult( - title = "删除规则组", + onClickDelete = mainVm.viewModelScope.launchAsFn { + dismissGroupShow() + val r = mainVm.dialogFlow.getResult( + title = "删除规则", text = "确定删除 ${showGroup.name} ?", error = true, ) @@ -337,14 +372,72 @@ 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 - ) + 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 { + 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 = { + TowLineText( + title = excludeGroup.name, + subtitle = "编辑禁用", + ) + }, + 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/ScaffoldDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ScaffoldDialog.kt new file mode 100644 index 0000000000..44565a5db3 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ScaffoldDialog.kt @@ -0,0 +1,43 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ScaffoldDialog( + title: String, + onClose: () -> Unit, + content: @Composable (ColumnScope.() -> Unit) +) = FullscreenDialog(onDismissRequest = onClose) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + PerfTopAppBar( + title = { Text(text = title) }, + actions = { + PerfIconButton( + imageVector = PerfIcon.Close, + onClick = onClose, + ) + }, + ) + }, + content = { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(it), + 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 65fc4bdfa1..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 @@ -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 @@ -25,15 +22,20 @@ fun SettingItem( title: String, subtitle: String? = null, suffix: String? = null, + suffixUnderline: Boolean = false, onSuffixClick: (() -> Unit)? = null, - imageVector: ImageVector? = Icons.AutoMirrored.Filled.KeyboardArrowRight, + 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 } @@ -58,7 +60,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), @@ -75,7 +83,10 @@ fun SettingItem( } } if (imageVector != null) { - Icon(imageVector = imageVector, contentDescription = title) + PerfIcon( + imageVector = imageVector, + contentDescription = null, + ) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/ShareDataDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt similarity index 54% rename from app/src/main/kotlin/li/songe/gkd/ui/component/ShareDataDialog.kt rename to app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt index 26fdfd797f..93e9bced15 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/ShareDataDialog.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/ShareLogDlg.kt @@ -8,30 +8,30 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.MainActivity -import li.songe.gkd.data.exportData +import li.songe.gkd.ui.share.LocalMainViewModel +import li.songe.gkd.ui.share.asMutableState +import li.songe.gkd.util.buildLogFile import li.songe.gkd.util.launchTry import li.songe.gkd.util.saveFileToDownloads import li.songe.gkd.util.shareFile import li.songe.gkd.util.throttle @Composable -fun ShareDataDialog( - vm: ViewModel, - showShareDataIdsFlow: MutableStateFlow?>, -) { - val showShareDataIds = showShareDataIdsFlow.collectAsState().value - if (showShareDataIds != null) { +fun ShareLogDlg(showShareLogDlgFlow: MutableStateFlow) { + var visible by showShareLogDlgFlow.asMutableState() + if (visible) { + val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity - Dialog(onDismissRequest = { showShareDataIdsFlow.value = null }) { + Dialog(onDismissRequest = { visible = false }) { Card( modifier = Modifier .fillMaxWidth() @@ -44,26 +44,36 @@ fun ShareDataDialog( Text( text = "分享到其他应用", modifier = Modifier .clickable(onClick = throttle { - showShareDataIdsFlow.value = null - vm.viewModelScope.launchTry(Dispatchers.IO) { - val file = exportData(showShareDataIds) - context.shareFile(file, "分享数据文件") + visible = false + mainVm.viewModelScope.launchTry(Dispatchers.IO) { + val logZipFile = buildLogFile() + context.shareFile(logZipFile, "分享日志文件") } }) .then(modifier) ) Text( - text = "保存到下载", - modifier = Modifier + text = "保存到下载", modifier = Modifier .clickable(onClick = throttle { - showShareDataIdsFlow.value = null - vm.viewModelScope.launchTry(Dispatchers.IO) { - val file = exportData(showShareDataIds) - context.saveFileToDownloads(file) + visible = false + mainVm.viewModelScope.launchTry(Dispatchers.IO) { + val logZipFile = buildLogFile() + context.saveFileToDownloads(logZipFile) } }) .then(modifier) ) + Text( + text = "生成链接(需科学上网)", + modifier = Modifier + .clickable(onClick = throttle { + visible = false + mainVm.uploadOptions.startTask( + getFile = { buildLogFile() } + ) + }) + .then(modifier) + ) } } } 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..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 @@ -4,59 +4,48 @@ 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 }, - onClick: (() -> Unit)? = null, - onValueChange: ((Boolean) -> Unit)? = null, + data: SubsAppInfoItem, + onClick: (() -> Unit), + onValueChange: ((Boolean) -> Unit), ) { + val rawApp = data.rawApp Row( modifier = Modifier - .clickable { - onClick?.invoke() - } + .clickable(onClick = onClick) .appItemPadding(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - AppIcon(appInfo = appInfo) - 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) { + 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, @@ -69,13 +58,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 9431009147..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 @@ -14,28 +14,33 @@ 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 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 +import androidx.lifecycle.viewmodel.compose.viewModel 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 @@ -49,18 +54,18 @@ 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.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() @@ -83,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( @@ -100,6 +115,9 @@ fun SubsItemCard( ) { if (subscription != null) { Text( + modifier = Modifier.semantics { + contentDescription = "订阅顺序:$index, 订阅名称 ${subscription.name}" + }, text = "$index. ${subscription.name}", maxLines = 1, softWrap = false, @@ -121,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, ) } @@ -170,23 +199,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 bcfd963647..0f152f3297 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 @@ -31,6 +22,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,18 +30,20 @@ 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 import androidx.lifecycle.viewModelScope -import com.ramcosta.composedestinations.generated.destinations.ActionLogPageDestination -import com.ramcosta.composedestinations.generated.destinations.SubsAppListPageDestination -import com.ramcosta.composedestinations.generated.destinations.SubsCategoryPageDestination -import com.ramcosta.composedestinations.generated.destinations.SubsGlobalGroupListPageDestination 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.ActionLogRoute +import li.songe.gkd.ui.SubsAppListRoute +import li.songe.gkd.ui.SubsCategoryRoute +import li.songe.gkd.ui.SubsGlobalGroupListRoute +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 @@ -57,8 +51,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 +73,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, @@ -101,8 +95,14 @@ fun SubsSheet( } } val scrollState = rememberScrollState() - LaunchedEffect(scrollState.value) { - swipeEnabled = scrollState.value == 0 + remember { + derivedStateOf { + scrollState.value == 0 + } + }.let { a -> + LaunchedEffect(a.value) { + swipeEnabled = a.value + } } ModalBottomSheet( onDismissRequest = { @@ -132,7 +132,10 @@ fun SubsSheet( ) if (subscription != null) { Column( - modifier = childModifier + modifier = childModifier.clearAndSetSemantics { + contentDescription = + "作者:${subscription.author ?: "未知"}, 版本号:v${subscription.version}, 更新时间:${subsItem.mtimeStr}" + } ) { Row( modifier = Modifier.fillMaxWidth(), @@ -190,10 +193,10 @@ 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)) + mainVm.navigatePage(SubsGlobalGroupListRoute(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, @@ -207,7 +210,7 @@ fun SubsSheet( style = MaterialTheme.typography.labelLarge, ) Text( - text = if (subscription.globalGroups.isNotEmpty()) "共 ${subscription.globalGroups.size} 全局规则组" else "暂无", + text = if (subscription.globalGroups.isNotEmpty()) "共 ${subscription.globalGroups.size} 全局规则" else "暂无", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.let { if (subscription.globalGroups.isEmpty()) { @@ -218,19 +221,18 @@ fun SubsSheet( }, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } } 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)) + mainVm.navigatePage(SubsAppListRoute(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, @@ -244,7 +246,7 @@ fun SubsSheet( style = MaterialTheme.typography.labelLarge, ) Text( - text = if (subscription.appGroups.isNotEmpty()) "共 ${subscription.apps.size} 应用 ${subscription.appGroups.size} 规则组" else "暂无", + text = if (subscription.appGroups.isNotEmpty()) "共 ${subscription.apps.size} 应用 ${subscription.appGroups.size} 规则" else "暂无", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.let { if (subscription.appGroups.isEmpty()) { @@ -255,9 +257,8 @@ fun SubsSheet( }, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } @@ -265,10 +266,10 @@ 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)) + mainVm.navigatePage(SubsCategoryRoute(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, @@ -293,16 +294,15 @@ fun SubsSheet( }, ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null + PerfIcon( + imageVector = PerfIcon.KeyboardArrowRight, ) } } if (!subsItem.isLocal && subsItem.updateUrl != null) { Row( modifier = Modifier - .clickable(onClick = throttle { + .clickable(onClickLabel = "编辑订阅链接", onClick = throttle { if (updateSubsMutex.mutex.isLocked) { toast("正在刷新订阅,请稍后操作") return@throttle @@ -331,15 +331,15 @@ fun SubsSheet( softWrap = false, overflow = TextOverflow.MiddleEllipsis, modifier = Modifier - .clickable(onClick = { - mainVm.urlFlow.value = subsItem.updateUrl + .clearAndSetSemantics {} + .clickable(onClickLabel = "查看订阅链接", onClick = { + mainVm.textFlow.value = subsItem.updateUrl }) ) } Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Edit, ) } } @@ -372,45 +372,34 @@ 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 { - mainVm.showShareDataIdsFlow.value = setOf(subsItem.id) - }) { - Icon(imageVector = Icons.Default.Share, contentDescription = null) - } - } + mainVm.navigatePage(ActionLogRoute(subsId = subsItem.id)) + }) 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/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/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/TextListDialog.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextListDialog.kt new file mode 100644 index 0000000000..a1afbb72af --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextListDialog.kt @@ -0,0 +1,42 @@ +package li.songe.gkd.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import li.songe.gkd.util.throttle + +@Composable +fun TextListDialog( + onDismiss: () -> Unit, + textList: List Unit>> +) { + val textModifier = Modifier + .fillMaxWidth() + .padding(16.dp) + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + ) { + textList.forEach { (text, onClickItem) -> + Text( + text = text, modifier = Modifier + .clickable(onClick = throttle { + onDismiss() + onClickItem() + }) + .then(textModifier) + ) + } + } + } +} \ No newline at end of file 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 1b6e34d945..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 @@ -1,16 +1,20 @@ 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 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 @@ -20,16 +24,27 @@ 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, + onClick: (() -> Unit)? = { onCheckedChange?.invoke(!checked) }, + onClickLabel: String? = "切换${title}状态", ) { + val topModifier = if (onClick != null) { + modifier.clickable(onClick = onClick, onClickLabel = onClickLabel) + } else { + modifier + } Row( - modifier = modifier.itemPadding(), - verticalAlignment = Alignment.CenterVertically + modifier = if (paddingDisabled) topModifier else topModifier.itemPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Column(modifier = Modifier.weight(1f)) { Text( @@ -38,7 +53,9 @@ fun TextSwitch( ) if (subtitle != null) { if (suffix != null) { - Row { + FlowRow( + modifier = Modifier.fillMaxWidth(), + ) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, @@ -46,8 +63,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), @@ -63,11 +85,14 @@ fun TextSwitch( } } } - Spacer(modifier = Modifier.width(8.dp)) - Switch( + suffixIcon?.invoke() + 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/component/TooltipIconButtonBox.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TooltipIconButtonBox.kt new file mode 100644 index 0000000000..e36aa68924 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TooltipIconButtonBox.kt @@ -0,0 +1,27 @@ +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 +import li.songe.gkd.ui.share.LocalIsTalkbackEnabled + +@Composable +fun TooltipIconButtonBox(contentDescription: String?, content: @Composable () -> Unit) { + // 视障用户使用 TalkBack 朗读 contentDescription,不需要 Tooltip + if (contentDescription.isNullOrEmpty() || LocalIsTalkbackEnabled.current) { + content() + } else { + TooltipBox( + tooltip = { PlainTooltip { Text(text = contentDescription) } }, + 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/TowLineText.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt index aad70d3ef9..7192ba3952 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,29 +6,35 @@ 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, + appFallbackName: String? = null, ) { - 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) { - AppNameText(appId = subtitle) + AppNameText(appId = subtitle, fallbackName = appFallbackName) } else { Text( text = subtitle, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) } } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TriStateIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TriStateIcon.kt new file mode 100644 index 0000000000..b653ce2343 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TriStateIcon.kt @@ -0,0 +1,135 @@ +package li.songe.gkd.ui.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A tri-state toggle icon drawn via Canvas. + * + * @param checked + * - `false` → thumb on the LEFT (toggle-off state, matches the uploaded SVG) + * - `null` → thumb in the CENTER (indeterminate state) + * - `true` → thumb on the RIGHT (toggle-on state) + * @param size Overall size of the icon. + * @param trackColor Color of the outer rounded-rectangle track. + * @param thumbColor Color of the circular thumb. + * @param animDurationMs Duration (ms) of the thumb translation animation. + */ +@Composable +fun TriStateIcon( + checked: Boolean?, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + trackColor: Color = Color(0xFF5F6368), + thumbColor: Color = Color(0xFF5F6368), + animDurationMs: Int = 300, +) { + // false → 0f (left), null → 0.5f (center), true → 1f (right) + val targetFraction = when (checked) { + false -> 0f + null -> 0.5f + true -> 1f + } + + val animatedFraction by animateFloatAsState( + targetValue = targetFraction, + animationSpec = tween(durationMillis = animDurationMs), + label = "triStateThumbFraction", + ) + + Canvas(modifier = modifier.size(size)) { + drawTriStateToggle( + fraction = animatedFraction, + trackColor = trackColor, + thumbColor = thumbColor, + ) + } +} + +// --------------------------------------------------------------------------- +// Private drawing helpers +// --------------------------------------------------------------------------- + +/** + * Replicates the Google Fonts "Toggle Off / On" geometry inside [DrawScope]. + * + * The original SVG uses a 960×960 view-box. All constants below are derived + * from that coordinate space and then scaled to the canvas size. + */ +private fun DrawScope.drawTriStateToggle( + fraction: Float, + trackColor: Color, + thumbColor: Color, +) { + // --- coordinate system --- + // SVG viewBox: 0 -960 960 960 (origin at top-left after axis flip) + val svgSize = 960f + val scaleX = size.width / svgSize + val scaleY = size.height / svgSize + + // --- track --- + // Outer path describes a rounded rect from (40,240) to (920,720) in SVG coords. + // Width = 880, Height = 480, corner radius = 240 (half of height → pill shape). + val trackLeft = 40f * scaleX + val trackTop = 240f * scaleY + val trackWidth = 880f * scaleX + val trackHeight = 480f * scaleY + val trackRadius = trackHeight / 2f + + drawRoundRect( + color = trackColor, + topLeft = Offset(trackLeft, trackTop), + size = Size(trackWidth, trackHeight), + cornerRadius = CornerRadius(trackRadius, trackRadius), + ) + + // --- thumb --- + // In the SVG the thumb circle has center (280, 480) for the OFF state and + // center (680, 480) for the ON state (radius ≈ 160 → fits inside the track). + // + // We animate between these two extremes (plus the midpoint for null). + val thumbRadius = 160f * scaleX // use scaleX; icon is square + val thumbCentreY = 480f * scaleY + + val thumbCentreXLeft = 280f * scaleX // false → left + val thumbCentreXRight = 680f * scaleX // true → right + val thumbCentreX = thumbCentreXLeft + (thumbCentreXRight - thumbCentreXLeft) * fraction + + drawCircle( + color = thumbColor, + radius = thumbRadius, + center = Offset(thumbCentreX, thumbCentreY), + ) +} + +// --------------------------------------------------------------------------- +// Preview +// --------------------------------------------------------------------------- + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun TriStateIconPreview() { + // Cycle through false → null → true → false on each composition tick + // (replace with a Button click in real usage) + Column( + modifier = Modifier, + ) { + TriStateIcon(checked = false, size = 48.dp) // LEFT – matches uploaded SVG + TriStateIcon(checked = null, size = 48.dp) // CENTRE + TriStateIcon(checked = true, size = 48.dp) // RIGHT + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TriStateSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TriStateSwitch.kt new file mode 100644 index 0000000000..cd19393d67 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TriStateSwitch.kt @@ -0,0 +1,695 @@ +package li.songe.gkd.ui.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.SnapSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.invalidateMeasurement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +// --------------------------------------------------------------------------- +// Dimension constants (inlined from SwitchTokens / M3 spec) +// --------------------------------------------------------------------------- + +private val TrackWidth = 52.dp +private val TrackHeight = 32.dp +private val TrackOutlineWidth = 2.dp +private val ThumbDiameter = 24.dp // selected handle width +private val UncheckedThumbDiameter = 16.dp // unselected handle width +private val PressedHandleWidth = 28.dp +private val StateLayerSize = 40.dp +private val ThumbPadding = (TrackHeight - ThumbDiameter) / 2 + +///** Icon size to use for [thumbContent] */ +val TriStateSwitchIconSize = 16.dp + +private val DefaultAnimationSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow) + +private val SnapSpecInstance = SnapSpec() + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * A three-state Material3-style switch. + * + * | [checked] value | Visual state | Thumb position | + * |-----------------|---------------|-----------------| + * | `true` | ON | End | + * | `null` | Indeterminate | Center | + * | `false` | OFF | Start | + * + * @param checked `true` = on, `false` = off, `null` = indeterminate. + * @param onCheckedChange Callback when the switch is clicked. Receives the *next* logical state: + * `false→null`, `null→true`, `true→false` (cycle). + * Pass `null` to make the switch non-interactive. + * @param modifier Modifier applied to this composable. + * @param thumbContent Optional content drawn inside the thumb (expected size [TriStateSwitchIconSize]). + * @param enabled When `false` the switch is non-interactive and visually dimmed. + * @param colors [TriStateSwitchColors] controlling the switch appearance. + * @param interactionSource Optional hoisted [MutableInteractionSource]. + */ +@Composable +private fun TriStateSwitch( + checked: Boolean?, + onCheckedChange: ((Boolean?) -> Unit)?, + modifier: Modifier = Modifier, + thumbContent: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + colors: TriStateSwitchColors = TriStateSwitchDefaults.colors(), + interactionSource: MutableInteractionSource? = null, +) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } + + val toggleableModifier = + if (onCheckedChange != null) { + // Cycle: false → null → true → false + val nextState: Boolean? = when (checked) { + false -> null + null -> true + true -> false + } + Modifier + .minimumInteractiveComponentSize() + .toggleable( + value = checked == true, + onValueChange = { onCheckedChange(nextState) }, + enabled = enabled, + role = Role.Switch, + interactionSource = interactionSource, + indication = null, + ) + } else { + Modifier + } + + TriStateSwitchImpl( + modifier = modifier + .then(toggleableModifier) + .wrapContentSize(Alignment.Center) + .requiredSize(TrackWidth, TrackHeight), + checked = checked, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + thumbShape = MaterialTheme.shapes.extraLarge, // CornerFull equivalent + thumbContent = thumbContent, + ) +} + +// --------------------------------------------------------------------------- +// Internal implementation +// --------------------------------------------------------------------------- + +@Composable +@Suppress("ComposableLambdaParameterNaming", "ComposableLambdaParameterPosition") +private fun TriStateSwitchImpl( + modifier: Modifier, + checked: Boolean?, + enabled: Boolean, + colors: TriStateSwitchColors, + thumbContent: (@Composable () -> Unit)?, + interactionSource: InteractionSource, + thumbShape: Shape, +) { + val trackColor = colors.trackColor(enabled, checked) + val thumbColor = colors.thumbColor(enabled, checked) + val borderColor = colors.borderColor(enabled, checked) + val trackShape = MaterialTheme.shapes.extraLarge // CornerFull + + Box( + modifier + .border(TrackOutlineWidth, borderColor, trackShape) + .background(trackColor, trackShape) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .then( + TriStateThumbElement( + interactionSource = interactionSource, + checked = checked, + animationSpec = DefaultAnimationSpec, + ) + ) + .indication( + interactionSource = interactionSource, + indication = ripple(bounded = false, radius = StateLayerSize / 2), + ) + .background(thumbColor, thumbShape), + contentAlignment = Alignment.Center, + ) { + if (thumbContent != null) { + val iconColor = colors.iconColor(enabled, checked) + CompositionLocalProvider( + LocalContentColor provides iconColor, + content = thumbContent, + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// ThumbElement / ThumbNode – layout modifier handling offset + size animation +// --------------------------------------------------------------------------- + +private data class TriStateThumbElement( + val interactionSource: InteractionSource, + val checked: Boolean?, + val animationSpec: FiniteAnimationSpec, +) : ModifierNodeElement() { + + override fun create() = TriStateThumbNode(interactionSource, checked, animationSpec) + + override fun update(node: TriStateThumbNode) { + node.interactionSource = interactionSource + if (node.checked != checked) node.invalidateMeasurement() + node.checked = checked + node.animationSpec = animationSpec + node.update() + } + + override fun InspectorInfo.inspectableProperties() { + name = "triStateSwitchThumb" + properties["interactionSource"] = interactionSource + properties["checked"] = checked + properties["animationSpec"] = animationSpec + } +} + +private class TriStateThumbNode( + var interactionSource: InteractionSource, + var checked: Boolean?, + var animationSpec: FiniteAnimationSpec, +) : Modifier.Node(), LayoutModifierNode { + + override val shouldAutoInvalidate: Boolean get() = false + + private var isPressed = false + private var offsetAnim: Animatable? = null + private var sizeAnim: Animatable? = null + private var initialOffset: Float = Float.NaN + private var initialSize: Float = Float.NaN + + override fun onAttach() { + coroutineScope.launch { + var pressCount = 0 + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> pressCount++ + is PressInteraction.Release -> pressCount-- + is PressInteraction.Cancel -> pressCount-- + } + val pressed = pressCount > 0 + if (isPressed != pressed) { + isPressed = pressed + invalidateMeasurement() + } + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val hasContent = + measurable.maxIntrinsicHeight(constraints.maxWidth) != 0 && + measurable.maxIntrinsicWidth(constraints.maxHeight) != 0 + + // Target thumb size + val size = when { + isPressed -> PressedHandleWidth.toPx() + hasContent || checked == true -> ThumbDiameter.toPx() + checked == null -> androidx.compose.ui.util.lerp( + UncheckedThumbDiameter.toPx(), + ThumbDiameter.toPx(), + 0.5f + ) + + else /* false */ -> UncheckedThumbDiameter.toPx() + } + + val actualSize = (sizeAnim?.value ?: size).toInt() + val placeable = measurable.measure(Constraints.fixed(actualSize, actualSize)) + + // Offset bounds + val thumbPaddingStart = (TrackHeight - size.toDp()) / 2f + val minBound = thumbPaddingStart.toPx() + val thumbPathLength = (TrackWidth - ThumbDiameter) - ThumbPadding + val maxBound = thumbPathLength.toPx() + val midBound = (minBound + maxBound) / 2f + + val offset = when { + isPressed && checked == true -> maxBound - TrackOutlineWidth.toPx() + isPressed && checked == false -> TrackOutlineWidth.toPx() + isPressed && checked == null -> midBound + checked == true -> maxBound + checked == null -> midBound + else -> minBound + } + + // Animate size + if (sizeAnim?.targetValue != size) { + coroutineScope.launch { + sizeAnim?.animateTo(size, if (isPressed) SnapSpecInstance else animationSpec) + } + } + // Animate offset + if (offsetAnim?.targetValue != offset) { + coroutineScope.launch { + offsetAnim?.animateTo(offset, if (isPressed) SnapSpecInstance else animationSpec) + } + } + + if (initialSize.isNaN() && initialOffset.isNaN()) { + initialSize = size + initialOffset = offset + } + + return layout(actualSize, actualSize) { + placeable.placeRelative(offsetAnim?.value?.toInt() ?: offset.toInt(), 0) + } + } + + fun update() { + if (sizeAnim == null && !initialSize.isNaN()) { + sizeAnim = Animatable(initialSize) + } + if (offsetAnim == null && !initialOffset.isNaN()) { + offsetAnim = Animatable(initialOffset) + } + } +} + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +/** Default factory object for [TriStateSwitchColors]. */ +object TriStateSwitchDefaults { + + /** + * Creates a [TriStateSwitchColors] using Material3 color-scheme values. + * + * The **indeterminate** colors are derived by linearly interpolating (50 %) between the + * checked and unchecked colors so the visual midpoint looks natural. + * + * @param checkedThumbColor Thumb color when enabled + checked. + * @param checkedTrackColor Track color when enabled + checked. + * @param checkedBorderColor Border color when enabled + checked. + * @param checkedIconColor Icon color when enabled + checked. + * @param uncheckedThumbColor Thumb color when enabled + unchecked. + * @param uncheckedTrackColor Track color when enabled + unchecked. + * @param uncheckedBorderColor Border color when enabled + unchecked. + * @param uncheckedIconColor Icon color when enabled + unchecked. + * @param indeterminateThumbColor Thumb color when enabled + indeterminate (`null`). + * @param indeterminateTrackColor Track color when enabled + indeterminate. + * @param indeterminateBorderColor Border color when enabled + indeterminate. + * @param indeterminateIconColor Icon color when enabled + indeterminate. + * @param disabledCheckedThumbColor Thumb color when disabled + checked. + * @param disabledCheckedTrackColor Track color when disabled + checked. + * @param disabledCheckedBorderColor Border color when disabled + checked. + * @param disabledCheckedIconColor Icon color when disabled + checked. + * @param disabledUncheckedThumbColor Thumb color when disabled + unchecked. + * @param disabledUncheckedTrackColor Track color when disabled + unchecked. + * @param disabledUncheckedBorderColor Border color when disabled + unchecked. + * @param disabledUncheckedIconColor Icon color when disabled + unchecked. + * @param disabledIndeterminateThumbColor Thumb color when disabled + indeterminate. + * @param disabledIndeterminateTrackColor Track color when disabled + indeterminate. + * @param disabledIndeterminateBorderColor Border color when disabled + indeterminate. + * @param disabledIndeterminateIconColor Icon color when disabled + indeterminate. + */ + @Composable + fun colors( + // --- enabled checked --- + checkedThumbColor: Color = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor: Color = MaterialTheme.colorScheme.primary, + checkedBorderColor: Color = Color.Transparent, + checkedIconColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + // --- enabled unchecked --- + uncheckedThumbColor: Color = MaterialTheme.colorScheme.outline, + uncheckedTrackColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + uncheckedBorderColor: Color = MaterialTheme.colorScheme.outline, + uncheckedIconColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + // --- enabled indeterminate (null) — default = midpoint blend --- + indeterminateThumbColor: Color = lerp(uncheckedThumbColor, checkedThumbColor, 0.5f), + indeterminateTrackColor: Color = lerp(uncheckedTrackColor, checkedTrackColor, 0.5f), + indeterminateBorderColor: Color = lerp(uncheckedBorderColor, checkedBorderColor, 0.5f), + indeterminateIconColor: Color = lerp(uncheckedIconColor, checkedIconColor, 0.5f), + // --- disabled checked --- + disabledCheckedThumbColor: Color = + MaterialTheme.colorScheme.surface + .copy(alpha = 1.0f) + .compositeOver(MaterialTheme.colorScheme.surface), + disabledCheckedTrackColor: Color = + MaterialTheme.colorScheme.onSurface + .copy(alpha = 0.12f) + .compositeOver(MaterialTheme.colorScheme.surface), + disabledCheckedBorderColor: Color = Color.Transparent, + disabledCheckedIconColor: Color = + MaterialTheme.colorScheme.onSurface + .copy(alpha = 0.38f) + .compositeOver(MaterialTheme.colorScheme.surface), + // --- disabled unchecked --- + disabledUncheckedThumbColor: Color = + MaterialTheme.colorScheme.onSurface + .copy(alpha = 0.38f) + .compositeOver(MaterialTheme.colorScheme.surface), + disabledUncheckedTrackColor: Color = + MaterialTheme.colorScheme.surfaceContainerHighest + .copy(alpha = 0.12f) + .compositeOver(MaterialTheme.colorScheme.surface), + disabledUncheckedBorderColor: Color = + MaterialTheme.colorScheme.onSurface + .copy(alpha = 0.12f) + .compositeOver(MaterialTheme.colorScheme.surface), + disabledUncheckedIconColor: Color = + MaterialTheme.colorScheme.surfaceContainerHighest + .copy(alpha = 0.38f) + .compositeOver(MaterialTheme.colorScheme.surface), + // --- disabled indeterminate --- + disabledIndeterminateThumbColor: Color = lerp( + disabledUncheckedThumbColor, + disabledCheckedThumbColor, + 0.5f + ), + disabledIndeterminateTrackColor: Color = lerp( + disabledUncheckedTrackColor, + disabledCheckedTrackColor, + 0.5f + ), + disabledIndeterminateBorderColor: Color = lerp( + disabledUncheckedBorderColor, + disabledCheckedBorderColor, + 0.5f + ), + disabledIndeterminateIconColor: Color = lerp( + disabledUncheckedIconColor, + disabledCheckedIconColor, + 0.5f + ), + ): TriStateSwitchColors = TriStateSwitchColors( + checkedThumbColor = checkedThumbColor, + checkedTrackColor = checkedTrackColor, + checkedBorderColor = checkedBorderColor, + checkedIconColor = checkedIconColor, + uncheckedThumbColor = uncheckedThumbColor, + uncheckedTrackColor = uncheckedTrackColor, + uncheckedBorderColor = uncheckedBorderColor, + uncheckedIconColor = uncheckedIconColor, + indeterminateThumbColor = indeterminateThumbColor, + indeterminateTrackColor = indeterminateTrackColor, + indeterminateBorderColor = indeterminateBorderColor, + indeterminateIconColor = indeterminateIconColor, + disabledCheckedThumbColor = disabledCheckedThumbColor, + disabledCheckedTrackColor = disabledCheckedTrackColor, + disabledCheckedBorderColor = disabledCheckedBorderColor, + disabledCheckedIconColor = disabledCheckedIconColor, + disabledUncheckedThumbColor = disabledUncheckedThumbColor, + disabledUncheckedTrackColor = disabledUncheckedTrackColor, + disabledUncheckedBorderColor = disabledUncheckedBorderColor, + disabledUncheckedIconColor = disabledUncheckedIconColor, + disabledIndeterminateThumbColor = disabledIndeterminateThumbColor, + disabledIndeterminateTrackColor = disabledIndeterminateTrackColor, + disabledIndeterminateBorderColor = disabledIndeterminateBorderColor, + disabledIndeterminateIconColor = disabledIndeterminateIconColor, + ) + + /** Icon size to use for `thumbContent`. */ +// val IconSize = TriStateSwitchIconSize +} + +/** + * Represents the colors used by a [TriStateSwitch] in all states. + * + * Use [TriStateSwitchDefaults.colors] to obtain the default Material3 instance. + */ +@Immutable +class TriStateSwitchColors( + val checkedThumbColor: Color, + val checkedTrackColor: Color, + val checkedBorderColor: Color, + val checkedIconColor: Color, + val uncheckedThumbColor: Color, + val uncheckedTrackColor: Color, + val uncheckedBorderColor: Color, + val uncheckedIconColor: Color, + val indeterminateThumbColor: Color, + val indeterminateTrackColor: Color, + val indeterminateBorderColor: Color, + val indeterminateIconColor: Color, + val disabledCheckedThumbColor: Color, + val disabledCheckedTrackColor: Color, + val disabledCheckedBorderColor: Color, + val disabledCheckedIconColor: Color, + val disabledUncheckedThumbColor: Color, + val disabledUncheckedTrackColor: Color, + val disabledUncheckedBorderColor: Color, + val disabledUncheckedIconColor: Color, + val disabledIndeterminateThumbColor: Color, + val disabledIndeterminateTrackColor: Color, + val disabledIndeterminateBorderColor: Color, + val disabledIndeterminateIconColor: Color, +) { + /** Returns a copy overriding only the supplied parameters (Color.Unspecified = keep original). */ + fun copy( + checkedThumbColor: Color = this.checkedThumbColor, + checkedTrackColor: Color = this.checkedTrackColor, + checkedBorderColor: Color = this.checkedBorderColor, + checkedIconColor: Color = this.checkedIconColor, + uncheckedThumbColor: Color = this.uncheckedThumbColor, + uncheckedTrackColor: Color = this.uncheckedTrackColor, + uncheckedBorderColor: Color = this.uncheckedBorderColor, + uncheckedIconColor: Color = this.uncheckedIconColor, + indeterminateThumbColor: Color = this.indeterminateThumbColor, + indeterminateTrackColor: Color = this.indeterminateTrackColor, + indeterminateBorderColor: Color = this.indeterminateBorderColor, + indeterminateIconColor: Color = this.indeterminateIconColor, + disabledCheckedThumbColor: Color = this.disabledCheckedThumbColor, + disabledCheckedTrackColor: Color = this.disabledCheckedTrackColor, + disabledCheckedBorderColor: Color = this.disabledCheckedBorderColor, + disabledCheckedIconColor: Color = this.disabledCheckedIconColor, + disabledUncheckedThumbColor: Color = this.disabledUncheckedThumbColor, + disabledUncheckedTrackColor: Color = this.disabledUncheckedTrackColor, + disabledUncheckedBorderColor: Color = this.disabledUncheckedBorderColor, + disabledUncheckedIconColor: Color = this.disabledUncheckedIconColor, + disabledIndeterminateThumbColor: Color = this.disabledIndeterminateThumbColor, + disabledIndeterminateTrackColor: Color = this.disabledIndeterminateTrackColor, + disabledIndeterminateBorderColor: Color = this.disabledIndeterminateBorderColor, + disabledIndeterminateIconColor: Color = this.disabledIndeterminateIconColor, + ) = TriStateSwitchColors( + checkedThumbColor = checkedThumbColor, + checkedTrackColor = checkedTrackColor, + checkedBorderColor = checkedBorderColor, + checkedIconColor = checkedIconColor, + uncheckedThumbColor = uncheckedThumbColor, + uncheckedTrackColor = uncheckedTrackColor, + uncheckedBorderColor = uncheckedBorderColor, + uncheckedIconColor = uncheckedIconColor, + indeterminateThumbColor = indeterminateThumbColor, + indeterminateTrackColor = indeterminateTrackColor, + indeterminateBorderColor = indeterminateBorderColor, + indeterminateIconColor = indeterminateIconColor, + disabledCheckedThumbColor = disabledCheckedThumbColor, + disabledCheckedTrackColor = disabledCheckedTrackColor, + disabledCheckedBorderColor = disabledCheckedBorderColor, + disabledCheckedIconColor = disabledCheckedIconColor, + disabledUncheckedThumbColor = disabledUncheckedThumbColor, + disabledUncheckedTrackColor = disabledUncheckedTrackColor, + disabledUncheckedBorderColor = disabledUncheckedBorderColor, + disabledUncheckedIconColor = disabledUncheckedIconColor, + disabledIndeterminateThumbColor = disabledIndeterminateThumbColor, + disabledIndeterminateTrackColor = disabledIndeterminateTrackColor, + disabledIndeterminateBorderColor = disabledIndeterminateBorderColor, + disabledIndeterminateIconColor = disabledIndeterminateIconColor, + ) + + @Stable + internal fun thumbColor(enabled: Boolean, checked: Boolean?): Color = + if (enabled) when (checked) { + true -> checkedThumbColor + false -> uncheckedThumbColor + null -> indeterminateThumbColor + } else when (checked) { + true -> disabledCheckedThumbColor + false -> disabledUncheckedThumbColor + null -> disabledIndeterminateThumbColor + } + + @Stable + internal fun trackColor(enabled: Boolean, checked: Boolean?): Color = + if (enabled) when (checked) { + true -> checkedTrackColor + false -> uncheckedTrackColor + null -> indeterminateTrackColor + } else when (checked) { + true -> disabledCheckedTrackColor + false -> disabledUncheckedTrackColor + null -> disabledIndeterminateTrackColor + } + + @Stable + internal fun borderColor(enabled: Boolean, checked: Boolean?): Color = + if (enabled) when (checked) { + true -> checkedBorderColor + false -> uncheckedBorderColor + null -> indeterminateBorderColor + } else when (checked) { + true -> disabledCheckedBorderColor + false -> disabledUncheckedBorderColor + null -> disabledIndeterminateBorderColor + } + + @Stable + internal fun iconColor(enabled: Boolean, checked: Boolean?): Color = + if (enabled) when (checked) { + true -> checkedIconColor + false -> uncheckedIconColor + null -> indeterminateIconColor + } else when (checked) { + true -> disabledCheckedIconColor + false -> disabledUncheckedIconColor + null -> disabledIndeterminateIconColor + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TriStateSwitchColors) return false + return checkedThumbColor == other.checkedThumbColor && + checkedTrackColor == other.checkedTrackColor && + checkedBorderColor == other.checkedBorderColor && + checkedIconColor == other.checkedIconColor && + uncheckedThumbColor == other.uncheckedThumbColor && + uncheckedTrackColor == other.uncheckedTrackColor && + uncheckedBorderColor == other.uncheckedBorderColor && + uncheckedIconColor == other.uncheckedIconColor && + indeterminateThumbColor == other.indeterminateThumbColor && + indeterminateTrackColor == other.indeterminateTrackColor && + indeterminateBorderColor == other.indeterminateBorderColor && + indeterminateIconColor == other.indeterminateIconColor && + disabledCheckedThumbColor == other.disabledCheckedThumbColor && + disabledCheckedTrackColor == other.disabledCheckedTrackColor && + disabledCheckedBorderColor == other.disabledCheckedBorderColor && + disabledCheckedIconColor == other.disabledCheckedIconColor && + disabledUncheckedThumbColor == other.disabledUncheckedThumbColor && + disabledUncheckedTrackColor == other.disabledUncheckedTrackColor && + disabledUncheckedBorderColor == other.disabledUncheckedBorderColor && + disabledUncheckedIconColor == other.disabledUncheckedIconColor && + disabledIndeterminateThumbColor == other.disabledIndeterminateThumbColor && + disabledIndeterminateTrackColor == other.disabledIndeterminateTrackColor && + disabledIndeterminateBorderColor == other.disabledIndeterminateBorderColor && + disabledIndeterminateIconColor == other.disabledIndeterminateIconColor + } + + override fun hashCode(): Int { + var r = checkedThumbColor.hashCode() + r = 31 * r + checkedTrackColor.hashCode() + r = 31 * r + checkedBorderColor.hashCode() + r = 31 * r + checkedIconColor.hashCode() + r = 31 * r + uncheckedThumbColor.hashCode() + r = 31 * r + uncheckedTrackColor.hashCode() + r = 31 * r + uncheckedBorderColor.hashCode() + r = 31 * r + uncheckedIconColor.hashCode() + r = 31 * r + indeterminateThumbColor.hashCode() + r = 31 * r + indeterminateTrackColor.hashCode() + r = 31 * r + indeterminateBorderColor.hashCode() + r = 31 * r + indeterminateIconColor.hashCode() + r = 31 * r + disabledCheckedThumbColor.hashCode() + r = 31 * r + disabledCheckedTrackColor.hashCode() + r = 31 * r + disabledCheckedBorderColor.hashCode() + r = 31 * r + disabledCheckedIconColor.hashCode() + r = 31 * r + disabledUncheckedThumbColor.hashCode() + r = 31 * r + disabledUncheckedTrackColor.hashCode() + r = 31 * r + disabledUncheckedBorderColor.hashCode() + r = 31 * r + disabledUncheckedIconColor.hashCode() + r = 31 * r + disabledIndeterminateThumbColor.hashCode() + r = 31 * r + disabledIndeterminateTrackColor.hashCode() + r = 31 * r + disabledIndeterminateBorderColor.hashCode() + r = 31 * r + disabledIndeterminateIconColor.hashCode() + return r + } +} + +@Composable +fun PerfTriStateSwitch( + checked: Boolean?, + onCheckedChange: ((Boolean?) -> Unit)?, + modifier: Modifier = Modifier, + key: Any? = null, + thumbContent: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + colors: TriStateSwitchColors = TriStateSwitchDefaults.colors(), + interactionSource: MutableInteractionSource? = null, +) = androidx.compose.runtime.key(key) { + TriStateSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + thumbContent = thumbContent, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewTriStateSwitch() { + Column { + PerfTriStateSwitch( + checked = false, + onCheckedChange = {}, + ) + PerfTriStateSwitch( + checked = null, + onCheckedChange = {}, + ) + PerfTriStateSwitch( + checked = true, + onCheckedChange = {}, + ) + } +} 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..ceddd74f88 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 @@ -8,7 +8,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -17,6 +16,7 @@ import li.songe.gkd.MainViewModel import li.songe.gkd.data.GithubPoliciesAsset import li.songe.gkd.util.GithubCookieException import li.songe.gkd.util.LoadStatus +import li.songe.gkd.util.LogUtils import li.songe.gkd.util.launchTry import li.songe.gkd.util.toast import li.songe.gkd.util.uploadFileToGithub @@ -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 6a5f9ea002..1ce8cedacc 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,83 +2,99 @@ 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 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 +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.filled.Apps -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 +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 +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 import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 -import com.blankj.utilcode.util.KeyboardUtils -import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import li.songe.gkd.MainActivity -import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.AnimatedIcon +import li.songe.gkd.R +import li.songe.gkd.data.AppInfo +import li.songe.gkd.permission.canQueryPkgState +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.ui.AppConfigRoute +import li.songe.gkd.ui.EditBlockAppListRoute +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 import li.songe.gkd.ui.component.AppNameText import li.songe.gkd.ui.component.EmptyText +import li.songe.gkd.ui.component.MenuGroupCard +import li.songe.gkd.ui.component.MenuItemCheckbox +import li.songe.gkd.ui.component.MenuItemRadioButton +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.updateDialogOptions import li.songe.gkd.ui.component.useListScrollState -import li.songe.gkd.ui.local.LocalMainViewModel +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.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.AppGroupOption +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.appListAuthAbnormalFlow +import li.songe.gkd.util.getUpDownTransform import li.songe.gkd.util.ruleSummaryFlow +import li.songe.gkd.util.switchItem import li.songe.gkd.util.throttle - -val appListNav = BottomNavItem( - label = "应用", icon = Icons.Default.Apps -) +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 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() @@ -87,12 +103,31 @@ fun useAppListPage(): ScaffoldExt { } else { null } - 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 refreshing by updateAppMutex.state.collectAsState() + val pullToRefreshState = rememberPullToRefreshState() + val editWhiteListMode by vm.editWhiteListModeFlow.collectAsState() + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, listState) = useListScrollState(scrollKey) + LaunchedEffect(null) { + listOf( + canQueryPkgState.stateFlow, + vm.appInfosFlow, + ).forEach { + launch { + it.drop(1).collect { + scrollKey.intValue++ + } + } + } + mainVm.resetPageScrollEvent.collect { + if (it == BottomNavItem.AppList) { + scrollKey.intValue++ + } + } + } return ScaffoldExt( - navItem = appListNav, + navItem = BottomNavItem.AppList, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { DisposableEffect(null) { @@ -100,15 +135,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 } } @@ -119,42 +153,88 @@ 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 } + scrollKey.intValue++ } - ), - text = appListNav.label, - ) - } - }, actions = { - IconButton(onClick = throttle { - if (showSearchBar) { - if (vm.searchStrFlow.value.isEmpty()) { - vm.showSearchBarFlow.value = false + ) + if (editWhiteListMode) { + BackHandler { + vm.editWhiteListModeFlow.value = false + } + } + AnimatedContent( + targetState = editWhiteListMode, + transitionSpec = { getUpDownTransform() }, + ) { localEditWhiteListMode -> + if (localEditWhiteListMode) { + Text( + modifier = titleModifier, + text = "应用白名单", + ) } else { - vm.searchStrFlow.value = "" + Text( + modifier = titleModifier, + text = BottomNavItem.AppList.label, + ) } - } else { - vm.showSearchBarFlow.value = true } - }) { - AnimatedIcon( - id = SafeR.ic_anim_search_close, - atEnd = showSearchBar, - ) } - var expanded by remember { mutableStateOf(false) } - IconButton(onClick = { - expanded = true - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null - ) + }, actions = { + if (appListAuthAbnormalFlow.collectAsState().value) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) { + PerfIconButton( + imageVector = PerfIcon.WarningAmber, + contentDescription = canQueryPkgState.name + "异常", + onClick = throttle { + mainVm.dialogFlow.updateDialogOptions( + title = "权限异常", + text = "检测到已授予「${canQueryPkgState.name}」但实际获取应用数量稀少,已使用其它方式获取但可能不全,在应用列表下拉刷新可重新获取,若无法解决可尝试关闭权限后重新授予或重启设备" + ) + }, + ) + } } + PerfIconButton( + imageVector = PerfIcon.Block, + contentDescription = "切换白名单编辑模式", + onClickLabel = if (editWhiteListMode) "退出编辑" else "进入编辑", + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (editWhiteListMode) { + CheckboxDefaults.colors().checkedBoxColor + } else { + LocalContentColor.current + } + ), + onClick = throttle { + vm.editWhiteListModeFlow.update { !it } + }, + ) + AnimatedIconButton( + onClick = throttle { + if (showSearchBar) { + if (vm.searchStrFlow.value.isEmpty()) { + vm.showSearchBarFlow.value = false + } else { + vm.searchStrFlow.value = "" + } + } else { + vm.showSearchBarFlow.value = true + } + }, + id = R.drawable.ic_anim_search_close, + atEnd = showSearchBar, + contentDescription = if (showSearchBar) "关闭搜索" else "搜索应用列表", + ) + var expanded by remember { mutableStateOf(false) } + PerfIconButton( + imageVector = PerfIcon.Sort, + contentDescription = "排序筛选", + onClick = { + expanded = true + } + ) Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) @@ -163,109 +243,73 @@ fun useAppListPage(): ScaffoldExt { 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 { s -> s.copy(sortType = sortOption.value) } - } - ) - }, - onClick = { - storeFlow.update { s -> s.copy(sortType = sortOption.value) } - }, - ) + MenuGroupCard(inTop = true, title = "排序") { + var sortType by vm.sortTypeFlow.asMutableState() + AppSortOption.objects.forEach { option -> + MenuItemRadioButton( + text = option.label, + selected = sortType == option, + onClick = { sortType = option }, + ) + } } - Text( - text = "筛选", - modifier = Modifier.menuPadding(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - DropdownMenuItem( - text = { - Text("显示系统应用") - }, - trailingIcon = { - Checkbox( - checked = showSystemApp, - onCheckedChange = { - storeFlow.update { s -> s.copy(showSystemApp = !showSystemApp) } - } + MenuGroupCard(title = "分组") { + var appGroupType by vm.appGroupTypeFlow.asMutableState() + AppGroupOption.normalObjects.forEach { option -> + val newValue = option.invert(appGroupType) + MenuItemCheckbox( + enabled = newValue != 0, + text = option.label, + checked = option.include(appGroupType), + onClick = { appGroupType = newValue }, ) - }, - onClick = { - storeFlow.update { s -> s.copy(showSystemApp = !showSystemApp) } - }, - ) - DropdownMenuItem( - text = { - Text("显示隐藏应用") - }, - trailingIcon = { - Checkbox( - checked = showHiddenApp, - onCheckedChange = { - storeFlow.update { s -> s.copy(showHiddenApp = !s.showHiddenApp) } - }) - }, - onClick = { - storeFlow.update { s -> s.copy(showHiddenApp = !showHiddenApp) } - }, - ) + } + } + MenuGroupCard(title = "筛选") { + MenuItemCheckbox( + text = "白名单", + stateFlow = vm.showBlockAppFlow, + ) + } } } - }) + }, + floatingActionButton = { + AnimationFloatingActionButton( + visible = editWhiteListMode, + contentDescription = "编辑白名单", + onClick = { + mainVm.navigatePage(EditBlockAppListRoute) + }, + imageVector = PerfIcon.Edit, + ) } ) { contentPadding -> - LazyColumn( + val canQueryPkg by canQueryPkgState.stateFlow.collectAsState() + PullToRefreshBox( modifier = Modifier.padding(contentPadding), - state = listState + state = pullToRefreshState, + isRefreshing = refreshing, + onRefresh = { updateAllAppInfo() } ) { - 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( - modifier = Modifier - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - AppNameText(appInfo = appInfo) + 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 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}关闭" - } - + 0 -> "${appGroups.size}组规则" + appGroups.size -> "${appGroups.size}组规则/${disabledCount}关闭" else -> { "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" } @@ -273,7 +317,7 @@ fun useAppListPage(): ScaffoldExt { } else { null } - val desc = if (globalDesc != null) { + if (globalDesc != null) { if (appDesc != null) { "$globalDesc/$appDesc" } else { @@ -282,27 +326,94 @@ fun useAppListPage(): ScaffoldExt { } else { appDesc } - if (desc != null) { - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - softWrap = false - ) - } + } + AppItemCard( + appInfo = appInfo, + desc = desc, + ) + } + item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { + Spacer(modifier = Modifier.height(EmptyHeight)) + if (appInfos.isEmpty() && searchStr.isNotEmpty()) { + EmptyText(text = if (vm.appFilter.showAllAppFlow.collectAsState().value) "暂无搜索结果" else "暂无搜索结果,或修改筛选") + Spacer(modifier = Modifier.height(EmptyHeight / 2)) } } } - item(LIST_PLACEHOLDER_KEY) { - Spacer(modifier = Modifier.height(EmptyHeight)) - if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) { - val hasShowAll = showSystemApp && showHiddenApp - EmptyText(text = if (hasShowAll) "暂无搜索结果" else "暂无搜索结果,请尝试修改筛选条件") + } + } +} + +@Composable +private fun AppItemCard( + appInfo: AppInfo, + desc: String?, +) { + 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) } + } else { + context.justHideSoftInput() + mainVm.navigatePage(AppConfigRoute(appInfo.id)) + } + }) + .clearAndSetSemantics { + contentDescription = if (editWhiteListMode) { + appInfo.name + } else { + "应用:${appInfo.name},${desc ?: appInfo.id}" + } + if (inWhiteList) { + stateDescription = "已加入白名单" + } else if (editWhiteListMode) { + stateDescription = "未加入白名单" } - QueryPkgAuthCard() + onClick( + label = if (editWhiteListMode) if (inWhiteList) "从白名单中移除" else "加入白名单" else "进入规则汇总页面", + action = null + ) } + .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 + ) + } + 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, + ) } } -} \ No newline at end of file +} 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..d5c1d19ac6 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 @@ -12,182 +13,244 @@ 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.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.Home -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.CardDefaults 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.LaunchedEffect 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.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 -import com.ramcosta.composedestinations.generated.destinations.ActionLogPageDestination -import com.ramcosta.composedestinations.generated.destinations.ActivityLogPageDestination -import com.ramcosta.composedestinations.generated.destinations.AppConfigPageDestination -import com.ramcosta.composedestinations.generated.destinations.AuthA11YPageDestination -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination +import kotlinx.coroutines.Dispatchers 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 +import li.songe.gkd.R +import li.songe.gkd.data.SubsConfig +import li.songe.gkd.permission.appOpsRestrictedFlow import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService -import li.songe.gkd.service.ManageService -import li.songe.gkd.service.switchA11yService +import li.songe.gkd.service.ActivityService +import li.songe.gkd.service.StatusService +import li.songe.gkd.service.a11yPartDisabledFlow +import li.songe.gkd.service.switchAutomatorService +import li.songe.gkd.service.topAppIdFlow +import li.songe.gkd.shizuku.shizukuContextFlow +import li.songe.gkd.shizuku.uiAutomationFlow +import li.songe.gkd.store.actualA11yScopeAppList import li.songe.gkd.store.storeFlow +import li.songe.gkd.ui.ActionLogRoute +import li.songe.gkd.ui.ActivityLogRoute +import li.songe.gkd.ui.AppConfigRoute +import li.songe.gkd.ui.AuthA11yRoute +import li.songe.gkd.ui.WebViewRoute 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.local.LocalMainViewModel +import li.songe.gkd.ui.component.useScrollBehaviorState +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 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.ShortUrlSet +import li.songe.gkd.util.latestRecordDescFlow +import li.songe.gkd.util.latestRecordFlow 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 val mainVm = LocalMainViewModel.current val vm = viewModel() - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - val scrollState = rememberScrollState() - val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, scrollState) = useScrollBehaviorState(scrollKey) + LaunchedEffect(null) { + mainVm.resetPageScrollEvent.collect { + if (it == BottomNavItem.Control) { + scrollKey.intValue++ + } + } + } return ScaffoldExt( - navItem = controlNav, + navItem = BottomNavItem.Control, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, title = { + PerfTopAppBar(scrollBehavior = scrollBehavior, title = { Text( - text = stringResource(SafeR.app_name), + text = stringResource(R.string.app_name) ) }, actions = { - IconButton(onClick = throttle { - mainVm.navigatePage(AuthA11YPageDestination) - }) { - Icon( - imageVector = Icons.Outlined.RocketLaunch, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.RocketLaunch, + onClickLabel = "前往工作模式页面", + contentDescription = "工作模式", + onClick = throttle { + mainVm.navigatePage(AuthA11yRoute) + }, + ) }) - } - ) { contentPadding -> + }) { contentPadding -> val store by storeFlow.collectAsState() val a11yRunning by A11yService.isRunning.collectAsState() - val manageRunning by ManageService.isRunning.collectAsState() - val a11yServiceEnabled by a11yServiceEnabledFlow.collectAsState() - - // 无障碍故障: 设置中无障碍开启, 但是实际 service 没有运行 - val a11yBroken = !a11yRunning && a11yServiceEnabled + val manageRunning by StatusService.isRunning.collectAsState() + val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() Column( modifier = Modifier .verticalScroll(scrollState) .padding(contentPadding) + .padding(horizontal = itemHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(itemHorizontalPadding / 2) ) { - PageItemCard( - imageVector = Icons.Default.Memory, - title = "服务状态", - subtitle = if (a11yRunning) "无障碍服务正在运行" else if (a11yBroken) "无障碍服务发生故障" else if (writeSecureSettings) "无障碍服务已关闭" else "无障碍服务未授权", - rightContent = { - Switch( - checked = a11yRunning, - onCheckedChange = throttle(vm.viewModelScope.launchAsFn { newEnabled -> - if (writeSecureSettings || !newEnabled) { - switchA11yService() - } else { - mainVm.navigatePage(AuthA11YPageDestination) - } - }), - ) + if (appOpsRestrictedFlow.collectAsState().value) { + Card( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + this.onClick(label = "前往解除限制页面", action = null) + }, + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + onClick = throttle { + mainVm.navigateWebPage(ShortUrlSet.URL2) + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(itemVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PerfIcon(imageVector = PerfIcon.WarningAmber) + Text( + modifier = Modifier.weight(1f), + text = "检测到权限受限制,请前往解除", + style = MaterialTheme.typography.bodyLarge, + ) + PerfIcon(imageVector = PerfIcon.KeyboardArrowRight) + } } - ) + } + if (store.useA11y || actualA11yScopeAppList.contains(topAppIdFlow.collectAsState().value)) { + PageSwitchItemCard( + imageVector = PerfIcon.Memory, + title = "服务状态", + subtitle = if (a11yRunning) { + "无障碍正在运行" + } else if (mainVm.a11yServiceEnabledFlow.collectAsState().value) { + "无障碍发生故障" + } else if (writeSecureSettings) { + if (store.enableAutomator && a11yPartDisabledFlow.collectAsState().value) { + "无障碍局部关闭" + } else { + "无障碍已关闭" + } + } else { + "无障碍未授权" + }, + checked = a11yRunning, + onCheckedChange = { newEnabled -> + if (newEnabled && !writeSecureSettingsState.value) { + mainVm.navigatePage(AuthA11yRoute) + } else { + switchAutomatorService() + } + }, + ) + } else { + PageSwitchItemCard( + imageVector = PerfIcon.Memory, + title = "服务状态", + subtitle = if (uiAutomationFlow.collectAsState().value != null) { + "自动化正在运行" + } else if (!shizukuContextFlow.collectAsState().value.ok) { + "自动化未授权" + } else { + if (store.enableAutomator && a11yPartDisabledFlow.collectAsState().value) { + "自动化局部关闭" + } else { + "自动化已关闭" + } + }, + checked = uiAutomationFlow.collectAsState().value != null, + onCheckedChange = vm.viewModelScope.launchAsFn(Dispatchers.IO) { newEnabled -> + if (newEnabled) { + mainVm.guardShizukuContext() + } + switchAutomatorService() + }, + ) + } - PageItemCard( - imageVector = Icons.Outlined.Notifications, + PageSwitchItemCard( + imageVector = PerfIcon.Notifications, title = "常驻通知", subtitle = "显示运行状态及统计数据", - rightContent = { - Switch( - checked = manageRunning && store.enableStatusService, - onCheckedChange = throttle(fn = vm.viewModelScope.launchAsFn { - if (it) { - requiredPermission(context, foregroundServiceSpecialUseState) - requiredPermission(context, notificationState) - ManageService.start() - } else { - ManageService.stop() - } - storeFlow.value = store.copy( - enableStatusService = it - ) - }), - ) - } + 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 = Icons.Default.History, + imageVector = PerfIcon.History, + onClickLabel = "打开触发记录页面", onClick = { - mainVm.navigatePage(ActionLogPageDestination()) - } - ) + mainVm.navigatePage(ActionLogRoute()) + }) - if (store.enableActivityLog) { + if (ActivityService.isRunning.collectAsState().value) { PageItemCard( - title = "界面记录", + title = "界面日志", subtitle = "记录打开的应用及界面", - imageVector = Icons.Outlined.Layers, + imageVector = PerfIcon.Layers, + onClickLabel = "打开界面日志页面", onClick = { - mainVm.navigatePage(ActivityLogPageDestination) - } - ) + mainVm.navigatePage(ActivityLogRoute) + }) } PageItemCard( title = "了解 GKD", subtitle = "查阅规则文档和常见问题", - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + imageVector = PerfIcon.HelpOutline, + onClickLabel = "打开 GKD 文档页面", onClick = { - mainVm.navigatePage(WebViewPageDestination(initUrl = HOME_PAGE_URL)) - } - ) + mainVm.navigatePage(WebViewRoute(initUrl = HOME_PAGE_URL)) + }) Spacer(modifier = Modifier.height(EmptyHeight)) } } @@ -199,14 +262,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) ) { @@ -226,18 +291,57 @@ 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, + ) } } } @Composable private fun IconTextCard( - imageVector: ImageVector, - content: @Composable () -> Unit + imageVector: ImageVector, content: @Composable () -> Unit ) { Row( modifier = Modifier @@ -245,15 +349,15 @@ private fun IconTextCard( .padding(itemVerticalPadding), verticalAlignment = Alignment.CenterVertically ) { - Icon( + PerfIcon( imageVector = imageVector, - contentDescription = null, modifier = Modifier .clip(CircleShape) .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() @@ -261,16 +365,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(), - shape = RoundedCornerShape(20.dp), - colors = surfaceCardColors, - onClick = {} - ) { + .fillMaxWidth() + .semantics { + onClick(label = "不执行操作", action = null) + }, shape = RoundedCornerShape(20.dp), colors = surfaceCardColors, onClick = {}) { Row( modifier = Modifier .fillMaxWidth() @@ -279,12 +382,10 @@ private fun ServerStatusCard(vm: HomeVm) { end = itemVerticalPadding, top = itemVerticalPadding, bottom = itemVerticalPadding / 2 - ), - verticalAlignment = Alignment.CenterVertically + ), verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Outlined.Equalizer, - contentDescription = null, + PerfIcon( + imageVector = PerfIcon.Equalizer, modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer) @@ -313,11 +414,8 @@ private fun ServerStatusCard(vm: HomeVm) { Column( modifier = Modifier .fillMaxWidth() - .padding( - horizontal = itemVerticalPadding, - ) + .padding(horizontal = itemVerticalPadding) ) { - val latestRecordDesc by vm.latestRecordDescFlow.collectAsState() val subsStatus by vm.subsStatusFlow.collectAsState() AnimatedVisibility(subsStatus.isNotEmpty()) { Text( @@ -327,18 +425,18 @@ 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 { + .clickable(onClickLabel = "前往应用的规则汇总页面", onClick = throttle { + latestRecordFlow.value?.let { mainVm.navigatePage( - AppConfigPageDestination( - appId = it.appId, - focusLog = it + AppConfigRoute( + appId = it.appId, focusLog = it ) ) } @@ -346,19 +444,20 @@ private fun ServerStatusCard(vm: HomeVm) { .fillMaxWidth() .padding(horizontal = 4.dp) ) { - GroupNameText( - modifier = Modifier - .weight(1f), - preText = "最近触发: ", - isGlobal = isGlobal, - text = latestRecordDesc ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, + Column( + modifier = Modifier.weight(1f), + ) { + 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), 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 eee0462d90..19e42b5a73 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,6 +1,5 @@ package li.songe.gkd.ui.home -import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -11,49 +10,71 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.style.ProfileTransitions +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import li.songe.gkd.ui.component.PerfIcon +import li.songe.gkd.ui.share.LocalMainViewModel -data class BottomNavItem( +sealed class BottomNavItem( + val key: Int, val label: String, val icon: ImageVector, -) +) { + object Control : BottomNavItem( + key = 0, + label = "首页", + icon = PerfIcon.Home, + ) + + object SubsManage : BottomNavItem( + key = 1, + label = "订阅", + icon = PerfIcon.FormatListBulleted, + ) + + object AppList : BottomNavItem( + key = 2, + label = "应用", + icon = PerfIcon.Apps, + ) + + object Settings : BottomNavItem( + key = 3, + label = "设置", + icon = PerfIcon.Settings, + ) + + companion object { + val allSubObjects by lazy { arrayOf(Control, SubsManage, AppList, Settings) } + } +} + +@Serializable +data object HomeRoute : NavKey -@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) - }, + onClick = { mainVm.handleClickTab(page.navItem) }, icon = { - Icon( + PerfIcon( imageVector = page.navItem.icon, - contentDescription = page.navItem.label + contentDescription = null, ) }, label = { @@ -62,6 +83,6 @@ fun HomePage() { } } }, - content = currentPage.content + content = page.content ) } 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..abe699c885 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,132 +1,79 @@ 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.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.asMutableStateFlow +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.findOption import li.songe.gkd.util.getSubsStatus -import li.songe.gkd.util.map -import li.songe.gkd.util.orderedAppInfosFlow import li.songe.gkd.util.ruleSummaryFlow -import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.usedSubsEntriesFlow -class HomeVm : ViewModel() { - - val latestRecordFlow = DbSet.actionLogDao.queryLatest().stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val latestRecordIsGlobalFlow = - latestRecordFlow.map(viewModelScope) { it?.groupType == SubsConfig.GlobalGroupType } - val latestRecordDescFlow = combine( - latestRecordFlow, subsIdToRawFlow, appInfoCacheFlow - ) { 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 - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) +class HomeVm : BaseViewModel() { 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.map(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.asMutableStateFlow( + getter = { AppSortOption.objects.findOption(it.appSort) }, + setter = { + storeFlow.value.copy(appSort = it.value) + } + ) + val showBlockAppFlow = storeFlow.asMutableStateFlow( + getter = { it.showBlockApp }, + setter = { + storeFlow.value.copy(showBlockApp = it) + } + ) + val appGroupTypeFlow = storeFlow.asMutableStateFlow( + getter = { it.appGroupType }, + setter = { + storeFlow.value.copy(appGroupType = it) + } + ) - val sortTypeFlow = storeFlow.map(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 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 } - } + } + } - 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 appFilter = useAppFilter( + appGroupTypeFlow = appGroupTypeFlow, + sortTypeFlow = sortTypeFlow, + showBlockAppFlow = showBlockAppFlow, + blockAppListFlow = blockAppListFlow, + ) + val searchStrFlow = appFilter.searchStrFlow - init { - viewModelScope.launch { - showSearchBarFlow.collect { - if (!it) { - searchStrFlow.value = "" - } + val showSearchBarFlow = MutableStateFlow(false).apply { + launchCollect { + if (!it) { + searchStrFlow.value = "" } } } + val appInfosFlow = appFilter.appListFlow -} \ No newline at end of file + val showToastInputDlgFlow = MutableStateFlow(false) + val showNotifTextInputDlgFlow = MutableStateFlow(false) + val showToastSettingsDlgFlow = MutableStateFlow(false) + val showA11yBlockDlgFlow = MutableStateFlow(false) + val showBackupDlgFlow = MutableStateFlow(false) + val showExportBackupDlgFlow = MutableStateFlow(false) +} 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 948a4dc88a..edf20f4dd9 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,94 +1,151 @@ 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.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 +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.LocalContentColor +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.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable 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 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.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.appScope +import li.songe.gkd.MainActivity +import li.songe.gkd.R +import li.songe.gkd.permission.canDrawOverlaysState +import li.songe.gkd.permission.foregroundServiceSpecialUseState +import li.songe.gkd.permission.ignoreBatteryOptimizationsState +import li.songe.gkd.permission.notificationState +import li.songe.gkd.permission.requiredPermission +import li.songe.gkd.service.StatusService +import li.songe.gkd.service.TrackService +import li.songe.gkd.service.fixRestartAutomatorService +import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow -import li.songe.gkd.ui.component.RotatingLoadingIcon +import li.songe.gkd.ui.AboutRoute +import li.songe.gkd.ui.AdvancedPageRoute +import li.songe.gkd.ui.BlockA11yAppListRoute +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 import li.songe.gkd.ui.component.SettingItem +import li.songe.gkd.ui.component.TextListDialog 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.useScrollBehaviorState 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.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight -import li.songe.gkd.ui.style.itemPadding +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.ui.theme.supportDynamicColor +import li.songe.gkd.util.AndroidTarget +import li.songe.gkd.util.BackupUtils 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.mapState +import li.songe.gkd.util.openAppDetailsSettings +import li.songe.gkd.util.saveFileToDownloads +import li.songe.gkd.util.shareFile 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 + val context = LocalActivity.current as MainActivity 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 { - mutableStateOf(store.clickToast) + mutableStateOf(store.actionToast) } - val maxCharLen = 32 + val maxCharLen = 64 AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), - title = { Text(text = "触发提示") }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = "触发提示") + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + contentDescription = "文案规则", + onClickLabel = "打开文案规则弹窗", + onClick = throttle { + showToastInputDlg = false + val confirmAction = { + mainVm.dialogFlow.value = null + showToastInputDlg = true + } + mainVm.dialogFlow.updateDialogOptions( + title = "文案规则", + text = $$"触发文案支持变量替换,规则如下\n${1} 子规则名称\n${2} 规则名称\n${3} 触发次数\n\n示例模板\n${1}/${2}/${3}\n\n替换结果\n子规则a/规则A/3", + confirmAction = confirmAction, + onDismissRequest = confirmAction, + ) + }, + ) + } + }, text = { OutlinedTextField( value = value, @@ -98,7 +155,6 @@ fun useSettingsPage(): ScaffoldExt { onValueChange = { value = it.take(maxCharLen) }, - singleLine = true, supportingText = { Text( text = "${value.length} / $maxCharLen", @@ -114,27 +170,27 @@ 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( - text = "确认", - ) + Text(text = "确认") } }, dismissButton = { TextButton(onClick = { showToastInputDlg = false }) { - Text( - text = "取消", - ) + Text(text = "取消") } } ) } + + var showNotifTextInputDlg by vm.showNotifTextInputDlgFlow.asMutableState() 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 = { @@ -144,48 +200,89 @@ fun useSettingsPage(): ScaffoldExt { modifier = Modifier.fillMaxWidth(), ) { Text(text = "通知文案") - IconButton(onClick = throttle { - 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触发", - ) - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.HelpOutline, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.HelpOutline, + contentDescription = "文案规则", + onClickLabel = "打开文案规则弹窗", + 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 = { - 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 = { + context.justHideSoftInput() + if (store.customNotifTitle != textValue || store.customNotifText != textValue) { + storeFlow.update { + it.copy( + customNotifTitle = titleValue, + customNotifText = textValue + ) + } + toast("更新成功") + } showNotifTextInputDlg = false }) { Text( @@ -202,17 +299,64 @@ fun useSettingsPage(): ScaffoldExt { }) } - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() - val scrollState = rememberScrollState() + + var showA11yBlockDlg by vm.showA11yBlockDlgFlow.asMutableState() + if (showA11yBlockDlg) { + BlockA11yDialog(onDismissRequest = { showA11yBlockDlg = false }) + } + if (vm.showBackupDlgFlow.collectAsState().value) { + TextListDialog( + onDismiss = { vm.showBackupDlgFlow.value = false }, + textList = listOf( + "导入备份" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { + val uri = context.pickFile("application/zip") + if (uri != null) { + BackupUtils.importBackUpData(uri) + } + }, + "导出备份" to { + vm.showExportBackupDlgFlow.value = true + }, + ) + ) + } + if (vm.showExportBackupDlgFlow.collectAsState().value) { + TextListDialog( + onDismiss = { vm.showExportBackupDlgFlow.value = false }, + textList = listOf( + "分享到其他应用" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { + val file = BackupUtils.exportBackUpData() + context.shareFile(file, "分享备份文件") + }, + "保存到下载" to vm.viewModelScope.launchAsFn(Dispatchers.IO) { + val file = BackupUtils.exportBackUpData() + context.saveFileToDownloads(file) + }, + ) + ) + } + + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, scrollState) = useScrollBehaviorState(scrollKey) + LaunchedEffect(null) { + mainVm.resetPageScrollEvent.collect { + if (it == BottomNavItem.Settings) { + scrollKey.intValue++ + } + } + } return ScaffoldExt( - navItem = settingsNav, + navItem = BottomNavItem.Settings, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar(scrollBehavior = scrollBehavior, title = { - Text( - text = settingsNav.label, - ) - }) + PerfTopAppBar( + scrollBehavior = scrollBehavior, + title = { + Text( + text = BottomNavItem.Settings.label, + ) + }, + ) }, ) { contentPadding -> Column( @@ -227,47 +371,84 @@ fun useSettingsPage(): ScaffoldExt { style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) - + val showToastSettingsDlg by vm.showToastSettingsDlgFlow.asMutableState() TextSwitch( title = "触发提示", - subtitle = store.clickToast, + subtitle = store.actionToast, checked = store.toastWhenClick, - modifier = Modifier.clickable { + onClickLabel = "打开触发提示弹窗", + onClick = { showToastInputDlg = true }, + suffixIcon = { + PerfCustomIconButton( + size = 32.dp, + iconSize = 20.dp, + onClickLabel = "打开提示设置弹窗", + onClick = { vm.showToastSettingsDlgFlow.update { !it } }, + id = R.drawable.ic_page_info, + contentDescription = "提示设置", + tint = if (showToastSettingsDlg) MaterialTheme.colorScheme.primary else LocalContentColor.current, + ) + }, 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 - ) - }) + AnimatedVisibility(visible = showToastSettingsDlg) { + Column { + TextSwitch( + title = "提示样式", + subtitle = "使用系统样式", + suffix = "查看限制", + onSuffixClick = { + mainVm.dialogFlow.updateDialogOptions( + title = "限制说明", + text = "系统 Toast 存在频率限制, 触发过于频繁会被系统强制不显示\n\n如果只使用开屏一类低频率规则可使用系统提示, 否则建议关闭此项使用自定义样式提示", + ) + }, + checked = store.useSystemToast, + onCheckedChange = { + storeFlow.value = store.copy( + useSystemToast = it + ) + }) + TextSwitch( + title = "轨迹提示", + subtitle = "显示触发位置信息", + checked = TrackService.isRunning.collectAsState().value, + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + mainVm.dialogFlow.waitResult( + title = "使用须知", + text = "开启「轨迹提示」后点击或滑动后会在屏幕上使用悬浮窗绘制轨迹(一段时间后消失),如果新触摸事件恰好在悬浮窗区域内,可能会被目标应用拒绝,从而导致点击或滑动无响应", + confirmText = "继续", + ) + requiredPermission(context, foregroundServiceSpecialUseState) + requiredPermission(context, notificationState) + requiredPermission(context, canDrawOverlaysState) + TrackService.start() + } else { + TrackService.stop() + } + } + ) + } } val subsStatus by vm.subsStatusFlow.collectAsState() TextSwitch( title = "通知文案", - subtitle = if (store.useCustomNotifText) store.customNotifText else subsStatus, - checked = store.useCustomNotifText, - modifier = Modifier.clickable { - showNotifTextInputDlg = true + subtitle = if (store.useCustomNotifText) { + store.customNotifTitle + " / " + store.customNotifText + } else { + subsStatus }, + checked = store.useCustomNotifText, + onClickLabel = "打开修改通知文案弹窗", + onClick = { showNotifTextInputDlg = true }, onCheckedChange = { storeFlow.value = store.copy( useCustomNotifText = it @@ -276,45 +457,57 @@ fun useSettingsPage(): ScaffoldExt { TextSwitch( title = "后台隐藏", - subtitle = "在「最近任务」隐藏本应用", + subtitle = "在「最近任务」隐藏卡片", checked = store.excludeFromRecents, - onCheckedChange = { + onCheckedChange = vm.viewModelScope.launchAsFn { + if (it) { + mainVm.dialogFlow.waitResult( + title = "后台隐藏", + text = "隐藏卡片后可能导致部分设备无法给任务卡片加锁后台,建议先加锁后再隐藏,若已加锁或没有锁后台机制请继续", + confirmText = "继续", + ) + } storeFlow.value = store.copy( - excludeFromRecents = it + excludeFromRecents = !store.excludeFromRecents ) }) - 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, - ) { + 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( - text = "重载应用列表", - style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .titleItemPadding(), + text = "无障碍", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, ) - RotatingLoadingIcon(loading = updateAppMutex.state.collectAsState().value) + } + 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) + fixRestartAutomatorService() + } + }, + ) + AnimatedVisibility(visible = lazyOn.value) { + SettingItem(title = "白名单", onClickLabel = "进入无障碍白名单页面", onClick = { + mainVm.navigatePage(BlockA11yAppListRoute) + }) } Text( - text = "主题", + text = "外观", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, @@ -322,21 +515,20 @@ fun useSettingsPage(): ScaffoldExt { TextMenu( title = "深色模式", - option = DarkThemeOption.allSubObject.findOption(store.enableDarkTheme) - ) { - storeFlow.update { s -> s.copy(enableDarkTheme = it.value) } - } + 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.value = store.copy( - enableDynamicColor = it - ) - }) + storeFlow.update { s -> s.copy(enableDynamicColor = it) } + } + ) } Text( @@ -347,14 +539,180 @@ fun useSettingsPage(): ScaffoldExt { ) SettingItem(title = "高级设置", onClick = { - mainVm.navigatePage(AdvancedPageDestination) + mainVm.navigatePage(AdvancedPageRoute) + }) + SettingItem(title = "备份恢复", onClick = { + vm.showBackupDlgFlow.value = true }) SettingItem(title = "关于", onClick = { - mainVm.navigatePage(AboutPageDestination) + mainVm.navigatePage(AboutRoute) }) Spacer(modifier = Modifier.height(EmptyHeight)) } } } + +@Composable +private fun BlockA11yDialog(onDismissRequest: () -> Unit) = FullscreenDialog(onDismissRequest) { + val mainVm = LocalMainViewModel.current + val statusRunning by StatusService.isRunning.collectAsState() + val shizukuContext by shizukuContextFlow.collectAsState() + val ignoreBatteryOptimizations by ignoreBatteryOptimizationsState.stateFlow.collectAsState() + val context = LocalActivity.current as MainActivity + Scaffold( + topBar = { + PerfTopAppBar( + navigationIcon = { + PerfIconButton( + imageVector = PerfIcon.Close, + onClickLabel = "关闭弹窗", + onClick = onDismissRequest, + ) + }, + title = { + Text(text = "局部关闭") + }, + ) + }, + bottomBar = { + BottomAppBar { + Spacer(modifier = Modifier.weight(1f)) + TextButton( + enabled = shizukuContext.ok && statusRunning && ignoreBatteryOptimizations, + 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 = !shizukuContext.ok, + imageVector = if (shizukuContext.ok) PerfIcon.Check else PerfIcon.ArrowForward, + onClick = mainVm.viewModelScope.launchAsFn(Dispatchers.IO) { + mainVm.guardShizukuContext() + }, + ) + 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, + onClickLabel = "打开忽略电池优化设置页面", + onClick = mainVm.viewModelScope.launchAsFn { + requiredPermission(context, ignoreBatteryOptimizationsState) + }, + ) + RequiredTextItem( + text = "(可选) 允许自启动", + enabled = true, + imageVector = PerfIcon.OpenInNew, + onClickLabel = "打开应用详情页面", + onClick = { + openAppDetailsSettings() + }, + ) + RequiredTextItem( + text = "(可选) 在「最近任务」锁定", + enabled = true, + imageVector = PerfIcon.OpenInNew, + onClickLabel = "打开应用详情页面", + onClick = { + val m = shizukuContextFlow.value.inputManager + if (m != null) { + m.key(KeyEvent.KEYCODE_APP_SWITCH) + } else { + toast("请先授权 Shizuku") + } + }, + ) + } + 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, + onClickLabel: String? = null, +) { + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .run { + if (onClick != null) { + clickable( + enabled = enabled, + onClick = throttle(onClick), + onClickLabel = onClickLabel + ) + } else { + this + } + } + .padding(horizontal = 4.dp), + ) { + val lineHeightDp = LocalDensity.current.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/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 9c409bfc6b..53de97f0fd 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 @@ -1,15 +1,12 @@ package li.songe.gkd.ui.home -import android.content.Intent import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,26 +17,13 @@ import androidx.compose.foundation.layout.padding 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.automirrored.filled.FormatListBulleted -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 import androidx.compose.runtime.Composable @@ -47,40 +31,41 @@ 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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable 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 import androidx.lifecycle.viewmodel.compose.viewModel -import com.dylanc.activityresult.launcher.launchForResult -import com.ramcosta.composedestinations.generated.destinations.SlowGroupPageDestination -import com.ramcosta.composedestinations.generated.destinations.UpsertRuleGroupPageDestination -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update -import li.songe.gkd.MainActivity +import li.songe.gkd.R import li.songe.gkd.data.Value -import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.store.storeFlow import li.songe.gkd.store.switchStoreEnableMatch +import li.songe.gkd.ui.SlowGroupRoute +import li.songe.gkd.ui.UpsertRuleGroupRoute +import li.songe.gkd.ui.WebViewRoute 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.ScaffoldDialog import li.songe.gkd.ui.component.SubsItemCard import li.songe.gkd.ui.component.TextMenu +import li.songe.gkd.ui.component.TextSwitch +import li.songe.gkd.ui.component.usePinnedScrollBehaviorState import li.songe.gkd.ui.component.waitResult -import li.songe.gkd.ui.local.LocalMainViewModel +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 import li.songe.gkd.util.UpdateTimeOption import li.songe.gkd.util.checkSubsUpdate @@ -89,10 +74,10 @@ 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 +import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubsMutex @@ -100,18 +85,13 @@ 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 val mainVm = LocalMainViewModel.current val vm = viewModel() val subItems by subsItemsFlow.collectAsState() - val subsIdToRaw by subsIdToRawFlow.collectAsState() + val subsIdToRaw by subsMapFlow.collectAsState() var orderSubItems by remember { mutableStateOf(subItems) @@ -141,70 +121,49 @@ fun useSubsManagePage(): ScaffoldExt { var showSettingsDlg by remember { mutableStateOf(false) } if (showSettingsDlg) { - AlertDialog( - onDismissRequest = { showSettingsDlg = false }, - title = { Text("订阅设置") }, - text = { + ScaffoldDialog( + onClose = { showSettingsDlg = false }, + title = "订阅设置", + content = { val store by storeFlow.collectAsState() - Column { - TextMenu( - modifier = Modifier.padding(0.dp, itemVerticalPadding), - title = "更新订阅", - option = UpdateTimeOption.allSubObject.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), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "耗电警告", - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = "启用多条订阅时弹窗确认", - style = MaterialTheme.typography.bodySmall, - ) - } - Checkbox( - checked = store.subsPowerWarn, - onCheckedChange = { updateValue() } - ) - } - } - }, - confirmButton = { - TextButton(onClick = { showSettingsDlg = false }) { - Text("关闭") + TextMenu( + title = "更新订阅", + option = UpdateTimeOption.objects.findOption(store.updateSubsInterval) + ) { + storeFlow.update { s -> s.copy(updateSubsInterval = it.value) } } + TextSwitch( + title = "耗电警告", + subtitle = "启用多条订阅时弹窗确认", + checked = store.subsPowerWarn, + onCheckedChange = throttle { + storeFlow.update { s -> s.copy(subsPowerWarn = it) } + } + ) } ) } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val scrollKey = rememberSaveable { mutableIntStateOf(0) } + val (scrollBehavior, lazyListState) = usePinnedScrollBehaviorState(scrollKey) + LaunchedEffect(null) { + mainVm.resetPageScrollEvent.collect { + if (it == BottomNavItem.SubsManage) { + scrollKey.intValue++ + } + } + } return ScaffoldExt( - navItem = subsNav, + 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, + contentDescription = "取消选择", + onClick = { isSelectedMode = false }, + ) } }, title = { if (isSelectedMode) { @@ -213,7 +172,7 @@ fun useSubsManagePage(): ScaffoldExt { ) } else { Text( - text = subsNav.label, + text = BottomNavItem.SubsManage.label, ) } }, actions = { @@ -225,14 +184,6 @@ fun useSubsManagePage(): ScaffoldExt { ) { Row { if (it) { - IconButton(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 { @@ -242,23 +193,22 @@ 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, + contentDescription = "删除选中订阅", + 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() @@ -267,49 +217,51 @@ fun useSubsManagePage(): ScaffoldExt { enter = scaleIn(), exit = scaleOut(), ) { - IconButton(onClick = throttle { - mainVm.navigatePage(SlowGroupPageDestination) - }) { - Icon( - imageVector = Icons.Outlined.Eco, - contentDescription = null, - ) - } - } - IconButton(onClick = throttle { switchStoreEnableMatch() }) { - val scope = rememberCoroutineScope() - val enableMatch by remember { - storeFlow.map(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 = { - showSettingsDlg = true - }) { - Icon( - painter = painterResource(id = SafeR.ic_page_info), - contentDescription = null, - ) + PerfIconButton( + imageVector = PerfIcon.Eco, + contentDescription = "缓慢查询规则列表", + onClickLabel = "查看列表", + onClick = throttle { + mainVm.navigatePage(SlowGroupRoute) + }) } + val scope = rememberCoroutineScope() + val enableMatch by remember { + storeFlow.mapState(scope) { s -> s.enableMatch } + }.collectAsState() + PerfIconButton( + id = if (enableMatch) R.drawable.ic_flash_on else R.drawable.ic_flash_off, + colors = IconButtonDefaults.iconButtonColors( + contentColor = if (!enableMatch) { + CheckboxDefaults.colors().checkedBoxColor + } else { + LocalContentColor.current + } + ), + contentDescription = "规则匹配" + if (enableMatch) "已启用" else "已禁用", + onClickLabel = "切换开关", + onClick = throttle { switchStoreEnableMatch() }, + ) + PerfIconButton( + id = R.drawable.ic_page_info, + contentDescription = "订阅设置", + onClickLabel = "打开设置弹窗", + onClick = { + showSettingsDlg = true + }) } } } - IconButton(onClick = { - if (updateSubsMutex.mutex.isLocked) { - toast("正在刷新订阅,请稍后操作") - } else { - expanded = true - } - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = null, - ) - } + PerfIconButton( + imageVector = PerfIcon.MoreVert, + contentDescription = "更多操作", + onClick = { + if (updateSubsMutex.mutex.isLocked) { + toast("正在刷新订阅,请稍后操作") + } else { + expanded = true + } + }) Box( modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { @@ -343,31 +295,12 @@ fun useSubsManagePage(): ScaffoldExt { } ) } else { - DropdownMenuItem( - text = { - Text(text = "导入本地数据") - }, - onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) { - expanded = false - val result = - context.launcher.launchForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/zip" - }) - val uri = result.data?.data - if (uri == null) { - toast("未选择文件") - return@launchAsFn - } - importData(uri) - }, - ) DropdownMenuItem( text = { Text(text = "添加应用规则") }, onClick = throttle { expanded = false mainVm.navigatePage( - UpsertRuleGroupPageDestination( + UpsertRuleGroupRoute( subsId = LOCAL_SUBS_ID, groupKey = null, appId = "", @@ -381,7 +314,7 @@ fun useSubsManagePage(): ScaffoldExt { onClick = throttle { expanded = false mainVm.navigatePage( - UpsertRuleGroupPageDestination( + UpsertRuleGroupRoute( subsId = LOCAL_SUBS_ID, groupKey = null, appId = null, @@ -398,6 +331,8 @@ fun useSubsManagePage(): ScaffoldExt { }, floatingActionButton = { AnimationFloatingActionButton( + contentDescription = "添加订阅", + onClickLabel = "打开添加订阅弹窗", visible = !isSelectedMode, onClick = { if (updateSubsMutex.mutex.isLocked) { @@ -408,16 +343,11 @@ fun useSubsManagePage(): ScaffoldExt { mainVm.addOrModifySubs(url) } } - } - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - ) - } + }, + imageVector = PerfIcon.Add, + ) }, ) { contentPadding -> - val lazyListState = rememberLazyListState() val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to -> orderSubItems = orderSubItems.toMutableList().apply { @@ -443,7 +373,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, ) { @@ -478,7 +408,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 -> @@ -493,7 +422,7 @@ fun useSubsManagePage(): ScaffoldExt { modifier = Modifier.clickable(onClick = throttle { mainVm.dialogFlow.value = null mainVm.navigatePage( - WebViewPageDestination( + WebViewRoute( initUrl = ShortUrlSet.URL6 ) ) @@ -525,7 +454,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/icon/BackCloseIcon.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt index 2d2265f25d..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,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.TooltipIconButtonBox 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 +) = TooltipIconButtonBox( + contentDescription = 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) "back" else "close" + this.contentDescription = contentDescription this.role = Role.Image }, ) { 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/icon/ToggleMid.kt b/app/src/main/kotlin/li/songe/gkd/ui/icon/ToggleMid.kt new file mode 100644 index 0000000000..9999a03422 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/icon/ToggleMid.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 ToggleMid: 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(280f, 720f) + quadToRelative(-100f, 0f, -170f, -70f) + reflectiveQuadTo(40f, 480f) + quadToRelative(0f, -100f, 70f, -170f) + reflectiveQuadToRelative(170f, -70f) + horizontalLineToRelative(400f) + quadToRelative(100f, 0f, 170f, 70f) + reflectiveQuadToRelative(70f, 170f) + quadToRelative(0f, 100f, -70f, 170f) + reflectiveQuadToRelative(-170f, 70f) + lineTo(280f, 720f) + close() + moveTo(280f, 640f) + horizontalLineToRelative(400f) + quadToRelative(66f, 0f, 113f, -47f) + reflectiveQuadToRelative(47f, -113f) + quadToRelative(0f, -66f, -47f, -113f) + reflectiveQuadToRelative(-113f, -47f) + lineTo(280f, 320f) + quadToRelative(-66f, 0f, -113f, 47f) + reflectiveQuadToRelative(-47f, 113f) + quadToRelative(0f, 66f, 47f, 113f) + reflectiveQuadToRelative(113f, 47f) + close() + moveTo(565f, 565f) + quadToRelative(35f, -35f, 35f, -85f) + reflectiveQuadToRelative(-35f, -85f) + quadToRelative(-35f, -35f, -85f, -35f) + reflectiveQuadToRelative(-85f, 35f) + quadToRelative(-35f, 35f, -35f, 85f) + reflectiveQuadToRelative(35f, 85f) + quadToRelative(35f, 35f, 85f, 35f) + reflectiveQuadToRelative(85f, -35f) + close() + } + }.build() + + return _IconName!! + } + +@Suppress("ObjectPropertyName") +private var _IconName: ImageVector? = null diff --git a/app/src/main/kotlin/li/songe/gkd/ui/local/LocalExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/local/LocalExt.kt deleted file mode 100644 index 415645fc52..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/local/LocalExt.kt +++ /dev/null @@ -1,18 +0,0 @@ -package li.songe.gkd.ui.local - -import androidx.compose.runtime.compositionLocalOf -import androidx.navigation.NavHostController -import li.songe.gkd.MainViewModel - - -val LocalNavController = compositionLocalOf { - error("not found NavHostController") -} - -val LocalMainViewModel = compositionLocalOf { - error("not found MainViewModel") -} - -val LocalDarkTheme = compositionLocalOf { - error("not found LocalDarkTheme") -} 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..5133efa45a --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt @@ -0,0 +1,203 @@ +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 +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.data.RawSubscription +import li.songe.gkd.db.DbSet +import li.songe.gkd.store.blockMatchAppListFlow +import li.songe.gkd.util.AppGroupOption +import li.songe.gkd.util.AppSortOption +import li.songe.gkd.util.appInfoMapFlow +import li.songe.gkd.util.collator +import li.songe.gkd.util.visibleAppInfosFlow + +class AppFilter( + val searchStrFlow: MutableStateFlow, + val appListFlow: StateFlow>, + val showAllAppFlow: StateFlow, +) + +fun BaseViewModel.useAppFilter( + appGroupTypeFlow: StateFlow, + sortTypeFlow: StateFlow, + appOrderListFlow: StateFlow> = MainViewModel.instance.appOrderListFlow, + showBlockAppFlow: StateFlow? = null, + blockAppListFlow: StateFlow> = blockMatchAppListFlow, +): AppFilter { + + var tempListFlow: Flow> = visibleAppInfosFlow + + if (showBlockAppFlow != null) { + tempListFlow = combine( + tempListFlow, + showBlockAppFlow, + blockAppListFlow, + ) { appInfos, showBlockApp, blockAppList -> + if (showBlockApp) { + appInfos + } else { + appInfos.filterNot { it.id in blockAppList } + } + } + } + + tempListFlow = combine( + tempListFlow, + appGroupTypeFlow, + ) { list, type -> + if (type == 0) { + return@combine emptyList() + } + if (AppGroupOption.normalObjects.all { it.include(type) }) { + return@combine list + } + var resultList = list + if (!AppGroupOption.SystemGroup.include(type)) { + resultList = resultList.filterNot { it.isSystem } + } + if (!AppGroupOption.UserGroup.include(type)) { + resultList = resultList.filterNot { !it.isSystem } + } + resultList + } + + val showAllAppFlow = combine( + tempListFlow, + visibleAppInfosFlow, + ) { a, b -> + a.size == b.size + }.stateInit(true) + + val searchStrFlow = MutableStateFlow("") + val debounceSearchStrFlow = searchStrFlow.debounce(200) + .stateInit(searchStrFlow.value) + val appActionOrderMapFlow = appOrderListFlow.map { + it.mapIndexed { i, appId -> appId to i }.toMap() + } + 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, + showAllAppFlow = showAllAppFlow, + ) +} + +fun BaseViewModel.useSubsAppFilter( + subsId: Long, + appsFlow: StateFlow>, + sortTypeFlow: StateFlow, + appGroupTypeFlow: StateFlow, + showBlockAppFlow: StateFlow, +): StateFlow> { + var tempListFlow: Flow> = appsFlow + tempListFlow = combine( + tempListFlow, + appInfoMapFlow, + ) { apps, appMap -> + apps.sortedWith { a, b -> + // 默认顺序: 已安装(有名字->无名字)->未安装(有名字(来自订阅)->无名字) + val x = appMap[a.id]?.name ?: a.name?.let { "\uFFFF" + it } + ?: ("\uFFFF\uFFFF" + a.id) + val y = appMap[b.id]?.name ?: b.name?.let { "\uFFFF" + it } + ?: ("\uFFFF\uFFFF" + b.id) + collator.compare(x, y) + } + } + val appActionOrderMapFlow = DbSet.actionLogDao + .queryLatestUniqueAppIds(subsId) + .map { + it.mapIndexed { i, appId -> appId to i }.toMap() + } + tempListFlow = combine( + tempListFlow, + sortTypeFlow, + appActionOrderMapFlow, + MainViewModel.instance.appVisitOrderMapFlow, + ) { apps, sortType, appIdToOrder, appVisitOrderMap -> + when (sortType) { + AppSortOption.ByActionTime -> { + apps.sortedBy { a -> appIdToOrder[a.id] ?: Int.MAX_VALUE } + } + + AppSortOption.ByAppName -> { + apps + } + + AppSortOption.ByUsedTime -> { + apps.sortedBy { a -> appVisitOrderMap[a.id] ?: Int.MAX_VALUE } + } + } + } + tempListFlow = combine( + tempListFlow, + appGroupTypeFlow, + appInfoMapFlow, + ) { apps, appGroupType, appMap -> + if (appGroupType == 0) { + emptyList() + } else if (AppGroupOption.allObjects.all { it.include(appGroupType) }) { + apps + } else { + var tempList = apps + if (!AppGroupOption.SystemGroup.include(appGroupType)) { + tempList = tempList.filterNot { appMap[it.id]?.isSystem == true } + } + if (!AppGroupOption.UserGroup.include(appGroupType)) { + tempList = tempList.filterNot { appMap[it.id]?.isSystem == false } + } + if (!AppGroupOption.UnInstalledGroup.include(appGroupType)) { + tempList = tempList.filterNot { appMap[it.id] == null } + } + tempList + } + } + tempListFlow = combine( + tempListFlow, + showBlockAppFlow, + blockMatchAppListFlow + ) { apps, showBlock, blockSet -> + if (showBlock) { + apps + } else { + apps.filterNot { it.id in blockSet } + } + } + return tempListFlow.stateInit(appsFlow.value) +} 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..93e2a3b5b2 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt @@ -0,0 +1,68 @@ +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 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.mapNew { 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) + } + + 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/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/share/LocalExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt new file mode 100644 index 0000000000..7eb9e9b4f4 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt @@ -0,0 +1,14 @@ +package li.songe.gkd.ui.share + +import androidx.compose.runtime.staticCompositionLocalOf +import li.songe.gkd.MainViewModel + +val LocalMainViewModel = staticCompositionLocalOf { + error("not found MainViewModel") +} + +val LocalDarkTheme = staticCompositionLocalOf { false } + +val LocalIsTalkbackEnabled = staticCompositionLocalOf { + false +} 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..345f1a6b4d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt @@ -0,0 +1,62 @@ +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 { + val setter: (T) -> Unit = { stateFlow.value = it } + override var value: T + get() = state.value + set(newValue) = setter(newValue) + + override fun component1() = value + override fun component2() = setter + } + } +} + +@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 6430c5b860..bb9e4809ad 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,23 +2,21 @@ package li.songe.gkd.ui.style import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MenuDefaults +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalTextStyle +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 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, @@ -28,13 +26,24 @@ fun Modifier.titleItemPadding(showTop: Boolean = true) = this.padding( fun Modifier.appItemPadding() = this.padding(itemHorizontalPadding, itemVerticalPadding) -fun Modifier.menuPadding() = this - .padding(MenuDefaults.DropdownMenuItemContentPadding) - .padding(vertical = 8.dp) - fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { - return this.padding( + return padding( top = values.calculateTopPadding(), - // 被 LazyColumn( 使用时, 移除 bottom padding, 否则 底部导航栏 无法实现透明背景 + // 被 LazyXXX 使用时, 移除 bottom padding, 否则 底部导航栏 无法实现透明背景 ) } + +@Composable +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 if (square) { + padding((lineHeightDp - fontSizeDp) / 2).size(fontSizeDp) + } else { + size(height = lineHeightDp, width = fontSizeDp) + } +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/style/ProfileTransitions.kt b/app/src/main/kotlin/li/songe/gkd/ui/style/ProfileTransitions.kt deleted file mode 100644 index df1f9b480d..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/style/ProfileTransitions.kt +++ /dev/null @@ -1,32 +0,0 @@ -package li.songe.gkd.ui.style - -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.navigation.NavBackStackEntry -import com.ramcosta.composedestinations.spec.DestinationStyle - -typealias EnterTransitionType = AnimatedContentTransitionScope.() -> EnterTransition? -typealias ExitTransitionType = AnimatedContentTransitionScope.() -> ExitTransition? - -object ProfileTransitions : DestinationStyle.Animated() { - override val enterTransition: EnterTransitionType = { - slideInHorizontally(tween()) { it } - } - - override val exitTransition: ExitTransitionType = { - slideOutHorizontally(tween()) { -it } + fadeOut(tween()) - } - - override val popEnterTransition: EnterTransitionType = { - slideInHorizontally(tween()) { -it } - } - - override val popExitTransition: ExitTransitionType = { - slideOutHorizontally(tween()) { it } - } -} \ No newline at end of file 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 55% 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 ab9e0d8261..5aa5a3bab5 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,6 @@ -package li.songe.gkd.ui.theme +package li.songe.gkd.ui.style -import android.os.Build +import android.view.accessibility.AccessibilityManager import androidx.activity.compose.LocalActivity import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -13,43 +13,87 @@ 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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.core.view.WindowInsetsControllerCompat -import li.songe.gkd.ui.local.LocalDarkTheme -import li.songe.gkd.ui.local.LocalMainViewModel +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.share.LocalDarkTheme +import li.songe.gkd.ui.share.LocalIsTalkbackEnabled +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( + 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 { 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() - 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) + AndroidTarget.S && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app) + AndroidTarget.S && 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 + } + } + val bg = colorScheme.background.toArgb() + LaunchedEffect(darkTheme, bg) { + activity.window.decorView.setBackgroundColor(bg) + } + } + + var isTalkbackEnabled by remember { mutableStateOf(app.a11yManager.isTouchExplorationEnabled) } + DisposableEffect(null) { + val listener = AccessibilityManager.TouchExplorationStateChangeListener { + isTalkbackEnabled = it + } + app.a11yManager.addTouchExplorationStateChangeListener(listener) + onDispose { + app.a11yManager.removeTouchExplorationStateChangeListener(listener) } } - CompositionLocalProvider(LocalDarkTheme provides darkTheme) { + CompositionLocalProvider( + LocalDarkTheme provides darkTheme, + LocalIsTalkbackEnabled provides isTalkbackEnabled + ) { MaterialTheme( colorScheme = colorScheme.animation(), content = content, 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..4d68896580 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt @@ -0,0 +1,38 @@ +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 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 + + /** Android 17+ */ + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.CINNAMON_BUN) + val CINNAMON_BUN = Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN +} \ 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 a09382ff90..923c3f80cc 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -4,62 +4,62 @@ 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 -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.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.withContext -import li.songe.gkd.META +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 +import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.data.toAppInfo +import li.songe.gkd.data.toAppInfoAndIcon import li.songe.gkd.permission.canQueryPkgState -import kotlin.time.Duration.Companion.days +import li.songe.gkd.shizuku.currentUserId +import li.songe.gkd.shizuku.shizukuContextFlow val userAppInfoMapFlow = MutableStateFlow(emptyMap()) -val allPackageInfoMapFlow = MutableStateFlow(emptyMap>()) +val userAppIconMapFlow = MutableStateFlow(emptyMap()) val otherUserAppInfoMapFlow = MutableStateFlow(emptyMap()) -val appInfoCacheFlow by lazy { +val otherUserAppIconMapFlow = MutableStateFlow(emptyMap()) + +val appInfoMapFlow by lazy { combine(otherUserAppInfoMapFlow, userAppInfoMapFlow) { a, b -> a + b } .stateIn(appScope, SharingStarted.Eagerly, emptyMap()) } +val appIconMapFlow by lazy { + combine(otherUserAppIconMapFlow, userAppIconMapFlow) { a, b -> a + b } + .stateIn(appScope, SharingStarted.Eagerly, emptyMap()) +} + val systemAppInfoCacheFlow by lazy { - appInfoCacheFlow.map(appScope) { c -> + appInfoMapFlow.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 -> - c.values.sortedWith { a, b -> +val visibleAppInfosFlow by lazy { + appInfoMapFlow.mapState(appScope) { c -> + c.values.filterNot { it.hidden }.sortedWith { a, b -> collator.compare(a.name, b.name) } } } -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.map(appScope) { it.getMayQueryPkgNoAccess() } -} - private val willUpdateAppIds by lazy { MutableStateFlow(emptySet()) } private val packageReceiver by lazy { @@ -71,14 +71,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,81 +89,153 @@ 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 val updateAppMutex = MutexState() -private var lastUpdateAppListTime = 0L -private suspend fun updateAppInfo(appIds: Set) { - if (appIds.isEmpty()) return - 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) +private fun updateOtherUserAppInfo(userAppInfoMap: Map? = null) { + val pkgManager = shizukuContextFlow.value.packageManager + val userManager = shizukuContextFlow.value.userManager + val actualUserAppInfoMap = userAppInfoMap ?: userAppInfoMapFlow.value + if (pkgManager == null || userManager == null || actualUserAppInfoMap.isEmpty()) { + 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 + ).filterNot { actualUserAppInfoMap.contains(it.packageName) } + } + val newIconMap = HashMap() + val newAppMap = HashMap() + userPackageInfoMap.forEach { (userId, pkgInfoList) -> + pkgInfoList.forEach { pkgInfo -> + if (!newAppMap.contains(pkgInfo.packageName)) { + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon(userId) + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon + } } } - userAppInfoMapFlow.value = newMap } + otherUserMapFlow.value = otherUsers.associateBy { it.id } + otherUserAppInfoMapFlow.value = newAppMap + otherUserAppIconMapFlow.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() - } +private fun updatePartAppInfo( + appIds: Set, +) = updateAppMutex.launchTry(appScope, Dispatchers.IO) { + willUpdateAppIds.update { it - appIds } + val newAppMap = HashMap(userAppInfoMapFlow.value) + val newIconMap = HashMap(userAppIconMapFlow.value) + appIds.forEach { appId -> + val info = app.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) + } + } + updateOtherUserAppInfo(newAppMap) + userAppInfoMapFlow.value = newAppMap + userAppIconMapFlow.value = newIconMap +} + +val appListAuthAbnormalFlow = MutableStateFlow(false) + +fun updateAllAppInfo(): Unit = updateAppMutex.launchTry(appScope, Dispatchers.IO) { + val newAppMap = HashMap() + val newIconMap = HashMap() + // see #1169 DeadObjectException BadParcelableException + val pkgList = app.packageManager.getInstalledPackages(PKG_FLAGS) + pkgList.forEach { pkgInfo -> + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon() + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon + } } - if (!canQueryPkgState.updateAndGet() || appMap.getMayQueryPkgNoAccess()) { - withContext(Dispatchers.IO) { - arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).map { action -> - app.packageManager.queryIntentActivities( - Intent(action), - PackageManager.MATCH_DISABLED_COMPONENTS, - ) - }.flatten().map { it.activityInfo.packageName }.toSet().forEach { appId -> - if (!appMap.contains(appId)) { - getPkgInfo(appId)?.let { - appMap[appId] = it.toAppInfo() + 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 -> + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon() + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon + } + } + } else { + val visiblePkgList = + arrayOf(Intent.ACTION_MAIN, Intent.ACTION_VIEW).asSequence().flatMap { action -> + try { + // DeadObjectException BadParcelableException + app.packageManager.queryIntentActivities( + Intent(action), + PackageManager.MATCH_DISABLED_COMPONENTS + ) + } catch (_: Throwable) { + emptyList() } } + .map { it.activityInfo.packageName }.toSet() + .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) }.toList() + visiblePkgList.forEach { pkgInfo -> + val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon(hidden = false) + newAppMap[pkgInfo.packageName] = appInfo + if (appIcon != null) { + newIconMap[pkgInfo.packageName] = appIcon + } } } } - 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() } + updateOtherUserAppInfo(newAppMap) + userAppInfoMapFlow.value = newAppMap + userAppIconMapFlow.value = newIconMap + if (!app.justStarted) { + toast("应用列表更新成功") + } + if (canQueryPkgState.value && mayAuthDenied && app.justStarted) { + // 概率出现:即使有「读取应用列表权限」在刚启动时也只能获取到少量应用,延迟几秒再试一次 + appScope.launch { + delay(App.START_WAIT_TIME) + updateAllAppInfo() + } } } fun initAppState() { packageReceiver - appScope.launchTry(Dispatchers.IO) { + updateAllAppInfo() + appScope.launchTry { + shizukuContextFlow.drop(1).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/BackupUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/BackupUtils.kt new file mode 100644 index 0000000000..85e219eb23 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/BackupUtils.kt @@ -0,0 +1,133 @@ +package li.songe.gkd.util + +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import li.songe.gkd.data.AppConfig +import li.songe.gkd.data.CategoryConfig +import li.songe.gkd.data.RawSubscription +import li.songe.gkd.data.SubsConfig +import li.songe.gkd.data.SubsItem +import li.songe.gkd.db.DbSet +import li.songe.gkd.store.a11yScopeAppListFlow +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 java.io.File + +@Serializable +private data class DbData( + val subsItems: List?, + val subsConfigs: List?, + val categoryConfigs: List?, + val appConfigs: List?, +) + +object BackupUtils { + private val backupStoreFlowList + get() = listOf( + storeFlow, + actionCountFlow, + blockMatchAppListFlow, + blockA11yAppListFlow, + a11yScopeAppListFlow, + ) + + suspend fun exportBackUpData(): File { + val tempDir = createGkdTempDir() + tempDir.resolve("store").run { + mkdir() + backupStoreFlowList.forEach { storeFlow -> + resolve(storeFlow.filename).writeText(storeFlow.encodeSelf()) + } + } + tempDir.resolve("db.json").writeText( + json.encodeToString( + DbData( + subsItems = DbSet.subsItemDao.queryAll(), + subsConfigs = DbSet.subsConfigDao.queryAll(), + categoryConfigs = DbSet.categoryConfigDao.queryAll(), + appConfigs = DbSet.appConfigDao.queryAll(), + ) + ) + ) + tempDir.resolve("subscription").run { + mkdir() + subsMapFlow.value.values.forEach { subs -> + resolve("${subs.id}.json").writeText(json.encodeToString(subs)) + } + } + val file = sharedDir.resolve("gkd-backup-${System.currentTimeMillis()}.zip") + ZipUtils.zipFiles(tempDir.listFiles()!!.filterNotNull(), file) + tempDir.deleteRecursively() + return file + } + + suspend fun importBackUpData(uri: Uri) { + toast("导入备份中...") + val tempDir = createGkdTempDir() + val zipFile = tempDir.resolve("file.zip").apply { + writeBytes(UriUtils.uri2Bytes(uri)) + } + val unzipDir = tempDir.resolve("unzip") + try { + ZipUtils.unzipFile(zipFile, unzipDir) + zipFile.delete() + } catch (e: Exception) { + LogUtils.d("importBackUpData.unzipFile", e) + toast("解压失败,非法备份文件") + tempDir.deleteRecursively() + return + } + backupStoreFlowList.forEach { storeFlow -> + val file = unzipDir.resolve("store/${storeFlow.filename}") + if (file.exists() && file.isFile) { + try { + storeFlow.updateByDecode(file.readText()) + } catch (e: Exception) { + LogUtils.d("importBackUpData.updateByDecode", storeFlow.filename, e) + } + } + } + val dbFile = unzipDir.resolve("db.json") + if (dbFile.exists() && dbFile.isFile) { + val dbData = withContext(Dispatchers.Default) { + json.decodeFromString(dbFile.readText()) + } + if (!dbData.subsItems.isNullOrEmpty()) { + DbSet.subsItemDao.insertOrIgnore(*dbData.subsItems.toTypedArray()) + } + if (!dbData.subsConfigs.isNullOrEmpty()) { + DbSet.subsConfigDao.insertOrIgnore(*dbData.subsConfigs.toTypedArray()) + } + if (!dbData.categoryConfigs.isNullOrEmpty()) { + DbSet.categoryConfigDao.insertOrIgnore(*dbData.categoryConfigs.toTypedArray()) + } + if (!dbData.appConfigs.isNullOrEmpty()) { + DbSet.appConfigDao.insertOrIgnore(*dbData.appConfigs.toTypedArray()) + } + } + val subsDir = unzipDir.resolve("subscription") + if (subsDir.exists() && subsDir.isDirectory) { + (subsDir.listFiles { + it.isFile && it.name.endsWith(".json") + } ?: emptyArray()).filterNotNull().forEach { file -> + try { + val subs = withContext(Dispatchers.Default) { + json.decodeFromString(file.readText()) + } + updateSubscription(subs) + } catch (e: Exception) { + LogUtils.d("importBackUpData.saveSubs", file.name, e) + } + } + } + toast("导入成功") + tempDir.deleteRecursively() + delay(1000) + checkSubsUpdate(false) + } +} \ 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..bdbbf08fda --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/BarUtils.kt @@ -0,0 +1,52 @@ +package li.songe.gkd.util + +import android.annotation.SuppressLint +import android.content.res.Resources +import android.graphics.Rect +import android.view.WindowInsets +import android.view.accessibility.AccessibilityWindowInfo +import androidx.annotation.WorkerThread +import li.songe.gkd.a11y.A11yRuleEngine +import li.songe.gkd.app + +@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) + } + + @WorkerThread + fun checkStatusBarVisible(): Boolean? { + val r = if (AndroidTarget.R) { + // 后台/小窗模式下依然可判断 + app.windowManager.currentWindowMetrics.windowInsets.getInsets(WindowInsets.Type.statusBars()).top > 0 + } else { + null + } + if (r == false) return r + val windows = A11yRuleEngine.compatWindows() + val rect = Rect() // Rect(0, 0 - 1280, 152) + if (windows.isNotEmpty()) { + return windows.any { w -> + w.getBoundsInScreen(rect) + w.type == AccessibilityWindowInfo.TYPE_SYSTEM + && !w.isFocused && !w.isActive + && rect.top == 0 && rect.left == 0 && rect.right == ScreenUtils.getScreenWidth() + && rect.bottom <= getStatusBarHeight() * 2 + } + } + return r + } +} \ 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 13334d0ae0..812689b764 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/" @@ -26,17 +24,16 @@ 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 URL15 = "https://gkd.li?r=15" } const val shizukuAppId = "moe.shizuku.privileged.api" -const val shizukuMiniVersionCode = 1049 - -const val LIST_PLACEHOLDER_KEY = PI 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/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/CoroutineExt.kt b/app/src/main/kotlin/li/songe/gkd/util/CoroutineExt.kt index 07fe02b4bc..ff7bc4daf1 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/CoroutineExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/CoroutineExt.kt @@ -1,16 +1,16 @@ package li.songe.gkd.util import androidx.compose.runtime.Composable -import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch import kotlinx.coroutines.yield +import li.songe.gkd.data.RpcError import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.coroutineContext fun CoroutineScope.launchTry( context: CoroutineContext = EmptyCoroutineContext, @@ -24,10 +24,9 @@ fun CoroutineScope.launchTry( e.printStackTrace() } catch (_: InterruptRuleMatchException) { } catch (e: Throwable) { - e.printStackTrace() LogUtils.d(e) if (!silent) { - toast(e.message ?: e.stackTraceToString()) + toast(e.message ?: e.stackTraceToString(), loc = "", forced = e is RpcError) } } } @@ -44,8 +43,8 @@ fun CoroutineScope.launchAsFn( } catch (e: CancellationException) { e.printStackTrace() } catch (e: Throwable) { - e.printStackTrace() - toast(e.message ?: e.stackTraceToString()) + LogUtils.d(e) + toast(e.message ?: e.stackTraceToString(), loc = "") } } } @@ -62,14 +61,14 @@ fun CoroutineScope.launchAsFn( } catch (e: CancellationException) { e.printStackTrace() } catch (e: Throwable) { - e.printStackTrace() - toast(e.message ?: e.stackTraceToString()) + LogUtils.d(e) + toast(e.message ?: e.stackTraceToString(), loc = "") } } } suspend fun stopCoroutine(): Nothing { - coroutineContext[Job]?.cancel() + currentCoroutineContext()[Job]?.cancel() yield() // the following code will not be run throw CancellationException("Coroutine stopped") 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/FolderExt.kt b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt index 20d853f37d..c461720513 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt @@ -1,9 +1,17 @@ package li.songe.gkd.util import android.text.format.DateUtils -import com.blankj.utilcode.util.LogUtils -import com.blankj.utilcode.util.ZipUtils +import androidx.annotation.WorkerThread +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +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 fun File.autoMk(): File { @@ -14,20 +22,36 @@ fun File.autoMk(): File { } private val filesDir: File by lazy { - app.getExternalFilesDir(null) ?: error("failed getExternalFilesDir") + val markFile = app.filesDir.resolve(".gkd") + if (markFile.isFile) { + app.filesDir + } else { + // fix #1333 + app.getExternalFilesDir(null) ?: app.filesDir.also { + markFile.createNewFile() + } + } } 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 get() = filesDir.resolve("subscription").autoMk() val snapshotFolder: File get() = filesDir.resolve("snapshot").autoMk() +val logFolder: File + get() = filesDir.resolve("log").autoMk() +val crashFolder: File + get() = filesDir.resolve("crash").autoMk() +val crashTempFolder: File + get() = filesDir.resolve("crash/temp").autoMk() val privateStoreFolder: File - get() = app.filesDir.resolve("store").autoMk() + get() = app.filesDir.resolve("private-store").autoMk() private val cacheDir by lazy { app.externalCacheDir ?: app.cacheDir } val coilCacheDir: File @@ -37,7 +61,7 @@ val sharedDir: File private val tempDir: File get() = cacheDir.resolve("temp").autoMk() -fun createTempDir(): File { +fun createGkdTempDir(): File { return tempDir .resolve(System.currentTimeMillis().toString()) .apply { mkdirs() } @@ -60,12 +84,40 @@ 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(appInfoCacheFlow.value.values.toList())) + val tempDir = createGkdTempDir() + val files = mutableListOf(dbFolder, storeFolder, subsFolder, logFolder, crashFolder) + tempDir.resolve("apps.json").also { + it.writeText(json.encodeToString(AppJsonData())) + files.add(it) + } + tempDir.resolve("shizuku.txt").also { + it.writeText(shizukuContextFlow.value.states.joinToString("\n") { state -> + state.first + ": " + state.second.toString() + }) + files.add(it) + } + tempDir.resolve("permission.txt").also { + it.writeText(allPermissionStates.joinToString("\n") { state -> + state.name + ": " + state.stateFlow.value.toString() + }) + it.appendText("\nappListAuthAbnormalFlow: ${appListAuthAbnormalFlow.value}") + files.add(it) + } + val formattedJson = Json(from = json) { + prettyPrint = true + } + tempDir.resolve("gkd-${META.versionCode}-v${META.versionName}.json").also { + it.writeText(formattedJson.encodeToString(META)) 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 bdd77b92cb..5706f7c507 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 @@ -20,7 +16,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.window.DialogProperties -import com.ramcosta.composedestinations.generated.destinations.WebViewPageDestination import io.ktor.client.call.body import io.ktor.client.plugins.onUpload import io.ktor.client.request.forms.MultiPartFormDataContent @@ -40,8 +35,11 @@ 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.WebViewRoute +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.local.LocalMainViewModel +import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.json5.Json5 import java.io.File @@ -139,7 +137,8 @@ suspend fun uploadFileToGithub( cookie, """ { - query: '50e7774b5a519b88858e02e46e0348da', + persistedQueryName: 'addCommentMutation', + query: 'edafa18ab5734f05c9893cbc92d0dfb1', variables: { connections: [ 'client:I_kwDOJ3SWBc6viUWN:__Issue__backTimelineItems_connection(visibleEventsOnly:true)', @@ -153,6 +152,8 @@ suspend fun uploadFileToGithub( """.json5ToJsonString() ) val commentResult = json.decodeFromString(commentResultResp.bodyAsText()) + + // "xxx" val commentId = (commentResult.jsonObject["data"] ?.jsonObject["addComment"] ?.jsonObject["timelineEdge"] @@ -167,6 +168,7 @@ suspend fun uploadFileToGithub( cookie, """ { + persistedQueryName: 'updateIssueSubscriptionMutation', query: 'd0752b2e49295017f67c84f21bfe41a3', variables: { input: { state: 'UNSUBSCRIBED', subscribableId: 'I_kwDOJ3SWBc6viUWN' }, @@ -180,9 +182,13 @@ suspend fun uploadFileToGithub( cookie, """ { + persistedQueryName: 'deleteIssueCommentMutation', query: 'b0f125991160e607a64d9407db9c01b3', variables: { - connections: [], + connections: [ + 'client:I_kwDOJ3SWBc6viUWN:__Issue__frontTimelineItems_connection(visibleEventsOnly:true)', + 'client:I_kwDOJ3SWBc6viUWN:__Issue__backTimelineItems_connection(visibleEventsOnly:true)', + ], input: { id: $commentId }, }, } @@ -211,15 +217,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(WebViewRoute(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 112336b60d..f8210f9b1c 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt @@ -1,22 +1,23 @@ 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 import android.net.Uri -import android.os.Build import android.os.Environment import android.provider.MediaStore import android.provider.Settings import android.webkit.MimeTypeMap import androidx.core.content.FileProvider 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 @@ -43,14 +44,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) @@ -63,6 +57,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} 到下载") } @@ -94,6 +95,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() @@ -134,6 +143,15 @@ 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}", forced = true) } } + +val Intent.extraCptName: ComponentName? + get() = if (AndroidTarget.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/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 8de8f8e64d..55bf838334 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt @@ -1,125 +1,100 @@ package li.songe.gkd.util -import android.view.accessibility.AccessibilityEvent -import com.blankj.utilcode.util.LogUtils +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 java.util.WeakHashMap +import kotlinx.coroutines.launch +import li.songe.loc.Loc -private val callbacksMap by lazy { WeakHashMap>>() } +typealias CbFn = () -> Unit @Suppress("UNCHECKED_CAST") -private fun Any.getCallbacks(method: Int): MutableList { - return callbacksMap.getOrPut(this) { hashMapOf() } - .getOrPut(method) { mutableListOf() } as MutableList +private fun OnSimpleLife.cbs(method: Int): MutableList = synchronized(this) { + return cbMap.getOrPut(method) { mutableListOf() } as MutableList } -interface CanOnCallback +interface OnSimpleLife { + val cbMap: HashMap> -interface OnCreate : CanOnCallback { -// fun onBeforeCreate(f: () -> Unit) { -// getCallbacks<() -> Unit>(1).add(f) -// } -// -// fun onBeforeCreate() { -// getCallbacks<() -> Unit>(1).forEach { it() } -// } + fun onCreated(f: CbFn) = cbs(1).add(f) + fun onCreated() = cbs(1).forEach { it() } - fun onCreated(f: () -> Unit) { - getCallbacks<() -> Unit>(2).add(f) - } - - fun onCreated() { - getCallbacks<() -> Unit>(2).forEach { it() } - } -} + fun onDestroyed(f: CbFn) = cbs(2).add(f) + fun onDestroyed() = cbs(2).forEach { it() } -interface OnDestroy : CanOnCallback { - fun onDestroyed(f: () -> Unit) { - getCallbacks<() -> Unit>(4).add(f) + fun useLogLifecycle(@Loc loc: String = "") { + onCreated { LogUtils.d("onCreated -> " + this::class.simpleName, loc = loc) } + onDestroyed { LogUtils.d("onDestroyed -> " + this::class.simpleName, loc = loc) } + if (this is OnA11yLife) { + onA11yConnected { + LogUtils.d( + "onA11yConnected -> " + this::class.simpleName, + loc = loc, + ) + } + } + if (this is OnTileLife) { + onTileClicked { LogUtils.d("onTileClicked -> " + this::class.simpleName, loc = loc) } + } } - fun onDestroyed() { - getCallbacks<() -> Unit>(4).forEach { it() } - } -} + val scope: CoroutineScope -interface OnA11yEvent : CanOnCallback { - val a11yEventCallbacks: MutableList<(AccessibilityEvent) -> Unit> - get() = getCallbacks(6) - - 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, + delayMillis: Long = 0L, + @Loc loc: String = "", + ) { + onCreated { + toast("${name}已启动", loc = loc, delayMillis = delayMillis) + } + onDestroyed { + toast("${name}已关闭", loc = loc) + } + } + + fun runScopePost(delayMillis: Long, r: Runnable) { + if (delayMillis == 0L && isMainThread) { + r.run() + return + } + scope.launch(Dispatchers.Main) { + delay(delayMillis) + r.run() + } } } -interface OnA11yConnected : CanOnCallback { - fun onA11yConnected(f: () -> Unit) { - getCallbacks<() -> Unit>(8).add(f) - } - - fun onA11yConnected() { - getCallbacks<() -> Unit>(8).forEach { it() } - } +open class DefaultSimpleLifeImpl : OnSimpleLife { + override val cbMap: HashMap> = hashMapOf() + override val scope: CoroutineScope by lazy { MainScope().apply { onDestroyed { cancel() } } } } -interface OnChangeListen : CanOnCallback { - fun onStartListened(f: () -> Unit) { - getCallbacks<() -> Unit>(10).add(f) - } - - fun onStartListened() { - getCallbacks<() -> Unit>(10).forEach { it() } - } - - fun onStopListened(f: () -> Unit) { - getCallbacks<() -> Unit>(12).add(f) - } - - fun onStopListened() { - getCallbacks<() -> Unit>(12).forEach { it() } - } +interface OnA11yLife : OnSimpleLife { + fun onA11yConnected(f: CbFn) = cbs(3).add(f) + fun onA11yConnected() = cbs(3).forEach { it() } } -interface OnTileClick : CanOnCallback { - fun onTileClicked(f: () -> Unit) { - getCallbacks<() -> Unit>(14).add(f) - } +class DefaultTileLifeImpl : DefaultSimpleLifeImpl(), OnTileLife - fun onTileClicked() { - getCallbacks<() -> Unit>(14).forEach { it() } - } -} +interface OnTileLife : OnSimpleLife { + fun onStartListened(f: CbFn) = cbs(4).add(f) + fun onStartListened() = cbs(4).forEach { it() } -fun CanOnCallback.useAliveFlow(stateFlow: MutableStateFlow) { - if (this is OnCreate) { - onCreated { stateFlow.value = true } - } - if (this is OnDestroy) { - onDestroyed { stateFlow.value = false } - } + fun onStopListened(f: CbFn) = cbs(5).add(f) + fun onStopListened() = cbs(5).forEach { it() } + + fun onTileClicked(f: CbFn) = cbs(6).add(f) + fun onTileClicked() = cbs(6).forEach { it() } } -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 +class DefaultA11yLifeImpl : DefaultSimpleLifeImpl(), OnA11yLife 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/LogUtils.kt b/app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt new file mode 100644 index 0000000000..e1f2a7cc31 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt @@ -0,0 +1,130 @@ +package li.songe.gkd.util + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import com.hjq.device.compat.DeviceBrand +import com.hjq.device.compat.DeviceMarketName +import com.hjq.device.compat.DeviceOs +import li.songe.gkd.META +import li.songe.gkd.app +import li.songe.loc.Loc +import java.util.concurrent.Executors +import kotlin.time.Duration.Companion.days + +object LogUtils { + fun d( + vararg args: Any?, + @Loc loc: String = "", + @Loc("{fileName}") fileName: String = "", + tag: String = fileName.substringBeforeLast('.'), + ) { + val name = Thread.currentThread().name + val actualLoc = loc.substring("li.songe.gkd.".length) + val texts = args.map { stringify(it) } + if (META.debuggable) { + val msg = buildString { + append("$name, $actualLoc") + texts.forEachIndexed { i, text -> + if (texts.size == 1) { + append("\n") + } else { + append("\n[$i]: ") + } + append(text) + } + } + Log.d(tag, msg) + } + val t = System.currentTimeMillis() + logFileExecutor.run { + logToFile(tag, name, actualLoc, texts, t) + } + } +} + +private val logFileExecutor = Executors.newSingleThreadExecutor() +private const val MAX_LOG_KEEP_DAYS = 7 +val deviceInfoDesc by lazy { + listOf( + android.os.Build.MANUFACTURER, + android.os.Build.MODEL, + DeviceBrand.getBrandName(), + DeviceOs.getOsName() + DeviceOs.getOsVersionName() + DeviceOs.getOsBigVersionCode(), + DeviceMarketName.getMarketName(app) + ).joinToString("/") +} +private val deviceInfoText by lazy { + buildString { + append("Android: ${android.os.Build.VERSION.RELEASE} (${android.os.Build.VERSION.SDK_INT})\n") + append("Device: ${deviceInfoDesc}\n") + append("App: ${META.versionName} (${META.versionCode})\n") + } +} + +private fun logToFile(tag: String, name: String, loc: String, texts: List, t: Long) { + val file = logFolder.resolve("gkd-${t.format("yyyyMMdd")}.log") + val sb = StringBuilder() + if (!file.exists()) { + val files = logFolder.listFiles() + if (files != null && files.size >= MAX_LOG_KEEP_DAYS) { + files.forEach { + if (t - it.lastModified() > MAX_LOG_KEEP_DAYS.days.inWholeMilliseconds) { + it.delete() + } + } + } + sb.append("=== Log ===\n") + sb.append("Date: ${t.format("yyyy-MM-dd HH:mm:ss.SSS")}\n") + sb.append(deviceInfoText) + sb.append("=== Log ===\n\n") + } + sb.append(t.format("HH:mm:ss.SSS")) + sb.append(" $tag, $name, $loc") + if (texts.size == 1) { + sb.append('\n') + sb.append(texts[0]) + } else { + texts.forEachIndexed { i, text -> + sb.append("\n[$i]: ") + sb.append(text) + } + } + sb.append("\n\n") + file.appendText(sb.toString()) +} + +private fun stringify(arg: Any?): String = when (arg) { + is Bundle -> { + val sb = StringBuilder() + sb.append("Bundle{") + val keys = arg.keySet() + keys.forEachIndexed { index, key -> + @Suppress("DEPRECATION") + val value = arg.get(key) + sb.append("$key=${stringify(value)}") + if (index < keys.size - 1) { + sb.append(",") + } + } + sb.append("}") + sb.toString() + } + + is Intent -> { + val sb = StringBuilder() + sb.append("Intent{") + arg.action?.let { sb.append("action=$it,") } + arg.data?.let { sb.append("data=$it,") } + arg.type?.let { sb.append("type=$it,") } + arg.component?.let { sb.append("component=$it,") } + arg.categories?.let { sb.append("categories=$it,") } + arg.extras?.let { sb.append("extras=${stringify(it)}") } + sb.append("}") + sb.toString() + } + + is Throwable -> Log.getStackTraceString(arg) + + else -> arg.toString() +} 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..86fa5b9c0c 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,60 @@ package li.songe.gkd.util +import kotlinx.coroutines.CoroutineScope +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 +import kotlin.coroutines.CoroutineContext -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) } + + fun launchTry( + scope: CoroutineScope, + context: CoroutineContext, + block: suspend () -> Unit, + ) = scope.launchTry(context = context) { + withStateLock { + block() + } + }.let { } } 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/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..3a8cf1e066 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,35 @@ 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, "按触发时间") +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 +37,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 +70,92 @@ 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, + "稳定版", + "https://registry.npmmirror.com/@gkd-kit/app/latest/files/index.json" + ) + + data object Beta : UpdateChannelOption( + 1, + "测试版", + "https://registry.npmmirror.com/@gkd-kit/app-beta/latest/files/index.json" + ) - data object Stable : UpdateChannelOption(0, "稳定版") { - override val url = "https://registry.npmmirror.com/@gkd-kit/app/latest/files/index.json" + companion object { + val objects by lazy { listOf(Stable, Beta) } } +} - data object Beta : UpdateChannelOption(1, "测试版") { - override val url = - "https://registry.npmmirror.com/@gkd-kit/app-beta/latest/files/index.json" +sealed interface BinaryOption : Option { + fun include(flag: Int): Boolean = (value and flag) != 0 + fun invert(flag: Int): Int = value xor flag + + companion object { + fun combine(options: Collection): Int { + return options.fold(0) { a, b -> a or b.value } + } } +} + + +sealed class AppGroupOption( + override val value: Int, + override val label: String +) : BinaryOption { + override val options get() = allObjects + + data object SystemGroup : AppGroupOption(1 shl 0, "系统应用") + data object UserGroup : AppGroupOption(1 shl 1, "用户应用") + data object UnInstalledGroup : AppGroupOption(1 shl 2, "未安装应用") + + companion object { + val normalObjects by lazy { listOf(SystemGroup, UserGroup) } + val allObjects by lazy { listOf(SystemGroup, UserGroup, UnInstalledGroup) } + } +} + +sealed class AutomatorModeOption( + override val value: Int, + override val label: String, +) : Option { + override val options get() = objects + + data object A11yMode : AutomatorModeOption(1, "无障碍") + data object AutomationMode : AutomatorModeOption(2, "自动化") companion object { - val allSubObject by lazy { arrayOf(Stable, Beta) } + val objects by lazy { listOf(A11yMode, AutomationMode) } } } + 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..3f5d742f87 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Others.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Others.kt @@ -2,11 +2,18 @@ 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.os.Build +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 import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.SizeTransform @@ -17,19 +24,17 @@ 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 +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 java.io.File 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 @@ -66,7 +71,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") @@ -87,29 +92,11 @@ 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) { + return Json5.encodeToString(value, defaultJson5Config) + } return json.encodeToJson5String(value, defaultJson5Config) } @@ -130,3 +117,121 @@ fun drawTextToBitmap(text: String, bitmap: Bitmap) { ) } } + +// 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 + +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 == '_' +} + +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 + 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 +} + +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() + } + + fun encode(set: Set, append: Boolean = false): String { + val list = set.sorted() + if (append) { + return list.sortedBy { id -> if (id in appInfoMapFlow.value) 0 else 1 } + .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 + } +} + +val isMainThread: Boolean get() = Looper.getMainLooper() == Looper.myLooper() + +fun runMainPost(delayMillis: Long = 0L, r: Runnable) { + if (delayMillis == 0L && isMainThread) { + r.run() + return + } + Handler(Looper.getMainLooper()).postDelayed(r, delayMillis) +} + +fun getShareApkFile(): File { + return sharedDir.resolve("gkd-v${META.versionName}.apk").apply { + File(app.packageCodePath).copyTo(this, overwrite = true) + } +} \ No newline at end of file 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/SafeR.kt b/app/src/main/kotlin/li/songe/gkd/util/SafeR.kt deleted file mode 100644 index a3fa3d0e7e..0000000000 --- a/app/src/main/kotlin/li/songe/gkd/util/SafeR.kt +++ /dev/null @@ -1,19 +0,0 @@ -package li.songe.gkd.util - -import li.songe.gkd.R - -/** - * ![image](https://github.com/gkd-kit/gkd/assets/38517192/c9325110-d90f-4041-a01d-404d14c5d34d) - */ -@Suppress("UNRESOLVED_REFERENCE") // fix android studio can't find R -data object SafeR { - val ic_anim_logo: Int = R.drawable.ic_anim_logo - val ic_anim_search_close: Int = R.drawable.ic_anim_search_close - val app_name: Int = R.string.app_name - val ic_status: Int = R.drawable.ic_status - val ic_page_info: Int = R.drawable.ic_page_info - val ic_flash_on: Int = R.drawable.ic_flash_on - val ic_flash_off: Int = R.drawable.ic_flash_off - val better_black: Int = R.color.better_black - val better_white: Int = R.color.better_white -} \ No newline at end of file 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..5d9e30302d --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/util/ScreenUtils.kt @@ -0,0 +1,39 @@ +package li.songe.gkd.util + +import android.content.res.Configuration +import android.content.res.Resources +import android.graphics.Point +import androidx.compose.ui.unit.IntSize +import li.songe.gkd.app + + +object ScreenUtils { + fun getScreenSize(): IntSize = if (AndroidTarget.R) { + val b = app.windowManager.currentWindowMetrics.bounds + IntSize(b.width(), b.height()) + } else { + val p = Point().apply { + @Suppress("DEPRECATION") + app.compatDisplay.getRealSize(this) + } + IntSize(p.x, p.y) + } + + fun getScreenWidth(): Int = getScreenSize().width + + fun getScreenHeight(): Int = getScreenSize().height + + fun getScreenDensityDpi(): Int = Resources.getSystem().displayMetrics.densityDpi + + fun isLandscape(): Boolean { + return app.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + } + + @Suppress("DEPRECATION") + fun isScreenLock(): Boolean = app.keyguardManager.inKeyguardRestrictedInputMode() + + fun inScreen(x: Float, y: Float): Boolean { + val (w, h) = getScreenSize() + return 0 <= x && 0 <= y && x <= w && y <= h + } +} \ 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..2dd4b71cd6 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt @@ -15,10 +15,9 @@ 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 kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine // https://github.com/npes87184/ScreenShareTile/blob/master/app/src/main/java/com/npes87184/screenshottile/ScreenshotService.kt @@ -54,7 +53,7 @@ class ScreenshotUtil( } // TODO android13 上一半概率获取到全透明图片, android12 暂无此问题 - suspend fun execute() = suspendCoroutine { block -> + suspend fun execute() = suspendCancellableCoroutine { cont -> imageReader = ImageReader.newInstance( width, height, PixelFormat.RGBA_8888, 2 @@ -93,14 +92,18 @@ class ScreenshotUtil( bitmap = Bitmap.createBitmap(bitmapWithStride, 0, 0, width, height) if (!bitmap.isFullTransparent()) { imageReader?.setOnImageAvailableListener(null, null) - block.resume(bitmap) + if (cont.isActive) { + cont.resume(bitmap) + } resumed = true } } } catch (e: Exception) { e.printStackTrace() imageReader?.setOnImageAvailableListener(null, null) - block.resumeWithException(e) + if (cont.isActive) { + cont.resumeWithException(e) + } } finally { bitmapWithStride?.recycle() image?.close() 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..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,24 +1,13 @@ package li.songe.gkd.util -import android.os.Build -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 { @@ -46,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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.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)!! } 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 50% 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 b5c5a906cf..2e0bd17b99 100644 --- a/app/src/main/kotlin/li/songe/gkd/debug/SnapshotExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt @@ -1,32 +1,27 @@ -package li.songe.gkd.debug +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 +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.A11yRuleEngine +import li.songe.gkd.a11y.TopActivity +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.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.shizukuContextFlow 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 @@ -34,6 +29,32 @@ 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): JsonObject { + val f = minSnapshotFile(id) + if (!f.exists()) { + val text = withContext(Dispatchers.IO) { snapshotFile(id).readText() } + val snapshotJson = withContext(Dispatchers.Default) { + // #1185 + json.decodeFromString(text) + } + 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) + } + } + fun screenshotFile(id: Long) = snapshotParentPath(id).resolve("${id}.png") suspend fun snapshotZipFile( @@ -43,7 +64,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) @@ -78,27 +99,24 @@ object SnapshotExt { } } + @Suppress("SameParameterValue") private fun emptyScreenBitmap(text: String): Bitmap { return createBitmap(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight()).apply { drawTextToBitmap(text, this) } } - private suspend fun screenshot(): Bitmap? { - return A11yService.screenshot() ?: ScreenshotService.screenshot() - } - 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) + copy(Bitmap.Config.ARGB_8888, true) } else { 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 } } @@ -106,28 +124,43 @@ object SnapshotExt { } private val captureLoading = MutableStateFlow(false) - suspend fun captureSnapshot(skipScreenshot: Boolean = false): ComplexSnapshot { - if (!A11yService.isRunning.value) { - throw RpcError("无障碍不可用,请先授权") + suspend fun captureSnapshot(forcedCropStatusBar: Boolean = false): ComplexSnapshot { + if (A11yRuleEngine.instance == null) { + throw RpcError("服务不可用,请先授权") } if (captureLoading.value) { - throw RpcError("正在保存快照,不可重复操作") + throw RpcError("正在保存快照,不可重复操作") } captureLoading.value = true try { val rootNode = - A11yService.instance?.safeActiveWindow - ?: throw RpcError("当前应用没有无障碍信息,捕获失败") + A11yRuleEngine.instance?.safeActiveWindow + ?: throw RpcError("当前应用没有无障碍信息,捕获失败") if (storeFlow.value.showSaveSnapshotToast) { - toast("正在保存快照...") + toast("正在保存快照...", forced = true) } - val (snapshot, bitmap) = coroutineScope { val d1 = async(Dispatchers.IO) { + val appId = rootNode.packageName.toString() + var activityId = shizukuContextFlow.value.topCpn()?.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(), @@ -135,17 +168,16 @@ object SnapshotExt { ) } val d2 = async(Dispatchers.IO) { - if (skipScreenshot) { - emptyScreenBitmap("跳过截图\n请自行替换") - } else { - screenshot() ?: emptyScreenBitmap("无截图权限\n请自行替换") - }.let { - if (storeFlow.value.hideSnapshotStatusBar) { - cropBitmapStatusBar(it) - } else { - it + (A11yRuleEngine.screenshot() + ?: ScreenshotService.screenshot() + ?: emptyScreenBitmap("无截图权限\n请自行替换") + ).let { + if (storeFlow.value.hideSnapshotStatusBar && (forcedCropStatusBar || BarUtils.checkStatusBarVisible() == true)) { + cropBitmapStatusBar(it) + } else { + it + } } - } } d1.await() to d2.await() } @@ -154,19 +186,19 @@ 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("快照成功") + toast("快照成功", forced = true) 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 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..5fe6e9c8ec 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,5 @@ 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 +63,46 @@ data class UsedSubsEntry( val subsLoadErrorsFlow = MutableStateFlow>(emptyMap()) val subsRefreshErrorsFlow = MutableStateFlow>(emptyMap()) -val subsIdToRawFlow = 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, - subsIdToRawFlow, + subsMapFlow, ) { subsItems, subsIdToRaw -> subsItems.map { s -> SubsEntry( @@ -82,20 +114,20 @@ 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?.hasRule == true } .map { UsedSubsEntry(it.subsItem, it.subscription!!) } }.stateIn(appScope, SharingStarted.Eagerly, emptyList()) } fun updateSubscription(subscription: RawSubscription) { appScope.launchTry { - updateSubsMutex.withLock { + updateSubsMutex.withStateLock { val subsId = subscription.id val subsName = subscription.name - val newMap = subsIdToRawFlow.value.toMutableMap() - if (subsId < 0 && newMap[subsId]?.version == subscription.version) { - newMap[subsId] = subscription.run { + val newMap = subsMapFlow.value.toMutableMap() + val nextSubsRaw = if (subsId < 0 && newMap[subsId]?.version == subscription.version) { + subscription.run { copy( version = version + 1, apps = apps.filterIfNotAll { it.groups.isNotEmpty() } @@ -103,9 +135,10 @@ fun updateSubscription(subscription: RawSubscription) { ) } } else { - newMap[subsId] = subscription + subscription } - subsIdToRawFlow.value = newMap + newMap[subsId] = nextSubsRaw + subsMapFlow.value = newMap if (subsLoadErrorsFlow.value.contains(subsId)) { subsLoadErrorsFlow.update { it.toMutableMap().apply { @@ -114,9 +147,10 @@ fun updateSubscription(subscription: RawSubscription) { } } withContext(Dispatchers.IO) { + cleanupSubsConfig(subsId, nextSubsRaw) DbSet.subsItemDao.updateMtime(subsId, System.currentTimeMillis()) subsFolder.resolve("${subsId}.json") - .writeText(json.encodeToString(newMap[subsId])) + .writeText(json.encodeToString(nextSubsRaw)) } LogUtils.d("更新订阅文件:id=${subsId},name=${subsName}") } @@ -131,7 +165,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 +174,7 @@ fun deleteSubscription(vararg subsIds: Long) { } } } - subsIdToRawFlow.value = newMap + subsMapFlow.value = newMap toast("删除成功") LogUtils.d("deleteSubscription", subsIds) } @@ -195,7 +229,7 @@ data class RuleSummary( } else { "" } + if (appGroupSize > 0) { - "${appSize}应用/${appGroupSize}规则组" + "${appSize}应用/${appGroupSize}规则" } else { "" } @@ -215,7 +249,7 @@ data class RuleSummary( val ruleSummaryFlow by lazy { combine( usedSubsEntriesFlow, - appInfoCacheFlow, + appInfoMapFlow, DbSet.appConfigDao.queryUsedList(), DbSet.subsConfigDao.queryUsedList(), DbSet.categoryConfigDao.queryUsedList(), @@ -276,7 +310,7 @@ val ruleSummaryFlow by lazy { val subAppGroupToRules = mutableMapOf>() val groupAndEnables = appRaw.groups.map { group -> val config = appGroupConfigs.find { c -> c.groupKey == group.key } - val category = rawSubs.groupToCategoryMap[group] + val category = rawSubs.getCategory(group.name) val categoryConfig = subCategoryConfigs.find { c -> c.categoryKey == category?.key } val enable = getGroupEnable( @@ -374,7 +408,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 +420,7 @@ private fun refreshRawSubsList(items: List): Boolean { errors[s.id] = e } } - subsIdToRawFlow.value = subscriptions + subsMapFlow.value = subscriptions subsLoadErrorsFlow.value = errors return changed } @@ -394,13 +428,36 @@ 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) } } } +private suspend fun cleanupSubsConfig(subsId: Long, subsRaw: RawSubscription): Int { + val globalGroupKeys = subsRaw.globalGroups.map { it.key }.toHashSet() + val appIdToGroupKeys = subsRaw.apps.associate { a -> + a.id to a.groups.map { g -> g.key }.toHashSet() + } + val configs = DbSet.subsConfigDao.querySubsItemConfig(listOf(subsId)) + val deleteList = configs.filter { c -> + when (c.type) { + SubsConfig.AppGroupType -> { + val groupKeys = appIdToGroupKeys[c.appId] + groupKeys == null || !groupKeys.contains(c.groupKey) + } + + SubsConfig.GlobalGroupType -> !globalGroupKeys.contains(c.groupKey) + else -> false + } + } + if (deleteList.isEmpty()) return 0 + DbSet.subsConfigDao.delete(*deleteList.toTypedArray()) + LogUtils.d("清理已移除规则配置", "subsId=$subsId, delete=${deleteList.size}") + return deleteList.size +} + val updateSubsMutex = MutexState() private suspend fun updateSubs(subsEntry: SubsEntry): RawSubscription? { @@ -413,15 +470,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 @@ -452,12 +505,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("开始检测更新") // 文件不存在, 重新加载 @@ -487,7 +540,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/TimeExt.kt b/app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt index 310bc09b4f..58f2c863cd 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,29 +38,28 @@ 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 ) { + 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() } } @@ -72,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 2daa107eea..d3b8a180c5 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Toast.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Toast.kt @@ -1,14 +1,13 @@ package li.songe.gkd.util -import android.accessibilityservice.AccessibilityService +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.os.Handler -import android.os.Looper +import android.graphics.text.LineBreaker import android.util.TypedValue import android.view.Gravity import android.view.View @@ -20,17 +19,36 @@ 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.ScreenUtils import com.hjq.toast.Toaster import com.hjq.toast.style.WhiteToastStyle -import kotlinx.coroutines.Dispatchers import li.songe.gkd.app -import li.songe.gkd.appScope +import li.songe.gkd.data.ResolvedRule +import li.songe.gkd.isActivityVisible +import li.songe.gkd.permission.canDrawOverlaysState +import li.songe.gkd.service.A11yService +import li.songe.gkd.service.OverlayWindowService +import li.songe.gkd.store.actionCountFlow import li.songe.gkd.store.storeFlow +import li.songe.loc.Loc - -fun toast(text: CharSequence) { - Toaster.show(text) +fun toast( + text: CharSequence, + forced: Boolean = false, + delayMillis: Long = 0L, + @Loc loc: String = "", +) { + if (delayMillis > 0) { + runMainPost(delayMillis) { + toast(text = text, forced = forced, loc = loc) + } + return + } + if (forced || isActivityVisible || OverlayWindowService.isAnyAlive) { + Toaster.show(text) + } + if (loc.isNotEmpty()) { + LogUtils.d(text, loc = loc) + } } private val darkTheme: Boolean @@ -72,6 +90,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()) @@ -83,7 +104,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 @@ -98,19 +119,20 @@ fun setReactiveToastStyle() { private var triggerTime = 0L private const val triggerInterval = 2000L -fun showActionToast(context: AccessibilityService) { +fun showActionToast(rule: ResolvedRule) { if (!storeFlow.value.toastWhenClick) return - appScope.launchTry(Dispatchers.Main) { + runMainPost { val t = System.currentTimeMillis() if (t - triggerTime > triggerInterval + 100) { // 100ms 保证二次显示的时候上一次已经完全消失 triggerTime = t + val text = storeFlow.value.actionToast + .replace($$"${1}", rule.rule.name.toString()) + .replace($$"${2}", rule.g.group.name) + .replace($$"${3}", actionCountFlow.value.toString()) if (storeFlow.value.useSystemToast) { - showSystemToast(storeFlow.value.clickToast) + showSystemToast(text) } else { - showAccessibilityToast( - context, - storeFlow.value.clickToast - ) + showA11yToast(text) } } } @@ -122,31 +144,38 @@ private fun showSystemToast(message: CharSequence) { cacheToast = Toast.makeText(app, message, Toast.LENGTH_SHORT).apply { show() } + runMainPost(Toast.LENGTH_SHORT.toLong()) { cacheToast = null } } // 1.使用 WeakReference 在某些机型上导致无法取消 // 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 wm = A11yService.instance?.wm + ?: if (canDrawOverlaysState.updateAndGet()) app.windowManager else null + if (wm == null) { + showSystemToast(message) + return + } + val textView = TextView(app).apply { text = message id = android.R.id.message gravity = Gravity.CENTER updateToastView() } - val layoutParams = WindowManager.LayoutParams().apply { - type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY + type = if (wm == app.windowManager) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY + } format = PixelFormat.TRANSLUCENT - flags = arrayOf( - flags, - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, - ).reduce { acc, i -> acc or i } - packageName = context.packageName + flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + packageName = app.packageName width = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT gravity = Gravity.BOTTOM @@ -154,10 +183,22 @@ private fun showAccessibilityToast(context: AccessibilityService, message: CharS 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) { + app.clipboardManager.setPrimaryClip(ClipData.newPlainText(app.packageName, text)) + toast("复制成功") +} + +fun initToast() { + Toaster.init(app) + Toaster.setDebugMode(false) + Toaster.setInterceptor { false } // 覆盖默认拦截器 + setReactiveToastStyle() } 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..b48b0e1876 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 @@ -35,10 +34,11 @@ import li.songe.gkd.store.createAnyFlow import li.songe.gkd.store.storeFlow import java.io.File import java.net.URI +import kotlin.time.Duration.Companion.days 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( @@ -57,6 +57,8 @@ data class VersionLog( val desc: String, ) +private var lastCheckTime = 0L + class UpdateStatus(val scope: CoroutineScope) { private val checkUpdatingMutex = MutexState() val checkUpdatingFlow @@ -74,9 +76,12 @@ class UpdateStatus(val scope: CoroutineScope) { } private var lastManual = false - fun checkUpdate(manual: Boolean = false) = scope.launchTry(Dispatchers.IO, silent = manual) { + val canRecheck get() = System.currentTimeMillis() - lastCheckTime > 1.days.inWholeMilliseconds + + fun checkUpdate(manual: Boolean = false) = scope.launchTry(Dispatchers.IO, silent = !manual) { lastManual = manual checkUpdatingMutex.whenUnLock { + lastCheckTime = System.currentTimeMillis() if (!NetworkUtils.isAvailable()) { error("网络不可用") } 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/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) - } -} 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/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 new file mode 100644 index 0000000000..915263f1dc --- /dev/null +++ b/app/src/main/res/drawable/ic_event_list.xml @@ -0,0 +1,9 @@ + + + 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 new file mode 100644 index 0000000000..ab787077cd --- /dev/null +++ b/app/src/main/res/drawable/ic_layers.xml @@ -0,0 +1,9 @@ + + + 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" /> diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index ed11b9eca4..9c4ef0f9d4 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,5 +1,5 @@ - @color/better_white - @color/better_black + @color/better_black + @color/better_white diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 31bf75ca66..79efc9909e 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,14 +1,7 @@ - + + + - + + +