Repository: gkd-kit/gkd Branch: main Commit: 8fa475fc1dea Files: 362 Total size: 1.5 MB Directory structure: gitextract_z6y5c1r5/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── Build-Apk.yml │ └── Build-Release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── li.songe.gkd.db.AppDb/ │ │ ├── 1.json │ │ ├── 10.json │ │ ├── 11.json │ │ ├── 12.json │ │ ├── 13.json │ │ ├── 14.json │ │ ├── 2.json │ │ ├── 3.json │ │ ├── 4.json │ │ ├── 5.json │ │ ├── 6.json │ │ ├── 7.json │ │ ├── 8.json │ │ └── 9.json │ └── src/ │ ├── androidTest/ │ │ └── kotlin/ │ │ └── li/ │ │ └── songe/ │ │ └── gkd/ │ │ └── ExampleInstrumentedTest.kt │ ├── gkd/ │ │ └── AndroidManifest.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── aidl/ │ │ │ └── li/ │ │ │ └── songe/ │ │ │ └── gkd/ │ │ │ └── shizuku/ │ │ │ ├── CommandResult.aidl │ │ │ └── IUserService.aidl │ │ ├── kotlin/ │ │ │ ├── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── accessibility/ │ │ │ │ └── selecttospeak/ │ │ │ │ └── SelectToSpeakService.kt │ │ │ └── li/ │ │ │ └── songe/ │ │ │ └── gkd/ │ │ │ ├── App.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── OpenFileActivity.kt │ │ │ ├── OpenSchemeActivity.kt │ │ │ ├── OpenTileActivity.kt │ │ │ ├── a11y/ │ │ │ │ ├── A11yCommonImpl.kt │ │ │ │ ├── A11yContext.kt │ │ │ │ ├── A11yExt.kt │ │ │ │ ├── A11yFeat.kt │ │ │ │ ├── A11yRuleEngine.kt │ │ │ │ └── A11yState.kt │ │ │ ├── data/ │ │ │ │ ├── A11yEventLog.kt │ │ │ │ ├── ActionLog.kt │ │ │ │ ├── ActivityLog.kt │ │ │ │ ├── AppConfig.kt │ │ │ │ ├── AppInfo.kt │ │ │ │ ├── AppRule.kt │ │ │ │ ├── AppVisitLog.kt │ │ │ │ ├── AttrInfo.kt │ │ │ │ ├── BaseSnapshot.kt │ │ │ │ ├── CategoryConfig.kt │ │ │ │ ├── ComplexSnapshot.kt │ │ │ │ ├── DeviceInfo.kt │ │ │ │ ├── GithubPoliciesAsset.kt │ │ │ │ ├── GkdAction.kt │ │ │ │ ├── GlobalRule.kt │ │ │ │ ├── NodeInfo.kt │ │ │ │ ├── RawSubscription.kt │ │ │ │ ├── ResolvedGroup.kt │ │ │ │ ├── ResolvedRule.kt │ │ │ │ ├── RpcError.kt │ │ │ │ ├── Snapshot.kt │ │ │ │ ├── SubsConfig.kt │ │ │ │ ├── SubsItem.kt │ │ │ │ ├── SubsVersion.kt │ │ │ │ ├── TransferData.kt │ │ │ │ ├── UserInfo.kt │ │ │ │ └── Value.kt │ │ │ ├── db/ │ │ │ │ └── AppDb.kt │ │ │ ├── notif/ │ │ │ │ ├── Notif.kt │ │ │ │ ├── NotifChannel.kt │ │ │ │ └── StopServiceReceiver.kt │ │ │ ├── permission/ │ │ │ │ ├── PermissionDialog.kt │ │ │ │ └── PermissionState.kt │ │ │ ├── service/ │ │ │ │ ├── A11yService.kt │ │ │ │ ├── ActivityService.kt │ │ │ │ ├── ActivityTileService.kt │ │ │ │ ├── BaseTileService.kt │ │ │ │ ├── ButtonService.kt │ │ │ │ ├── ButtonTileService.kt │ │ │ │ ├── EventService.kt │ │ │ │ ├── EventTileService.kt │ │ │ │ ├── ExposeService.kt │ │ │ │ ├── GkdTileService.kt │ │ │ │ ├── HttpService.kt │ │ │ │ ├── HttpTileService.kt │ │ │ │ ├── MatchTileService.kt │ │ │ │ ├── OverlayWindowService.kt │ │ │ │ ├── ScreenshotService.kt │ │ │ │ ├── SnapshotTileService.kt │ │ │ │ └── StatusService.kt │ │ │ ├── shizuku/ │ │ │ │ ├── AccessibilityManager.kt │ │ │ │ ├── ActivityManager.kt │ │ │ │ ├── ActivityTaskManager.kt │ │ │ │ ├── AppOpsService.kt │ │ │ │ ├── AutomationService.kt │ │ │ │ ├── CommandResult.kt │ │ │ │ ├── HiddenCast.kt │ │ │ │ ├── InputManager.kt │ │ │ │ ├── InputShellCommand.kt │ │ │ │ ├── PackageManager.kt │ │ │ │ ├── ProxyUiAutomationConnection.kt │ │ │ │ ├── ShizukuApi.kt │ │ │ │ ├── TaskStackListener.kt │ │ │ │ ├── UserManager.kt │ │ │ │ ├── UserService.kt │ │ │ │ └── WindowManager.kt │ │ │ ├── store/ │ │ │ │ ├── SettingsStore.kt │ │ │ │ ├── StorageExt.kt │ │ │ │ └── StoreExt.kt │ │ │ ├── ui/ │ │ │ │ ├── A11yEventLogPage.kt │ │ │ │ ├── A11yEventLogVm.kt │ │ │ │ ├── A11yScopeAppListPage.kt │ │ │ │ ├── A11yScopeAppListVm.kt │ │ │ │ ├── AboutPage.kt │ │ │ │ ├── AboutVm.kt │ │ │ │ ├── ActionLogPage.kt │ │ │ │ ├── ActionLogVm.kt │ │ │ │ ├── ActivityLogPage.kt │ │ │ │ ├── ActivityLogVm.kt │ │ │ │ ├── AdvancedPage.kt │ │ │ │ ├── AdvancedVm.kt │ │ │ │ ├── AppConfigPage.kt │ │ │ │ ├── AppConfigVm.kt │ │ │ │ ├── AppOpsAllowPage.kt │ │ │ │ ├── AppOpsAllowVm.kt │ │ │ │ ├── AuthA11yPage.kt │ │ │ │ ├── AuthA11yVm.kt │ │ │ │ ├── BlockA11yAppListPage.kt │ │ │ │ ├── BlockA11yAppListVm.kt │ │ │ │ ├── EditBlockAppListPage.kt │ │ │ │ ├── EditBlockAppListVm.kt │ │ │ │ ├── ImagePreviewPage.kt │ │ │ │ ├── SlowGroupPage.kt │ │ │ │ ├── SnapshotPage.kt │ │ │ │ ├── SnapshotVm.kt │ │ │ │ ├── SubsAppGroupListPage.kt │ │ │ │ ├── SubsAppGroupListVm.kt │ │ │ │ ├── SubsAppListPage.kt │ │ │ │ ├── SubsAppListVm.kt │ │ │ │ ├── SubsCategoryPage.kt │ │ │ │ ├── SubsCategoryVm.kt │ │ │ │ ├── SubsGlobalGroupExcludePage.kt │ │ │ │ ├── SubsGlobalGroupExcludeVm.kt │ │ │ │ ├── SubsGlobalGroupListPage.kt │ │ │ │ ├── SubsGlobalGroupListVm.kt │ │ │ │ ├── UpsertRuleGroupPage.kt │ │ │ │ ├── UpsertRuleGroupVm.kt │ │ │ │ ├── WebViewPage.kt │ │ │ │ ├── component/ │ │ │ │ │ ├── AnimatedBooleanContent.kt │ │ │ │ │ ├── AnimatedIcon.kt │ │ │ │ │ ├── Animation.kt │ │ │ │ │ ├── AnimationFloatingActionButton.kt │ │ │ │ │ ├── AppBarTextField.kt │ │ │ │ │ ├── AppCheckBoxCard.kt │ │ │ │ │ ├── AppIcon.kt │ │ │ │ │ ├── AppNameText.kt │ │ │ │ │ ├── AuthButtonGroup.kt │ │ │ │ │ ├── AuthCard.kt │ │ │ │ │ ├── CopyTextCard.kt │ │ │ │ │ ├── CustomIconButton.kt │ │ │ │ │ ├── CustomOutlinedTextField.kt │ │ │ │ │ ├── DialogOptions.kt │ │ │ │ │ ├── EmptyText.kt │ │ │ │ │ ├── FixedTimeText.kt │ │ │ │ │ ├── FullscreenDialog.kt │ │ │ │ │ ├── GroupNameText.kt │ │ │ │ │ ├── Hooks.kt │ │ │ │ │ ├── InnerDisableSwitch.kt │ │ │ │ │ ├── InputSubsLinkOption.kt │ │ │ │ │ ├── ManualAuthDialog.kt │ │ │ │ │ ├── MenuExt.kt │ │ │ │ │ ├── ModifierExt.kt │ │ │ │ │ ├── MultiTextField.kt │ │ │ │ │ ├── PerfCheckbox.kt │ │ │ │ │ ├── PerfIcon.kt │ │ │ │ │ ├── PerfSwitch.kt │ │ │ │ │ ├── PerfTopAppBar.kt │ │ │ │ │ ├── QueryPkgTipCard.kt │ │ │ │ │ ├── RotatingLoadingIcon.kt │ │ │ │ │ ├── RuleGroupCard.kt │ │ │ │ │ ├── RuleGroupDialog.kt │ │ │ │ │ ├── RuleGroupState.kt │ │ │ │ │ ├── SettingItem.kt │ │ │ │ │ ├── ShareDataDialog.kt │ │ │ │ │ ├── SubsAppCard.kt │ │ │ │ │ ├── SubsItemCard.kt │ │ │ │ │ ├── SubsSheet.kt │ │ │ │ │ ├── TermsAcceptDialog.kt │ │ │ │ │ ├── TextDialog.kt │ │ │ │ │ ├── TextMenu.kt │ │ │ │ │ ├── TextSwitch.kt │ │ │ │ │ ├── TooltipIconButtonBox.kt │ │ │ │ │ ├── TowLineText.kt │ │ │ │ │ └── UploadOptions.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── AppListPage.kt │ │ │ │ │ ├── ControlPage.kt │ │ │ │ │ ├── HomePage.kt │ │ │ │ │ ├── HomeVm.kt │ │ │ │ │ ├── ScaffoldExt.kt │ │ │ │ │ ├── SettingsPage.kt │ │ │ │ │ └── SubsManagePage.kt │ │ │ │ ├── icon/ │ │ │ │ │ ├── BackCloseIcon.kt │ │ │ │ │ ├── DragPan.kt │ │ │ │ │ ├── LockOpenRight.kt │ │ │ │ │ ├── ResetSettings.kt │ │ │ │ │ └── SportsBasketball.kt │ │ │ │ ├── share/ │ │ │ │ │ ├── AppFilter.kt │ │ │ │ │ ├── BaseViewModel.kt │ │ │ │ │ ├── FixedWindowInsets.kt │ │ │ │ │ ├── ListPlaceholder.kt │ │ │ │ │ ├── LocalExt.kt │ │ │ │ │ ├── ModifierExt.kt │ │ │ │ │ └── StateExt.kt │ │ │ │ └── style/ │ │ │ │ ├── Color.kt │ │ │ │ ├── Padding.kt │ │ │ │ ├── TextTransformation.kt │ │ │ │ └── Theme.kt │ │ │ └── util/ │ │ │ ├── AndroidTarget.kt │ │ │ ├── AppInfoState.kt │ │ │ ├── BarUtils.kt │ │ │ ├── CollectionExt.kt │ │ │ ├── Constants.kt │ │ │ ├── CoroutineExt.kt │ │ │ ├── FlowExt.kt │ │ │ ├── FolderExt.kt │ │ │ ├── Github.kt │ │ │ ├── ImageUtils.kt │ │ │ ├── IntentExt.kt │ │ │ ├── KeyboardUtils.kt │ │ │ ├── LifecycleCallbacks.kt │ │ │ ├── LinkLoad.kt │ │ │ ├── LoadStatus.kt │ │ │ ├── LogUtils.kt │ │ │ ├── MutexState.kt │ │ │ ├── NetworkExt.kt │ │ │ ├── NetworkUtils.kt │ │ │ ├── Option.kt │ │ │ ├── Others.kt │ │ │ ├── ScreenUtils.kt │ │ │ ├── ScreenshotUtil.kt │ │ │ ├── Singleton.kt │ │ │ ├── SnapshotExt.kt │ │ │ ├── SubsState.kt │ │ │ ├── TimeExt.kt │ │ │ ├── Toast.kt │ │ │ ├── Unit.kt │ │ │ ├── Upgrade.kt │ │ │ ├── UriUtils.kt │ │ │ └── ZipUtils.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_anim_logo.xml │ │ │ ├── ic_anim_search_close.xml │ │ │ ├── ic_capture.xml │ │ │ ├── ic_event_list.xml │ │ │ ├── ic_flash_off.xml │ │ │ ├── ic_flash_on.xml │ │ │ ├── ic_http.xml │ │ │ ├── ic_launcher.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_layers.xml │ │ │ ├── ic_page_info.xml │ │ │ ├── ic_radio_button.xml │ │ │ └── ic_status.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ └── xml/ │ │ ├── ab_desc.xml │ │ ├── file_paths.xml │ │ └── network_security_config.xml │ └── test/ │ └── kotlin/ │ └── li/ │ └── songe/ │ └── gkd/ │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── hidden_api/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ ├── android/ │ │ ├── accessibilityservice/ │ │ │ ├── AccessibilityServiceInfoHidden.java │ │ │ └── IAccessibilityServiceClient.java │ │ ├── app/ │ │ │ ├── AppOpsManagerHidden.java │ │ │ ├── IActivityManager.java │ │ │ ├── IActivityTaskManager.java │ │ │ ├── IApplicationThread.java │ │ │ ├── ITaskStackListener.java │ │ │ ├── IUiAutomationConnection.java │ │ │ └── UiAutomationHidden.java │ │ ├── content/ │ │ │ ├── ContextHidden.java │ │ │ └── pm/ │ │ │ ├── IPackageManager.java │ │ │ ├── PackageInfoHidden.java │ │ │ ├── ParceledListSlice.java │ │ │ └── UserInfo.java │ │ ├── hardware/ │ │ │ └── input/ │ │ │ ├── IInputManager.java │ │ │ └── InputManagerHidden.java │ │ ├── os/ │ │ │ └── IUserManager.java │ │ ├── view/ │ │ │ ├── IWindowManager.java │ │ │ ├── KeyEventHidden.java │ │ │ ├── MotionEventHidden.java │ │ │ ├── SurfaceControlHidden.java │ │ │ └── accessibility/ │ │ │ ├── AccessibilityNodeInfoHidden.java │ │ │ └── IAccessibilityManager.java │ │ └── window/ │ │ └── ScreenCapture.java │ └── com/ │ └── android/ │ └── internal/ │ ├── R.java │ └── app/ │ └── IAppOpsService.java ├── selector/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/ │ │ └── kotlin/ │ │ └── li/ │ │ └── songe/ │ │ └── selector/ │ │ ├── Exception.kt │ │ ├── FastQuery.kt │ │ ├── MatchOption.kt │ │ ├── QueryContext.kt │ │ ├── QueryPath.kt │ │ ├── QueryResult.kt │ │ ├── Selector.kt │ │ ├── Stringify.kt │ │ ├── Transform.kt │ │ ├── TypeInfo.kt │ │ ├── connect/ │ │ │ ├── ConnectExpression.kt │ │ │ ├── ConnectOperator.kt │ │ │ ├── ConnectSegment.kt │ │ │ ├── ConnectWrapper.kt │ │ │ ├── PolynomialExpression.kt │ │ │ └── TupleExpression.kt │ │ ├── parser/ │ │ │ ├── AstNode.kt │ │ │ ├── AstParser.kt │ │ │ ├── BaseParser.kt │ │ │ ├── ConnectParser.kt │ │ │ ├── PropertyParser.kt │ │ │ └── SelectorParser.kt │ │ ├── property/ │ │ │ ├── BinaryExpression.kt │ │ │ ├── CompareOperator.kt │ │ │ ├── Expression.kt │ │ │ ├── LogicalExpression.kt │ │ │ ├── LogicalOperator.kt │ │ │ ├── NotExpression.kt │ │ │ ├── PropertySegment.kt │ │ │ ├── PropertyUnit.kt │ │ │ ├── PropertyWrapper.kt │ │ │ └── ValueExpression.kt │ │ ├── toMatches.kt │ │ ├── unit/ │ │ │ ├── LogicalSelectorExpression.kt │ │ │ ├── NotSelectorExpression.kt │ │ │ ├── SelectorExpression.kt │ │ │ ├── SelectorLogicalOperator.kt │ │ │ └── UnitSelectorExpression.kt │ │ └── util.kt │ ├── jsMain/ │ │ └── kotlin/ │ │ └── li/ │ │ └── songe/ │ │ └── selector/ │ │ └── toMatches.js.kt │ ├── jvmMain/ │ │ └── kotlin/ │ │ └── li/ │ │ └── songe/ │ │ └── selector/ │ │ └── toMatches.jvm.kt │ └── jvmTest/ │ └── kotlin/ │ └── li/ │ └── songe/ │ └── selector/ │ ├── ParserUnitTest.kt │ ├── QueryUnitTest.kt │ ├── Snapshot.kt │ └── TypeUnitTest.kt ├── settings.gradle.kts └── stability_config.conf ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms # github: [lisonge] custom: ['https://github.com/lisonge/sponsor'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 错误反馈 / Bug report title: "[BUG] " description: 反馈你遇到的错误 / Report the bug you encountered labels: ["pending triage"] body: - type: markdown attributes: value: | ## 感谢您花时间填写,在提交问题之前,请确保您完成以下操作 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 填写一个简洁明了的标题,以便他人快速检索 - type: textarea id: log-file attributes: label: | 日志文件 description: | 首页-设置-关于-日志,上传日志文件或生成链接并粘贴到下面的输入框\ 任何问题都需要提供日志文件. 否则将直接关闭,请不要纯发文字/截图/视频 validations: required: true - type: textarea id: bug-1 attributes: label: | BUG描述(文字/截图/视频) description: | 请使用尽量准确的描述,否则你的问题将会被直接关闭\ 另外如果你的问题是关于快照/选择器的,请必须提供快照链接或者快照文件,否则你的问题将会被直接关闭 validations: required: true - type: textarea id: bug-2 attributes: label: | 期望行为(文字/截图/视频) description: | 请使用尽量准确的描述,否则你的问题将会被直接关闭 validations: required: true - type: textarea id: bug-3 attributes: label: | 实际行为(文字/截图/视频) description: | 请使用尽量准确的描述,否则你的问题将会被直接关闭 validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 讨论交流 / Discussions url: https://github.com/gkd-kit/gkd/discussions about: '其他提问或请求: 选择器/规则/快照/无障碍/设备/相关问题. 在 issues 搜索不到可前往搜索讨论' ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 功能请求 / Feature request title: "[Feature] " description: 提出你的功能请求 / Propose your feature request labels: ["pending triage"] body: - type: markdown attributes: value: | ## 感谢您花时间填写,在提交问题之前,请确保您完成以下操作 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 填写一个简洁明了的标题,以便他人快速检索 - type: textarea id: feature-description attributes: label: | 新功能描述 description: | 例如: 我希望在 GKD 中的什么页面添加什么功能,以及这个功能的作用是什么\ 或者在规则定义中添加某个字段,以及这个字段的作用是什么\ 请使用准确的描述,否则你的问题将会被直接关闭 validations: required: true ================================================ FILE: .github/workflows/Build-Apk.yml ================================================ name: Build-Apk on: workflow_dispatch: push: branches: - '**' paths-ignore: - 'LICENSE' - '*.md' - '.github/**' jobs: build: if: ${{ !startsWith(github.event.head_commit.message, 'chore:') && !startsWith(github.event.head_commit.message, 'chore(') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '21' - uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - name: write gkd secrets info run: | echo ${{ secrets.GKD_STORE_FILE_BASE64 }} | base64 --decode > ${{ github.workspace }}/gkd.jks echo GKD_STORE_FILE='${{ github.workspace }}/gkd.jks' >> gradle.properties echo GKD_STORE_PASSWORD='${{ secrets.GKD_STORE_PASSWORD }}' >> gradle.properties echo GKD_KEY_ALIAS='${{ secrets.GKD_KEY_ALIAS }}' >> gradle.properties echo GKD_KEY_PASSWORD='${{ secrets.GKD_KEY_PASSWORD }}' >> gradle.properties - run: chmod 777 ./gradlew - run: ./gradlew app:assembleGkdRelease - uses: actions/upload-artifact@v4 with: name: release path: app/build/outputs/apk/gkd/release - uses: actions/upload-artifact@v4 with: name: outputs path: app/build/outputs ================================================ FILE: .github/workflows/Build-Release.yml ================================================ name: Build-Release on: push: tags: - v* jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '21' - uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} - name: write gkd secrets info run: | echo ${{ secrets.GKD_STORE_FILE_BASE64 }} | base64 --decode > ${{ github.workspace }}/gkd.jks echo GKD_STORE_FILE='${{ github.workspace }}/gkd.jks' >> gradle.properties echo GKD_STORE_PASSWORD='${{ secrets.GKD_STORE_PASSWORD }}' >> gradle.properties echo GKD_KEY_ALIAS='${{ secrets.GKD_KEY_ALIAS }}' >> gradle.properties echo GKD_KEY_PASSWORD='${{ secrets.GKD_KEY_PASSWORD }}' >> gradle.properties - name: write play secrets info run: | echo ${{ secrets.PLAY_STORE_FILE_BASE64 }} | base64 --decode > ${{ github.workspace }}/play.jks echo PLAY_STORE_FILE='${{ github.workspace }}/play.jks' >> gradle.properties echo PLAY_STORE_PASSWORD='${{ secrets.PLAY_STORE_PASSWORD }}' >> gradle.properties echo PLAY_KEY_ALIAS='${{ secrets.PLAY_KEY_ALIAS }}' >> gradle.properties echo PLAY_KEY_PASSWORD='${{ secrets.PLAY_KEY_PASSWORD }}' >> gradle.properties - run: chmod 777 ./gradlew - run: ./gradlew app:assembleGkdRelease app:bundlePlayRelease - uses: actions/upload-artifact@v4 with: name: release path: app/build/outputs/apk/gkd/release - uses: actions/upload-artifact@v4 with: name: playRelease path: app/build/outputs/bundle/playRelease - uses: actions/upload-artifact@v4 with: name: outputs path: app/build/outputs - uses: actions/upload-artifact@v4 with: name: CHANGELOG.md path: CHANGELOG.md release: needs: build permissions: write-all runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: name: outputs path: outputs - uses: actions/download-artifact@v4 with: name: release path: release - uses: actions/download-artifact@v4 with: name: CHANGELOG.md - run: ls -R - id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref_name }} release_name: Release ${{ github.ref_name }} body_path: ./CHANGELOG.md prerelease: ${{ contains(github.ref_name, 'beta') }} - uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} 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 ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties .idea /kotlin-js-store .vscode *.jks *.keystore /_assets /.kotlin /gradle/libs.versions.updates.toml ================================================ FILE: CHANGELOG.md ================================================ # v1.11.6 以下是本次更新的主要内容 ## 优化和修复 - 新增权限受限提示 - 新增应用列表分组筛选 - 新增图标按钮长按提示 - 优化触发记录日期显示 - 修复某些情况下界面背景颜色异常 ## 更新方式 - GKD - 设置 - 关于 - 检测更新 - 下列方式之一 Download from GKD.LIDownload from Google PlayDownload from GitHub releases ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # gkd

GKD.LI

基于 [高级选择器](https://gkd.li/guide/selector) + [订阅规则](https://gkd.li/guide/subscription) + [快照审查](https://github.com/gkd-kit/inspect) 的自定义屏幕点击 Android 应用 通过自定义规则,在指定界面,满足指定条件(如屏幕上存在特定文字)时,点击特定的节点或位置或执行其他操作 - **快捷操作** 帮助你简化一些重复的流程, 如某些软件自动确认电脑登录 - **跳过流程** 某些软件可能在启动时存在一些烦人的流程, 这个软件可以帮助你点击跳过这个流程 ## 免责声明 **本项目遵循 [GPL-3.0](/LICENSE) 开源,项目仅供学习交流,禁止用于商业或非法用途** ## 安装 Download from GKD.LIDownload from Google PlayDownload from GitHub releases 如遇问题请先查看 [疑难解答](https://gkd.li/guide/faq) ## 截图 | | | | | | ------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | | ![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) | ## 订阅 GKD **默认不提供规则**,需自行添加本地规则,或者通过订阅链接的方式获取远程规则 也可通过 [subscription-template](https://github.com/gkd-kit/subscription-template) 快速构建自己的远程订阅 第三方订阅列表可在 查看 要加入此列表, 需点击仓库主页右上角设置图标后在 Topics 中添加 `gkd-subscription`
示例图片 - 添加至 Topics (点击展开) ![image](https://e.gkd.li/9e340459-254f-4ca0-8a44-cc823069e5a7)
## 选择器 一个类似 CSS 选择器的选择器, 能联系节点上下文信息, 更容易也更精确找到目标节点 [@[vid=\"menu\"] < [vid=\"menu_container\"] - [vid=\"dot_text_layout\"] > [text^=\"广告\"]](https://i.gkd.li/i/14881985?gkd=QFt2aWQ9Im1lbnUiXSA8IFt2aWQ9Im1lbnVfY29udGFpbmVyIl0gLSBbdmlkPSJkb3RfdGV4dF9sYXlvdXQiXSA-IFt0ZXh0Xj0i5bm_5ZGKIl0)
示例图片 - 选择器路径视图 (点击展开) [![image](https://e.gkd.li/a2ae667b-b8c5-4556-a816-37743347b972)](https://i.gkd.li/i/14881985?gkd=QFt2aWQ9Im1lbnUiXSA8IFt2aWQ9Im1lbnVfY29udGFpbmVyIl0gLSBbdmlkPSJkb3RfdGV4dF9sYXlvdXQiXSA-IFt0ZXh0Xj0i5bm_5ZGKIl0)
## 捐赠 如果 GKD 对你有用, 可以通过以下链接支持该项目 或前往 [Google Play](https://play.google.com/store/apps/details?id=li.songe.gkd) 给个好评 ## Star History [![Stargazers over time](https://starchart.cc/gkd-kit/gkd.svg?variant=adaptive)](https://starchart.cc/gkd-kit/gkd) ================================================ FILE: app/.gitignore ================================================ /build /release ================================================ FILE: app/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import kotlin.reflect.full.declaredMemberProperties fun String.runCommand(): String { val process = ProcessBuilder(split(" ")) .redirectErrorStream(true) .start() val output = process.inputStream.bufferedReader().readText().trim() val exitCode = process.waitFor() if (exitCode != 0) { error("Command failed with exit code $exitCode: $output") } return output } data class GitInfo( val commitId: String, val commitTime: String, val tagName: String?, ) val gitInfo = GitInfo( commitId = "git rev-parse HEAD".runCommand(), commitTime = "git log -1 --format=%ct".runCommand() + "000", tagName = runCatching { "git describe --tags --exact-match".runCommand() }.getOrNull(), ) val debugSuffixPairList by lazy { javax.xml.parsers.DocumentBuilderFactory .newInstance() .newDocumentBuilder() .parse(file("$projectDir/src/main/res/values/strings.xml")) .documentElement.getElementsByTagName("string").run { (0 until length).mapNotNull { i -> val node = item(i) if (node.attributes.getNamedItem("debug_suffix") != null) { val key = node.attributes.getNamedItem("name").nodeValue val value = node.textContent key to value } else { null } } } } plugins { alias(libs.plugins.android.application) alias(libs.plugins.androidx.room) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlinx.atomicfu) alias(libs.plugins.google.ksp) alias(libs.plugins.rikka.refine) alias(libs.plugins.loc) } android { namespace = rootProject.ext["android.namespace"].toString() compileSdk = rootProject.ext["android.compileSdk"] as Int buildToolsVersion = rootProject.ext["android.buildToolsVersion"].toString() defaultConfig { minSdk = rootProject.ext["android.minSdk"] as Int targetSdk = rootProject.ext["android.targetSdk"] as Int applicationId = "li.songe.gkd" versionCode = 81 versionName = "1.11.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } androidResources { localeFilters += listOf("zh", "en") } ndk { // noinspection ChromeOsAbiSupport abiFilters += listOf("arm64-v8a", "x86_64") } GitInfo::class.declaredMemberProperties.onEach { manifestPlaceholders[it.name] = it.get(gitInfo) ?: "" } } 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 playSigningConfig = if (project.hasProperty("PLAY_STORE_FILE")) { signingConfigs.create("play") { storeFile = file(project.properties["PLAY_STORE_FILE"].toString()) storePassword = project.properties["PLAY_STORE_PASSWORD"].toString() keyAlias = project.properties["PLAY_KEY_ALIAS"].toString() keyPassword = project.properties["PLAY_KEY_PASSWORD"].toString() } } else { gkdSigningConfig } buildTypes { all { if (gitInfo.tagName == null) { versionNameSuffix = "-${gitInfo.commitId.take(7)}" } } release { isMinifyEnabled = true isShrinkResources = true isDebuggable = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } debug { signingConfig = gkdSigningConfig applicationIdSuffix = ".debug" resValue("color", "better_black", "#FF5D92") debugSuffixPairList.onEach { (key, value) -> resValue("string", key, "$value-debug") } } } productFlavors { flavorDimensions += "channel" create("gkd") { isDefault = true signingConfig = gkdSigningConfig resValue("bool", "is_accessibility_tool", "true") } create("play") { signingConfig = playSigningConfig resValue("bool", "is_accessibility_tool", "false") } all { dimension = flavorDimensions.first() manifestPlaceholders["channel"] = name } } compileOptions { sourceCompatibility = rootProject.ext["android.javaVersion"] as JavaVersion targetCompatibility = rootProject.ext["android.javaVersion"] as JavaVersion } dependenciesInfo.includeInApk = false packaging.resources.excludes += setOf( // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 "META-INF/**", "**/attach_hotspot_windows.dll", "**.properties", "**.bin", "**/*.proto", "**/kotlin-tooling-metadata.json", // ktor "**/custom.config.conf", "**/custom.config.yaml", ) } 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", "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", "-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi", "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi", "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", "-Xcontext-parameters", "-XXLanguage:+MultiDollarInterpolation", ) } } // https://developer.android.com/jetpack/androidx/releases/room?hl=zh-cn#compiler-options room { schemaDirectory("$projectDir/schemas") } composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") stabilityConfigurationFiles.addAll( rootProject.layout.projectDirectory.file("stability_config.conf"), ) } loc { template = "{packageName}.{methodName}({fileName}:{lineNumber})" } dependencies { implementation(libs.kotlin.stdlib) implementation(project(":selector")) 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) implementation(libs.compose.animation) implementation(libs.compose.animation.graphics) implementation(libs.compose.icons) implementation(libs.compose.preview) debugImplementation(libs.compose.tooling) androidTestImplementation(libs.compose.junit4) implementation(libs.compose.activity) implementation(libs.compose.material3) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.material3.adaptive.navigation3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso) compileOnly(project(":hidden_api")) implementation(libs.rikka.shizuku.api) implementation(libs.rikka.shizuku.provider) implementation(libs.lsposed.hiddenapibypass) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.paging) ksp(libs.androidx.room.compiler) implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) 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.activityResultLauncher) implementation(libs.reorderable) implementation(libs.androidx.splashscreen) implementation(libs.coil.compose) implementation(libs.coil.network) implementation(libs.coil.gif) implementation(libs.exp4j) implementation(libs.toaster) implementation(libs.permissions) implementation(libs.device) implementation(libs.json5) compileOnly(libs.loc.annotation) implementation(libs.kevinnzouWebview) } ================================================ FILE: app/proguard-rules.pro ================================================ # http://developer.android.com/guide/developing/tools/proguard.html -dontwarn ** ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "e755c87d937b753ba02d02e8fa9afcec", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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 } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, 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", "notNull": true }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `group_key` INTEGER NOT NULL, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subsId", "columnName": "subs_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleIndex", "columnName": "rule_index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleKey", "columnName": "rule_key", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'e755c87d937b753ba02d02e8fa9afcec')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/10.json ================================================ { "formatVersion": 1, "database": { "version": 10, "identityHash": "f3e8d3fb1a6de3876a6fceb921a456a2", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "fieldPath": "subsId", "columnName": "subs_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'f3e8d3fb1a6de3876a6fceb921a456a2')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/11.json ================================================ { "formatVersion": 1, "database": { "version": 11, "identityHash": "8cc58f95916481333213b24587c5d4d3", "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, `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" }, { "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, '8cc58f95916481333213b24587c5d4d3')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/12.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/13.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/14.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "3e6e28a11589fe6c2d8aff5b9467a489", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, 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", "notNull": true }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `group_key` INTEGER NOT NULL, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subsId", "columnName": "subs_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleIndex", "columnName": "rule_index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleKey", "columnName": "rule_key", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '3e6e28a11589fe6c2d8aff5b9467a489')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "b52c1f25e2052865818be5151b6ac6a0", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, 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", "notNull": true }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `group_key` INTEGER NOT NULL, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subsId", "columnName": "subs_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleIndex", "columnName": "rule_index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "ruleKey", "columnName": "rule_key", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'b52c1f25e2052865818be5151b6ac6a0')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "d1a618bf8475b588793fb1d201815a08", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, 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", "notNull": true }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "groupKey", "columnName": "group_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `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, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, 'd1a618bf8475b588793fb1d201815a08')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "4219672d163fce6e91926d9e15fc0e64", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_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", "notNull": true }, { "fieldPath": "subsItemId", "columnName": "subs_item_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" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `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, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '4219672d163fce6e91926d9e15fc0e64')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "6cbd7772c779598a5448b9d5dc36c524", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_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" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `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, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '6cbd7772c779598a5448b9d5dc36c524')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/7.json ================================================ { "formatVersion": 1, "database": { "version": 7, "identityHash": "9d5e657834ed630ac5cf00753cf24a55", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_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" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `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, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "activity_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `activity_id` TEXT, 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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '9d5e657834ed630ac5cf00753cf24a55')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/8.json ================================================ { "formatVersion": 1, "database": { "version": 8, "identityHash": "409bb51310bcdb55ea721a8e88b6cef6", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_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" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "click_log", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `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, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "appId", "columnName": "app_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '409bb51310bcdb55ea721a8e88b6cef6')" ] } } ================================================ FILE: app/schemas/li.songe.gkd.db.AppDb/9.json ================================================ { "formatVersion": 1, "database": { "version": 9, "identityHash": "343aa23eaf071a84fab19c5979d95f13", "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "snapshot", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` 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": false }, { "fieldPath": "activityId", "columnName": "activity_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appName", "columnName": "app_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "appVersionCode", "columnName": "app_version_code", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "appVersionName", "columnName": "app_version_name", "affinity": "TEXT", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "subs_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_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" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "category_config", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_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", "notNull": false }, { "fieldPath": "subsItemId", "columnName": "subs_item_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "categoryKey", "columnName": "category_key", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "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", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "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, '343aa23eaf071a84fab19c5979d95f13')" ] } } ================================================ FILE: app/src/androidTest/kotlin/li/songe/gkd/ExampleInstrumentedTest.kt ================================================ package li.songe.gkd import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("li.songe.gkd", appContext.packageName) } } ================================================ FILE: app/src/gkd/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/aidl/li/songe/gkd/shizuku/CommandResult.aidl ================================================ package li.songe.gkd.shizuku; parcelable CommandResult; ================================================ FILE: app/src/main/aidl/li/songe/gkd/shizuku/IUserService.aidl ================================================ 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; 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; } ================================================ FILE: app/src/main/kotlin/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt ================================================ package com.google.android.accessibility.selecttospeak import li.songe.gkd.service.A11yService // https://github.com/ven-coder/Assists/issues/12#issuecomment-2684469065 class SelectToSpeakService : A11yService() ================================================ FILE: app/src/main/kotlin/li/songe/gkd/App.kt ================================================ 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.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.database.ContentObserver import android.net.Uri import android.os.PowerManager import android.provider.Settings import android.view.WindowManager import android.view.accessibility.AccessibilityManager import android.view.inputmethod.InputMethodManager import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import li.songe.gkd.a11y.initA11yFeat import li.songe.gkd.data.selfAppInfo import li.songe.gkd.notif.initChannel import li.songe.gkd.service.clearHttpSubs import li.songe.gkd.service.initA11yWhiteAppList import li.songe.gkd.shizuku.initShizuku import li.songe.gkd.store.initStore import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.LogUtils import li.songe.gkd.util.PKG_FLAGS import li.songe.gkd.util.initAppState import li.songe.gkd.util.initSubsState import li.songe.gkd.util.initToast import li.songe.gkd.util.toast import org.lsposed.hiddenapibypass.HiddenApiBypass import kotlin.system.exitProcess val appScope by lazy { MainScope() } private lateinit var innerApp: App val app: App get() = innerApp private val applicationInfo by lazy { app.packageManager.getApplicationInfo( app.packageName, PackageManager.GET_META_DATA ) } private fun getMetaString(key: String): String { return applicationInfo.metaData.getString(key) ?: error("Missing meta-data: $key") } // 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( val channel: String = getMetaString("channel"), val commitId: String = getMetaString("commitId"), val commitTime: Long = getMetaString("commitTime").toLong(), val tagName: String? = getMetaString("tagName").takeIf { it.isNotEmpty() }, val debuggable: Boolean = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0, val versionCode: Int = selfAppInfo.versionCode, val versionName: String = selfAppInfo.versionName!!, val appId: String = app.packageName!!, 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 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 (AndroidTarget.P) { HiddenApiBypass.addHiddenApiExemptions("L") } } fun registerObserver( uri: Uri, observer: ContentObserver ) { contentResolver.registerContentObserver(uri, false, observer) } fun unregisterObserver(observer: ContentObserver) { contentResolver.unregisterContentObserver(observer) } fun getSecureString(name: String): String? = Settings.Secure.getString(contentResolver, name) fun putSecureString(name: String, value: String?): Boolean { return Settings.Secure.putString(contentResolver, name, value) } fun putSecureInt(name: String, value: Int): Boolean { return Settings.Secure.putInt(contentResolver, name, value) } 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() } ) } 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) } 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 ) startActivity(intent) } 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 windowManager by lazy { app.getSystemService(WINDOW_SERVICE) as WindowManager } val keyguardManager by lazy { app.getSystemService(KEYGUARD_SERVICE) as KeyguardManager } val clipboardManager by lazy { app.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager } val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } val a11yManager by lazy { getSystemService(ACCESSIBILITY_SERVICE) as AccessibilityManager } override fun onCreate() { super.onCreate() LogUtils.d() Thread.setDefaultUncaughtExceptionHandler { t, e -> toast(e.message ?: e.toString()) LogUtils.d("UncaughtExceptionHandler", t, e) appScope.launch(Dispatchers.IO) { 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/MainActivity.kt ================================================ package li.songe.gkd import android.app.Activity import android.content.Intent 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.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 import androidx.compose.foundation.layout.padding 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.material3.AlertDialog 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.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 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.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.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.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.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.PerfIcon import li.songe.gkd.ui.component.ShareDataDialog import li.songe.gkd.ui.component.SubsSheet import li.songe.gkd.ui.component.TermsAcceptDialog import li.songe.gkd.ui.component.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.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.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.concurrent.Volatile import kotlin.reflect.jvm.jvmName class MainActivity : ComponentActivity() { val startTime = System.currentTimeMillis() val mainVm by viewModels() 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()) 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 } override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() fixSomeProblems() super.onCreate(savedInstanceState) LogUtils.d() mainVm launcher pickContentLauncher lifecycleScope.launch { storeFlow.mapState(lifecycleScope) { s -> s.excludeFromRecents }.collect { app.activityManager.appTasks.forEach { task -> task.setExcludeFromRecents(it) } } } addOnNewIntentListener { mainVm.handleIntent(it) intent = null } watchKeyboardVisible() StatusService.autoStart() if (storeFlow.value.enableBlockA11yAppList) { updateTopTaskAppId(META.appId) } setContent { val latestInsets = TopAppBarDefaults.windowInsets val density = LocalDensity.current if (latestInsets.getTop(density) > topBarWindowInsets.getTop(density)) { topBarWindowInsets = FixedWindowInsets(latestInsets) } CompositionLocalProvider( LocalMainViewModel provides mainVm ) { AppTheme { 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) } }, 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) BuildDialog(mainVm.dialogFlow) mainVm.uploadOptions.ShowDialog() EditGithubCookieDlg() mainVm.updateStatus?.UpgradeDialog() SubsSheet(mainVm, mainVm.sheetSubsIdFlow) ShareDataDialog(mainVm, mainVm.showShareDataIdsFlow) mainVm.inputSubsLinkOption.ContentDialog() mainVm.ruleGroupState.Render() TextDialog(mainVm.textFlow) } } } LaunchedEffect(null) { intent?.let { mainVm.handleIntent(it) intent = null } } } } override fun onStart() { super.onStart() LogUtils.d() activityVisibleState++ if (topActivityFlow.value.appId != META.appId) { updateTopActivity(META.appId, MainActivity::class.jvmName) } } var isFirstResume = true override fun onResume() { super.onResume() LogUtils.d() if (isFirstResume && startTime - app.startTime < 2000) { isFirstResume = false } else { syncFixState() } } override fun onStop() { super.onStop() LogUtils.d() activityVisibleState-- } override fun onDestroy() { super.onDestroy() LogUtils.d() } } @Volatile private var activityVisibleState = 0 val isActivityVisible get() = activityVisibleState > 0 val activityNavSourceName by lazy { META.appId + ".activity.nav.source" } fun Activity.navToMainActivity() { if (intent != null) { 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() } private val syncStateMutex = Mutex() fun syncFixState() { appScope.launchTry(Dispatchers.IO) { if (syncStateMutex.isLocked) { LogUtils.d("syncFixState isLocked") } syncStateMutex.withLock { updateSystemDefaultAppId() shizukuContextFlow.value.grantSelf() updatePermissionState() fixRestartAutomatorService() } } } @Composable private fun ShizukuErrorDialog(stateFlow: MutableStateFlow) { val state = stateFlow.collectAsState().value if (state != null) { val errorText = remember { state.stackTraceToString() } val appInfoCache = appInfoMapFlow.collectAsState().value val installed = appInfoCache.contains(shizukuAppId) AlertDialog( onDismissRequest = { stateFlow.value = null }, title = { Text(text = "授权错误") }, text = { Column { Text( text = if (installed) { "Shizuku 授权失败,请检查是否运行" } else { "Shizuku 授权失败,检测到 Shizuku 未安装,请先下载后安装,如果你是通过其它方式授权,请忽略此提示自行查找原因" } ) Spacer(modifier = Modifier.height(8.dp)) Box( modifier = Modifier.fillMaxWidth() ) { SelectionContainer( modifier = Modifier .align(Alignment.TopStart) .fillMaxWidth() ) { Text( text = errorText, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) .padding(8.dp) .heightIn(max = 400.dp) .verticalScroll(rememberScrollState()), style = MaterialTheme.typography.bodySmall, ) } PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { copyText(errorText) }) .padding(4.dp) .size(20.dp), imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } } }, confirmButton = { if (installed) { TextButton(onClick = { stateFlow.value = null openApp(shizukuAppId) }) { Text(text = "打开 Shizuku") } } else { TextButton(onClick = { stateFlow.value = null openUri(ShortUrlSet.URL4) }) { Text(text = "去下载") } } }, dismissButton = { TextButton(onClick = { stateFlow.value = null }) { Text(text = "我知道了") } } ) } } val accessRestrictedSettingsShowFlow = MutableStateFlow(false) @Composable fun AccessRestrictedSettingsDlg() { val a11yRunning by A11yService.isRunning.collectAsState() LaunchedEffect(a11yRunning) { if (a11yRunning) { accessRestrictedSettingsShowFlow.value = false } } val accessRestrictedSettingsShow by accessRestrictedSettingsShowFlow.collectAsState() val mainVm = LocalMainViewModel.current val isA11yPage = mainVm.topRoute is AuthA11yRoute LaunchedEffect(isA11yPage, accessRestrictedSettingsShow) { if (isA11yPage && accessRestrictedSettingsShow && !a11yRunning) { toast("请重新授权以解除限制") accessRestrictedSettingsShowFlow.value = false } } if (accessRestrictedSettingsShow && !isA11yPage && !a11yRunning) { AlertDialog( title = { Text(text = "权限受限") }, text = { Text(text = "当前操作权限「访问受限设置」已被限制, 请先解除限制") }, onDismissRequest = { accessRestrictedSettingsShowFlow.value = false }, confirmButton = { TextButton({ accessRestrictedSettingsShowFlow.value = false mainVm.navigateWebPage(ShortUrlSet.URL2) }) { Text(text = "解除") } }, dismissButton = { TextButton({ accessRestrictedSettingsShowFlow.value = false }) { Text(text = "关闭") } }, ) } } @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 = "我知道了") } } ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/MainViewModel.kt ================================================ package li.songe.gkd import android.app.Activity import android.content.Intent import android.net.Uri import android.webkit.URLUtil import androidx.lifecycle.viewModelScope 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.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import li.songe.gkd.a11y.useA11yServiceEnabledFlow import li.songe.gkd.a11y.useEnabledA11yServicesFlow import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.data.importData import li.songe.gkd.db.DbSet import li.songe.gkd.permission.AuthReason import li.songe.gkd.permission.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.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.HomeRoute import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.util.AutomatorModeOption 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.findOption 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 rikka.shizuku.Shizuku import kotlin.reflect.jvm.jvmName class MainViewModel : BaseViewModel(), OnSimpleLife { companion object { private var _instance: MainViewModel? = null val instance get() = _instance!! private var tempTermsAccepted = false } init { _instance = this addCloseable { if (_instance == this) { // 可能同时存在 2 个 MainViewModel 实例 _instance = null } } } override val scope get() = viewModelScope val backStack: NavBackStack = NavBackStack(HomeRoute) val topRoute get() = backStack.last() private val backThrottleTimer = ThrottleTimer() fun popPage() = runMainPost { if (backThrottleTimer.expired() && backStack.size > 1) { backStack.removeAt(backStack.lastIndex) } } fun navigatePage(navKey: NavKey, replaced: Boolean = false) = runMainPost { if (navKey != backStack.last()) { if (replaced) { backStack[backStack.lastIndex] = navKey } else { backStack.add(navKey) } } } fun navigateWebPage(url: String) = navigatePage(WebViewRoute(url)) val dialogFlow = MutableStateFlow(null) val authReasonFlow = MutableStateFlow(null) val updateStatus = if (META.updateEnabled) UpdateStatus(viewModelScope) else null val shizukuErrorFlow = MutableStateFlow(null) val uploadOptions = UploadOptions(this) val showEditCookieDlgFlow = MutableStateFlow(false) val inputSubsLinkOption = InputSubsLinkOption() 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.withStateLock { val subItems = subsItemsFlow.value val text = try { client.get(url).bodyAsText() } catch (e: Exception) { e.printStackTrace() LogUtils.d(e) toast("下载订阅文件失败\n${e.message}".trimEnd()) return@launchTry } val newSubsRaw = try { RawSubscription.parse(text) } catch (e: Exception) { e.printStackTrace() LogUtils.d(e) toast("解析订阅文件失败\n${e.message}".trimEnd()) return@launchTry } if (oldItem == null) { if (subItems.any { it.id == newSubsRaw.id }) { toast("订阅已存在") return@launchTry } } else { if (oldItem.id != newSubsRaw.id) { toast("订阅id不对应") return@launchTry } } if (newSubsRaw.id < 0) { toast("订阅id不可为${newSubsRaw.id}\n负数id为内部使用") return@launchTry } val newItem = oldItem?.copy(updateUrl = url) ?: SubsItem( id = newSubsRaw.id, updateUrl = url, order = if (subItems.isEmpty()) 1 else (subItems.maxBy { it.order }.order + 1) ) updateSubscription(newSubsRaw) if (oldItem == null) { DbSet.subsItemDao.insert(newItem) toast("成功添加订阅") } else { DbSet.subsItemDao.update(newItem) toast("成功修改订阅") } } } val ruleGroupState = RuleGroupState(this) val textFlow = MutableStateFlow(null) fun openUrl(url: String) { if (URLUtil.isNetworkUrl(url)) { textFlow.value = url } else { openUri(url) } } val tabFlow = MutableStateFlow(BottomNavItem.Control.key) val resetPageScrollEvent = MutableSharedFlow() private var lastClickTabTime = 0L 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.key lastClickTabTime = t } fun handleGkdUri(uri: Uri) { val notFoundToast = { toast("未知URI\n${uri}") } when (uri.host) { "page" -> when (uri.path) { "" -> { val tab = uri.getQueryParameter("tab")?.toIntOrNull() if (tab != null && BottomNavItem.allSubObjects.any { it.key == tab }) { tabFlow.value = tab } } "/1" -> navigatePage(AdvancedPageRoute) "/2" -> navigatePage(SnapshotPageRoute) "/3" -> navigatePage(AppOpsAllowRoute) else -> notFoundToast() } "invoke" -> when (uri.path) { "/1" -> openWeChatScaner() else -> notFoundToast() } else -> notFoundToast() } } fun handleIntent(intent: Intent) = viewModelScope.launchTry { LogUtils.d(intent) val uri = intent.data?.normalizeScheme() val source = intent.getStringExtra(activityNavSourceName) if (uri?.scheme == "gkd") { handleGkdUri(uri) } else if (source == OpenFileActivity::class.jvmName && uri != null) { toast("加载导入中...") tabFlow.value = BottomNavItem.SubsManage.key withContext(Dispatchers.IO) { importData(uri) } } } val termsAcceptedFlow by lazy { if (tempTermsAccepted) { MutableStateFlow(true) } else { createTextFlow( key = "terms_accepted", decode = { it == "true" }, encode = { tempTermsAccepted = it it.toString() }, scope = viewModelScope, ).apply { tempTermsAccepted = value } } } val githubCookieFlow by lazy { createTextFlow( key = "github_cookie", decode = { it ?: "" }, encode = { it }, private = true, scope = viewModelScope, ) } 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 hasOtherA11yFlow = a11yServicesFlow.mapNew { list -> list.any { it != A11yService.a11yCn } } 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() } init { // preload appIconMapFlow.value viewModelScope.launchTry(Dispatchers.IO) { val subsItems = DbSet.subsItemDao.queryAll() if (!subsItems.any { s -> s.id == LOCAL_SUBS_ID }) { if (!subsFolder.resolve("${LOCAL_SUBS_ID}.json").exists()) { updateSubscription( RawSubscription( id = LOCAL_SUBS_ID, name = "本地订阅", version = 0 ) ) } DbSet.subsItemDao.insert( SubsItem( id = LOCAL_SUBS_ID, order = subsItems.minByOrNull { it.order }?.order ?: 0, ) ) } } viewModelScope.launchTry(Dispatchers.IO) { // 每次进入删除缓存 clearCache() } if (updateStatus != null && termsAcceptedFlow.value) { updateStatus.checkUpdate() } viewModelScope.launch(Dispatchers.IO) { // preload githubCookieFlow.value } // for OnSimpleLife onCreated() addCloseable { onDestroyed() } toast("MainViewModel:init") } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/OpenFileActivity.kt ================================================ package li.songe.gkd import android.app.Activity import android.os.Bundle class OpenFileActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) navToMainActivity() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/OpenSchemeActivity.kt ================================================ package li.songe.gkd import android.app.Activity import android.os.Bundle class OpenSchemeActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) navToMainActivity() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/OpenTileActivity.kt ================================================ 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/a11y/A11yCommonImpl.kt ================================================ 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) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/a11y/A11yContext.kt ================================================ package li.songe.gkd.a11y 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 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 private operator fun LruCache.set(child: K, value: V): V { return put(child, value) } private fun List.getInt(i: Int = 0) = get(i) as Int private const val MAX_CACHE_SIZE = MAX_DESCENDANTS_SIZE private val AccessibilityNodeInfo?.notExpiredNode: AccessibilityNodeInfo? get() { if (this != null) { val expiryMillis = if (text == null) 2000L else 1000L if (isExpired(expiryMillis)) { return null } } return this } class A11yContext( 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) val rootCache = atomic(null) private fun clearChildCache(node: AccessibilityNodeInfo) { repeat(node.childCount.coerceAtMost(MAX_CHILD_SIZE)) { i -> childCache.remove(node to i)?.let { clearChildCache(it) } } } fun clearNodeCache(eventNode: AccessibilityNodeInfo? = null) { if (rootCache.value?.packageName != topActivityFlow.value.appId) { rootCache.value = null } if (eventNode != null) { clearChildCache(eventNode) parentCache[eventNode]?.let { p -> getPureIndex(eventNode)?.let { i -> childCache[p to i] = eventNode } } if (rootCache.value == eventNode) { rootCache.value = eventNode } else { if (META.debuggable) { Log.d( "cache", "clear node cache ${eventNode.packageName}/${eventNode.className}" ) } return } } if (META.debuggable) { val sizeList = listOf(childCache.size(), parentCache.size(), indexCache.size()) if (sizeList.any { it > 0 }) { Log.d("cache", "clear cache -> $sizeList") } } try { childCache.evictAll() parentCache.evictAll() indexCache.evictAll() } catch (_: Exception) { // https://github.com/gkd-kit/gkd/issues/664 // 在某些机型上 未知原因 缓存不一致 导致删除失败 childCache = LruCache(MAX_CACHE_SIZE) indexCache = LruCache(MAX_CACHE_SIZE) parentCache = LruCache(MAX_CACHE_SIZE) } } private var lastAppChangeTime = appChangeTime fun clearOldAppNodeCache(): Boolean { if (appChangeTime != lastAppChangeTime) { lastAppChangeTime = appChangeTime clearNodeCache() return true } return false } var currentRule: ResolvedRule? = null @Volatile var interruptKey = 0 private var interruptInnerKey = 0 private fun guardInterrupt() { if (!interruptable) return if (interruptInnerKey == interruptKey) return interruptInnerKey = interruptKey val rule = currentRule ?: return if (!activityRuleFlow.value.activePriority) return if (!activityRuleFlow.value.currentRules.any { it === rule }) return if (rule.isPriority()) return if (META.debuggable) { Log.d("guardInterrupt", "中断 rule=${rule.statusText()}") } throw InterruptRuleMatchException() } private fun getA11Root(): AccessibilityNodeInfo? { guardInterrupt() return a11yEngine.safeActiveWindow } private fun getA11Child(node: AccessibilityNodeInfo, index: Int): AccessibilityNodeInfo? { guardInterrupt() return node.getChild(index)?.setGeneratedTime() } private fun getA11Parent(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { guardInterrupt() return node.parent?.setGeneratedTime() } private fun getA11ByText( node: AccessibilityNodeInfo, value: String ): List { guardInterrupt() return node.findAccessibilityNodeInfosByText(value).apply { forEach { it.setGeneratedTime() } } } private fun getA11ById( node: AccessibilityNodeInfo, value: String ): List { guardInterrupt() return node.findAccessibilityNodeInfosByViewId(value).apply { forEach { it.setGeneratedTime() } } } private fun getFastQueryNodes( node: AccessibilityNodeInfo, fastQuery: FastQuery ): List { return when (fastQuery) { is FastQuery.Id -> getA11ById(node, fastQuery.value) is FastQuery.Text -> getA11ByText(node, fastQuery.value) is FastQuery.Vid -> getA11ById(node, "${node.packageName}:id/${fastQuery.value}") } } private fun getCacheRoot(node: AccessibilityNodeInfo? = null): AccessibilityNodeInfo? { if (rootCache.value.notExpiredNode == null) { rootCache.value = getA11Root() } if (node == rootCache.value) return null return rootCache.value } private fun getCacheParent(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { if (getCacheRoot() == node) { return null } parentCache[node].notExpiredNode?.let { return it } return getA11Parent(node).apply { if (this != null) { parentCache[node] = this } else { rootCache.value = node } } } private fun getCacheChild(node: AccessibilityNodeInfo, index: Int): AccessibilityNodeInfo? { if (index !in 0 until node.childCount) { return null } return childCache[node to index].notExpiredNode ?: getA11Child(node, index)?.also { child -> indexCache[child] = index parentCache[child] = node childCache[node to index] = child } } private fun getPureIndex(node: AccessibilityNodeInfo): Int? { return indexCache[node] } private fun getCacheIndex(node: AccessibilityNodeInfo): Int { indexCache[node]?.let { return it } getCacheChildren(getCacheParent(node)).forEachIndexed { index, child -> if (child == node) { indexCache[node] = index return index } } return 0 } /** * 在无缓存时, 此方法小概率造成无限节点片段,底层原因未知 * * https://github.com/gkd-kit/gkd/issues/28 */ private fun getCacheDepth(node: AccessibilityNodeInfo): Int { var p: AccessibilityNodeInfo = node var depth = 0 while (true) { val p2 = getCacheParent(p) if (p2 != null) { p = p2 depth++ } else { break } } return depth } private fun getCacheChildren(node: AccessibilityNodeInfo?): Sequence { if (node == null) return emptySequence() return sequence { repeat(node.childCount.coerceAtMost(MAX_CHILD_SIZE)) { index -> val child = getCacheChild(node, index) ?: return@sequence yield(child) } } } private var tempVid: CharSequence? = null private var tempVidNode: AccessibilityNodeInfo? = null private fun getTempVid(n: AccessibilityNodeInfo): CharSequence? { if (n !== tempVidNode) { tempVid = n.getVid() tempVidNode = n } return tempVid } private fun getCacheAttr(node: AccessibilityNodeInfo, name: String): Any? = when (name) { "id" -> node.viewIdResourceName "vid" -> getTempVid(node) "name" -> node.className "text" -> node.text "desc" -> node.contentDescription "clickable" -> node.isClickable "focusable" -> node.isFocusable "checkable" -> node.isCheckable "checked" -> node.compatChecked "editable" -> node.isEditable "longClickable" -> node.isLongClickable "visibleToUser" -> node.isVisibleToUser "left" -> node.casted.boundsInScreen.left "top" -> node.casted.boundsInScreen.top "right" -> node.casted.boundsInScreen.right "bottom" -> node.casted.boundsInScreen.bottom "width" -> node.casted.boundsInScreen.width() "height" -> node.casted.boundsInScreen.height() "index" -> getCacheIndex(node) "depth" -> getCacheDepth(node) "childCount" -> node.childCount "parent" -> getCacheParent(node) else -> null } private val transform = Transform( getAttr = { target, name -> when (target) { is QueryContext<*> -> when (name) { "prev" -> target.prev "current" -> target.current else -> getCacheAttr(target.current as AccessibilityNodeInfo, name) } is AccessibilityNodeInfo -> getCacheAttr(target, name) is CharSequence -> getCharSequenceAttr(target, name) else -> null } }, getInvoke = { target, name, args -> when (target) { is AccessibilityNodeInfo -> when (name) { "getChild" -> { getCacheChild(target, args.getInt()) } else -> null } is QueryContext<*> -> when (name) { "getPrev" -> { args.getInt().let { target.getPrev(it) } } "getChild" -> { getCacheChild(target.current as AccessibilityNodeInfo, args.getInt()) } else -> null } is CharSequence -> getCharSequenceInvoke(target, name, args) is Int -> getIntInvoke(target, name, args) is Boolean -> getBooleanInvoke(target, name, args) else -> null } }, getName = { node -> node.className }, getChildren = ::getCacheChildren, getParent = ::getCacheParent, getRoot = ::getCacheRoot, getDescendants = { node -> sequence { val stack = getCacheChildren(node).toMutableList() if (stack.isEmpty()) return@sequence stack.reverse() val tempNodes = mutableListOf() do { val top = stack.removeAt(stack.lastIndex) yield(top) for (childNode in getCacheChildren(top)) { tempNodes.add(childNode) } if (tempNodes.isNotEmpty()) { for (i in tempNodes.size - 1 downTo 0) { stack.add(tempNodes[i]) } tempNodes.clear() } } while (stack.isNotEmpty()) }.take(MAX_DESCENDANTS_SIZE) }, traverseChildren = { node, connectExpression -> sequence { repeat(node.childCount.coerceAtMost(MAX_CHILD_SIZE)) { offset -> connectExpression.maxOffset?.let { maxOffset -> if (offset > maxOffset) return@sequence } if (connectExpression.checkOffset(offset)) { val child = getCacheChild(node, offset) ?: return@sequence yield(child) } } } }, traverseBeforeBrothers = { node, connectExpression -> sequence { val parentVal = getCacheParent(node) ?: return@sequence // 如果 node 由 fastQuery 得到, 则第一次调用此方法可能得到 cache.index 是空 val index = getPureIndex(node) if (index != null) { var i = index - 1 var offset = 0 while (0 <= i && i < parentVal.childCount) { connectExpression.maxOffset?.let { maxOffset -> if (offset > maxOffset) return@sequence } if (connectExpression.checkOffset(offset)) { val child = getCacheChild(parentVal, i) ?: return@sequence yield(child) } i-- offset++ } } else { val list = getCacheChildren(parentVal).takeWhile { it != node }.toMutableList() list.reverse() yieldAll(list.filterIndexed { i, _ -> connectExpression.checkOffset( i ) }) } } }, traverseAfterBrothers = { node, connectExpression -> val parentVal = getCacheParent(node) if (parentVal != null) { val index = getPureIndex(node) if (index != null) { sequence { var i = index + 1 var offset = 0 while (0 <= i && i < parentVal.childCount) { connectExpression.maxOffset?.let { maxOffset -> if (offset > maxOffset) return@sequence } if (connectExpression.checkOffset(offset)) { val child = getCacheChild(parentVal, i) ?: return@sequence yield(child) } i++ offset++ } } } else { getCacheChildren(parentVal).dropWhile { it != node } .drop(1) .let { if (connectExpression.maxOffset != null) { it.take(connectExpression.maxOffset!! + 1) } else { it } } .filterIndexed { i, _ -> connectExpression.checkOffset( i ) } } } else { emptySequence() } }, traverseDescendants = { node, connectExpression -> sequence { val stack = getCacheChildren(node).toMutableList() if (stack.isEmpty()) return@sequence stack.reverse() val tempNodes = mutableListOf() var offset = 0 do { val top = stack.removeAt(stack.lastIndex) if (connectExpression.checkOffset(offset)) { yield(top) } offset++ if (offset > MAX_DESCENDANTS_SIZE) { return@sequence } connectExpression.maxOffset?.let { maxOffset -> if (offset > maxOffset) return@sequence } for (childNode in getCacheChildren(top)) { tempNodes.add(childNode) } if (tempNodes.isNotEmpty()) { for (i in tempNodes.size - 1 downTo 0) { stack.add(tempNodes[i]) } tempNodes.clear() } } while (stack.isNotEmpty()) } }, traverseFastQueryDescendants = { node, list -> sequence { for (fastQuery in list) { val nodes = getFastQueryNodes(node, fastQuery) nodes.forEach { childNode -> yield(childNode) } } } } ) fun querySelfOrSelector( node: AccessibilityNodeInfo, selector: Selector, option: MatchOption, ): AccessibilityNodeInfo? { if (selector.isMatchRoot) { return selector.match( getCacheRoot() ?: return null, transform, option ) } selector.match(node, transform, option)?.let { return it } return transform.querySelector(node, selector, option) } fun queryRule( rule: ResolvedRule, node: AccessibilityNodeInfo, ): AccessibilityNodeInfo? { currentRule = rule try { val queryNode = if (rule.matchRoot) { getCacheRoot() } else { node } ?: return null var resultNode: AccessibilityNodeInfo? = null if (rule.anyMatches.isNotEmpty()) { for (selector in rule.anyMatches) { resultNode = querySelfOrSelector( queryNode, selector, rule.matchOption, ) if (resultNode != null) break } if (resultNode == null) return null } for (selector in rule.matches) { resultNode = querySelfOrSelector( queryNode, selector, rule.matchOption, ) ?: return null } for (selector in rule.excludeMatches) { querySelfOrSelector( queryNode, selector, rule.matchOption, )?.let { return null } } if (rule.excludeAllMatches.isNotEmpty()) { val allExclude = rule.excludeAllMatches.all { querySelfOrSelector( queryNode, it, rule.matchOption, ) == null } if (!allExclude) { return null } } return resultNode } finally { currentRule = null } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/a11y/A11yExt.kt ================================================ 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, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/a11y/A11yFeat.kt ================================================ package li.songe.gkd.a11y import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.util.Log 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 -> Log.d("A11yEventTransform", "getInvoke: $name(${args.joinToString()}) on $target") when (target) { is Int -> getIntInvoke(target, name, args) is Boolean -> getBooleanInvoke(target, name, args) is CharSequence -> getCharSequenceInvoke(target, name, args) is List<*> -> when (name) { "get" -> { (args.singleOrNull() as? Int)?.let { index -> target.getOrNull(index) } } else -> null } else -> null } }, getName = { it.className }, getChildren = { emptySequence() }, getParent = { null } ) } context(event: AccessibilityEvent) private fun watchCaptureScreenshot() { if (!storeFlow.value.captureScreenshot) return if (event.packageName != storeFlow.value.screenshotTargetAppId) return if (tempEventSelector.first != storeFlow.value.screenshotEventSelector) { tempEventSelector = storeFlow.value.screenshotEventSelector to Selector.parseOrNull(storeFlow.value.screenshotEventSelector) } val selector = tempEventSelector.second ?: return selector.match(event, a11yEventTransform, MatchOption(fastQuery = false)).let { if (it == null) return } appScope.launchTry { SnapshotExt.captureSnapshot(skipScreenshot = true) } } 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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/a11y/A11yRuleEngine.kt ================================================ package li.songe.gkd.a11y import android.accessibilityservice.AccessibilityService import android.graphics.Bitmap import android.util.Log 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.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.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 import kotlin.coroutines.suspendCoroutine 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 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 private var lastContentEventTime = 0L private var lastEventTime = 0L private val eventDeque = ArrayDeque() fun onA11yEvent(event: AccessibilityEvent?) { if (!effective) return if (!event.isUseful()) 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 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) { // tv.danmaku.bili, com.miui.home, com.miui.home.launcher.Launcher if (isActivity(evAppId, evActivityId)) { updateTopActivity(evAppId, evActivityId) } } } if (rightAppId != topActivityFlow.value.appId) { // 从 锁屏,下拉通知栏 返回等情况, 应用不会发送事件, 但是系统组件会发送事件 val topCpn = 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? = suspendCoroutine { s -> val temp = atomic?>(s) scope.launch(Dispatchers.IO) { delay(500L) temp.getAndUpdate { null }?.resume(null) } scope.launch(Dispatchers.IO) { val a = safeActiveWindow 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 = System.currentTimeMillis() try { Log.d( "A11yRuleEngine", "startQueryJob start byEvent=${byEvent != null}, byForced=$byForced, byDelayRule=${byDelayRule != null}" ) queryAction(byEvent, byForced, byDelayRule) } finally { checkFutureStartJob() 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 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 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 = 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 (activityRule !== activityRuleFlow.value) break if (delayRule != null && delayRule !== rule) continue if (rule.status != RuleStatus.StatusOk) continue if (byForced && !rule.checkForced()) continue lastNode?.let { n -> val refreshOk = (!lastNodeUsed) || (try { val e = n.refresh() if (e) { n.setGeneratedTime() } e } catch (_: Throwable) { false }) lastNodeUsed = true if (!refreshOk) { lastNode = null } } val nodeVal = (lastNode ?: 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 (activityRule !== activityRuleFlow.value) break if (rule.checkDelay() && rule.actionDelayJob.value == null) { rule.actionDelayJob.value = scope.launch(actionDispatcher) { delay(rule.actionDelay) rule.actionDelayJob.value = null startQueryJob(byDelayRule = rule) } continue } if (rule.status != RuleStatus.StatusOk) break val actionResult = rule.performAction(target) if (actionResult.result) { val topActivity = topActivityFlow.value rule.trigger() scope.launch(actionDispatcher) { delay(300) startQueryJob() } if (actionResult.action != ActionPerformer.None.action) { showActionToast(rule) } addActionLog(rule, topActivity, target, actionResult) } } } 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 { return (shizukuContextFlow.value.inputManager?.key(KeyEvent.KEYCODE_BACK) ?: 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.position) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/a11y/A11yState.kt ================================================ package li.songe.gkd.a11y import android.content.ComponentName import android.content.Intent 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 (_: Exception) { 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() } @Loc @Synchronized 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 isSame = scene != ActivityScene.ScreenOn && oldActivity.sameAs(appId, activityId) if (scene == ActivityScene.TaskStack) { lastActivityForceUpdateTime = t } else if (scene == ActivityScene.A11y) { if (lastActivityForceUpdateTime > 0) { // ITaskStackListener 的变速快于无障碍 if (t - lastActivityForceUpdateTime < 1000) return if (activityId != null && t - lastActivityForceUpdateTime < 3000) return } if (isSame && t - lastActivityUpdateTime < 1000) return } val number = if (isSame) { oldActivity.number + 1 } else { 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 oldActivityRule = activityRuleFlow.value val ruleSummary = ruleSummaryFlow.value val idChanged = (scene == ActivityScene.ScreenOn || topActivity.appId != oldActivityRule.topActivity.appId) 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 {} ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/A11yEventLog.kt ================================================ 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() } ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/ActionLog.kt ================================================ package li.songe.gkd.data import androidx.paging.PagingSource import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.DeleteTable import androidx.room.Entity import androidx.room.Insert import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.migration.AutoMigrationSpec import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import li.songe.gkd.util.format import li.songe.gkd.util.getShowActivityId @Serializable @Entity( tableName = "action_log", ) data class ActionLog( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int = 0, @ColumnInfo(name = "ctime") val ctime: Long, @ColumnInfo(name = "app_id") val appId: String, @ColumnInfo(name = "activity_id") val activityId: String? = null, @ColumnInfo(name = "subs_id") val subsId: Long, @ColumnInfo(name = "subs_version", defaultValue = "0") val subsVersion: Int, @ColumnInfo(name = "group_key") val groupKey: Int, @ColumnInfo(name = "group_type", defaultValue = "2") val groupType: Int, @ColumnInfo(name = "rule_index") val ruleIndex: Int, @ColumnInfo(name = "rule_key") val ruleKey: Int? = null, ) { val showActivityId by lazy { getShowActivityId(appId, activityId) } val date by lazy { ctime.format("MM-dd HH:mm:ss SSS") } @DeleteTable.Entries( DeleteTable(tableName = "click_log") ) class ActionLogSpec : AutoMigrationSpec @Dao interface ActionLogDao { @Insert suspend fun insert(vararg objects: ActionLog): List @Query("DELETE FROM action_log WHERE subs_id IN (:subsIds)") suspend fun deleteBySubsId(vararg subsIds: Long): Int @Query("DELETE FROM action_log") suspend fun deleteAll() @Query("DELETE FROM action_log WHERE subs_id=:subsId") suspend fun deleteSubsAll(subsId: Long) @Query("DELETE FROM action_log WHERE app_id=:appId") suspend fun deleteAppAll(appId: String) @Query("SELECT * FROM action_log ORDER BY id DESC LIMIT 1000") fun query(): Flow> @Query("SELECT * FROM action_log ORDER BY id DESC ") fun pagingSource(): PagingSource @Query("SELECT * FROM action_log WHERE subs_id=:subsId ORDER BY id DESC ") fun pagingSubsSource(subsId: Long): PagingSource @Query("SELECT * FROM action_log WHERE app_id=:appId ORDER BY id DESC ") fun pagingAppSource(appId: String): PagingSource @Query("SELECT COUNT(*) FROM action_log") fun count(): Flow @Query("SELECT * FROM action_log ORDER BY id DESC LIMIT 1") fun queryLatest(): Flow @Query( """ SELECT cl.* FROM action_log AS cl INNER JOIN ( SELECT subs_id, group_type, group_key, MAX(id) AS max_id FROM action_log WHERE app_id = :appId AND subs_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1) GROUP BY subs_id, group_type, group_key ) AS latest_log ON cl.subs_id = latest_log.subs_id AND cl.group_type = latest_log.group_type AND cl.group_key = latest_log.group_key AND cl.id = latest_log.max_id """ ) fun queryLatestByAppId(appId: String): Flow> @Query( """ DELETE FROM action_log WHERE ( SELECT COUNT(*) FROM action_log ) > 500 AND id <= ( SELECT id FROM action_log ORDER BY id DESC LIMIT 1 OFFSET 500 ) """ ) suspend fun deleteKeepLatest(): Int @Query("SELECT DISTINCT app_id FROM action_log ORDER BY id DESC") fun queryLatestUniqueAppIds(): Flow> @Query("SELECT DISTINCT app_id FROM action_log WHERE subs_id=:subsItemId AND group_type=${SubsConfig.AppGroupType} ORDER BY id DESC") fun queryLatestUniqueAppIds(subsItemId: Long): Flow> @Query("SELECT DISTINCT app_id FROM action_log WHERE subs_id=:subsItemId AND group_key=:globalGroupKey AND group_type=${SubsConfig.GlobalGroupType} ORDER BY id DESC") fun queryLatestUniqueAppIds(subsItemId: Long, globalGroupKey: Int): Flow> } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/ActivityLog.kt ================================================ package li.songe.gkd.data import androidx.paging.PagingSource import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.DeleteTable import androidx.room.Entity import androidx.room.Insert import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.migration.AutoMigrationSpec import kotlinx.coroutines.flow.Flow import li.songe.gkd.util.format import li.songe.gkd.util.getShowActivityId @Entity( tableName = "activity_log_v2", ) data class ActivityLog( // 不使用时间戳作为主键的原因 // https://github.com/gkd-kit/gkd/issues/704 @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int = 0, @ColumnInfo(name = "ctime") val ctime: Long, @ColumnInfo(name = "app_id") val appId: String, @ColumnInfo(name = "activity_id") val activityId: String? = null, ) { val showActivityId by lazy { getShowActivityId(appId, activityId) } val date by lazy { ctime.format("HH:mm:ss SSS") } @Dao interface ActivityLogDao { @Insert suspend fun insert(vararg objects: ActivityLog): List @Query("DELETE FROM activity_log_v2") suspend fun deleteAll() @Query("SELECT * FROM activity_log_v2 ORDER BY ctime DESC ") fun pagingSource(): PagingSource @Query("SELECT COUNT(*) FROM activity_log_v2") fun count(): Flow @Query( """ DELETE FROM activity_log_v2 WHERE ( SELECT COUNT(*) FROM activity_log_v2 ) > 500 AND ctime <= ( SELECT ctime FROM activity_log_v2 ORDER BY ctime DESC LIMIT 1 OFFSET 500 ) """ ) suspend fun deleteKeepLatest(): Int } @DeleteTable.Entries( DeleteTable(tableName = "activity_log") ) class ActivityLogV2Spec : AutoMigrationSpec } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/AppConfig.kt ================================================ 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.Update import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @Serializable @Entity( tableName = "app_config", ) 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, ) { @Dao interface AppConfigDao { @Update suspend fun update(vararg objects: AppConfig): Int @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(vararg users: AppConfig): List @Query("SELECT * FROM app_config WHERE subs_id=:subsId") fun queryAppTypeConfig(subsId: Long): Flow> @Query("SELECT * FROM app_config WHERE app_id=:appId AND subs_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1)") fun queryAppUsedList(appId: String): Flow> @Query("SELECT * FROM app_config WHERE subs_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1)") fun queryUsedList(): Flow> @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnore(vararg objects: AppConfig): List @Query("SELECT * FROM app_config WHERE subs_id IN (:subsItemIds)") suspend fun querySubsItemConfig(subsItemIds: List): List } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt ================================================ package li.songe.gkd.data import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable import kotlinx.serialization.Serializable 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, val versionCode: Int, val versionName: String?, val isSystem: Boolean, val mtime: Long, val hidden: Boolean, 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, 0).toAppInfo() } private val PackageInfo.compatVersionCode: Int get() = if (AndroidTarget.P) { longVersionCode.toInt() } else { @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 } } 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 = currentUserId, hidden: Boolean? = null, ): AppInfo { val isSystem = applicationInfo?.isSystem ?: false return AppInfo( userId = userId, id = packageName, versionCode = compatVersionCode, versionName = versionName, mtime = lastUpdateTime, isSystem = isSystem, name = applicationInfo?.run { loadLabel(app.packageManager).toString() } ?: packageName, hidden = hidden ?: (isSystem && (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 } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/AppRule.kt ================================================ package li.songe.gkd.data class AppRule( rule: RawSubscription.RawAppRule, g: ResolvedAppGroup, appInfo: AppInfo?, ) : ResolvedRule( rule = rule, g = g, ) { val group = g.group val app = g.app val enable = appInfo?.let { if (rule.versionCode?.match(it.versionCode) == false) { return@let false } if (rule.versionName?.match(it.versionName) == false) { return@let false } null } ?: true val appId = app.id private val activityIds = getFixActivityIds(app.id, rule.activityIds ?: group.activityIds) private val excludeActivityIds = (getFixActivityIds( app.id, rule.excludeActivityIds ?: group.excludeActivityIds ) + (excludeData.activityIds.filter { e -> e.first == appId } .map { e -> e.second })).distinct() override val type = "app" override fun matchActivity(appId: String, activityId: String?): Boolean { if (!enable) return false if (appId != app.id) return false activityId ?: return true if (excludeActivityIds.any { activityId.startsWith(it) }) return false return activityIds.isEmpty() || activityIds.any { activityId.startsWith(it) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/AppVisitLog.kt ================================================ 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, launcherAppId, systemUiAppId -> t - 60_000 else -> t } private var appLogCount = 0 ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt ================================================ package li.songe.gkd.data import android.view.accessibility.AccessibilityNodeInfo import kotlinx.serialization.Serializable import li.songe.gkd.a11y.compatChecked import li.songe.gkd.shizuku.casted @Serializable data class AttrInfo( val id: String?, val vid: String?, val name: String?, val text: String?, val desc: String?, val clickable: Boolean, val focusable: Boolean, val checkable: Boolean, val checked: Boolean?, val editable: Boolean, val longClickable: Boolean, val visibleToUser: Boolean, val left: Int, val top: Int, val right: Int, val bottom: Int, val width: Int, val height: Int, val childCount: Int, val index: Int, val depth: Int, ) { companion object { fun info2data( node: AccessibilityNodeInfo, index: Int, depth: Int, ): AttrInfo { val rect = node.casted.boundsInScreen val appId = node.packageName?.toString() ?: "" val id: String? = node.viewIdResourceName val idPrefix = "$appId:id/" val vid = if (id != null && id.startsWith(idPrefix)) { id.substring(idPrefix.length) } else { // 此处不使用 id 是因为某些节点的 id 没有 appId:id/ 前缀 null } return AttrInfo( id = id, vid = vid, name = node.className?.toString(), text = node.text?.toString(), desc = node.contentDescription?.toString(), clickable = node.isClickable, focusable = node.isFocusable, checkable = node.isCheckable, checked = node.compatChecked, editable = node.isEditable, longClickable = node.isLongClickable, visibleToUser = node.isVisibleToUser, left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom, width = rect.width(), height = rect.height(), childCount = node.childCount, index = index, depth = depth, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/BaseSnapshot.kt ================================================ package li.songe.gkd.data interface BaseSnapshot { val id: Long val appId: String val activityId: String? val screenHeight: Int val screenWidth: Int val isLandscape: Boolean } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/CategoryConfig.kt ================================================ package li.songe.gkd.data import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @Serializable @Entity( tableName = "category_config", ) data class CategoryConfig( @PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(), @ColumnInfo(name = "enable") val enable: Boolean? = null, @ColumnInfo(name = "subs_id") val subsId: Long, @ColumnInfo(name = "category_key") val categoryKey: Int, ) { @Dao interface CategoryConfigDao { @Update suspend fun update(vararg objects: CategoryConfig): Int @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(vararg objects: CategoryConfig): List @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnore(vararg objects: CategoryConfig): List @Delete suspend fun delete(vararg objects: CategoryConfig): Int @Query("DELETE FROM category_config WHERE subs_id=:subsItemId") suspend fun deleteBySubsItemId(subsItemId: Long): Int @Query("DELETE FROM category_config WHERE subs_id IN (:subsIds)") suspend fun deleteBySubsId(vararg subsIds: Long): Int @Query("DELETE FROM category_config WHERE subs_id=:subsItemId AND category_key=:categoryKey") suspend fun deleteByCategoryKey(subsItemId: Long, categoryKey: Int): Int @Query("SELECT * FROM category_config WHERE subs_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1)") fun queryUsedList(): Flow> @Query("SELECT * FROM category_config WHERE subs_id=:subsItemId") 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? @Query("SELECT * FROM category_config WHERE subs_id IN (:subsItemIds)") suspend fun querySubsItemConfig(subsItemIds: List): List @Query("SELECT * FROM category_config WHERE subs_id IN (:subsItemIds)") fun queryBySubsIds(subsItemIds: List): Flow> } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt ================================================ package li.songe.gkd.data import kotlinx.serialization.Serializable import li.songe.gkd.util.appInfoMapFlow @Serializable data class ComplexSnapshot( override val id: Long, override val appId: String, override val activityId: String?, override val screenHeight: Int, override val screenWidth: Int, override val isLandscape: Boolean, val appInfo: AppInfo? = appInfoMapFlow.value[appId], val gkdAppInfo: AppInfo? = selfAppInfo, val device: DeviceInfo = DeviceInfo(), val nodes: List, ) : BaseSnapshot { fun toSnapshot(): Snapshot { return Snapshot( id = id, appId = appId, activityId = activityId, screenHeight = screenHeight, screenWidth = screenWidth, isLandscape = isLandscape, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/DeviceInfo.kt ================================================ package li.songe.gkd.data import android.os.Build import kotlinx.serialization.Serializable @Serializable data class DeviceInfo( 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, ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/GithubPoliciesAsset.kt ================================================ package li.songe.gkd.data import kotlinx.serialization.Serializable import li.songe.gkd.util.FILE_SHORT_URL @Serializable data class GithubPoliciesAsset( val id: Int, val href: String, ) { val shortHref: String get() = FILE_SHORT_URL + id } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/GkdAction.kt ================================================ package li.songe.gkd.data import android.accessibilityservice.GestureDescription import android.graphics.Path import android.view.ViewConfiguration import android.view.accessibility.AccessibilityNodeInfo import kotlinx.serialization.Serializable import li.songe.gkd.a11y.A11yRuleEngine import li.songe.gkd.service.A11yService 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, ) @Serializable data class ActionResult( val action: String, val result: Boolean, val shizuku: Boolean = false, val position: Pair? = null, ) sealed class ActionPerformer(val action: String) { abstract fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult data object ClickNode : ActionPerformer("clickNode") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { return ActionResult( action = action, result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) ) } } data object ClickCenter : ActionPerformer("clickCenter") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { val rect = node.casted.boundsInScreen val p = position?.calc(rect) val x = p?.first ?: ((rect.right + rect.left) / 2f) val y = p?.second ?: ((rect.bottom + rect.top) / 2f) return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { if (shizukuContextFlow.value.tap(x, y)) { return ActionResult( action = action, result = true, shizuku = true, position = x to y ) } val gestureDescription = GestureDescription.Builder() val path = Path() path.moveTo(x, y) gestureDescription.addStroke( GestureDescription.StrokeDescription( path, 0, ViewConfiguration.getTapTimeout().toLong() ) ) A11yService.instance?.dispatchGesture( gestureDescription.build(), null, null ) != null } else { false }, position = x to y ) } } data object Click : ActionPerformer("click") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { if (node.isClickable) { val result = ClickNode.perform(node, position) if (result.result) { return result } } return ClickCenter.perform(node, position) } } data object LongClickNode : ActionPerformer("longClickNode") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { return ActionResult( action = action, result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK) ) } } data object LongClickCenter : ActionPerformer("longClickCenter") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { val rect = node.casted.boundsInScreen val p = position?.calc(rect) val x = p?.first ?: ((rect.right + rect.left) / 2f) val y = p?.second ?: ((rect.bottom + rect.top) / 2f) // 某些系统的 ViewConfiguration.getLongPressTimeout() 返回 300 , 这将导致触发普通的 click 事件 val longClickDuration = 500L return ActionResult( action = action, result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) { if (shizukuContextFlow.value.tap( x, y, longClickDuration ) ) { return ActionResult( action = action, result = true, shizuku = true, position = x to y ) } val gestureDescription = GestureDescription.Builder() val path = Path() path.moveTo(x, y) gestureDescription.addStroke( GestureDescription.StrokeDescription( path, 0, longClickDuration ) ) A11yService.instance?.dispatchGesture( gestureDescription.build(), null, null ) != null } else { false }, position = x to y ) } } data object LongClick : ActionPerformer("longClick") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { if (node.isLongClickable) { val result = LongClickNode.perform(node, position) if (result.result) { return result } } return LongClickCenter.perform(node, position) } } data object Back : ActionPerformer("back") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { return ActionResult( action = action, result = A11yRuleEngine.performActionBack() ) } } data object None : ActionPerformer("none") { override fun perform( node: AccessibilityNodeInfo, position: RawSubscription.Position?, ): ActionResult { return ActionResult( action = action, result = true ) } } companion object { private val allSubObjects by lazy { arrayOf( ClickNode, ClickCenter, Click, LongClickNode, LongClickCenter, LongClick, Back, None ) } fun getAction(action: String?): ActionPerformer { return allSubObjects.find { it.action == action } ?: Click } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt ================================================ package li.songe.gkd.data import li.songe.gkd.a11y.launcherAppId import li.songe.gkd.util.systemAppsFlow data class GlobalApp( val id: String, val enable: Boolean, val activityIds: List, val excludeActivityIds: List, ) class GlobalRule( rule: RawSubscription.RawGlobalRule, g: ResolvedGlobalGroup, appInfoCache: Map, ) : ResolvedRule( rule = rule, g = g, ) { val groupExcludeAppIds = g.groupExcludeAppIds val group = g.group private val matchAnyApp = rule.matchAnyApp ?: group.matchAnyApp ?: true private val matchLauncher = rule.matchLauncher ?: group.matchLauncher ?: false private val matchSystemApp = rule.matchSystemApp ?: group.matchSystemApp ?: false val apps = mutableMapOf().apply { (rule.apps ?: group.apps ?: emptyList()).filter { a -> // https://github.com/gkd-kit/gkd/issues/619 appInfoCache.isEmpty() || appInfoCache.containsKey(a.id) // 过滤掉未安装应用 }.forEach { a -> val enable = a.enable ?: appInfoCache[a.id]?.let { appInfo -> if (a.versionCode?.match(appInfo.versionCode) == false) { return@let false } if (a.versionName?.match(appInfo.versionName) == false) { return@let false } null } ?: true this[a.id] = GlobalApp( id = a.id, enable = enable, activityIds = getFixActivityIds(a.id, a.activityIds), excludeActivityIds = getFixActivityIds(a.id, a.excludeActivityIds), ) } } override val type = "global" private val excludeAppIds = apps.filter { e -> !e.value.enable }.keys private val enableApps = apps.filter { e -> e.value.enable } /** * 内置禁用>用户配置>规则自带 * 范围越精确优先级越高 */ override fun matchActivity(appId: String, activityId: String?): Boolean { // 规则自带禁用 if (excludeAppIds.contains(appId) || groupExcludeAppIds.contains(appId)) { return false } // 用户自定义禁用 if (excludeData.excludeAppIds.contains(appId)) { return false } if (activityId != null && excludeData.activityIds.contains(appId to activityId)) { return false } if (excludeData.includeAppIds.contains(appId)) { activityId ?: return true val app = enableApps[appId] ?: return true // 规则自带页面的禁用 return !app.excludeActivityIds.any { e -> e.startsWith(activityId) } } // 范围比较 val app = enableApps[appId] if (app != null) { // 规则自定义启用 activityId ?: return true return app.activityIds.isEmpty() || app.activityIds.any { e -> e.startsWith(activityId) } } else { if (!matchLauncher && appId == launcherAppId) { return false } if (!matchSystemApp && systemAppsFlow.value.contains(appId)) { return false } return matchAnyApp } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/NodeInfo.kt ================================================ package li.songe.gkd.data import android.view.accessibility.AccessibilityNodeInfo import kotlinx.serialization.Serializable 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 @Serializable data class NodeInfo( val id: Int, val pid: Int, val idQf: Boolean?, val textQf: Boolean?, val attr: AttrInfo, ) private data class TempNodeData( val node: AccessibilityNodeInfo, val parent: TempNodeData?, val index: Int, val depth: Int, ) { var id = 0 val attr = AttrInfo.info2data(node, index, depth) var children: List = emptyList() var idQfInit = false var idQf: Boolean? = null set(value) { field = value idQfInit = true } var textQfInit = false var textQf: Boolean? = null set(value) { field = value textQfInit = true } } private fun getChildren(node: AccessibilityNodeInfo) = sequence { repeat(node.childCount.coerceAtMost(MAX_CHILD_SIZE)) { i -> val child = node.getChild(i) ?: return@sequence yield(child) } } private const val MAX_KEEP_SIZE = 5000 // 先获取所有节点构建树结构, 然后再判断 idQf/textQf 如果存在一个能同时 idQf 和 textQf 的节点, 则认为 idQf 和 textQf 等价 fun info2nodeList(root: AccessibilityNodeInfo?): List { if (root == null) { return emptyList() } val nodes = mutableListOf() val collectTime = measureTimeMillis { val stack = mutableListOf() var times = 0 stack.add(TempNodeData(root, null, 0, 0)) while (stack.isNotEmpty()) { times++ val node = stack.removeAt(stack.lastIndex) node.id = times - 1 val children = getChildren(node.node).mapIndexed { i, child -> TempNodeData( child, node, i, node.depth + 1 ) }.toList() node.children = children nodes.add(node) repeat(children.size) { i -> stack.add(children[children.size - i - 1]) } if (times > MAX_KEEP_SIZE) { // https://github.com/gkd-kit/gkd/issues/28 toast("节点数量至多保留$MAX_KEEP_SIZE,丢弃后续节点") LogUtils.d( "节点数量过多", root.packageName, topActivityFlow.value.activityId, ) break } } } val qfTime = measureTimeMillis { val idQfCache = mutableMapOf>() val textQfCache = mutableMapOf>() var idTextQf = false fun updateQf(n: TempNodeData) { if (!n.idQfInit && !n.attr.id.isNullOrEmpty()) { n.idQf = (idQfCache[n.attr.id] ?: root.findAccessibilityNodeInfosByViewId(n.attr.id)).apply { idQfCache[n.attr.id] = this } .any { t -> t == n.node } } if (!n.textQfInit && !n.attr.text.isNullOrEmpty()) { n.textQf = (textQfCache[n.attr.text] ?: root.findAccessibilityNodeInfosByText(n.attr.text)).apply { textQfCache[n.attr.text] = this } .any { t -> t == n.node } } if (n.idQf == true && n.textQf == true) { idTextQf = true } if (!n.idQfInit && n.idQf != null) { n.parent?.children?.forEach { c -> c.idQf = n.idQf if (idTextQf) { c.textQf = n.textQf } } if (n.idQf == true) { var p = n.parent while (p != null && !p.idQfInit) { p.idQf = n.idQf if (idTextQf) { p.textQf = n.textQf } p = p.parent p?.children?.forEach { bro -> bro.idQf = n.idQf if (idTextQf) { bro.textQf = n.textQf } } } } else { val tempStack = mutableListOf(n) while (tempStack.isNotEmpty()) { val top = tempStack.removeAt(tempStack.lastIndex) top.idQf = n.idQf if (idTextQf) { top.textQf = n.textQf } repeat(top.children.size) { i -> tempStack.add(top.children[top.children.size - i - 1]) } } } } if (!n.textQfInit && n.textQf != null) { n.parent?.children?.forEach { c -> c.textQf = n.textQf if (idTextQf) { c.idQf = n.idQf } } if (n.textQf == true) { var p = n.parent while (p != null && !p.textQfInit) { p.textQf = n.textQf if (idTextQf) { p.idQf = n.idQf } p = p.parent p?.children?.forEach { bro -> bro.textQf = n.textQf if (idTextQf) { bro.idQf = bro.idQf } } } } else { val tempStack = mutableListOf(n) while (tempStack.isNotEmpty()) { val top = tempStack.removeAt(tempStack.lastIndex) top.textQf = n.textQf if (idTextQf) { top.idQf = n.idQf } repeat(top.children.size) { i -> tempStack.add(top.children[top.children.size - i - 1]) } } } } n.idQfInit = true n.textQfInit = true } for (i in (nodes.size - 1) downTo 0) { val n = nodes[i] if (n.children.isEmpty()) { updateQf(n) } } for (i in (nodes.size - 1) downTo 0) { val n = nodes[i] if (n.children.isNotEmpty()) { updateQf(n) } } } LogUtils.d( topActivityFlow.value, "快照节点数量:${nodes.size}, 总耗时:${collectTime + qfTime}ms", "收集节点耗时:${collectTime}ms, 收集 fastQuery 耗时:${qfTime}ms", ) return nodes.map { n -> NodeInfo( id = n.id, pid = n.parent?.id ?: -1, idQf = n.idQf, textQf = n.textQf, attr = n.attr ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt ================================================ package li.songe.gkd.data import android.graphics.Rect import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import li.songe.gkd.a11y.typeInfo import li.songe.gkd.util.LOCAL_SUBS_IDS import li.songe.gkd.util.LogUtils import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.distinctByIfAny import li.songe.gkd.util.filterIfNotAll import li.songe.gkd.util.json import li.songe.gkd.util.toJson5String import li.songe.gkd.util.toast import li.songe.json5.Json5 import li.songe.selector.Selector import net.objecthunter.exp4j.Expression import net.objecthunter.exp4j.ExpressionBuilder import java.util.Objects @Serializable data class RawSubscription( val id: Long, val name: String, val version: Int, val author: String? = null, val updateUrl: String? = null, val supportUri: String? = null, val checkUpdateUrl: String? = null, val globalGroups: List = emptyList(), val categories: List = emptyList(), val apps: List = emptyList(), ) { // 重写 equals 和 hashCode 便于 compose 重组比较 override fun equals(other: Any?): Boolean { return other === this } override fun hashCode(): Int { return Objects.hash(id, name, version) } val isEmpty: Boolean get() = globalGroups.isEmpty() && apps.all { it.groups.isEmpty() } && categories.isEmpty() val isLocal: Boolean get() = LOCAL_SUBS_IDS.contains(id) val hasRule get() = globalGroups.isNotEmpty() || apps.any { it.groups.isNotEmpty() } val usedApps by lazy { apps.run { if (any { it.groups.isEmpty() }) { filterNot { it.groups.isEmpty() } } else { this } } } fun getSafeCategory(key: Int): RawCategory { return categories.find { it.key == key } ?: RawCategory( key = key, name = key.toString(), enable = false, desc = null ) } 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) } } } private val categoryToAppMap by lazy { val map = mutableMapOf>() categories.forEach { c -> apps.forEach { a -> if (a.groups.any { g -> g.name.startsWith(c.name) }) { val list = map[c.key] if (list == null) { map[c.key] = mutableListOf(a) } else { list.add(a) } } } } map } fun getCategoryApps(categoryKey: Int): List { return categoryToAppMap[categoryKey] ?: emptyList() } fun getAppGroups(appId: String): List { return apps.find { a -> a.id == appId }?.groups ?: emptyList() } fun getApp(appId: String): RawApp { return apps.find { a -> a.id == appId } ?: RawApp( id = appId, 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 } val appGroups by lazy { apps.flatMap { a -> a.groups } } val groupsSize by lazy { appGroups.size + globalGroups.size } val globalGroupAppGroupNameDisableMap by lazy { globalGroups.mapNotNull { g -> val n = g.disableIfAppGroupMatch if (n != null) { val gName = n.ifEmpty { g.name } g.key to apps.filter { a -> a.groups.any { ag -> ag.ignoreGlobalGroupMatch != true && ag.name.startsWith(gName) } }.map { it.id }.toHashSet() } else { null } }.toMap() } fun getGlobalGroupInnerDisabled(globalGroup: RawGlobalGroup, appId: String): Boolean { globalGroup.appIdEnable[appId]?.let { if (!it) return true } globalGroupAppGroupNameDisableMap[globalGroup.key]?.let { if (it.contains(appId)) { return true } } return false } val numText by lazy { val appsSize = apps.size val appGroupsSize = appGroups.size val globalGroupSize = globalGroups.size if (appGroupsSize + globalGroupSize > 0) { if (globalGroupSize > 0) { "${globalGroupSize}全局" + if (appGroupsSize > 0) { "/" } else { "" } } else { "" } + if (appGroupsSize > 0) { "${appsSize}应用/${appGroupsSize}规则组" } else { "" } } else { "暂无规则" } } @Serializable data class StringMatcher( val pattern: String?, val include: List?, val exclude: List?, ) { private val patternRegex by lazy { pattern?.let { p -> runCatching { Regex(p) }.getOrNull() } } fun match(value: String?): Boolean { if (value == null) return false if (exclude?.contains(value) == true) return false if (include?.contains(value) == false) return false if (patternRegex?.matches(value) == false) return false return true } } @Serializable data class IntegerMatcher( val minimum: Int?, val maximum: Int?, val include: List?, val exclude: List?, ) { fun match(value: Int?): Boolean { if (value == null) return false if (exclude?.contains(value) == true) return false if (include?.contains(value) == false) return false if (minimum != null && value < minimum) return false if (maximum != null && value > maximum) return false return true } } @Serializable data class RawApp( val id: String, val name: String?, val groups: List = emptyList(), ) @Serializable 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? ) { 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) } val isValid by lazy { ((leftExp != null && (topExp != null || bottomExp != null)) || (rightExp != null && (topExp != null || bottomExp != null))) } /** * return (x, y) */ fun calc(rect: Rect): Pair? { if (!isValid) return null arrayOf( leftExp, topExp, rightExp, bottomExp ).forEach { exp -> if (exp != null) { setVariables(exp, 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()) } } } catch (e: Exception) { // 可能存在 1/0 导致错误 e.printStackTrace() LogUtils.d(e) toast(e.message ?: e.stackTraceToString()) } return null } } sealed interface RawCommonProps { val actionCd: Long? val actionDelay: Long? val fastQuery: Boolean? val matchRoot: Boolean? val matchDelay: Long? val matchTime: Long? val actionMaximum: Int? val resetMatch: String? val actionCdKey: Int? val actionMaximumKey: Int? val order: Int? val forcedTime: Long? val snapshotUrls: List? val excludeSnapshotUrls: List? val exampleUrls: List? val priorityTime: Long? val priorityActionMaximum: Int? } sealed interface RawRuleProps : RawCommonProps { val name: String? val key: Int? val preKeys: List? val action: String? val position: Position? val matches: List? val anyMatches: List? val excludeMatches: List? val excludeAllMatches: List? fun getAllSelectorStrings(): List { return listOfNotNull(matches, excludeMatches, anyMatches, excludeAllMatches).flatten() } } sealed interface RawGroupProps : RawCommonProps { val name: String val key: Int val desc: String? val enable: Boolean? val scopeKeys: List? val rules: List val valid: Boolean val errorDesc: String? val allExampleUrls: List val cacheMap: MutableMap val cacheStr: String val cacheJsonObject: JsonObject val groupType: Int get() = when (this) { is RawAppGroup -> SubsConfig.AppGroupType is RawGlobalGroup -> SubsConfig.GlobalGroupType } } sealed interface RawAppRuleProps { val activityIds: List? val excludeActivityIds: List? val versionCode: IntegerMatcher? val versionName: StringMatcher? } sealed interface RawGlobalRuleProps { val matchAnyApp: Boolean? val matchSystemApp: Boolean? val matchLauncher: Boolean? val apps: List? } @Serializable data class RawGlobalApp( val id: String, val enable: Boolean?, override val activityIds: List?, override val excludeActivityIds: List?, override val versionCode: IntegerMatcher?, override val versionName: StringMatcher?, ) : RawAppRuleProps @Serializable data class RawGlobalGroup( override val key: Int, override val name: String, override val desc: String?, override val enable: Boolean?, override val scopeKeys: List?, override val actionCd: Long?, override val actionDelay: Long?, override val fastQuery: Boolean?, override val matchRoot: Boolean?, override val matchDelay: Long?, override val matchTime: Long?, override val actionMaximum: Int?, override val resetMatch: String?, override val actionCdKey: Int?, override val actionMaximumKey: Int?, override val priorityTime: Long?, override val priorityActionMaximum: Int?, override val order: Int?, override val forcedTime: Long?, override val snapshotUrls: List?, override val excludeSnapshotUrls: List?, override val exampleUrls: List?, override val matchAnyApp: Boolean?, override val matchSystemApp: Boolean?, override val matchLauncher: Boolean?, val disableIfAppGroupMatch: String?, override val rules: List, override val apps: List?, ) : RawGroupProps, RawGlobalRuleProps { val appIdEnable: Map by lazy { if (rules.all { r -> r.apps.isNullOrEmpty() }) { apps?.associate { a -> a.id to (a.enable ?: true) } ?: emptyMap() } else { val allIds = mutableSetOf() apps?.forEach { a -> allIds.add(a.id) } rules.forEach { r -> r.apps?.forEach { a -> allIds.add(a.id) } } val dataMap = mutableMapOf() allIds.forEach forEachId@{ id -> var temp: Boolean? = null rules.forEach { r -> val v = (r.apps ?: apps)?.find { it.id == id }?.enable ?: return@forEachId if (temp == null) { temp = v } else if (temp != v) { return@forEachId } } if (temp != null) { dataMap[id] = temp } } dataMap } } override val cacheMap by lazy { HashMap() } override val errorDesc by lazy { getErrorDesc() } override val valid by lazy { errorDesc == null } override val allExampleUrls by lazy { ((exampleUrls ?: emptyList()) + rules.flatMap { r -> r.exampleUrls ?: emptyList() }).distinct() } override val cacheStr by lazy { toJson5String(this) } override val cacheJsonObject by lazy { json.encodeToJsonElement(this).jsonObject } } @Serializable data class RawGlobalRule( override val key: Int?, override val name: String?, override val actionCd: Long?, override val actionDelay: Long?, override val fastQuery: Boolean?, override val matchRoot: Boolean?, override val matchDelay: Long?, override val matchTime: Long?, override val actionMaximum: Int?, override val resetMatch: String?, override val actionCdKey: Int?, override val actionMaximumKey: Int?, override val priorityTime: Long?, override val priorityActionMaximum: Int?, override val order: Int?, override val forcedTime: Long?, override val snapshotUrls: List?, override val excludeSnapshotUrls: List?, override val exampleUrls: List?, override val preKeys: List?, override val action: String?, override val position: Position?, override val matches: List?, override val excludeMatches: List?, override val excludeAllMatches: List?, override val anyMatches: List?, override val matchAnyApp: Boolean?, override val matchSystemApp: Boolean?, override val matchLauncher: Boolean?, override val apps: List? ) : RawRuleProps, RawGlobalRuleProps @Serializable data class RawAppGroup( override val key: Int, override val name: String, override val desc: String?, override val enable: Boolean?, override val scopeKeys: List?, override val actionCdKey: Int?, override val actionMaximumKey: Int?, override val actionCd: Long?, override val actionDelay: Long?, override val fastQuery: Boolean?, override val matchRoot: Boolean?, override val actionMaximum: Int?, override val priorityTime: Long?, override val priorityActionMaximum: Int?, override val order: Int?, override val forcedTime: Long?, override val matchDelay: Long?, override val matchTime: Long?, override val resetMatch: String?, override val snapshotUrls: List?, override val excludeSnapshotUrls: List?, override val exampleUrls: List?, override val activityIds: List?, override val excludeActivityIds: List?, override val rules: List, override val versionCode: IntegerMatcher?, override val versionName: StringMatcher?, val ignoreGlobalGroupMatch: Boolean?, ) : RawGroupProps, RawAppRuleProps { override val cacheMap by lazy { HashMap() } override val errorDesc by lazy { getErrorDesc() } override val valid by lazy { errorDesc == null } override val allExampleUrls by lazy { ((exampleUrls ?: emptyList()) + rules.flatMap { r -> r.exampleUrls ?: emptyList() }).distinct() } override val cacheStr by lazy { toJson5String(this) } override val cacheJsonObject by lazy { json.encodeToJsonElement(this).jsonObject } } @Serializable data class RawAppRule( override val key: Int?, override val name: String?, override val preKeys: List?, override val action: String?, override val position: Position?, override val matches: List?, override val excludeMatches: List?, override val excludeAllMatches: List?, override val anyMatches: List?, override val actionCdKey: Int?, override val actionMaximumKey: Int?, override val actionCd: Long?, override val actionDelay: Long?, override val fastQuery: Boolean?, override val matchRoot: Boolean?, override val actionMaximum: Int?, override val priorityTime: Long?, override val priorityActionMaximum: Int?, override val order: Int?, override val forcedTime: Long?, override val matchDelay: Long?, override val matchTime: Long?, override val resetMatch: String?, override val snapshotUrls: List?, override val excludeSnapshotUrls: List?, override val exampleUrls: List?, override val activityIds: List?, override val excludeActivityIds: List?, override val versionCode: IntegerMatcher?, override val versionName: StringMatcher?, ) : RawRuleProps, RawAppRuleProps companion object { private fun RawGroupProps.getErrorDesc(): String? { val allSelectorStrings = rules.map { r -> r.getAllSelectorStrings() }.flatten() allSelectorStrings.forEach { source -> try { val selector = Selector.parse(source) selector.checkType(typeInfo) cacheMap[source] = selector } catch (e: Exception) { LogUtils.d("非法选择器", source, e.toString()) return "非法选择器\n$source\n${e.message}" } } rules.forEach { r -> if (r.position?.isValid == false) { return "非法位置:${r.position}" } } return null } private val expVars = arrayOf( "left", "top", "right", "bottom", "width", "height", "random" ) private fun setVariables(exp: Expression, rect: Rect) { exp.setVariable("left", rect.left.toDouble()) exp.setVariable("top", rect.top.toDouble()) exp.setVariable("right", rect.right.toDouble()) exp.setVariable("bottom", rect.bottom.toDouble()) exp.setVariable("width", rect.width().toDouble()) exp.setVariable("height", rect.height().toDouble()) exp.setVariable("random", Math.random()) } private fun getExpression(value: String?): Expression? { return if (value != null) { try { ExpressionBuilder(value).variables(*expVars).build().apply { expVars.forEach { v -> // 预填充作 validate setVariable(v, 0.0) } }.let { e -> if (e.validate().isValid) { e } else { null } } } catch (e: Exception) { e.printStackTrace() null } } else { null } } 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, ) } else -> null } } private fun getStringIArray(jsonObject: JsonObject?, name: String): List? { return when (val element = jsonObject?.get(name)) { JsonNull, null -> null is JsonObject -> error("Element ${this::class} can not be object") is JsonArray -> element.map { when (it) { is JsonObject, is JsonArray, JsonNull -> error("Element ${this::class} is not a int") is JsonPrimitive -> it.content } } is JsonPrimitive -> listOf(element.content) } } @Suppress("SameParameterValue") private fun getIntMatcher(jsonObject: JsonObject?, key: String): IntegerMatcher? { return when (val element = jsonObject?.get(key)) { JsonNull, null -> null is JsonObject -> IntegerMatcher( minimum = getInt(element, "minimum"), maximum = getInt(element, "maximum"), include = getIntIArray(element, "include"), exclude = getIntIArray(element, "exclude"), ) else -> error("Element $element is not a IntMatcher") } } @Suppress("SameParameterValue") private fun getStringMatcher(jsonObject: JsonObject?, key: String): StringMatcher? { return when (val element = jsonObject?.get(key)) { JsonNull, null -> null is JsonObject -> StringMatcher( pattern = getString(element, "pattern"), include = getStringIArray(element, "include"), exclude = getStringIArray(element, "exclude"), ) else -> error("Element $element is not a StringMatcher") } } private fun getIntIArray(jsonObject: JsonObject?, key: String): List? { return when (val element = jsonObject?.get(key)) { JsonNull, null -> null is JsonArray -> element.map { when (it) { is JsonObject, is JsonArray, JsonNull -> error("Element $it is not a int") is JsonPrimitive -> it.int } } is JsonPrimitive -> listOf(element.int) else -> error("Element $element is not a Array") } } private fun getString(jsonObject: JsonObject?, key: String): String? = when (val p = jsonObject?.get(key)) { JsonNull, null -> null is JsonPrimitive -> { if (p.isString) { p.content } else { null } } else -> null } private fun getLong(jsonObject: JsonObject?, key: String): Long? = when (val p = jsonObject?.get(key)) { JsonNull, null -> null is JsonPrimitive -> { p.long } else -> null } private fun getInt(jsonObject: JsonObject?, key: String): Int? = when (val p = jsonObject?.get(key)) { JsonNull, null -> null is JsonPrimitive -> { p.int } else -> null } private fun getBoolean(jsonObject: JsonObject?, key: String): Boolean? = when (val p = jsonObject?.get(key)) { JsonNull, null -> null is JsonPrimitive -> { p.boolean } else -> null } private fun getCompatVersionCode(jsonObject: JsonObject): IntegerMatcher? { getIntMatcher(jsonObject, "versionCode")?.let { return it } // compat old value val a = getIntIArray(jsonObject, "versionCodes") val b = getIntIArray(jsonObject, "excludeVersionCodes") if (a != null || b != null) { return IntegerMatcher( minimum = null, maximum = null, include = a, exclude = b ) } return null } private fun getCompatVersionName(jsonObject: JsonObject): StringMatcher? { getStringMatcher(jsonObject, "versionName")?.let { return it } // compat old value val a = getStringIArray(jsonObject, "versionNames") val b = getStringIArray(jsonObject, "excludeVersionNames") if (a != null || b != null) { return StringMatcher( pattern = null, include = a, exclude = b ) } return null } private fun jsonToRuleRaw(rulesRawJson: JsonElement): RawAppRule { val jsonObject = when (rulesRawJson) { JsonNull -> error("miss current rule") is JsonObject -> rulesRawJson is JsonPrimitive, is JsonArray -> JsonObject(mapOf("matches" to rulesRawJson)) } return RawAppRule( activityIds = getStringIArray(jsonObject, "activityIds"), excludeActivityIds = getStringIArray(jsonObject, "excludeActivityIds"), matches = getStringIArray(jsonObject, "matches"), excludeMatches = getStringIArray(jsonObject, "excludeMatches"), excludeAllMatches = getStringIArray(jsonObject, "excludeAllMatches"), anyMatches = getStringIArray(jsonObject, "anyMatches"), key = getInt(jsonObject, "key"), name = getString(jsonObject, "name"), actionCd = getLong(jsonObject, "actionCd") ?: getLong(jsonObject, "cd"), actionDelay = getLong(jsonObject, "actionDelay") ?: getLong(jsonObject, "delay"), preKeys = getIntIArray(jsonObject, "preKeys"), action = getString(jsonObject, "action"), fastQuery = getBoolean(jsonObject, "fastQuery"), matchRoot = getBoolean(jsonObject, "matchRoot"), actionMaximum = getInt(jsonObject, "actionMaximum"), matchDelay = getLong(jsonObject, "matchDelay"), matchTime = getLong(jsonObject, "matchTime"), resetMatch = getString(jsonObject, "resetMatch"), snapshotUrls = getStringIArray(jsonObject, "snapshotUrls"), excludeSnapshotUrls = getStringIArray(jsonObject, "excludeSnapshotUrls"), exampleUrls = getStringIArray(jsonObject, "exampleUrls"), actionMaximumKey = getInt(jsonObject, "actionMaximumKey"), actionCdKey = getInt(jsonObject, "actionCdKey"), order = getInt(jsonObject, "order"), versionCode = getCompatVersionCode(jsonObject), versionName = getCompatVersionName(jsonObject), position = getPosition(jsonObject), forcedTime = getLong(jsonObject, "forcedTime"), priorityTime = getLong(jsonObject, "priorityTime"), priorityActionMaximum = getInt(jsonObject, "priorityActionMaximum"), ) } private fun jsonToGroupRaw(groupRawJson: JsonElement): RawAppGroup { val jsonObject = when (groupRawJson) { JsonNull -> error("group must not be null") is JsonObject -> groupRawJson is JsonPrimitive, is JsonArray -> JsonObject(mapOf("rules" to groupRawJson)) } return RawAppGroup( activityIds = getStringIArray(jsonObject, "activityIds"), excludeActivityIds = getStringIArray(jsonObject, "excludeActivityIds"), actionCd = getLong(jsonObject, "actionCd") ?: getLong(jsonObject, "cd"), actionDelay = getLong(jsonObject, "actionDelay") ?: getLong(jsonObject, "delay"), name = getString(jsonObject, "name") ?: error("miss group name"), desc = getString(jsonObject, "desc"), enable = getBoolean(jsonObject, "enable"), key = getInt(jsonObject, "key") ?: error("miss group key"), rules = when (val rulesJson = jsonObject["rules"]) { null, JsonNull -> emptyList() is JsonPrimitive, is JsonObject -> JsonArray(listOf(rulesJson)) is JsonArray -> rulesJson }.map { jsonToRuleRaw(it) }.distinctNotNullBy { it.key }, fastQuery = getBoolean(jsonObject, "fastQuery"), matchRoot = getBoolean(jsonObject, "matchRoot"), actionMaximum = getInt(jsonObject, "actionMaximum"), matchDelay = getLong(jsonObject, "matchDelay"), matchTime = getLong(jsonObject, "matchTime"), resetMatch = getString(jsonObject, "resetMatch"), snapshotUrls = getStringIArray(jsonObject, "snapshotUrls"), excludeSnapshotUrls = getStringIArray(jsonObject, "excludeSnapshotUrls"), exampleUrls = getStringIArray(jsonObject, "exampleUrls"), actionMaximumKey = getInt(jsonObject, "actionMaximumKey"), actionCdKey = getInt(jsonObject, "actionCdKey"), order = getInt(jsonObject, "order"), forcedTime = getLong(jsonObject, "forcedTime"), scopeKeys = getIntIArray(jsonObject, "scopeKeys"), versionCode = getCompatVersionCode(jsonObject), versionName = getCompatVersionName(jsonObject), priorityTime = getLong(jsonObject, "priorityTime"), priorityActionMaximum = getInt(jsonObject, "priorityActionMaximum"), ignoreGlobalGroupMatch = getBoolean(jsonObject, "ignoreGlobalGroupMatch"), ) } private fun jsonToAppRaw(jsonObject: JsonObject, appIndex: Int? = null): RawApp { return RawApp( id = getString(jsonObject, "id") ?: error( if (appIndex != null) { "miss subscription.apps[$appIndex].id" } else { "miss id" } ), name = getString(jsonObject, "name"), groups = (when (val groupsJson = jsonObject["groups"]) { null, JsonNull -> emptyList() is JsonPrimitive, is JsonObject -> JsonArray(listOf(groupsJson)) is JsonArray -> groupsJson }).map { jsonElement -> jsonToGroupRaw(jsonElement) }.distinctByIfAny { it.key }, ) } private fun jsonToGlobalApp(jsonObject: JsonObject, index: Int): RawGlobalApp { return RawGlobalApp( id = getString(jsonObject, "id") ?: error("miss apps[$index].id"), enable = getBoolean(jsonObject, "enable"), activityIds = getStringIArray(jsonObject, "activityIds"), excludeActivityIds = getStringIArray(jsonObject, "excludeActivityIds"), versionCode = getCompatVersionCode(jsonObject), versionName = getCompatVersionName(jsonObject), ) } private fun jsonToGlobalRule(jsonObject: JsonObject): RawGlobalRule { return RawGlobalRule( key = getInt(jsonObject, "key"), name = getString(jsonObject, "name"), actionCd = getLong(jsonObject, "actionCd"), actionDelay = getLong(jsonObject, "actionDelay"), fastQuery = getBoolean(jsonObject, "fastQuery"), matchRoot = getBoolean(jsonObject, "matchRoot"), actionMaximum = getInt(jsonObject, "actionMaximum"), matchDelay = getLong(jsonObject, "matchDelay"), matchTime = getLong(jsonObject, "matchTime"), resetMatch = getString(jsonObject, "resetMatch"), snapshotUrls = getStringIArray(jsonObject, "snapshotUrls"), excludeSnapshotUrls = getStringIArray(jsonObject, "excludeSnapshotUrls"), exampleUrls = getStringIArray(jsonObject, "exampleUrls"), actionMaximumKey = getInt(jsonObject, "actionMaximumKey"), actionCdKey = getInt(jsonObject, "actionCdKey"), matchAnyApp = getBoolean(jsonObject, "matchAnyApp"), matchSystemApp = getBoolean(jsonObject, "matchSystemApp"), matchLauncher = getBoolean(jsonObject, "matchLauncher"), apps = jsonObject["apps"]?.jsonArray?.mapIndexed { index, jsonElement -> jsonToGlobalApp( jsonElement.jsonObject, index ) }?.distinctByIfAny { it.id }, action = getString(jsonObject, "action"), preKeys = getIntIArray(jsonObject, "preKeys"), excludeMatches = getStringIArray(jsonObject, "excludeMatches"), excludeAllMatches = getStringIArray(jsonObject, "excludeAllMatches"), matches = getStringIArray(jsonObject, "matches"), anyMatches = getStringIArray(jsonObject, "anyMatches"), order = getInt(jsonObject, "order"), forcedTime = getLong(jsonObject, "forcedTime"), position = getPosition(jsonObject), priorityTime = getLong(jsonObject, "priorityTime"), priorityActionMaximum = getInt(jsonObject, "priorityActionMaximum"), ) } private fun jsonToGlobalGroup(jsonObject: JsonObject, groupIndex: Int): RawGlobalGroup { return RawGlobalGroup( key = getInt(jsonObject, "key") ?: error("miss group[$groupIndex].key"), name = getString(jsonObject, "name") ?: error("miss group[$groupIndex].name"), desc = getString(jsonObject, "desc"), enable = getBoolean(jsonObject, "enable"), actionCd = getLong(jsonObject, "actionCd"), actionDelay = getLong(jsonObject, "actionDelay"), fastQuery = getBoolean(jsonObject, "fastQuery"), matchRoot = getBoolean(jsonObject, "matchRoot"), actionMaximum = getInt(jsonObject, "actionMaximum"), matchDelay = getLong(jsonObject, "matchDelay"), matchTime = getLong(jsonObject, "matchTime"), resetMatch = getString(jsonObject, "resetMatch"), snapshotUrls = getStringIArray(jsonObject, "snapshotUrls"), excludeSnapshotUrls = getStringIArray(jsonObject, "excludeSnapshotUrls"), exampleUrls = getStringIArray(jsonObject, "exampleUrls"), actionMaximumKey = getInt(jsonObject, "actionMaximumKey"), actionCdKey = getInt(jsonObject, "actionCdKey"), matchSystemApp = getBoolean(jsonObject, "matchSystemApp"), matchAnyApp = getBoolean(jsonObject, "matchAnyApp"), matchLauncher = getBoolean(jsonObject, "matchLauncher"), apps = jsonObject["apps"]?.jsonArray?.mapIndexed { index, jsonElement -> jsonToGlobalApp( jsonElement.jsonObject, index ) }?.distinctByIfAny { it.id }, rules = (jsonObject["rules"]?.jsonArray?.map { jsonElement -> jsonToGlobalRule(jsonElement.jsonObject) } ?: emptyList()).distinctNotNullBy { it.key }, order = getInt(jsonObject, "order"), scopeKeys = getIntIArray(jsonObject, "scopeKeys"), forcedTime = getLong(jsonObject, "forcedTime"), priorityTime = getLong(jsonObject, "priorityTime"), priorityActionMaximum = getInt(jsonObject, "priorityActionMaximum"), disableIfAppGroupMatch = getString(jsonObject, "disableIfAppGroupMatch"), ) } private fun jsonToSubscriptionRaw(rootJson: JsonObject): RawSubscription { return RawSubscription( id = getLong(rootJson, "id") ?: error("miss subscription.id"), name = getString(rootJson, "name") ?: error("miss subscription.name"), version = getInt(rootJson, "version") ?: error("miss subscription.version"), author = getString(rootJson, "author"), updateUrl = getString(rootJson, "updateUrl"), supportUri = getString(rootJson, "supportUri"), checkUpdateUrl = getString(rootJson, "checkUpdateUrl"), apps = (rootJson["apps"]?.jsonArray?.mapIndexed { index, jsonElement -> jsonToAppRaw( jsonElement.jsonObject, index ) } ?: emptyList()).filterIfNotAll { it.groups.isNotEmpty() } .distinctByIfAny { it.id }, categories = (rootJson["categories"]?.jsonArray?.mapIndexed { index, jsonElement -> RawCategory( key = getInt(jsonElement.jsonObject, "key") ?: error("miss categories[$index].key"), 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 }, globalGroups = (rootJson["globalGroups"]?.jsonArray?.mapIndexed { index, jsonElement -> jsonToGlobalGroup(jsonElement.jsonObject, index) } ?: emptyList()).distinctByIfAny { it.key } ) } private fun List.distinctNotNullBy(selector: (T) -> Any?): List { val set = HashSet() val list = ArrayList() forEach { e -> val key = selector(e) if (key == null || set.add(key)) { list.add(e) } } return list } fun parse(source: String, json5: Boolean = true): RawSubscription { val element = if (json5) { Json5.parseToJson5Element(source) } else { json.parseToJsonElement(source) } return jsonToSubscriptionRaw(element.jsonObject) } fun parseApp(jsonObject: JsonObject): RawApp { return jsonToAppRaw(jsonObject) } fun parseAppGroup(jsonObject: JsonObject): RawAppGroup { return jsonToGroupRaw(jsonObject) } fun parseGlobalGroup(jsonObject: JsonObject): RawGlobalGroup { return jsonToGlobalGroup(jsonObject, 0) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/ResolvedGroup.kt ================================================ package li.songe.gkd.data sealed class ResolvedGroup( open val group: RawSubscription.RawGroupProps, val subscription: RawSubscription, val subsItem: SubsItem, val config: SubsConfig?, ) { val excludeData by lazy { ExcludeData.parse(config?.exclude) } abstract val appId: String? } class ResolvedAppGroup( override val group: RawSubscription.RawAppGroup, subscription: RawSubscription, subsItem: SubsItem, config: SubsConfig?, val app: RawSubscription.RawApp, val enable: Boolean, ) : ResolvedGroup(group, subscription, subsItem, config) { override val appId: String? get() = app.id } class ResolvedGlobalGroup( override val group: RawSubscription.RawGlobalGroup, subscription: RawSubscription, subsItem: SubsItem, config: SubsConfig?, ) : ResolvedGroup(group, subscription, subsItem, config) { override val appId: String? get() = null val groupExcludeAppIds by lazy { subscription.globalGroupAppGroupNameDisableMap[group.key] ?: emptySet() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt ================================================ package li.songe.gkd.data import android.view.accessibility.AccessibilityNodeInfo import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update import kotlinx.coroutines.Job 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 sealed class ResolvedRule( val rule: RawSubscription.RawRuleProps, val g: ResolvedGroup, ) { private val group = g.group val subsItem = g.subsItem val rawSubs = g.subscription val key = rule.key val index = group.rules.indexOfFirst { r -> r === rule } val excludeData = g.excludeData private val preKeys = (rule.preKeys ?: emptyList()).toSet() val matches = (rule.matches ?: emptyList()).map { s -> group.cacheMap[s] ?: Selector.parse(s) } val anyMatches = (rule.anyMatches ?: emptyList()).map { s -> group.cacheMap[s] ?: Selector.parse(s) } val excludeMatches = (rule.excludeMatches ?: emptyList()).map { s -> group.cacheMap[s] ?: Selector.parse(s) } val excludeAllMatches = (rule.excludeAllMatches ?: emptyList()).map { s -> group.cacheMap[s] ?: Selector.parse(s) } private val resetMatch = rule.resetMatch ?: group.resetMatch val matchDelay = rule.matchDelay ?: group.matchDelay ?: 0L val actionDelay = rule.actionDelay ?: group.actionDelay ?: 0L private val matchTime = rule.matchTime ?: group.matchTime private val forcedTime = rule.forcedTime ?: group.forcedTime ?: 0L val matchOption = MatchOption( fastQuery = rule.fastQuery ?: group.fastQuery ?: false ) val matchRoot = rule.matchRoot ?: group.matchRoot ?: false val order = rule.order ?: group.order ?: 0 private val actionCdKey = rule.actionCdKey ?: group.actionCdKey private val actionCd = rule.actionCd ?: if (actionCdKey != null) { group.rules.find { r -> r.key == actionCdKey }?.actionCd } else { null } ?: group.actionCd ?: 1000L private val actionMaximumKey = rule.actionMaximumKey ?: group.actionMaximumKey private val actionMaximum = rule.actionMaximum ?: if (actionMaximumKey != null) { group.rules.find { r -> r.key == actionMaximumKey }?.actionMaximum } else { null } ?: group.actionMaximum private val hasSlowSelector by lazy { (matches + excludeMatches + anyMatches + excludeAllMatches).any { s -> s.isSlow(matchOption) } } val priorityTime = rule.priorityTime ?: group.priorityTime ?: 0 val priorityActionMaximum = rule.priorityActionMaximum ?: group.priorityActionMaximum ?: 1 val priorityEnabled: Boolean get() = priorityTime > 0 fun isPriority(): Boolean { if (!priorityEnabled) return false if (priorityActionMaximum <= actionCount.value) return false if (!status.ok) return false val t = System.currentTimeMillis() return t - matchChangedTime.value < priorityTime + matchDelay } val isSlow by lazy { preKeys.isEmpty() && (matchTime == null || matchTime > 10_000L) && hasSlowSelector } var groupToRules: Map> = emptyMap() set(value) { field = value val selfGroupRules = field[group] ?: emptyList() val othersGroupRules = (group.scopeKeys ?: emptyList()).distinct().filter { k -> k != group.key } .flatMap { k -> field.entries.find { e -> e.key.key == k }?.value ?: emptyList() } val groupRules = selfGroupRules + othersGroupRules // 共享次数 if (actionMaximumKey != null) { val otherRule = groupRules.find { r -> r.key == actionMaximumKey } if (otherRule != null) { actionCount = otherRule.actionCount } } // 共享 cd if (actionCdKey != null) { val otherRule = groupRules.find { r -> r.key == actionCdKey } if (otherRule != null) { actionTriggerTime = otherRule.actionTriggerTime } } preRules = groupRules.filter { otherRule -> (otherRule.key != null) && preKeys.contains( otherRule.key ) }.toSet() } private var preRules = emptySet() val hasNext = group.rules.any { r -> r.preKeys?.any { k -> k == rule.key } == true } private var actionDelayTriggerTime = atomic(0L) val actionDelayJob = atomic(null) fun checkDelay(): Boolean { if (actionDelay > 0 && actionDelayTriggerTime.value == 0L) { actionDelayTriggerTime.value = System.currentTimeMillis() return true } return false } fun checkForced(): Boolean { if (forcedTime <= 0) return false return System.currentTimeMillis() < matchChangedTime.value + matchDelay + forcedTime } private var actionTriggerTime = atomic(0L) fun trigger() { val t = System.currentTimeMillis() actionTriggerTime.value = t actionDelayTriggerTime.value = 0L actionCount.incrementAndGet() lastTriggerTime = t lastTriggerRule = this actionCountFlow.updateAndGet { it + 1 } } private var actionCount = atomic(0) private val matchChangedTime = atomic(0L) val isFirstMatchApp: Boolean get() = matchChangedTime.value < appChangeTime private val matchLimitTime = (matchTime ?: 0) + matchDelay val resetMatchType = ResetMatchType.allSubObject.find { it.value == resetMatch } ?: ResetMatchType.Activity fun resetState(t: Long) { actionCount.value = 0 actionDelayTriggerTime.value = 0L actionTriggerTime.value = 0 actionDelayJob.update { it?.cancel(); null } matchDelayJob.update { it?.cancel(); null } matchChangedTime.value = t } private val performer = ActionPerformer.getAction(rule.action ?: rule.position?.let { ActionPerformer.ClickCenter.action }) fun performAction(node: AccessibilityNodeInfo): ActionResult { return performer.perform(node, rule.position) } val matchDelayJob = atomic(null) val status: RuleStatus get() { if (actionMaximum != null) { if (actionCount.value >= actionMaximum) { return RuleStatus.Status1 // 达到最大执行次数 } } if (preRules.isNotEmpty() && !preRules.any { it === lastTriggerRule }) { return RuleStatus.Status2 // 需要提前触发某个规则 } val t = System.currentTimeMillis() val c = matchChangedTime.value if (matchDelay > 0 && t - c < matchDelay) { return RuleStatus.Status3 // 处于匹配延迟中 } if (matchTime != null && t - c > matchLimitTime) { return RuleStatus.Status4 // 超出匹配时间 } if (actionTriggerTime.value + actionCd > t) { return RuleStatus.Status5 // 处于冷却时间 } val d = actionDelayTriggerTime.value if (d > 0) { if (d + actionDelay > t) { return RuleStatus.Status6 // 处于触发延迟中 } } return RuleStatus.StatusOk } fun statusText(): String { return "id:${subsItem.id}, v:${rawSubs.version}, type:${type}, gKey=${group.key}, gName:${group.name}, index:${index}, key:${key}, status:${status.name}" } abstract val type: String // 范围越精确, 优先级越高 abstract fun matchActivity(appId: String, activityId: String? = null): Boolean } sealed class ResetMatchType(val value: String) { data object Activity : ResetMatchType("activity") data object Match : ResetMatchType("match") data object App : ResetMatchType("app") companion object { val allSubObject by lazy { listOf(Activity, Match, App) } } } sealed class RuleStatus(val name: String) { data object StatusOk : RuleStatus("ok") data object Status1 : RuleStatus("达到最大执行次数") data object Status2 : RuleStatus("需要提前触发某个规则") data object Status3 : RuleStatus("处于匹配延迟") data object Status4 : RuleStatus("超出匹配时间") data object Status5 : RuleStatus("处于冷却时间") data object Status6 : RuleStatus("处于触发延迟") val ok: Boolean get() = this === StatusOk val alive: Boolean get() = this !== Status1 && this !== Status2 && this !== Status4 } fun getFixActivityIds( appId: String, activityIds: List?, ): List { if (activityIds.isNullOrEmpty()) return emptyList() return activityIds.map { activityId -> if (activityId.startsWith('.')) { // .a.b.c -> com.x.y.x.a.b.c appId + activityId } else { activityId } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/RpcError.kt ================================================ package li.songe.gkd.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RpcError( override val message: String, @SerialName("__error") val error: Boolean = true, val unknown: Boolean = false, ) : Exception(message) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt ================================================ package li.songe.gkd.data import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.format @Entity( tableName = "snapshot", ) @Serializable data class Snapshot( @PrimaryKey @ColumnInfo(name = "id") override val id: Long, @ColumnInfo(name = "app_id") override val appId: String, @ColumnInfo(name = "activity_id") override val activityId: String?, @ColumnInfo(name = "screen_height") override val screenHeight: Int, @ColumnInfo(name = "screen_width") override val screenWidth: Int, @ColumnInfo(name = "is_landscape") override val isLandscape: Boolean, @ColumnInfo(name = "github_asset_id") val githubAssetId: Int? = null, ) : BaseSnapshot { val date by lazy { id.format("MM-dd HH:mm:ss") } val screenshotFile by lazy { SnapshotExt.screenshotFile(id) } @Dao interface SnapshotDao { @Update suspend fun update(vararg objects: Snapshot): Int @Insert suspend fun insert(vararg users: Snapshot): List @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnore(vararg users: Snapshot): List @Query("DELETE FROM snapshot") suspend fun deleteAll() @Delete suspend fun delete(vararg users: Snapshot): Int @Query("SELECT * FROM snapshot ORDER BY id DESC") fun query(): Flow> @Query("UPDATE snapshot SET github_asset_id=null WHERE id = :id") suspend fun deleteGithubAssetId(id: Long) @Query("SELECT COUNT(*) FROM snapshot") fun count(): Flow } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt ================================================ package li.songe.gkd.data import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete 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 androidx.room.Update import kotlinx.coroutines.flow.Flow 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 { val id = System.currentTimeMillis() if (id > lastId) { lastId = id } else { lastId += 1 } return lastId } @Serializable @Entity( tableName = "subs_config", ) data class SubsConfig( @PrimaryKey @ColumnInfo(name = "id") val id: Long = buildUniqueTimeMillisId(), @ColumnInfo(name = "type") val type: Int, @ColumnInfo(name = "enable") val enable: Boolean? = null, @ColumnInfo(name = "subs_id") val subsId: Long, @ColumnInfo(name = "app_id") val appId: String = "", @ColumnInfo(name = "group_key") val groupKey: Int = -1, @ColumnInfo(name = "exclude", defaultValue = "") val exclude: String = "", ) { @Suppress("ConstPropertyName") companion object { const val AppGroupType = 2 const val GlobalGroupType = 3 } @Dao interface SubsConfigDao { @Update suspend fun update(vararg objects: SubsConfig): Int @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(vararg users: SubsConfig): List @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnore(vararg users: SubsConfig): List @Delete suspend fun delete(vararg users: SubsConfig): Int @Transaction suspend fun insertAndDelete(newList: List, deleteList: List) { insert(*newList.toTypedArray()) delete(*deleteList.toTypedArray()) } @Query("DELETE FROM subs_config WHERE subs_id=:subsItemId") suspend fun delete(subsItemId: Long): Int @Query("DELETE FROM subs_config WHERE subs_id IN (:subsIds)") suspend fun deleteBySubsId(vararg subsIds: Long): Int @Query("DELETE FROM subs_config WHERE subs_id=:subsItemId AND app_id=:appId") suspend fun deleteAppConfig(subsItemId: Long, appId: String): Int @Query("DELETE FROM subs_config WHERE type=${AppGroupType} AND subs_id=:subsItemId AND app_id=:appId AND group_key=:groupKey") suspend fun deleteAppGroupConfig(subsItemId: Long, appId: String, groupKey: Int): Int @Query("DELETE FROM subs_config WHERE type=${AppGroupType} AND subs_id=:subsItemId AND app_id=:appId AND group_key IN (:keyList)") suspend fun batchDeleteAppGroupConfig( subsItemId: Long, appId: String, keyList: List ): Int @Query("DELETE FROM subs_config WHERE type=${GlobalGroupType} AND subs_id=:subsItemId AND group_key=:groupKey") suspend fun deleteGlobalGroupConfig(subsItemId: Long, groupKey: Int): Int @Query("DELETE FROM subs_config WHERE type=${GlobalGroupType} AND subs_id=:subsItemId AND group_key IN (:keyList)") suspend fun batchDeleteGlobalGroupConfig(subsItemId: Long, keyList: List): Int @Query("SELECT * FROM subs_config WHERE subs_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1)") fun queryUsedList(): Flow> @Query("SELECT * FROM subs_config WHERE type=${AppGroupType} AND subs_id=:subsItemId") fun querySubsGroupTypeConfig(subsItemId: Long): Flow> @Query("SELECT * FROM subs_config WHERE type=${AppGroupType} AND subs_id=:subsItemId AND app_id=:appId") fun queryAppGroupTypeConfig(subsItemId: Long, appId: String): Flow> @Query("SELECT * FROM subs_config WHERE type=${AppGroupType} AND subs_id=:subsItemId AND app_id=:appId AND group_key=:groupKey") fun queryAppGroupTypeConfig( subsItemId: Long, appId: String, groupKey: Int ): Flow @Query("SELECT * FROM subs_config WHERE type=${GlobalGroupType} AND subs_id=:subsItemId") fun queryGlobalGroupTypeConfig(subsItemId: Long): Flow> @Query("SELECT * FROM subs_config WHERE type=${GlobalGroupType} AND subs_id=:subsItemId AND group_key=:groupKey") fun queryGlobalGroupTypeConfig(subsItemId: Long, groupKey: Int): Flow @Query("SELECT * FROM subs_config WHERE type=${AppGroupType} AND app_id=:appId AND subs_id IN (:subsItemIds)") fun queryAppConfig(subsItemIds: List, appId: String): Flow> @Query("SELECT * FROM subs_config WHERE type=${GlobalGroupType} AND subs_id IN (:subsItemIds)") fun queryGlobalConfig(subsItemIds: List): Flow> @Query("SELECT * FROM subs_config WHERE type=${GlobalGroupType} AND subs_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1)") fun queryUsedGlobalConfig(): Flow> @Query("SELECT * FROM subs_config WHERE subs_id IN (:subsItemIds) ") suspend fun querySubsItemConfig(subsItemIds: List): List @Query("UPDATE subs_config SET enable = null WHERE type=${AppGroupType} AND subs_id=:subsItemId AND app_id=:appId AND group_key=:groupKey AND enable IS NOT NULL") suspend fun resetAppGroupTypeEnable(subsItemId: Long, appId: String, groupKey: Int): Int @Transaction suspend fun batchResetAppGroupEnable( subsItemId: Long, list: List> ): List> { return list.filter { (g, a) -> resetAppGroupTypeEnable(subsItemId, a.id, g.key) > 0 } } } } data class ExcludeData( val appIds: Map, val activityIds: Set>, ) { val excludeAppIds = appIds.entries.filter { e -> e.value }.map { e -> e.key }.toHashSet() val includeAppIds = appIds.entries.filter { e -> !e.value }.map { e -> e.key }.toHashSet() fun stringify(appId: String? = null): String { return if (appId != null) { activityIds.filter { e -> e.first == appId }.map { e -> e.second }.sorted() .joinToString("\n\n") } else { (appIds.entries.map { e -> if (e.value) { e.key } else { "!${e.key}" } } + activityIds.map { e -> "${e.first}/${e.second}" }).sorted().joinToString("\n\n") } } fun clear(appId: String): ExcludeData { return copy( appIds = appIds.toMutableMap().apply { remove(appId) }, ) } fun switch(appId: String, activityId: String? = null): ExcludeData { return if (activityId == null) { copy( appIds = appIds.toMutableMap().apply { if (get(appId) != false) { set(appId, false) } else { set(appId, true) } }, ) } else { copy(activityIds = activityIds.toMutableSet().apply { val e = appId to activityId if (contains(e)) { remove(e) } else { add(e) } }) } } companion object { private val empty = ExcludeData(emptyMap(), emptySet()) fun parse(exclude: String?): ExcludeData { if (exclude.isNullOrBlank()) { return empty } val appIds = HashMap() val activityIds = HashSet>() exclude.split('\n') .filter { it.isNotBlank() } .forEach { s -> if (s[0] == '!') { val appId = s.substring(1) if (appId.isValidAppId()) { appIds[appId] = false } } else { val a = s.split('/', limit = 2) val appId = a[0] if (appId.isValidAppId()) { val activityId = a.getOrNull(1) if (activityId != null) { if (activityId.isValidActivityId()) { activityIds.add(appId to activityId) } } else { appIds[appId] = true } } } } return ExcludeData( appIds = appIds, activityIds = activityIds, ) } fun parse(exclude: String?, appId: String): ExcludeData { if (exclude.isNullOrBlank()) return empty return parse(exclude.split('\n').joinToString("\n") { "$appId/$it" }) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/SubsItem.kt ================================================ package li.songe.gkd.data import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete 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 androidx.room.Update import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import li.songe.gkd.util.LOCAL_SUBS_IDS import li.songe.gkd.util.format @Serializable @Entity( tableName = "subs_item", ) data class SubsItem( @PrimaryKey @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "ctime") val ctime: Long = System.currentTimeMillis(), @ColumnInfo(name = "mtime") val mtime: Long = System.currentTimeMillis(), @ColumnInfo(name = "enable") val enable: Boolean = false, @ColumnInfo(name = "enable_update") val enableUpdate: Boolean = true, @ColumnInfo(name = "order") val order: Int, @ColumnInfo(name = "update_url") val updateUrl: String? = null, ) { val isLocal: Boolean get() = LOCAL_SUBS_IDS.contains(id) val mtimeStr by lazy { mtime.format("yyyy-MM-dd HH:mm:ss") } @Dao interface SubsItemDao { @Update suspend fun update(vararg objects: SubsItem): Int @Query("UPDATE subs_item SET enable=:enable WHERE id=:id") suspend fun updateEnable(id: Long, enable: Boolean): Int @Query("UPDATE subs_item SET `order`=:order WHERE id=:id") suspend fun updateOrder(id: Long, order: Int): Int @Transaction suspend fun batchUpdateOrder(subsItems: List) { subsItems.forEach { subsItem -> updateOrder(subsItem.id, subsItem.order) } } @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(vararg users: SubsItem): List @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnore(vararg users: SubsItem): List @Delete suspend fun delete(vararg users: SubsItem): Int @Query("UPDATE subs_item SET mtime=:mtime WHERE id=:id") suspend fun updateMtime(id: Long, mtime: Long = System.currentTimeMillis()): Int @Query("SELECT * FROM subs_item ORDER BY `order`") fun query(): Flow> @Query("SELECT * FROM subs_item ORDER BY `order`") fun queryAll(): List @Query("DELETE FROM subs_item WHERE id IN (:ids)") suspend fun deleteById(vararg ids: Long): Int } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/SubsVersion.kt ================================================ package li.songe.gkd.data import kotlinx.serialization.Serializable @Serializable data class SubsVersion(val id: Long, val version: Int) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/TransferData.kt ================================================ package li.songe.gkd.data import android.net.Uri 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.LogUtils import li.songe.gkd.util.UriUtils import li.songe.gkd.util.ZipUtils import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.createGkdTempDir import li.songe.gkd.util.json import li.songe.gkd.util.sharedDir import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription import java.io.File @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 const val subsDirName = "files" private suspend fun importTransferData(transferData: TransferData): Boolean { 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 = createGkdTempDir() 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 localSubsList = subsMapFlow.value.values.filter { it.id < 0 && subsIds.contains(it.id) && !it.isEmpty } val files = if (localSubsList.isNotEmpty()) { val f = tempDir.resolve(subsDirName).apply { mkdir() } localSubsList.forEach { val file = f.resolve("${it.id}.json") file.writeText(json.encodeToString(it)) } f } else { null } val file = sharedDir.resolve("backup-${System.currentTimeMillis()}.zip") ZipUtils.zipFiles(listOfNotNull(dataFile, files), file) tempDir.deleteRecursively() return file } suspend fun importData(uri: Uri) { val tempDir = createGkdTempDir() val zipFile = tempDir.resolve("file.zip").apply { writeBytes(UriUtils.uri2Bytes(uri)) } val unzipDir = tempDir.resolve("unzip").apply { ZipUtils.unzipFile(zipFile, this) } val transferFile = unzipDir.resolve("${TransferData.TYPE}.json") if (!transferFile.exists() || !transferFile.isFile) { toast("导入无数据") tempDir.deleteRecursively() return } val data = withContext(Dispatchers.Default) { json.decodeFromString(transferFile.readText()) } val hasNewSubsItem = importTransferData(data) val files = unzipDir.resolve(subsDirName) if (files.exists()) { val subscriptions = (files.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: emptyArray()).mapNotNull { f -> try { RawSubscription.parse(f.readText()) } catch (e: Exception) { LogUtils.d(e) null } } subscriptions.forEach { subscription -> if (LOCAL_SUBS_IDS.contains(subscription.id)) { updateSubscription(subscription) } } } toast("导入成功") tempDir.deleteRecursively() if (hasNewSubsItem) { delay(1000) checkSubsUpdate(true) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/UserInfo.kt ================================================ package li.songe.gkd.data import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.Serializable @Serializable data class UserInfo( val id: Int, val name: String, ) val otherUserMapFlow = MutableStateFlow(emptyMap()) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/data/Value.kt ================================================ package li.songe.gkd.data data class Value( var value: T) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/db/AppDb.kt ================================================ package li.songe.gkd.db 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 = 14, entities = [ SubsItem::class, Snapshot::class, SubsConfig::class, CategoryConfig::class, ActionLog::class, ActivityLog::class, AppConfig::class, AppVisitLog::class, A11yEventLog::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), AutoMigration(from = 7, to = 8, spec = ActivityLog.ActivityLogV2Spec::class), 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 abstract fun subsConfigDao(): SubsConfig.SubsConfigDao abstract fun appConfigDao(): AppConfig.AppConfigDao 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( tableName = "subs_config", fromColumnName = "subs_item_id", toColumnName = "subs_id" ) @RenameColumn( tableName = "category_config", fromColumnName = "subs_item_id", toColumnName = "subs_id" ) class Migration9To10Spec : AutoMigrationSpec @DeleteColumn( tableName = "snapshot", columnName = "app_name" ) @DeleteColumn( tableName = "snapshot", columnName = "app_version_code" ) @DeleteColumn( tableName = "snapshot", 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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/notif/Notif.kt ================================================ package li.songe.gkd.notif import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.app.Service import android.content.Intent import android.content.pm.ServiceInfo 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.permission.foregroundServiceSpecialUseState import li.songe.gkd.permission.notificationState import li.songe.gkd.service.ActivityService import li.songe.gkd.service.ButtonService import li.songe.gkd.service.EventService import li.songe.gkd.service.HttpService import li.songe.gkd.service.ScreenshotService 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 channel: NotifChannel = NotifChannel.Default, val id: Int, 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, 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 ) val notification = NotificationCompat.Builder(app, channel.id) .setSmallIcon(smallIcon) .setContentTitle(title) .setContentText(text) .setContentIntent(contextIntent) .setOngoing(ongoing) .setAutoCancel(autoCancel) if (stopService != null) { val deleteIntent = PendingIntent.getBroadcast( app, 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()) } context(service: Service) fun notifyService() { if (!notificationState.updateAndGet()) return if (!foregroundServiceSpecialUseState.updateAndGet()) return ServiceCompat.startForeground( service, id, toNotification(), if (AndroidTarget.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST else -1 ) } } val abNotif by lazy { Notif( id = 100, title = META.appName, text = "无障碍正在运行", ) } val screenshotNotif = Notif( id = 101, title = "截屏服务正在运行", text = "保存快照时截取屏幕", uri = "gkd://page/1", stopService = ScreenshotService::class, ) val buttonNotif = Notif( id = 102, title = "快照按钮服务正在运行", text = "点击按钮捕获快照", uri = "gkd://page/1", stopService = ButtonService::class, ) val httpNotif = Notif( id = 103, title = "HTTP服务正在运行", uri = "gkd://page/1", stopService = HttpService::class, ) val exposeNotif = Notif( id = 104, title = "运行外部调用任务中", text = "任务完成后自动关闭", ) 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, ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt ================================================ package li.songe.gkd.notif import android.app.NotificationChannel import android.app.NotificationManager import androidx.core.app.NotificationManagerCompat import li.songe.gkd.META import li.songe.gkd.app sealed class NotifChannel( val id: String, val name: String? = null, val desc: String? = null, ) { data object Default : NotifChannel( id = "0", ) data object Snapshot : NotifChannel( id = "1", name = "保存快照通知", ) } fun initChannel() { val channels = arrayOf(NotifChannel.Default, NotifChannel.Snapshot) val manager = NotificationManagerCompat.from(app) // 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/notif/StopServiceReceiver.kt ================================================ package li.songe.gkd.notif import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import androidx.core.content.ContextCompat import li.songe.gkd.META 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.jvmName) { service.stopSelf() } } companion object { private val STOP_ACTION by lazy { META.appId + ".STOP_SERVICE" } 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( service, receiver, IntentFilter(STOP_ACTION), ContextCompat.RECEIVER_NOT_EXPORTED ) } service.onDestroyed { service.unregisterReceiver(receiver) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt ================================================ package li.songe.gkd.permission import android.app.Activity import androidx.activity.compose.LocalActivity 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.MainActivity import li.songe.gkd.util.stopCoroutine data class AuthReason( val text: () -> String, val confirm: ((Activity) -> Unit)? = null, ) @Composable fun AuthDialog(authReasonFlow: MutableStateFlow) { val authAction = authReasonFlow.collectAsState().value val context = LocalActivity.current as MainActivity if (authAction != null) { AlertDialog( title = { Text(text = "权限请求") }, text = { Text(text = authAction.text()) }, onDismissRequest = { authReasonFlow.value = null }, confirmButton = { TextButton(onClick = { authReasonFlow.value = null authAction.confirm?.invoke(context) }) { Text(text = "确认") } }, dismissButton = { TextButton(onClick = { authReasonFlow.value = null }) { Text(text = "取消") } } ) } } sealed class PermissionResult { data object Granted : PermissionResult() data class Denied(val doNotAskAgain: Boolean) : PermissionResult() } suspend fun requiredPermission( context: MainActivity, permissionState: PermissionState ) { 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt ================================================ 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.provider.Settings import com.hjq.permissions.XXPermissions import com.hjq.permissions.permission.PermissionLists import com.hjq.permissions.permission.base.IPermission import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow 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.appScope 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, /** * show it when user doNotAskAgain */ val reason: AuthReason? = null, ) { val stateFlow = MutableStateFlow(false) val value get() = stateFlow.value fun updateAndGet(): Boolean { return stateFlow.updateAndGet { check() } } fun updateChanged(): Boolean { return value != updateAndGet() } fun checkOrToast(): Boolean = if (!updateAndGet()) { val r = updateAndGet() if (!r) { reason?.text?.let { toast(it()) } } r } else { true } } private suspend fun asyncRequestPermission( context: Activity, permission: IPermission, ): PermissionResult { if (XXPermissions.isGrantedPermission(context, permission)) { return PermissionResult.Granted } val deferred = CompletableDeferred() XXPermissions.with(context) .unchecked() .permission(permission) .request { grantedList, _ -> if (grantedList.contains(permission)) { PermissionResult.Granted } else { PermissionResult.Denied( XXPermissions.isDoNotAskAgainPermissions( context, arrayOf(permission) ) ) }.let { deferred.complete(it) } } return deferred.await() } 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 = { if (AndroidTarget.UPSIDE_DOWN_CAKE) { checkAllowedOp(AppOpsManagerHidden.OPSTR_FOREGROUND_SERVICE_SPECIAL_USE) } else { true } }, reason = AuthReason( text = { "当前操作权限「特殊用途的前台服务」已被限制, 请先解除限制" }, 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) }, request = { asyncRequestPermission(it, permission) }, reason = AuthReason( text = { "当前操作需要「通知权限」\n请先前往权限页面授权" }, confirm = { XXPermissions.startPermissionActivity(app, permission) } ), ) } val canQueryPkgState by lazy { val permission = PermissionLists.getGetInstalledAppsPermission() val supported by lazy { permission.isSupportRequestPermission(app) } PermissionState( name = "读取应用列表权限", check = { if (supported) { // 此框架内部有两个 printStackTrace 导致每次检测都会打印日志污染控制台 XXPermissions.isGrantedPermission(app, permission) } else { true } }, request = { asyncRequestPermission(it, permission) }, reason = AuthReason( text = { "当前操作需要「读取应用列表权限」\n请先前往权限页面授权" }, confirm = { XXPermissions.startPermissionActivity(app, permission) } ), ) } 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 = { "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" }, confirm = { XXPermissions.startPermissionActivity( app, PermissionLists.getSystemAlertWindowPermission() ) } ), ) } val canWriteExternalStorage by lazy { PermissionState( name = "写入外部存储权限", check = { if (AndroidTarget.Q) { true } else { app.checkGrantedPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) } }, request = { if (AndroidTarget.Q) { PermissionResult.Granted } else { asyncRequestPermission(it, PermissionLists.getWriteExternalStoragePermission()) } }, reason = AuthReason( text = { "当前操作需要「写入外部存储权限」\n请先前往权限页面授权" }, confirm = { XXPermissions.startPermissionActivity( app, PermissionLists.getWriteExternalStoragePermission() ) } ), ) } 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( name = "写入安全设置权限", check = { app.checkGrantedPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, ) } 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() }, ) } val allPermissionStates by lazy { listOf( notificationState, foregroundServiceSpecialUseState, accessA11yState, createA11yOverlayState, getAppOpsStatsState, accessRestrictedSettingsState, canDrawOverlaysState, canWriteExternalStorage, ignoreBatteryOptimizationsState, writeSecureSettingsState, canQueryPkgState, shizukuGrantedState, ) } fun updatePermissionState() { allPermissionStates.forEach { if (it === canQueryPkgState && !updateAppMutex.mutex.isLocked) { if (canQueryPkgState.updateChanged()) { updateAllAppInfo() } } else { it.updateAndGet() } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/A11yService.kt ================================================ package li.songe.gkd.service import android.accessibilityservice.AccessibilityService import android.annotation.SuppressLint import android.content.Context.WINDOW_SERVICE import android.graphics.Bitmap import android.graphics.PixelFormat import android.view.Display import android.view.Gravity import android.view.View import android.view.WindowManager import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityWindowInfo import com.google.android.accessibility.selecttospeak.SelectToSpeakService import kotlinx.coroutines.flow.MutableStateFlow 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.LogUtils import li.songe.gkd.util.OnA11yLife import li.songe.gkd.util.componentName import li.songe.gkd.util.runMainPost import li.songe.gkd.util.toast import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @SuppressLint("AccessibilityPolicy") open class A11yService : AccessibilityService(), OnA11yLife, A11yCommonImpl { override val mode get() = AutomatorModeOption.A11yMode override val scope = useScope() override val windowNodeInfo: AccessibilityNodeInfo? get() = rootInActiveWindow override val windowInfos: List get() = windows override suspend fun screenshot(): Bitmap? = suspendCoroutine { continuation -> if (AndroidTarget.R) { takeScreenshot( Display.DEFAULT_DISPLAY, application.mainExecutor, object : TakeScreenshotCallback { override fun onFailure(errorCode: Int) = continuation.resume(null) override fun onSuccess(screenshot: ScreenshotResult) = try { continuation.resume( Bitmap.wrapHardwareBuffer( screenshot.hardwareBuffer, screenshot.colorSpace ) ) } finally { screenshot.hardwareBuffer.close() } } ) } else { continuation.resume(null) } } override val ruleEngine by lazy { A11yRuleEngine(this) } override fun onCreate() = onCreated() override fun onServiceConnected() = onA11yConnected() override fun onInterrupt() {} override fun onDestroy() = onDestroyed() override fun onAccessibilityEvent(event: AccessibilityEvent?) = ruleEngine.onA11yEvent(event) val startTime = System.currentTimeMillis() override var justStarted: Boolean = true get() { if (field) { field = System.currentTimeMillis() - startTime < 3_000 } return field } private var tempShutdownFlag = false override fun shutdown(temp: Boolean) { if (temp) { tempShutdownFlag = true } disableSelf() } 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) } } } onDestroyed { if (tempShutdownFlag) { toast("无障碍局部关闭") } else { toast("无障碍已关闭") updateEnableAutomator(false) } } useAliveOverlayView() onCreated { StatusService.autoStart() } onDestroyed { shizukuContextFlow.value.topCpn()?.let { cpn -> // com.android.systemui if (!topActivityFlow.value.sameAs(cpn.packageName, cpn.className)) { updateTopActivity(cpn.packageName, cpn.className) } } } onDestroyed { destroyed = true } onA11yConnected { connected = true toast("无障碍已启动") if (currentAppUseA11y) { ruleEngine.onA11yConnected() } } onCreated { runMainPost(3000) { if (!(destroyed || connected)) { toast("无障碍启动超时,请尝试关闭重启", forced = true) } } } } companion object { val a11yCn by lazy { SelectToSpeakService::class.componentName } val isRunning = MutableStateFlow(false) @Volatile var instance: A11yService? = null private set } } private fun A11yService.useAliveOverlayView() { val context = this var aliveView: View? = null val wm by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } fun removeA11View() { if (aliveView != null) { wm.removeView(aliveView) aliveView = null } } fun addA11View() { removeA11View() val tempView = View(context) val lp = WindowManager.LayoutParams().apply { type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY format = PixelFormat.TRANSLUCENT flags = flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE gravity = Gravity.START or Gravity.TOP width = 1 height = 1 packageName = context.packageName } 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/ActivityService.kt ================================================ 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) { 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(), ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/ActivityTileService.kt ================================================ package li.songe.gkd.service class ActivityTileService : BaseTileService() { override val activeFlow = ActivityService.isRunning init { onTileClicked { if (ActivityService.isRunning.value) { ActivityService.stop() } else { ActivityService.start() } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/BaseTileService.kt ================================================ 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.OnTileLife abstract class BaseTileService : TileService(), OnTileLife { override fun onCreate() = onCreated() override fun onStartListening() = onStartListened() override fun onClick() = onTileClicked() override fun onStopListening() = onStopListened() override fun onDestroy() = onDestroyed() abstract val activeFlow: StateFlow override val scope = useScope() 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 ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/ButtonService.kt ================================================ 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/ButtonTileService.kt ================================================ package li.songe.gkd.service class ButtonTileService : BaseTileService() { override val activeFlow = ButtonService.isRunning init { onTileClicked { if (ButtonService.isRunning.value) { ButtonService.stop() } else { ButtonService.start() } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/EventService.kt ================================================ 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.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import li.songe.gkd.META import li.songe.gkd.appScope import li.songe.gkd.data.A11yEventLog import li.songe.gkd.data.toA11yEventLog import li.songe.gkd.db.DbSet import li.songe.gkd.notif.StopServiceReceiver import li.songe.gkd.notif.eventNotif import li.songe.gkd.permission.canDrawOverlaysState import li.songe.gkd.ui.EventLogCard import li.songe.gkd.ui.component.LocalNumberCharWidth import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.isAtBottom import li.songe.gkd.ui.component.measureNumberTextWidth import li.songe.gkd.ui.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 = key(eventLogs.isEmpty()) { rememberLazyListState() } val isAtBottom by listState.isAtBottom() val subScope = rememberCoroutineScope() SideEffect { val latestId = eventLogs.lastOrNull()?.id ?: 0 if (tempEventId != latestId) { tempEventId = latestId if (isAtBottom) { subScope.launch { listState.scrollToItem(eventLogs.lastIndex) } } } } Column( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(bgColor.copy(alpha = 0.9f)) .width(256.dp) .padding(4.dp) ) { 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/EventTileService.kt ================================================ package li.songe.gkd.service class EventTileService : BaseTileService() { override val activeFlow = EventService.isRunning init { onTileClicked { if (EventService.isRunning.value) { EventService.stop() } else { EventService.start() } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/ExposeService.kt ================================================ 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' """ ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt ================================================ package li.songe.gkd.service import android.provider.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine 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.launchTry import li.songe.gkd.util.mapState import li.songe.gkd.util.runMainPost import li.songe.gkd.util.toast class GkdTileService : BaseTileService() { override val activeFlow = combine(A11yService.isRunning, uiAutomationFlow) { a11y, automator -> a11y || automator != null }.stateIn(scope, SharingStarted.Eagerly, false) init { onTileClicked { switchAutomatorService() } } } 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() } } } 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 } } } private fun switchAutomationService() { val newEnabled = uiAutomationFlow.value == null uiAutomationFlow.value?.shutdown() if (newEnabled && shizukuContextFlow.value.ok) { AutomationService.tryConnect() } } fun switchAutomatorService() = modifyA11yRun { if (currentAppUseA11y) { switchA11yService() } else { switchAutomationService() } } private fun skipBlockApp(): Boolean { if (storeFlow.value.enableBlockA11yAppList) { val topAppId = if (isActivityVisible || app.justStarted) { META.appId } else { shizukuContextFlow.value.topCpn()?.packageName } if (topAppId != null && topAppId in actualBlockA11yAppList) { return true } } return false } 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 fixAutomationService() { if (uiAutomationFlow.value == null && shizukuContextFlow.value.ok) { if (skipBlockApp()) return if (currentAppUseA11y) return AutomationService.tryConnect(true) } } fun fixRestartAutomatorService() = modifyA11yRun { if (storeFlow.value.enableAutomator) { if (currentAppUseA11y) { fixA11yService() } else { fixAutomationService() } } } val currentAppUseA11y get() = storeFlow.value.useA11y || topAppIdFlow.value in actualA11yScopeAppList val currentAppBlocked get() = storeFlow.value.enableBlockA11yAppList && topAppIdFlow.value in actualBlockA11yAppList 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) { return } if (!writeSecureSettingsState.stateFlow.value) { return } val names = app.getSecureA11yServices() names.add(A11yService.a11yCn) app.putSecureA11yServices(names) } else { AutomationService.tryConnect(true) } } 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) } } } } appScope.launch(Dispatchers.Main) { actualFlow.debounce(A11Y_WHITE_APP_AWAIT_TIME).collect { if (currentAppBlocked) { forcedUpdateA11yService(true) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/HttpService.kt ================================================ package li.songe.gkd.service import android.app.Service import android.content.Intent 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 import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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 import li.songe.gkd.data.GkdAction import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.RpcError import li.songe.gkd.data.SubsItem 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.store.storeFlow import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID 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.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 class HttpService : Service(), OnSimpleLife { override fun onBind(intent: Intent?) = null override fun onCreate() { super.onCreate() onCreated() } override fun onDestroy() { super.onDestroy() onDestroyed() } override val scope = useScope() val httpServerPortFlow = storeFlow.mapState(scope) { s -> s.httpServerPort } init { useLogLifecycle() useAliveFlow(isRunning) 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 } onCreated { httpNotif.notifyService() scope.launchTry(Dispatchers.IO) { httpServerPortFlow.collect { port -> val isReboot = httpServerFlow.value != null httpServerFlow.apply { value?.stop() value = null } if (!isPortAvailable(port)) { toast("端口 $port 被占用,请更换后重试") stopSelf() return@collect } httpServerFlow.value = try { scope.createServer(port).apply { start() } } catch (e: Exception) { toast("HTTP服务启动失败:${e.stackTraceToString()}") LogUtils.d("HTTP服务启动失败", e) null } if (httpServerFlow.value == null) { stopSelf() } else if (isReboot) { toast("HTTP服务重启成功") } } } } } companion object { val httpServerFlow = MutableStateFlow(null) val isRunning = MutableStateFlow(false) val localNetworkIpsFlow = MutableStateFlow(emptyList()) fun stop() = stopServiceByClass(HttpService::class) fun start() = startForegroundServiceByClass(HttpService::class) } } typealias ServerType = EmbeddedServer @Serializable data class RpcOk( val message: String? = null, ) @Serializable data class ReqId( val id: Long, ) @Serializable data class ServerInfo( val device: DeviceInfo = DeviceInfo(), val gkdAppInfo: AppInfo = selfAppInfo ) fun clearHttpSubs() { // 如果 app 被直接在任务列表划掉, HTTP订阅会没有清除, 所以在后续的第一次启动时清除 if (HttpService.isRunning.value) return appScope.launchTry { delay(1000) if (storeFlow.value.autoClearMemorySubs) { deleteSubscription(LOCAL_HTTP_SUBS_ID) } } } private val httpSubsItem = SubsItem( id = LOCAL_HTTP_SUBS_ID, order = -1, enableUpdate = false, ) private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) { install(getKtorCorsPlugin()) install(getKtorErrorPlugin()) install(ContentNegotiation) { json(keepNullJson) } routing { get("/") { call.respondText(ContentType.Text.Html) { "" } } route("/api") { post("/getServerInfo") { call.respond(ServerInfo()) } post("/getSnapshot") { val data = call.receive() val fp = SnapshotExt.snapshotFile(data.id) if (!fp.exists()) { throw RpcError("对应快照不存在") } call.respondFile(fp) } post("/getScreenshot") { val data = call.receive() val fp = SnapshotExt.screenshotFile(data.id) if (!fp.exists()) { throw RpcError("对应截图不存在") } call.respondFile(fp) } post("/captureSnapshot") { call.respond(SnapshotExt.captureSnapshot()) } post("/getSnapshots") { 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 = RawSubscription.parse(call.receiveText(), json5 = false) .copy( id = LOCAL_HTTP_SUBS_ID, name = "内存订阅", version = 0, author = "@gkd-kit/inspect" ) updateSubscription(subscription) DbSet.subsItemDao.insert((subsItemsFlow.value.find { s -> s.id == httpSubsItem.id } ?: httpSubsItem).copy(mtime = System.currentTimeMillis())) call.respond(RpcOk()) } post("/execSelector") { if (!A11yService.isRunning.value) { throw RpcError("无障碍没有运行") } val gkdAction = call.receive() 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() } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/HttpTileService.kt ================================================ package li.songe.gkd.service class HttpTileService : BaseTileService() { override val activeFlow = HttpService.isRunning init { onTileClicked { if (HttpService.isRunning.value) { HttpService.stop() } else { HttpService.start() } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/MatchTileService.kt ================================================ package li.songe.gkd.service import li.songe.gkd.store.storeFlow import li.songe.gkd.store.switchStoreEnableMatch import li.songe.gkd.util.mapState class MatchTileService : BaseTileService() { override val activeFlow = storeFlow.mapState(scope) { it.enableMatch } init { onTileClicked { switchStoreEnableMatch() } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/OverlayWindowService.kt ================================================ 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.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 { 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) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/ScreenshotService.kt ================================================ package li.songe.gkd.service import android.app.Service import android.content.Intent import coil3.Bitmap import kotlinx.coroutines.CoroutineScope 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.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 class ScreenshotService : Service(), OnSimpleLife { override val scope: CoroutineScope get() = throw NotImplementedError() override fun onBind(intent: Intent?) = null override fun onCreate() = onCreated() override fun onDestroy() = onDestroyed() override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { try { return super.onStartCommand(intent, flags, startId) } finally { intent?.let { screenshotUtil?.destroy() screenshotUtil = ScreenshotUtil(this, intent) LogUtils.d("screenshot restart") } } } private var screenshotUtil: ScreenshotUtil? = null init { useLogLifecycle() useAliveFlow(isRunning) useAliveToast("截屏服务") StopServiceReceiver.autoRegister() onCreated { screenshotNotif.notifyService() } onCreated { instance = this } onDestroyed { screenshotUtil?.destroy() instance = null } } companion object { private var instance: ScreenshotService? = null val isRunning = MutableStateFlow(false) suspend fun screenshot(): Bitmap? { if (!isRunning.value) return null return withTimeoutOrNull(5_000) { instance?.screenshotUtil?.execute() } } fun start(intent: Intent) { intent.component = ScreenshotService::class.componentName app.startForegroundService(intent) } fun stop() = stopServiceByClass(ScreenshotService::class) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/SnapshotTileService.kt ================================================ 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() } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/service/StatusService.kt ================================================ 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.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 { override fun onBind(intent: Intent?) = null override fun onCreate() = onCreated() override fun onDestroy() = onDestroyed() override val scope = useScope() 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()) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/AccessibilityManager.kt ================================================ 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)) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/ActivityManager.kt ================================================ 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) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/ActivityTaskManager.kt ================================================ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.IActivityTaskManager import android.content.ContextHidden import android.view.Display import li.songe.gkd.util.AndroidTarget 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 } private val getTasksType by lazy { IActivityTaskManager::class.java.detectHiddenMethod( "getTasks", 1 to listOf(Int::class.java), 2 to listOf(Int::class.java, Boolean::class.java, Boolean::class.java), 3 to listOf( Int::class.java, Boolean::class.java, Boolean::class.java, Int::class.java ), ) } } fun getTasks(maxNum: Int = 1): List? = safeInvokeShizuku { when (getTasksType) { 1 -> value.getTasks(maxNum) 2 -> value.getTasks(maxNum, false, false) 3 -> value.getTasks(maxNum, false, false, Display.INVALID_DISPLAY) else -> value.getTasks(maxNum) } } fun registerDefault() { safeInvokeShizuku { value.registerTaskStackListener(FixedTaskStackListener) } } fun unregisterDefault() { safeInvokeShizuku { value.unregisterTaskStackListener(FixedTaskStackListener) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/AppOpsService.kt ================================================ 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/?ref=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) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/AutomationService.kt ================================================ 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) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt ================================================ package li.songe.gkd.shizuku import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class CommandResult( val code: Int?, val result: String, val error: String? ) : Parcelable { val ok: Boolean get() = code == 0 } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/HiddenCast.kt ================================================ @file:Suppress("CAST_NEVER_SUCCEEDS") package li.songe.gkd.shizuku import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.AccessibilityServiceInfoHidden 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 ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/InputManager.kt ================================================ 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) } } fun key(keyCode: Int) = command.runKeyEvent(keyCode) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/InputShellCommand.kt ================================================ 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/PackageManager.kt ================================================ package li.songe.gkd.shizuku import android.Manifest import android.content.pm.IPackageManager import android.content.pm.PackageInfo 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 class SafePackageManager(private val value: IPackageManager) { companion object { fun newBinder() = getShizukuService("package")?.let { SafePackageManager(IPackageManager.Stub.asInterface(it)) } private var canUseGetInstalledApps = true } val isSafeMode get() = safeInvokeShizuku { value.isSafeMode } fun getInstalledPackages( flags: Int, userId: Int = currentUserId, ): List = safeInvokeShizuku { if (AndroidTarget.TIRAMISU) { value.getInstalledPackages(flags.toLong(), userId).list } else { value.getInstalledPackages(flags, userId).list } } ?: emptyList() @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) } } 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 ) } private fun grantSelfPermission(name: String, skipCheck: Boolean = false) { if (!skipCheck) { if (app.checkGrantedPermission(name)) return } grantRuntimePermission( packageName = META.appId, permissionName = name, ) } 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) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/ProxyUiAutomationConnection.kt ================================================ 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/?ref=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/?ref=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) { if (AndroidTarget.UPSIDE_DOWN_CAKE) { mWindowManager.freezeRotation( mInitialFrozenRotation, "UiAutomationConnection#restoreRotationStateLocked" ) } else { mWindowManager.freezeRotation(mInitialFrozenRotation) } } else { if (AndroidTarget.UPSIDE_DOWN_CAKE) { mWindowManager.thawRotation("UiAutomationConnection#restoreRotationStateLocked") } else { mWindowManager.thawRotation() } } } 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!") } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt ================================================ package li.songe.gkd.shizuku 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.map import kotlinx.coroutines.flow.stateIn import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.isActivityVisible import li.songe.gkd.permission.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.toast import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.SystemServiceHelper import java.lang.reflect.Method import kotlin.system.exitProcess 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 } } class ShizukuOffException : IllegalStateException("Shizuku is off") fun getShizukuService(name: String): ShizukuBinderWrapper? { return SystemServiceHelper.getSystemService(name)?.let(::ShizukuBinderWrapper) } private fun Method.simpleString(): String { return "${name}(${parameterTypes.joinToString(",") { it.name }}):${returnType.name}" } 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") } } // 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 topCpn(): ComponentName? { return (activityTaskManager?.getTasks() ?: activityManager?.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") } } } private val defaultShizukuContext by lazy { ShizukuContext( serviceWrapper = null, packageManager = null, userManager = null, activityManager = null, activityTaskManager = null, appOpsService = null, inputManager = null, a11yManager = null, wmManager = null, ) } 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 { 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 服务已断开") } } } 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() } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/TaskStackListener.kt ================================================ package li.songe.gkd.shizuku import android.app.ActivityManager import android.app.ITaskStackListener import android.content.ComponentName import android.os.Parcel import li.songe.gkd.a11y.ActivityScene import li.songe.gkd.a11y.updateTopActivity 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() { val cpn = shizukuContextFlow.value.topCpn() ?: return synchronized(this) { 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) { val cpn = cpn ?: shizukuContextFlow.value.topCpn() ?: return synchronized(this) { lastFront = System.currentTimeMillis() to cpn } updateTopActivity( appId = cpn.packageName, activityId = cpn.className, scene = ActivityScene.TaskStack, ) } override fun onTaskMovedToFront(taskId: Int) { onTaskMovedToFrontCompat() } override fun onTaskMovedToFront(taskInfo: ActivityManager.RunningTaskInfo) { onTaskMovedToFrontCompat(taskInfo.topActivity) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/UserManager.kt ================================================ package li.songe.gkd.shizuku import android.content.Context import android.os.IUserManager import li.songe.gkd.data.UserInfo class SafeUserManager(private val value: IUserManager) { companion object { fun newBinder() = getShizukuService(Context.USER_SERVICE)?.let { SafeUserManager(IUserManager.Stub.asInterface(it)) } private val getUsersType by lazy { IUserManager::class.java.detectHiddenMethod( "getUsers", 1 to listOf(Boolean::class.java), 2 to listOf(Boolean::class.java, Boolean::class.java, Boolean::class.java), ) } } fun getUsers( excludePartial: Boolean = true, excludeDying: Boolean = true, excludePreCreated: Boolean = true ): List = safeInvokeShizuku { when (getUsersType) { 1 -> value.getUsers(excludeDying) 2 -> value.getUsers(excludePartial, excludeDying, excludePreCreated) else -> value.getUsers(excludeDying) }.map { UserInfo(id = it.id, name = it.name.trim()) } } ?: emptyList() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt ================================================ 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.util.Log import android.view.SurfaceControlHidden import androidx.annotation.Keep import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import li.songe.gkd.META import li.songe.gkd.permission.shizukuGrantedState import li.songe.gkd.util.LogUtils import li.songe.gkd.util.componentName import rikka.shizuku.Shizuku import java.io.DataOutputStream import java.io.File import kotlin.coroutines.resume import kotlin.system.exitProcess // https://github.com/RikkaApps/Shizuku/issues/1171#issuecomment-2952442340 @Keep class UserService(val context: Context) : IUserService.Stub() { init { Log.d( "UserService", "constructor(context=${context.packageName},pid=${android.os.Process.myPid()},uid=${android.os.Process.myUid()})" ) } override fun destroy() { Log.d("UserService", "destroy") exitProcess(0) } override fun exit() { destroy() } 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 { command.split('\n').filter { it.isNotBlank() }.forEach { outputStream.write(it.toByteArray()) outputStream.writeBytes('\n'.toString()) outputStream.flush() } outputStream.writeBytes("exit\n") outputStream.flush() CommandResult( code = process.waitFor(), result = process.inputStream.bufferedReader().readText(), error = process.errorStream.bufferedReader().readText(), ) } catch (e: Exception) { e.printStackTrace() val message = e.message val aimErrStr = "error=" val index = message?.indexOf(aimErrStr) val code = if (index != null) { message.substring(index + aimErrStr.length) .takeWhile { c -> c.isDigit() } .toIntOrNull() } else { null } ?: 1 CommandResult( code = code, result = "", error = e.message, ) } finally { outputStream.close() process.inputStream.close() process.outputStream.close() process.destroy() } return commandResult } 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" }) } return legacyPids.size } } private const val shizukuPsSuffix = "shizuku-user-service" 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) { e.printStackTrace() } } data class UserServiceWrapper( val userService: IUserService, val connection: ServiceConnection, val serviceArgs: Shizuku.UserServiceArgs ) { 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 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 } } suspend fun buildServiceWrapper(): UserServiceWrapper? { val serviceArgs = Shizuku .UserServiceArgs(UserService::class.componentName) .daemon(false) .processNameSuffix(shizukuPsSuffix) .debuggable(META.debuggable) .version(META.versionCode) .tag("default") LogUtils.d("buildServiceWrapper", serviceArgs) var resumeCallback: ((UserServiceWrapper) -> Unit)? = null val connection = object : ServiceConnection { override fun onServiceConnected(componentName: ComponentName, binder: IBinder?) { LogUtils.d("onServiceConnected", componentName) resumeCallback ?: return if (binder?.pingBinder() == true) { resumeCallback?.invoke( UserServiceWrapper( IUserService.Stub.asInterface(binder), this, serviceArgs ) ) resumeCallback = null } else { LogUtils.d("invalid binder for $componentName received") } } override fun onServiceDisconnected(componentName: ComponentName) { LogUtils.d("onServiceDisconnected", componentName) } } return withTimeoutOrNull(3000) { suspendCancellableCoroutine { continuation -> resumeCallback = { continuation.resume(it) } try { Shizuku.bindUserService(serviceArgs, connection) } catch (_: Throwable) { resumeCallback = null continuation.resume(null) } } }.apply { if (this == null) { unbindUserService(serviceArgs, connection, "connect timeout") } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/shizuku/WindowManager.kt ================================================ 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)) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/store/SettingsStore.kt ================================================ 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.UpdateChannelOption import li.songe.gkd.util.UpdateTimeOption @Serializable data class SettingsStore( val enableAutomator: Boolean = false, val automatorMode: Int = AutomatorModeOption.A11yMode.value, val enableShizuku: Boolean = false, val enableMatch: 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 actionToast: String = META.appName, val autoClearMemorySubs: Boolean = true, val hideSnapshotStatusBar: Boolean = false, val enableDarkTheme: Boolean? = null, val enableDynamicColor: Boolean = true, val showSaveSnapshotToast: Boolean = true, val useSystemToast: Boolean = false, val useCustomNotifText: Boolean = false, val customNotifTitle: String = META.appName, val customNotifText: String = $$"${i}全局/${k}应用/${u}规则组/${n}触发", val 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 subsAppShowUninstall: Boolean = false, val subsAppGroupType: Int = AppGroupOption.UserGroup.value or AppGroupOption.SystemGroup.value, val subsAppShowBlock: 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 useA11y get() = automatorMode == AutomatorModeOption.A11yMode.value val useAutomation get() = automatorMode == AutomatorModeOption.AutomationMode.value } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/store/StorageExt.kt ================================================ package li.songe.gkd.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import li.songe.gkd.appScope 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 readStoreText( file: File ): String? = file.run { if (exists()) { readText() } else { null } } private fun writeStoreText(file: File, text: String) { val tempFile = File("${file.absolutePath}.tmp") tempFile.outputStream().use { it.write(text.toByteArray(Charsets.UTF_8)) it.fd.sync() } Files.move( tempFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE ) } fun createTextFlow( key: String, decode: (String?) -> T, encode: (T) -> String, private: Boolean = false, scope: CoroutineScope = appScope, debounceMillis: Long = 0, ): MutableStateFlow { val name = if (key.contains('.')) key else "$key.txt" val file = (if (private) privateStoreFolder else storeFolder).resolve(name) val initText = readStoreText(file) val initValue = decode(initText) val stateFlow = MutableStateFlow(initValue) scope.launch { stateFlow.drop(1).conflate().debounce(debounceMillis).collect { withContext(Dispatchers.IO) { writeStoreText(file, encode(it)) } } } return stateFlow } inline fun createAnyFlow( key: String, crossinline default: () -> T, crossinline initialize: (T) -> T = { it }, private: Boolean = false, scope: CoroutineScope = appScope, debounceMillis: Long = 0, ): MutableStateFlow { return createTextFlow( key = "$key.json", decode = { val initValue = it?.let { runCatching { json.decodeFromString(it) }.getOrNull() } initialize(initValue ?: default()) }, encode = { json.encodeToString(it) }, private = private, scope = scope, debounceMillis = debounceMillis, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/store/StoreExt.kt ================================================ package li.songe.gkd.store import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import li.songe.gkd.appScope import li.songe.gkd.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: MutableStateFlow by lazy { createAnyFlow( key = "store", default = { SettingsStore() } ) } val actionCountFlow: MutableStateFlow by lazy { createTextFlow( key = "action_count", decode = { it?.toLongOrNull() ?: 0L }, encode = { it.toString() }, ) } val blockMatchAppListFlow: MutableStateFlow> by lazy { createTextFlow( key = "block_match_app_list", decode = { it?.let(AppListString::decode) ?: AppListString.getDefaultBlockList() }, encode = AppListString::encode, ) } val blockA11yAppListFlow: MutableStateFlow> by lazy { createTextFlow( key = "block_a11y_app_list", decode = { it?.let(AppListString::decode) ?: emptySet() }, encode = AppListString::encode, ) } val actualBlockA11yAppList: Set get() = if (storeFlow.value.blockA11yAppListFollowMatch) { blockMatchAppListFlow.value } else { blockA11yAppListFlow.value } val a11yScopeAppListFlow: MutableStateFlow> 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 actionCountFlow.value blockMatchAppListFlow.value blockA11yAppListFlow.value a11yScopeAppListFlow.value gkdStartCommandText ExposeService.initCommandFile() } fun switchStoreEnableMatch() { if (storeFlow.value.enableMatch) { toast("暂停规则匹配") } else { toast("开启规则匹配") } storeFlow.update { it.copy(enableMatch = !it.enableMatch) } } fun updateEnableAutomator(value: Boolean) { if (value == storeFlow.value.enableAutomator) return storeFlow.update { it.copy(enableAutomator = value) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogPage.kt ================================================ 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(), ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/A11yEventLogVm.kt ================================================ 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) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListPage.kt ================================================ 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)) } } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/A11yScopeAppListVm.kt ================================================ 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++ } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AboutPage.kt ================================================ package li.songe.gkd.ui import androidx.activity.compose.LocalActivity import androidx.compose.animation.graphics.res.animatedVectorResource 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.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.aspectRatio 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.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.Icon 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.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.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.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 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.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.TextMenu import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.LocalDarkTheme import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.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.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.throttle import li.songe.gkd.util.toast @Serializable data object AboutRoute : NavKey @Composable fun AboutPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current val vm = viewModel() val store by storeFlow.collectAsState() var showInfoDlg by vm.showInfoDlgFlow.asMutableState() if (showInfoDlg) { AlertDialog( onDismissRequest = { showInfoDlg = false }, title = { Text(text = "版本信息") }, text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Column { Text(text = "构建渠道") Text(text = META.channel) } Column { Text(text = "版本代码") Text(text = META.versionCode.toString()) } Column { Text(text = "版本名称") Text(text = META.versionName) } Column { Text(text = "代码记录") Text( modifier = Modifier.clickable { openUri(META.commitUrl) }, text = META.tagName ?: META.commitId.substring(0, 16), color = MaterialTheme.colorScheme.primary, style = LocalTextStyle.current.copy(textDecoration = TextDecoration.Underline), ) } Column { Text(text = "提交时间") Text(text = META.commitTime.format("yyyy-MM-dd HH:mm:ss ZZ")) } } }, confirmButton = { TextButton(onClick = { showInfoDlg = false }) { Text(text = "关闭") } }, ) } var showShareLogDlg by vm.showShareLogDlgFlow.asMutableState() var showShareAppDlg by vm.showShareAppDlgFlow.asMutableState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton( imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }, ) }, title = { Text(text = "关于") }, actions = { PerfIconButton( imageVector = PerfIcon.Share, onClick = { showShareAppDlg = true }, ) } ) } ) { contentPadding -> Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(contentPadding), ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { AnimatedLogoIcon( modifier = Modifier .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = throttle { toast("你干嘛~ 哎呦~") } ) .fillMaxWidth(0.33f) .aspectRatio(1f) ) Column( modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = { showInfoDlg = true }) .padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text(text = META.appName, style = MaterialTheme.typography.titleMedium) Text( text = META.versionName, style = MaterialTheme.typography.bodyMedium, ) } Spacer(modifier = Modifier.height(32.dp)) } SettingItem( imageVector = null, title = "开源代码", onClick = { mainVm.openUrl(REPOSITORY_URL) }, ) if (META.isGkdChannel) { SettingItem( imageVector = null, title = "捐赠支持", onClick = { mainVm.navigateWebPage(ShortUrlSet.URL10) }, ) } SettingItem( imageVector = null, title = "使用协议", onClick = { mainVm.navigateWebPage(ShortUrlSet.URL12) }, ) SettingItem( imageVector = null, title = "隐私政策", onClick = { mainVm.navigateWebPage(ShortUrlSet.URL11) }, ) Text( text = "反馈", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) Column( modifier = Modifier .clickable(onClick = throttle(mainVm.viewModelScope.launchAsFn { mainVm.dialogFlow.waitResult( title = "反馈须知", textContent = { Text(text = buildAnnotatedString { val highlightStyle = SpanStyle( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) append("感谢您愿意花时间反馈,") withStyle(style = highlightStyle) { append("GKD 默认不携带任何规则,只接受应用本体功能相关的反馈") } append("\n\n") append("请先判断是不是第三方规则订阅的问题,如果是,您应该向规则提供者反馈,而不是在此处反馈。") withStyle(style = highlightStyle) { append("如果您已经确信是 GKD 应用本体的问题") } append(",可点击下方继续反馈") }) }, confirmText = "继续", dismissRequest = true, ) mainVm.openUrl(ISSUES_URL) })) .fillMaxWidth() .itemPadding() ) { Text( text = "问题反馈", style = MaterialTheme.typography.bodyLarge, ) } SettingItem( title = "导出日志", imageVector = PerfIcon.Share, onClick = { showShareLogDlg = true } ) if (mainVm.updateStatus != null) { Text( text = "更新", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) TextMenu( title = "更新渠道", option = UpdateChannelOption.objects.findOption(store.updateChannel) ) { if (mainVm.updateStatus.checkUpdatingFlow.value) return@TextMenu if (it.value == UpdateChannelOption.Beta.value) { mainVm.viewModelScope.launchTry { mainVm.dialogFlow.waitResult( title = "版本渠道", text = "测试版本渠道更新快\n但不稳定可能存在较多BUG\n请谨慎使用", ) storeFlow.update { s -> s.copy(updateChannel = it.value) } } } else { storeFlow.update { s -> s.copy(updateChannel = it.value) } } } Row( modifier = Modifier .clickable( onClick = throttle { mainVm.updateStatus.checkUpdate(true) } ) .fillMaxWidth() .itemPadding(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( text = "检查更新", style = MaterialTheme.typography.bodyLarge, ) RotatingLoadingIcon(loading = mainVm.updateStatus.checkUpdatingFlow.collectAsState().value) } } Spacer(modifier = Modifier.height(EmptyHeight)) } } 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) { 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) { if (!META.isGkdChannel) { mainVm.dialogFlow.waitResult( title = "分享提示", textContent = { Text(text = exportPlayTipTemplate()) }, confirmText = "继续", ) } context.shareFile(getShareApkFile(), "分享安装文件") } }) .then(modifier) ) Text( text = "保存到下载", modifier = Modifier .clickable(onClick = throttle { showShareAppDlg = false mainVm.viewModelScope.launchTry(Dispatchers.IO) { if (!META.isGkdChannel) { mainVm.dialogFlow.waitResult( title = "保存提示", textContent = { Text(text = exportPlayTipTemplate()) }, confirmText = "继续", ) } context.saveFileToDownloads(getShareApkFile()) } }) .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(",或点击下方继续操作") } } @Composable private fun AnimatedLogoIcon( modifier: Modifier = Modifier ) { 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 = R.drawable.ic_anim_logo) val painter = rememberAnimatedVectorPainter( animation, atEnd ) LaunchedEffect(Unit) { while (isActive) { atEnd = !atEnd delay(animation.totalDuration.toLong()) } } Icon( modifier = modifier, painter = painter, contentDescription = null, tint = colorResource(colorRid), ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AboutVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow class AboutVm : ViewModel() { val showInfoDlgFlow = MutableStateFlow(false) val showShareLogDlgFlow = MutableStateFlow(false) val showShareAppDlgFlow = MutableStateFlow(false) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/ActionLogPage.kt ================================================ 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 import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider 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.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 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 import li.songe.gkd.data.SubsConfig 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.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.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.iconTextSize import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.mapState import li.songe.gkd.util.subsMapFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Serializable data class ActionLogRoute( val subsId: Long? = null, val appId: String? = null, ) : NavKey @Composable fun ActionLogPage(route: ActionLogRoute) { val subsId = route.subsId val appId = route.appId val mainVm = LocalMainViewModel.current 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 = { PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { 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(), modifier = titleModifier, ) } else if (appId != null) { TowLineText( title = title, subtitle = appId, showApp = true, modifier = titleModifier, ) } else { Text( text = title, modifier = titleModifier, ) } }, actions = { 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 -> CompositionLocalProvider( LocalNumberCharWidth provides timeTextWidth ) { LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState, ) { items( count = list.itemCount, key = list.itemKey { c -> c.first.id } ) { i -> val item = list[i] ?: return@items val lastItem = if (i > 0) list[i - 1] else null ActionLogCard( modifier = Modifier.animateListItem(), i = i, item = item, lastItem = lastItem, onClick = { vm.showActionLogFlow.value = item.first }, subsId = subsId, appId = appId, ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (list.itemCount == 0 && list.loadState.refresh !is LoadState.Loading) { EmptyText(text = "暂无数据") } } } } }) vm.showActionLogFlow.collectAsState().value?.let { ActionLogDialog( vm = vm, actionLog = it, onDismissRequest = { vm.showActionLogFlow.value = null } ) } } @Composable private fun ActionLogCard( modifier: Modifier = Modifier, i: Int, item: Triple, lastItem: Triple?, onClick: () -> Unit, subsId: Long?, appId: String?, ) { val mainVm = LocalMainViewModel.current val (actionLog, group, rule) = item 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 subsMapFlow.collectAsState() val subscription = subsIdToRaw[actionLog.subsId] Column( modifier = modifier .fillMaxWidth() .padding( start = itemHorizontalPadding / 2, end = itemHorizontalPadding / 2, top = verticalPadding ) ) { if (isDiffApp && appId == null) { 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)) } Spacer( modifier = Modifier .fillMaxHeight() .width(2.dp) .background(MaterialTheme.colorScheme.primaryContainer), ) Spacer(modifier = Modifier.width(8.dp)) Column( modifier = Modifier.weight(1f) ) { FixedTimeText( text = actionLog.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, softWrap = false, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) } else { Text( text = "null", color = LocalContentColor.current.copy(alpha = 0.5f), ) } if (subsId == null) { 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() ) { val groupDesc = group?.name.toString() val textColor = LocalContentColor.current.let { if (group?.name == null) it.copy(alpha = 0.5f) else it } GroupNameText( isGlobal = actionLog.groupType == SubsConfig.GlobalGroupType, text = groupDesc, color = textColor, ) val ruleDesc = rule?.name ?: (if ((group?.rules?.size ?: 0) > 1) { val keyDesc = actionLog.ruleKey?.let { "key=$it, " } ?: "" "${keyDesc}index=${actionLog.ruleIndex}" } else { null }) if (ruleDesc != null) { Text( text = ruleDesc, modifier = Modifier.padding(start = 8.dp), color = LocalContentColor.current.copy(alpha = 0.8f), ) } } } } } } } @Composable private fun ActionLogDialog( vm: ViewModel, actionLog: ActionLog, onDismissRequest: () -> Unit, ) { val mainVm = LocalMainViewModel.current val scope = rememberCoroutineScope() val subsConfig = remember(actionLog) { (if (actionLog.groupType == SubsConfig.AppGroupType) { DbSet.subsConfigDao.queryAppGroupTypeConfig( actionLog.subsId, actionLog.appId, actionLog.groupKey ) } else { DbSet.subsConfigDao.queryGlobalGroupTypeConfig(actionLog.subsId, actionLog.groupKey) }).stateIn(vm.viewModelScope, SharingStarted.Eagerly, null) }.collectAsState().value val oldExclude = remember(subsConfig?.exclude) { ExcludeData.parse(subsConfig?.exclude) } Dialog(onDismissRequest = onDismissRequest) { Card( modifier = Modifier .fillMaxWidth() .padding(16.dp), shape = RoundedCornerShape(16.dp), ) { ItemText( text = "查看规则组", onClick = { onDismissRequest() if (actionLog.groupType == SubsConfig.AppGroupType) { mainVm.navigatePage( SubsAppGroupListRoute( actionLog.subsId, actionLog.appId, actionLog.groupKey ) ) } else if (actionLog.groupType == SubsConfig.GlobalGroupType) { mainVm.navigatePage( SubsGlobalGroupListRoute( actionLog.subsId, actionLog.groupKey ) ) } } ) HorizontalDivider() if (actionLog.groupType == SubsConfig.GlobalGroupType) { val subs = remember(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) { getGlobalGroupChecked( subs, oldExclude, group, actionLog.appId, ) } else { null } if (appChecked != null) { ItemText( text = if (appChecked) "在此应用禁用" else "移除在此应用的禁用", onClick = vm.viewModelScope.launchAsFn { val subsConfig = subsConfig ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = actionLog.subsId, groupKey = actionLog.groupKey, ) val newSubsConfig = subsConfig.copy( exclude = oldExclude .copy( appIds = oldExclude.appIds .toMutableMap() .apply { set(actionLog.appId, appChecked) }) .stringify() ) DbSet.subsConfigDao.insert(newSubsConfig) toast("更新成功") } ) HorizontalDivider() } } if (actionLog.activityId != null) { val disabled = oldExclude.activityIds.contains(actionLog.appId to actionLog.activityId) ItemText( text = if (disabled) "移除在此页面的禁用" else "在此页面禁用", onClick = vm.viewModelScope.launchAsFn { val subsConfig = if (actionLog.groupType == SubsConfig.AppGroupType) { subsConfig ?: SubsConfig( type = SubsConfig.AppGroupType, subsId = actionLog.subsId, appId = actionLog.appId, groupKey = actionLog.groupKey, ) } else { subsConfig ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = actionLog.subsId, groupKey = actionLog.groupKey, ) } val newSubsConfig = subsConfig.copy( exclude = oldExclude .switch( actionLog.appId, actionLog.activityId ) .stringify() ) DbSet.subsConfigDao.insert(newSubsConfig) toast("更新成功") } ) HorizontalDivider() } } } } @Composable fun ItemText( text: String, color: Color = Color.Unspecified, onClick: () -> Unit ) { val modifier = Modifier .clickable(onClick = throttle(onClick)) .fillMaxWidth() .padding(16.dp) Text( modifier = modifier, text = text, color = color, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/ActionLogVm.kt ================================================ 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 androidx.paging.map 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.subsMapFlow class ActionLogVm(val route: ActionLogRoute) : ViewModel() { val pagingDataFlow = Pager(PagingConfig(pageSize = 100)) { 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 .cachedIn(viewModelScope) .combine(subsMapFlow) { pagingData, subsMap -> pagingData.map { c -> val group = if (c.groupType == SubsConfig.AppGroupType) { val app = subsMap[c.subsId]?.apps?.find { a -> a.id == c.appId } app?.groups?.find { g -> g.key == c.groupKey } } else { subsMap[c.subsId]?.globalGroups?.find { g -> g.key == c.groupKey } } val rule = group?.rules?.run { if (c.ruleKey != null) { find { r -> r.key == c.ruleKey } } else { getOrNull(c.ruleIndex) } } Triple(c, group, rule) } } val showActionLogFlow = MutableStateFlow(null) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/ActivityLogPage.kt ================================================ 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 import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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 kotlinx.serialization.Serializable import li.songe.gkd.MainActivity import li.songe.gkd.data.ActivityLog 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.LocalMainViewModel 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.itemHorizontalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Serializable data object ActivityLogRoute : NavKey @Composable fun ActivityLogPage() { 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 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 = { PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { Text( text = "界面日志", modifier = Modifier.noRippleClickable { resetKey.intValue++ }, ) }, actions = { if (logCount > 0) { 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), state = listState, ) { items( count = list.itemCount, key = list.itemKey { it.id } ) { i -> val actionLog = list[i] ?: return@items val lastActionLog = if (i > 0) list[i - 1] else null CompositionLocalProvider( LocalNumberCharWidth provides timeTextWidth ) { ActivityLogCard(i = i, activityLog = actionLog, lastActivityLog = lastActionLog) } } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (logCount == 0 && list.loadState.refresh !is LoadState.Loading) { EmptyText(text = "暂无数据") } } } } } @Composable private fun ActivityLogCard( i: Int, activityLog: ActivityLog, lastActivityLog: ActivityLog?, ) { 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 / 2, end = itemHorizontalPadding / 2, top = verticalPadding ) ) { if (isDiffApp) { 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( modifier = Modifier .fillMaxHeight() .width(2.dp) .background(MaterialTheme.colorScheme.primaryContainer), ) Spacer(modifier = Modifier.width(8.dp)) Column( modifier = Modifier.weight(1f) ) { FixedTimeText( text = activityLog.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, ) CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { if (showActivityId != null) { Text( text = showActivityId, softWrap = false, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) } else { Text( text = "null", color = LocalContentColor.current.copy(alpha = 0.5f), ) } } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/ActivityLogVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import li.songe.gkd.db.DbSet import li.songe.gkd.ui.share.BaseViewModel class ActivityLogVm : BaseViewModel() { val pagingDataFlow = Pager(PagingConfig(pageSize = 100)) { DbSet.activityLogDao.pagingSource() } .flow.cachedIn(viewModelScope) val logCountFlow = DbSet.activityLogDao.count().stateInit(0) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AdvancedPage.kt ================================================ package li.songe.gkd.ui import android.app.Activity import android.content.Context import android.media.projection.MediaProjectionManager import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.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 import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavKey import com.dylanc.activityresult.launcher.launchForResult import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable 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.notificationState import li.songe.gkd.permission.requiredPermission 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.PerfSwitch 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.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.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.appInfoMapFlow import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import li.songe.selector.Selector @Serializable data object AdvancedPageRoute : NavKey @Composable fun AdvancedPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current val vm = viewModel() val store by storeFlow.collectAsState() 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()) } AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), title = { Text(text = "服务端口") }, text = { OutlinedTextField( value = value, placeholder = { Text(text = placeholderText) }, onValueChange = { value = it.filter { c -> c.isDigit() }.take(5) }, singleLine = true, modifier = Modifier .fillMaxWidth() .autoFocus(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), supportingText = { Text( text = "${value.length} / 5", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End, ) }, ) }, onDismissRequest = { showEditPortDlg = false }, confirmButton = { TextButton( enabled = value.isNotEmpty(), onClick = { val newPort = value.toIntOrNull() if (newPort == null || !(portRange.first <= newPort && newPort <= portRange.second)) { toast(placeholderText) return@TextButton } showEditPortDlg = false if (newPort != store.httpServerPort) { storeFlow.value = store.copy( httpServerPort = newPort ) toast("更新成功") } } ) { Text( text = "确认", modifier = Modifier ) } }, dismissButton = { TextButton(onClick = { showEditPortDlg = false }) { Text( text = "取消" ) } } ) } 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 = "取消", ) } }) } val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { Text(text = "高级设置") }, ) } ) { contentPadding -> Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(contentPadding), ) { 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 = "点击授权以优化体验", onAuthClick = { mainVm.requestShizuku() } ) } 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", 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, ) CompositionLocalProvider( LocalTextStyle provides MaterialTheme.typography.bodyMedium ) { 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) }) ) } } } } } PerfSwitch( 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 = PerfIcon.Edit, onClickLabel = "编辑服务端口", onClick = { showEditPortDlg = true } ) TextSwitch( title = "清除订阅", subtitle = "关闭服务时删除内存订阅", checked = store.autoClearMemorySubs, onCheckedChange = { storeFlow.update { it.copy(autoClearMemorySubs = !it.autoClearMemorySubs) } } ) Text( text = "快照", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) SettingItem( title = "快照记录", subtitle = "应用界面节点信息及截图", onClick = { mainVm.navigatePage(SnapshotPageRoute) } ) if (!AndroidTarget.R) { val screenshotRunning by ScreenshotService.isRunning.collectAsState() TextSwitch( title = "截屏服务", subtitle = "生成快照需要获取屏幕截图", checked = screenshotRunning, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { requiredPermission(context, notificationState) val mediaProjectionManager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val activityResult = context.launcher.launchForResult(mediaProjectionManager.createScreenCaptureIntent()) if (activityResult.resultCode == Activity.RESULT_OK && activityResult.data != null) { ScreenshotService.start(intent = activityResult.data!!) } } else { ScreenshotService.stop() } } ) } TextSwitch( title = "快照按钮", subtitle = "显示按钮点击保存快照", checked = ButtonService.isRunning.collectAsState().value, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { requiredPermission(context, foregroundServiceSpecialUseState) requiredPermission(context, notificationState) requiredPermission(context, canDrawOverlaysState) ButtonService.start() } else { ButtonService.stop() } }, ) TextSwitch( title = "音量快照", subtitle = "音量变化时保存快照", checked = store.captureVolumeChange, onCheckedChange = { storeFlow.value = store.copy( captureVolumeChange = it ) }, ) TextSwitch( title = "截屏快照", subtitle = "截屏时保存快照", checked = store.captureScreenshot, suffixIcon = { PerfCustomIconButton( size = 32.dp, iconSize = 20.dp, onClickLabel = "打开配置截屏快照弹窗", onClick = throttle { showCaptureScreenshotDlg = true }, id = R.drawable.ic_page_info, contentDescription = "截屏快照设置", ) }, onCheckedChange = { storeFlow.value = store.copy( captureScreenshot = it ) if (it && store.screenshotTargetAppId.isEmpty() || store.screenshotEventSelector.isEmpty()) { toast("请配置目标应用和特征事件选择器") } } ) TextSwitch( title = "隐藏状态栏", subtitle = "隐藏快照截图状态栏", checked = store.hideSnapshotStatusBar, onCheckedChange = { storeFlow.value = store.copy( hideSnapshotStatusBar = it ) } ) TextSwitch( title = "保存提示", subtitle = "提示「正在保存快照」", checked = store.showSaveSnapshotToast, onCheckedChange = { storeFlow.value = store.copy( showSaveSnapshotToast = it ) } ) SettingItem( title = "Github Cookie", subtitle = "生成快照/日志链接", suffix = "获取教程", suffixUnderline = true, onSuffixClick = { mainVm.navigateWebPage(ShortUrlSet.URL1) }, imageVector = PerfIcon.Edit, onClick = { mainVm.showEditCookieDlgFlow.value = true } ) Text( text = "日志", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) SettingItem( title = "界面日志", subtitle = "界面切换日志", onClick = { mainVm.navigatePage(ActivityLogRoute) } ) 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 = "显示无障碍事件", 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)) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow class AdvancedVm : ViewModel() { val showEditPortDlgFlow = MutableStateFlow(false) val showShizukuStateFlow = MutableStateFlow(false) val showCaptureScreenshotDlgFlow = MutableStateFlow(false) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt ================================================ package li.songe.gkd.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.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.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.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.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 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.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.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.iconTextSize import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.RuleSortOption import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.copyText import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.switchItem import li.songe.gkd.util.throttle import li.songe.gkd.util.toJson5String @Serializable data class AppConfigRoute( val appId: String, val focusLog: ActionLog? = null, ) : NavKey @Composable fun AppConfigPage(route: AppConfigRoute) { val appId = route.appId val focusLog = route.focusLog val mainVm = LocalMainViewModel.current val vm = viewModel { AppConfigVm(route) } val ruleSortType by vm.ruleSortTypeFlow.collectAsState() val groupSize by vm.groupSizeFlow.collectAsState() val firstLoading by vm.firstLoadingFlow.collectAsState() val resetKey = rememberSaveable { mutableIntStateOf(0) } val (scrollBehavior, listState) = useListScrollState( resetKey, groupSize > 0, ruleSortType.value ) if (focusLog != null && groupSize > 0) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { val i = vm.subsPairsFlow.value.run { var j = 0 forEach { (entry, groups) -> groups.forEach { if (entry.subsItem.id == focusLog.subsId && it.groupType == focusLog.groupType && it.key == focusLog.groupKey) { return@run j } j++ } } -1 } if (i >= 0) { listState.scrollToItem(i) } } } } val isSelectedMode = vm.isSelectedModeFlow.collectAsState().value val selectedDataSet = vm.selectedDataSetFlow.collectAsState().value LaunchedEffect(key1 = isSelectedMode) { if (!isSelectedMode) { vm.selectedDataSetFlow.value = emptySet() } } LaunchedEffect(key1 = selectedDataSet.isEmpty()) { if (selectedDataSet.isEmpty()) { vm.isSelectedModeFlow.value = false } } BackHandler(isSelectedMode) { vm.isSelectedModeFlow.value = false } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { if (isSelectedMode) { vm.isSelectedModeFlow.value = false } else { 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) } AnimatedBooleanContent( targetState = isSelectedMode, contentAlignment = Alignment.TopEnd, contentTrue = { Row { PerfIconButton( imageVector = PerfIcon.ContentCopy, enabled = selectedDataSet.any { a -> a.appId != null }, onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { val selectGroups = mutableListOf() vm.subsPairsFlow.value.forEach { (entry, groups) -> groups.forEach { g -> if (g is RawSubscription.RawAppGroup && selectedDataSet.any { v -> entry.subsItem.id == v.subsId && g.key == v.groupKey }) { selectGroups.add(g) } } } val a = RawSubscription.RawApp( id = appId, name = appInfoMapFlow.value[appId]?.name, groups = selectGroups, ) copyText(toJson5String(a)) }) ) BatchActionButtonGroup(vm, selectedDataSet) PerfIconButton(imageVector = PerfIcon.MoreVert, onClick = { expanded = true }) } }, contentFalse = { Row { PerfIconButton(imageVector = PerfIcon.History, onClick = throttle { mainVm.navigatePage(ActionLogRoute(appId = appId)) }) PerfIconButton(imageVector = PerfIcon.Sort, onClick = { expanded = true }) } }, ) Box( modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { key(isSelectedMode) { DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { if (isSelectedMode) { DropdownMenuItem( text = { Text(text = "全选") }, onClick = { expanded = false vm.selectAll() } ) DropdownMenuItem( text = { Text(text = "反选") }, onClick = { expanded = false vm.invertSelect() } ) } else { 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) }, ) } } } } } } }) }, floatingActionButton = { AnimationFloatingActionButton( visible = !isSelectedMode, onClick = { mainVm.navigatePage( UpsertRuleGroupRoute( subsId = LOCAL_SUBS_ID, groupKey = null, appId = appId ) ) }, imageVector = PerfIcon.Add, contentDescription = "添加规则" ) }, ) { contentPadding -> val globalSubsConfigs by vm.globalSubsConfigsFlow.collectAsState() val categoryConfigs by vm.categoryConfigsFlow.collectAsState() val appSubsConfigs by vm.appSubsConfigsFlow.collectAsState() val subsPairs by vm.subsPairsFlow.collectAsState() LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState, verticalArrangement = Arrangement.spacedBy(4.dp), ) { subsPairs.forEach { (entry, groups) -> val subsId = entry.subsItem.id stickyHeader(entry.subsItem.id) { Row( modifier = Modifier .background(MaterialTheme.colorScheme.background) .padding(horizontal = 8.dp) .clip(MaterialTheme.shapes.extraSmall) .clickable(onClick = throttle { mainVm.navigatePage( SubsAppGroupListRoute( subsItemId = subsId, appId = appId, ) ) }) .fillMaxWidth() .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.weight(1f), text = entry.subscription.name, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.iconTextSize() ) } } items(groups, { Triple(subsId, it.groupType, it.key) }) { 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.groupToCategoryMap[group] is RawSubscription.RawGlobalGroup -> null } val categoryConfig = if (category != null) { categoryConfigs.find { it.subsId == subsId && it.categoryKey == category.key } } else { null } val isSelected = selectedDataSet.any { it.subsId == subsId && it.groupType == group.groupType && it.groupKey == group.key } val onLongClick = { if (groupSize > 1 && !isSelectedMode) { vm.isSelectedModeFlow.value = true vm.selectedDataSetFlow.value = setOf( group.toGroupState( subsId = subsId, appId = appId, ) ) } } val onSelectedChange = { vm.selectedDataSetFlow.value = selectedDataSet.switchItem( group.toGroupState( subsId = subsId, appId = appId, ) ) } RuleGroupCard( modifier = Modifier.animateListItem(), subs = entry.subscription, appId = appId, group = group, subsConfig = subsConfig, category = category, categoryConfig = categoryConfig, onLongClick = onLongClick, isSelectedMode = isSelectedMode, isSelected = isSelected, onSelectedChange = onSelectedChange, focusGroupFlow = vm.focusGroupFlow, ) } } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (groupSize == 0 && !firstLoading) { EmptyText(text = "暂无规则") } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.viewModelScope 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.launch 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.toGroupState import li.songe.gkd.ui.share.BaseViewModel import li.songe.gkd.util.LogUtils import li.songe.gkd.util.RuleSortOption import li.songe.gkd.util.collator import li.songe.gkd.util.findOption import li.songe.gkd.util.subsItemsFlow import li.songe.gkd.util.usedSubsEntriesFlow class AppConfigVm(val route: AppConfigRoute) : BaseViewModel() { val ruleSortTypeFlow = storeFlow.mapNew { RuleSortOption.objects.findOption(it.appRuleSort) } private val usedSubsIdsFlow = subsItemsFlow.mapNew { list -> list.filter { it.enable }.map { it.id }.sorted() } private val appConfigsFlow = DbSet.appConfigDao.queryAppUsedList(route.appId).attachLoad() private val appUsedSubsIdsFlow = combine(usedSubsIdsFlow, appConfigsFlow) { ids, configs -> ids.filter { configs.find { c -> c.subsId == it }?.enable != false } }.stateInit(usedSubsIdsFlow.value) private val latestLogsFlow = ruleSortTypeFlow.map { if (it == RuleSortOption.ByActionTime) { DbSet.actionLogDao.queryLatestByAppId(route.appId) } else { flowOf(emptyList()) } }.flattenConcat().attachLoad().stateInit(emptyList()) val globalSubsConfigsFlow = DbSet.subsConfigDao.queryUsedGlobalConfig().attachLoad() .stateInit(emptyList()) val appSubsConfigsFlow = appUsedSubsIdsFlow.map { DbSet.subsConfigDao.queryAppConfig(it, route.appId) }.flattenConcat().attachLoad() .stateInit(emptyList()) val categoryConfigsFlow = appUsedSubsIdsFlow.map { DbSet.categoryConfigDao.queryBySubsIds(it) }.flattenConcat().attachLoad() .stateInit(emptyList()) private val temp1ListFlow = combine( appUsedSubsIdsFlow, usedSubsEntriesFlow, globalSubsConfigsFlow, ) { usedSubsIds, list, configs -> list.map { e -> val globalGroups = e.subscription.globalGroups .filter { g -> configs.find { it.subsId == e.subsItem.id && it.groupKey == g.key }?.enable != false } val appGroups = if (usedSubsIds.contains(e.subsItem.id)) { e.subscription.getAppGroups(route.appId) } else { emptyList() } e to (globalGroups + appGroups) }.filter { it.second.isNotEmpty() } }.stateInit(emptyList()) val subsPairsFlow = combine( temp1ListFlow, latestLogsFlow, ruleSortTypeFlow ) { list, logs, sortType -> when (sortType) { RuleSortOption.ByDefault -> list RuleSortOption.ByRuleName -> list.map { e -> e.first to e.second.sortedWith { a, b -> collator.compare( a.name, b.name ) } } 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 }?.id ?: 0) } } } }.combine(firstLoadingFlow) { list, firstLoading -> if (firstLoading) { emptyList() } else { list } }.stateInit(emptyList()) 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 -> e.second.map { g -> g.toGroupState(subsId = e.first.subsItem.id, appId = route.appId) } }.flatten().toSet() fun selectAll() { selectedDataSetFlow.value = getAllSelectedDataSet() } fun invertSelect() { selectedDataSetFlow.value = getAllSelectedDataSet() - selectedDataSetFlow.value } val focusGroupFlow = route.focusLog?.let { MutableStateFlow?>( Triple( it.subsId, if (it.groupType == SubsConfig.AppGroupType) it.appId else null, it.groupKey, ) ) } init { viewModelScope.launch { appUsedSubsIdsFlow.collect { LogUtils.d(it) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowPage.kt ================================================ package li.songe.gkd.ui 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.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers 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.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.itemHorizontalPadding import li.songe.gkd.util.getShareApkFile import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry import li.songe.gkd.util.saveFileToDownloads import li.songe.gkd.util.toast @Serializable data object AppOpsAllowRoute : NavKey @Composable fun AppOpsAllowPage() { val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity val vm = viewModel() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val appOpsRestricted by appOpsRestrictedFlow.collectAsState() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { Text(text = "解除限制") }) }) { contentPadding -> Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(contentPadding) ) { if (appOpsRestricted) { Column( modifier = Modifier .padding(itemHorizontalPadding, 0.dp) .fillMaxWidth(), ) { Text( text = "下列权限应默认授予,但可能因某些操作如系统升级,备份迁移等被限制", style = MaterialTheme.typography.bodyMedium, ) 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( 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(EmptyHeight)) if (!appOpsRestricted) { Spacer(modifier = Modifier.height(EmptyHeight)) EmptyText(text = "状态正常, 无需操作") } } } val showCopyDlg by vm.showCopyDlgFlow.collectAsState() ManualAuthDialog( commandText = gkdStartCommandText, show = showCopyDlg, onUpdateShow = { vm.showCopyDlgFlow.value = it } ) } @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, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AppOpsAllowVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import li.songe.gkd.permission.foregroundServiceSpecialUseState class AppOpsAllowVm : ViewModel() { val showCopyDlgFlow = MutableStateFlow(false) init { viewModelScope.launch(Dispatchers.IO) { while (isActive) { foregroundServiceSpecialUseState.updateAndGet() delay(1000) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AuthA11yPage.kt ================================================ package li.songe.gkd.ui 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.material3.Card 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.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 androidx.navigation3.runtime.NavKey import kotlinx.coroutines.Dispatchers 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.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.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight 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.shFolder import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Serializable data object AuthA11yRoute : NavKey @Composable fun AuthA11yPage() { val mainVm = LocalMainViewModel.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 = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton( imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { Text(text = "工作模式") }) }) { contentPadding -> Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(contentPadding) ) { Card( modifier = Modifier .padding(horizontal = itemHorizontalPadding) .fillMaxWidth(), 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(horizontal = cardHorizontalPadding) .padding(start = 4.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 || 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( 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( text = "无感保活", style = MaterialTheme.typography.bodyLarge, ) } Spacer(modifier = Modifier.height(12.dp)) } Spacer(modifier = Modifier.height(12.dp)) Card( modifier = Modifier .padding(horizontal = itemHorizontalPadding) .fillMaxWidth(), onClick = throttle { mainVm.updateAutomatorMode(AutomatorModeOption.AutomationMode) }, colors = surfaceCardColors, ) { Row( modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { RadioButton( selected = automatorMode == AutomatorModeOption.AutomationMode, onClick = null, ) Text( modifier = Modifier.padding(start = 12.dp), text = AutomatorModeOption.AutomationMode.label, style = MaterialTheme.typography.titleMedium, ) } TextListItem( modifier = Modifier .padding(horizontal = cardHorizontalPadding) .padding(start = 8.dp), style = MaterialTheme.typography.bodyMedium, list = listOf( "自动化驱动的无障碍", "不会导致界面显示异常", "不会被其它应用检测为无障碍", "部分应用仍需切换至无障碍模式", ), ) AnimatedBooleanContent( targetState = shizukuUsedFlow.collectAsState().value, contentTrue = { Text( 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 ) ) } ) TextButton( modifier = Modifier.padding(start = cardHorizontalPadding), onClick = throttle { mainVm.navigatePage(A11YScopeAppListRoute) }, ) { Text( text = "局部无障碍", style = MaterialTheme.typography.bodyLarge, ) } Spacer(modifier = Modifier.height(12.dp)) } Spacer(modifier = Modifier.height(EmptyHeight)) } } ManualAuthDialog( commandText = gkdStartCommandText, show = showCopyDlg, onUpdateShow = { vm.showCopyDlgFlow.value = it }, ) } @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 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 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) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/AuthA11yVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import li.songe.gkd.permission.writeSecureSettingsState class AuthA11yVm : ViewModel() { init { viewModelScope.launch(Dispatchers.IO) { while (isActive) { writeSecureSettingsState.updateAndGet() delay(1000) } } } val showCopyDlgFlow = MutableStateFlow(false) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListPage.kt ================================================ 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)) } } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/BlockA11yAppListVm.kt ================================================ 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++ } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListPage.kt ================================================ 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 ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/EditBlockAppListVm.kt ================================================ 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 } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt ================================================ package li.songe.gkd.ui import android.webkit.URLUtil import androidx.activity.compose.LocalActivity import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity 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.navigation3.runtime.NavKey import coil3.ImageLoader import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter import coil3.disk.DiskCache import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade import 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 okhttp3.OkHttpClient import okio.Path.Companion.toOkioPath import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @Serializable data class ImagePreviewRoute( val title: String? = null, val uri: String? = null, val uris: List = emptyList(), ) : NavKey @Composable fun ImagePreviewPage(route: ImagePreviewRoute) { val title = route.title val uri = route.uri val uris = route.uris val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity DisposableEffect(null) { val controller = WindowCompat.getInsetsController(context.window, context.window.decorView) controller.hide(WindowInsetsCompat.Type.statusBars()) onDispose { controller.show(WindowInsetsCompat.Type.statusBars()) } } Box( modifier = Modifier .background(MaterialTheme.colorScheme.background) .fillMaxSize() ) { val showUri = uri ?: if (uris.size == 1) uris.first() else null val state = rememberPagerState { uris.size } PerfTopAppBar( modifier = Modifier .zIndex(1f) .fillMaxWidth(), navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { if (title != null) { Text( text = title, maxLines = 1, softWrap = false, overflow = TextOverflow.MiddleEllipsis, style = MaterialTheme.typography.titleLarge.copy( color = MaterialTheme.colorScheme.onBackground, shadow = Shadow( color = Color.Black.copy(alpha = 0.7f), blurRadius = with(LocalDensity.current) { 2.dp.toPx() }, offset = with(LocalDensity.current) { Offset( 1.dp.toPx(), 1.dp.toPx() ) } ) ) ) } }, actions = { val currentUri = showUri ?: uris.getOrNull(state.currentPage) if (currentUri != null && URLUtil.isNetworkUrl(currentUri)) { PerfIconButton(imageVector = PerfIcon.OpenInNew, onClick = throttle(fn = { mainVm.openUrl(currentUri) })) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.1f) ) ) if (showUri != null) { UriImage(showUri) } else if (uris.isNotEmpty()) { Box( modifier = Modifier.fillMaxSize() ) { HorizontalPager( modifier = Modifier.fillMaxSize(), state = state, pageContent = { UriImage(uris[it]) } ) Box( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .padding(bottom = 150.dp), contentAlignment = Alignment.BottomCenter ) { Text( text = "${state.currentPage + 1}/${uris.size}", style = MaterialTheme.typography.titleLarge.copy( color = MaterialTheme.colorScheme.onPrimary, shadow = Shadow( color = Color.Black.copy(alpha = 0.8f), blurRadius = with(LocalDensity.current) { 3.dp.toPx() } ) ) ) } } } } } @Composable private fun UriImage(uri: String) { val context = LocalContext.current val model = remember(uri) { ImageRequest.Builder(context).data(uri) .crossfade(DefaultDurationMillis).run { if (URLUtil.isNetworkUrl(uri)) { this } else { diskCachePolicy(CachePolicy.DISABLED).memoryCachePolicy(CachePolicy.DISABLED) } } .build().apply { imageLoader.enqueue(this) } } val painter = rememberAsyncImagePainter(model) 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)) } } is AsyncImagePainter.State.Success -> { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.Center, ) { Image( painter = painter, contentDescription = null, modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, alignment = Alignment.Center, ) } } 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 ) } } } } 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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SlowGroupPage.kt ================================================ package li.songe.gkd.ui 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.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow 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.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.appInfoMapFlow import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.throttle @Serializable data object SlowGroupRoute : NavKey @Composable fun SlowGroupPage() { val mainVm = LocalMainViewModel.current val ruleSummary by ruleSummaryFlow.collectAsState() val appInfoCache by appInfoMapFlow.collectAsState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { Text(text = "缓慢查询") }, actions = { PerfIconButton(imageVector = PerfIcon.Info, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "缓慢查询", text = arrayOf( "任意单个规则同时满足以下 3 个条件即判定为缓慢查询", "1. 选择器右侧无法快速查询且不是主动查询, 或内部使用<<且无法快速查询\n2. preKeys 为空\n3. matchTime 为空或大于 10s", "缓慢查询可能导致触发缓慢或更多耗电, 一些可能优化的建议操作\n1. 降低选择器获取新节点次数\n2. 降低或限制规则查询时间或次数" ).joinToString("\n\n"), ) }) } ) } ) { contentPadding -> LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding) ) { items( ruleSummary.slowGlobalGroups, { (_, r) -> r.subsItem.id to r.group.key } ) { (group, rule) -> SlowGroupCard( modifier = Modifier .clickable(onClick = throttle { mainVm.navigatePage( SubsGlobalGroupListRoute( rule.subsItem.id, group.key ) ) }) .itemPadding(), title = group.name, desc = "${rule.rawSubs.name}/全局规则" ) } items( ruleSummary.slowAppGroups, { (_, r) -> Triple(r.subsItem.id, r.appId, r.group.key) } ) { (group, rule) -> SlowGroupCard( modifier = Modifier .clickable(onClick = throttle { mainVm.navigatePage( 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(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (ruleSummary.slowGroupCount == 0) { EmptyText(text = "暂无规则") } } } } } @Composable fun SlowGroupCard(title: String, desc: String, modifier: Modifier = Modifier) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) Text( text = desc, style = MaterialTheme.typography.bodyMedium, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SnapshotPage.kt ================================================ package li.songe.gkd.ui import android.graphics.BitmapFactory 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 import androidx.compose.foundation.layout.Spacer 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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 import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel 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.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.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.itemHorizontalPadding import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.IMPORT_SHORT_URL import li.songe.gkd.util.ImageUtils import li.songe.gkd.util.SnapshotExt import li.songe.gkd.util.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 import li.songe.gkd.util.shareFile import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Serializable data object SnapshotPageRoute : NavKey @Composable fun SnapshotPage() { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current val colorScheme = MaterialTheme.colorScheme val vm = viewModel() val firstLoading by vm.firstLoadingFlow.collectAsState() val snapshots by vm.snapshotsState.collectAsState() var selectedSnapshot by remember { mutableStateOf(null) } 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 = { PerfTopAppBar( scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }) }, title = { Text( text = "快照记录", modifier = Modifier.noRippleClickable { resetKey.intValue++ }, ) }, actions = { if (snapshots.isNotEmpty()) { 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 -> CompositionLocalProvider( LocalNumberCharWidth provides timeTextWidth ) { LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState, ) { items(snapshots, { it.id }) { snapshot -> SnapshotCard( modifier = Modifier.animateListItem(), snapshot = snapshot, onClick = { selectedSnapshot = snapshot } ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (snapshots.isEmpty() && !firstLoading) { EmptyText(text = "暂无数据") } } } } }) selectedSnapshot?.let { snapshotVal -> Dialog(onDismissRequest = { selectedSnapshot = null }) { 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(fn = vm.viewModelScope.launchAsFn { selectedSnapshot = null mainVm.navigatePage( ImagePreviewRoute( title = appInfoMapFlow.value[snapshotVal.appId]?.name ?: snapshotVal.appId, uri = snapshotVal.screenshotFile.absolutePath, ) ) })) .then(modifier) ) HorizontalDivider() Text( text = "分享到其他应用", modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn { selectedSnapshot = null val zipFile = SnapshotExt.snapshotZipFile( snapshotVal.id, snapshotVal.appId, snapshotVal.activityId ) context.shareFile(zipFile, "分享快照文件") })) .then(modifier) ) HorizontalDivider() Text( text = "保存到下载", modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { selectedSnapshot = null toast("正在保存...") val zipFile = SnapshotExt.snapshotZipFile( snapshotVal.id, snapshotVal.appId, snapshotVal.activityId ) context.saveFileToDownloads(zipFile) })) .then(modifier) ) HorizontalDivider() if (snapshotVal.githubAssetId != null) { Text( text = "复制链接", modifier = Modifier .clickable(onClick = throttle { selectedSnapshot = null copyText(IMPORT_SHORT_URL + snapshotVal.githubAssetId) }) .then(modifier) ) } else { Text( text = "生成链接(需科学上网)", modifier = Modifier .clickable(onClick = throttle { selectedSnapshot = null mainVm.uploadOptions.startTask( getFile = { SnapshotExt.snapshotZipFile(snapshotVal.id) }, showHref = { IMPORT_SHORT_URL + it.id }, onSuccessResult = { DbSet.snapshotDao.update(snapshotVal.copy(githubAssetId = it.id)) } ) }) .then(modifier) ) } HorizontalDivider() Text( text = "保存截图到相册", modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { toast("正在保存...") selectedSnapshot = null requiredPermission(context, canWriteExternalStorage) ImageUtils.save2Album(BitmapFactory.decodeFile(snapshotVal.screenshotFile.absolutePath)) toast("保存成功") })) .then(modifier) ) HorizontalDivider() Text( text = "替换截图(去除隐私)", modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn(Dispatchers.IO) { val uri = context.pickContentLauncher.launchForImageResult() val oldBitmap = BitmapFactory.decodeFile(snapshotVal.screenshotFile.absolutePath) val newBytes = UriUtils.uri2Bytes(uri) 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) { // 当本地快照变更时, 移除快照链接 DbSet.snapshotDao.deleteGithubAssetId(snapshotVal.id) } toast("替换成功") selectedSnapshot = null } else { toast("截图尺寸不一致, 无法替换") } })) .then(modifier) ) HorizontalDivider() Text( text = "删除", modifier = Modifier .clickable(onClick = throttle(fn = vm.viewModelScope.launchAsFn { selectedSnapshot = null mainVm.dialogFlow.waitResult( title = "删除快照", text = "确定删除当前快照吗?", error = true, ) DbSet.snapshotDao.delete(snapshotVal) withContext(Dispatchers.IO) { SnapshotExt.removeSnapshot(snapshotVal.id) } toast("删除成功") })) .then(modifier), color = colorScheme.error ) } } } } @Composable private fun SnapshotCard( modifier: Modifier = Modifier, snapshot: Snapshot, onClick: () -> Unit, ) { Row( modifier = modifier .clickable(onClick = onClick) .fillMaxWidth() .height(IntrinsicSize.Min) .padding(horizontal = itemHorizontalPadding, vertical = itemVerticalPadding / 2) ) { Spacer( modifier = Modifier .fillMaxHeight() .width(2.dp) .background(MaterialTheme.colorScheme.primaryContainer), ) Spacer(modifier = Modifier.width(8.dp)) Column( modifier = Modifier.weight(1f), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { val appInfo = appInfoMapFlow.collectAsState().value[snapshot.appId] val showAppName = appInfo?.name ?: snapshot.appId Text( text = showAppName, overflow = TextOverflow.Ellipsis, maxLines = 1, softWrap = false, ) FixedTimeText( text = snapshot.date, style = MaterialTheme.typography.bodySmall, ) } val showActivityId = if (snapshot.activityId != null) { if (snapshot.activityId.startsWith(snapshot.appId)) { snapshot.activityId.substring(snapshot.appId.length) } else { snapshot.activityId } } else { null } if (showActivityId != null) { Text( modifier = Modifier.height(MaterialTheme.typography.bodyMedium.lineHeight.value.dp), text = showActivityId, style = MaterialTheme.typography.bodyMedium, softWrap = false, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) } else { Text( text = "null", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.typography.bodyMedium.color.copy(alpha = 0.5f) ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SnapshotVm.kt ================================================ package li.songe.gkd.ui import li.songe.gkd.db.DbSet import li.songe.gkd.ui.share.BaseViewModel class SnapshotVm : BaseViewModel() { val snapshotsState = DbSet.snapshotDao.query().attachLoad() .stateInit(emptyList()) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListPage.kt ================================================ 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 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.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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 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 import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.scaffoldPadding 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 import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription @Serializable data class SubsAppGroupListRoute( val subsItemId: Long, val appId: String, val focusGroupKey: Int? = null, // 背景/边框高亮一下 ) : NavKey @Composable fun SubsAppGroupListPage(route: SubsAppGroupListRoute) { val subsItemId = route.subsItemId val appId = route.appId val focusGroupKey = route.focusGroupKey val mainVm = LocalMainViewModel.current 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 val editable = subsItemId < 0 val isSelectedMode = vm.isSelectedModeFlow.collectAsState().value val selectedDataSet = vm.selectedDataSetFlow.collectAsState().value LaunchedEffect(key1 = isSelectedMode) { if (!isSelectedMode) { vm.selectedDataSetFlow.value = emptySet() } } LaunchedEffect(key1 = selectedDataSet.isEmpty()) { if (selectedDataSet.isEmpty()) { vm.isSelectedModeFlow.value = false } } BackHandler(isSelectedMode) { vm.isSelectedModeFlow.value = false } val resetKey = rememberSaveable { mutableIntStateOf(0) } val (scrollBehavior, listState) = useListScrollState(resetKey, app.groups.isEmpty()) if (focusGroupKey != null) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { val i = app.groups.indexOfFirst { it.key == focusGroupKey } if (i >= 0) { listState.scrollToItem(i) } } } } Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { if (isSelectedMode) { vm.isSelectedModeFlow.value = false } else { 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( modifier = titleModifier, title = subs.name, subtitle = appId, showApp = true, ) } }, actions = { var expanded by remember { mutableStateOf(false) } AnimatedContent( targetState = isSelectedMode, transitionSpec = { getUpDownTransform() }, contentAlignment = Alignment.TopEnd, ) { if (it) { Row { 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) { PerfIconButton( imageVector = PerfIcon.Delete, onClick = throttle(vm.viewModelScope.launchAsFn { mainVm.dialogFlow.waitResult( title = "删除规则组", text = "删除当前所选规则组?", error = true, ) val keys = selectedDataSet.mapNotNull { g -> g.groupKey } vm.isSelectedModeFlow.value = false if (keys.size == app.groups.size) { updateSubscription( subs.copy( apps = subs.apps.filter { a -> a.id != appId } ) ) 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 }) } } } if (isSelectedMode) { Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) ) { DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { DropdownMenuItem( text = { Text(text = "全选") }, onClick = { expanded = false vm.selectedDataSetFlow.value = app.groups.map { it.toGroupState( subsId = subsItemId, appId = appId, ) }.toSet() } ) DropdownMenuItem( text = { Text(text = "反选") }, onClick = { expanded = false val newSelectedIds = app.groups.map { it.toGroupState( subsId = subsItemId, appId = appId, ) }.toSet() - selectedDataSet vm.selectedDataSetFlow.value = newSelectedIds } ) } } } }) }, floatingActionButton = { if (editable) { AnimationFloatingActionButton( visible = !isSelectedMode, onClick = { mainVm.navigatePage( UpsertRuleGroupRoute( subsId = subsItemId, groupKey = null, appId = appId ) ) }, 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 subsConfig = subsConfigs.find { it.groupKey == group.key } val categoryConfig = categoryConfigs.find { it.categoryKey == category?.key } RuleGroupCard( modifier = Modifier.animateListItem(), subs = subs, appId = appId, group = group, category = category, subsConfig = subsConfig, categoryConfig = categoryConfig, focusGroupFlow = vm.focusGroupFlow, isSelectedMode = isSelectedMode, isSelected = selectedDataSet.any { it.groupKey == group.key }, onLongClick = { if (app.groups.size > 1) { vm.isSelectedModeFlow.value = true vm.selectedDataSetFlow.value = setOf( group.toGroupState(subsItemId, appId) ) } }, onSelectedChange = { vm.selectedDataSetFlow.value = selectedDataSet.switchItem( group.toGroupState( subsItemId, appId, ) ) } ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (app.groups.isEmpty()) { EmptyText(text = "暂无规则") } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsAppGroupListVm.kt ================================================ package li.songe.gkd.ui import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.ShowGroupState import li.songe.gkd.ui.share.BaseViewModel class SubsAppGroupListVm(val route: SubsAppGroupListRoute) : BaseViewModel() { val subsFlow = mapSafeSubs(route.subsItemId) val subsAppFlow = subsFlow.mapNew { it.getApp(route.appId) } val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(route.subsItemId, route.appId) .stateInit(emptyList()) val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(route.subsItemId) .stateInit(emptyList()) val isSelectedModeFlow = MutableStateFlow(false) val selectedDataSetFlow = MutableStateFlow(emptySet()) val focusGroupFlow = route.focusGroupKey?.let { MutableStateFlow?>( Triple( route.subsItemId, route.appId, route.focusGroupKey ) ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsAppListPage.kt ================================================ 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.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.FloatingActionButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable 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.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.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.R import li.songe.gkd.data.AppConfig import li.songe.gkd.db.DbSet 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.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.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.AppSortOption import li.songe.gkd.util.LOCAL_SUBS_IDS import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle @Serializable data class SubsAppListRoute(val subsItemId: Long) : NavKey @Composable fun SubsAppListPage(route: SubsAppListRoute) { val subsItemId = route.subsItemId val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity val vm = viewModel { SubsAppListVm(route) } val appTripleList by vm.appItemListFlow.collectAsState() val searchStr by vm.searchStrFlow.collectAsState() var showSearchBar by rememberSaveable { mutableStateOf(false) } LaunchedEffect(key1 = showSearchBar, block = { if (!showSearchBar) { vm.searchStrFlow.value = "" } }) val (scrollBehavior, listState) = useListScrollState( vm.resetKey, ) var expanded by remember { mutableStateOf(false) } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { 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 (!context.justHideSoftInput()) { showSearchBar = false } } AppBarTextField( value = searchStr, onValueChange = { newValue -> vm.searchStrFlow.value = newValue.trim() }, hint = "请输入应用名称/ID", modifier = if (firstShowSearchBar) Modifier else Modifier.autoFocus(), ) } else { TowLineText( title = useSubs(subsItemId)?.name ?: subsItemId.toString(), subtitle = "应用规则", modifier = Modifier.noRippleClickable { vm.resetKey.intValue++ } ) } }, actions = { AnimatedIconButton( onClick = { 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(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, ) } } } }) }, floatingActionButton = { if (LOCAL_SUBS_IDS.contains(subsItemId)) { FloatingActionButton(onClick = throttle { mainVm.navigatePage( UpsertRuleGroupRoute( subsId = subsItemId, groupKey = null, appId = "", forward = true, ) ) }) { PerfIcon( imageVector = PerfIcon.Add, ) } } }, ) { contentPadding -> LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding), state = listState ) { items(appTripleList, { it.id }) { a -> SubsAppCard( data = a, onClick = throttle { context.justHideSoftInput() mainVm.navigatePage(SubsAppGroupListRoute(subsItemId, a.id)) }, onValueChange = vm.viewModelScope.launchAsFn { enable -> val newItem = a.appConfig?.copy( enable = enable ) ?: AppConfig( enable = enable, subsId = subsItemId, appId = a.id, ) DbSet.appConfigDao.insert(newItem) }, ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) val firstLoading by vm.firstLoadingFlow.collectAsState() if (appTripleList.isEmpty() && !firstLoading) { EmptyText( text = if (searchStr.isNotEmpty()) { if (vm.showAllAppFlow.collectAsState().value) "暂无搜索结果" else "暂无搜索结果,或修改筛选" } else { "暂无规则" } ) Spacer(modifier = Modifier.height(EmptyHeight / 2)) } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsAppListVm.kt ================================================ package li.songe.gkd.ui 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.MainViewModel import li.songe.gkd.data.AppConfig import li.songe.gkd.data.AppInfo import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.store.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.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.findOption import li.songe.gkd.util.getGroupEnable class SubsAppListVm(val route: SubsAppListRoute) : BaseViewModel() { val subsFlow = mapSafeSubs(route.subsItemId) private val appConfigsFlow = DbSet.appConfigDao.queryAppTypeConfig(route.subsItemId) .attachLoad().stateInit(emptyList()) private val groupSubsConfigsFlow = DbSet.subsConfigDao.querySubsGroupTypeConfig(route.subsItemId) .attachLoad().stateInit(emptyList()) private val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(route.subsItemId) .attachLoad().stateInit(emptyList()) 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) }, ) private val temp1ListFlow = run { var tempListFlow = combine( subsFlow, appInfoMapFlow, ) { subs, appMap -> subs.usedApps.map { it to appMap[it.id] }.sortedWith { a, b -> // 默认顺序: 已安装(有名字->无名字)->未安装(有名字(来自订阅)->无名字) val x = a.second?.name ?: a.first.name?.let { "\uFFFF" + it } ?: ("\uFFFF\uFFFF" + a.first.id) val y = b.second?.name ?: b.first.name?.let { "\uFFFF" + it } ?: ("\uFFFF\uFFFF" + b.first.id) collator.compare(x, y) } } tempListFlow = combine( tempListFlow, appGroupTypeFlow, ) { list, type -> if (type == 0) { return@combine emptyList() } if (AppGroupOption.allObjects.all { it.include(type) }) { return@combine list } var resultList = list if (!AppGroupOption.SystemGroup.include(type)) { resultList = resultList.filterNot { it.second?.isSystem == true } } if (!AppGroupOption.UserGroup.include(type)) { resultList = resultList.filterNot { it.second?.isSystem == false } } if (!AppGroupOption.UnInstalledGroup.include(type)) { resultList = resultList.filterNot { it.second == null } } resultList } tempListFlow = combine( tempListFlow, showBlockAppFlow, blockMatchAppListFlow ) { list, showBlock, blockSet -> if (showBlock) { list } else { list.filterNot { it.first.id in blockSet } } } tempListFlow } val showAllAppFlow = combine(subsFlow, temp1ListFlow) { subs, list -> subs.apps.size == list.size }.stateInit(false) val sortTypeFlow = storeFlow.asMutableStateFlow( getter = { AppSortOption.objects.findOption(it.subsAppSort) }, setter = { storeFlow.value.copy(subsAppSort = it.value) } ) private val appActionOrderMapFlow = DbSet.actionLogDao .queryLatestUniqueAppIds(route.subsItemId) .map { it.mapIndexed { i, appId -> appId to i }.toMap() } private val temp2ListFlow = combine( temp1ListFlow, appActionOrderMapFlow, sortTypeFlow, MainViewModel.instance.appVisitOrderMapFlow, ) { apps, appIdToOrder, sortType, appVisitOrderMap -> when (sortType) { AppSortOption.ByActionTime -> { apps.sortedBy { a -> appIdToOrder[a.first.id] ?: Int.MAX_VALUE } } AppSortOption.ByAppName -> { apps } AppSortOption.ByUsedTime -> { apps.sortedBy { a -> appVisitOrderMap[a.first.id] ?: Int.MAX_VALUE } } } } val searchStrFlow = MutableStateFlow("") private val debounceSearchStr = searchStrFlow.debounce(200).stateInit(searchStrFlow.value) val temp3ListFlow = combine( temp2ListFlow, debounceSearchStr, ) { apps, searchStr -> if (searchStr.isBlank()) { apps } else { val results = mutableListOf>() val tempList = apps.toMutableList() //1. 搜索已安装应用名称 tempList.toList().apply { tempList.clear() }.forEach { a -> if (a.second?.name?.contains(searchStr, true) == true) { results.add(a) } else { tempList.add(a) } } //2. 搜索未安装应用名称 tempList.toList().apply { tempList.clear() }.forEach { a -> val name = a.first.name if (a.second == null && name?.contains(searchStr, true) == true) { results.add(a) } else { tempList.add(a) } } //3. 搜索应用 id tempList.toList().apply { tempList.clear() }.forEach { a -> if (a.first.id.contains(searchStr, true)) { results.add(a) } else { tempList.add(a) } } results } }.stateInit(emptyList()) val appItemListFlow = combine( subsFlow, temp3ListFlow, categoryConfigsFlow, appConfigsFlow, groupSubsConfigsFlow, ) { subsRaw, apps, categoryConfigs, appConfigs, groupSubsConfigs -> val groupToCategoryMap = subsRaw.groupToCategoryMap apps.map { val appGroupSubsConfigs = groupSubsConfigs.filter { s -> s.appId == it.first.id } val enableSize = it.first.groups.count { g -> getGroupEnable( g, appGroupSubsConfigs.find { c -> c.groupKey == g.key }, groupToCategoryMap[g], categoryConfigs.find { c -> c.categoryKey == groupToCategoryMap[g]?.key } ) } SubsAppInfoItem( rawApp = it.first, appInfo = it.second, appConfig = appConfigs.find { s -> s.appId == it.first.id }, 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 } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryPage.kt ================================================ package li.songe.gkd.ui 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.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.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.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.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.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel 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.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.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.scaffoldPadding 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.toToggleableState import li.songe.gkd.util.toast import li.songe.gkd.util.updateSubscription @Serializable data class SubsCategoryRoute(val subsItemId: Long) : NavKey @Composable fun SubsCategoryPage(@Suppress("unused") route: SubsCategoryRoute) { val mainVm = LocalMainViewModel.current val vm = viewModel { SubsCategoryVm(route) } val subs = vm.subsRawFlow.collectAsState().value val categoryConfigMap = vm.categoryConfigMapFlow.collectAsState().value val categories = subs.categories val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { PerfIconButton( imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }, ) }, title = { TowLineText( title = subs.name, subtitle = "规则类别" ) }, actions = { PerfIconButton(imageVector = PerfIcon.Info, onClick = throttle { mainVm.dialogFlow.updateDialogOptions( title = "类别说明", text = arrayOf( "类别会捕获以当前类别开头的所有应用规则组, 因此可调整类别开关(分类手动配置)来批量开关规则组", "规则组开关优先级为:\n规则手动配置 > 分类手动配置 > 分类默认 > 规则默认", "因此如果手动开关了规则组(规则手动配置), 则该规则组不会被批量开关, 可通过点击类别-重置规则组开关, 来移除类别下所有规则手动配置", ).joinToString("\n\n"), ) }) }) }, floatingActionButton = { if (subs.isLocal) { FloatingActionButton(onClick = { vm.showAddCategoryFlow.value = true }) { PerfIcon( imageVector = PerfIcon.Add, ) } } }) { contentPadding -> LazyColumn( modifier = Modifier.scaffoldPadding(contentPadding) ) { items(categories, { it.key }) { category -> CategoryItemCard( vm = vm, subs = subs, category = category, categoryConfig = categoryConfigMap[category.key], ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (categories.isEmpty()) { EmptyText(text = "暂无类别") } } } } val editCategory by vm.editCategoryFlow.collectAsState() if (editCategory != null) { AddOrEditCategoryDialog( subs = subs, category = editCategory, ) { vm.editCategoryFlow.value = null } } val showAddCategory by vm.showAddCategoryFlow.collectAsState() if (showAddCategory) { AddOrEditCategoryDialog( subs = subs, category = null, ) { vm.showAddCategoryFlow.value = false } } } @Composable private fun CategoryItemCard( vm: SubsCategoryVm, subs: RawSubscription, category: RawSubscription.RawCategory, categoryConfig: CategoryConfig?, ) { val groups = subs.categoryToGroupsMap[category] ?: emptyList() var expanded by remember { mutableStateOf(false) } val onClick = { if (groups.isNotEmpty() || subs.isLocal) { expanded = true } } Card( onClick = onClick, shape = MaterialTheme.shapes.extraSmall, modifier = Modifier.padding( horizontal = 8.dp, vertical = 2.dp, ), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { Row( modifier = Modifier .fillMaxWidth() .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = category.name, style = MaterialTheme.typography.bodyLarge, ) if (!category.desc.isNullOrBlank()) Text( text = category.desc, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) else if (groups.isNotEmpty()) { val appSize = subs.getCategoryApps(category.key).size Text( text = "${appSize}应用/${groups.size}规则组", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( text = "暂无规则", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } } 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.objects.findOption(enable).toToggleableState(), onClick = throttle(appScope.launchAsFn { val option = when (enable) { false -> EnableGroupOption.FollowSubs null -> EnableGroupOption.AllEnable true -> EnableGroupOption.AllDisable } DbSet.categoryConfigDao.insert( (categoryConfig ?: CategoryConfig( enable = option.value, subsId = subs.id, categoryKey = category.key )).copy(enable = option.value) ) toast(option.label) }) ) } } } @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 = { PerfIcon( imageVector = ResetSettings, ) }, 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 = { PerfIcon( imageVector = PerfIcon.Edit, ) }, text = { Text(text = "编辑") }, onClick = { onCheckedChange(false) vm.editCategoryFlow.value = category } ) DropdownMenuItem( text = { Text(text = "删除", color = MaterialTheme.colorScheme.error) }, leadingIcon = { PerfIcon( imageVector = PerfIcon.Delete, ) }, 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( subs: RawSubscription, category: RawSubscription.RawCategory?, onDismissRequest: () -> Unit, ) { var nameValue by remember { mutableStateOf(category?.name ?: "") } var descValue by remember { mutableStateOf(category?.desc ?: "") } val onClick = appScope.launchAsFn { if (category != null) { onDismissRequest() 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 == nameValue }) { error("不可添加同名类别") } onDismissRequest() updateSubscription( subs.copy(categories = subs.categories.toMutableList().apply { val c = RawSubscription.RawCategory( key = (subs.categories.maxOfOrNull { c -> c.key } ?: -1) + 1, enable = null, name = nameValue, desc = descValue, ) add(c) }) ) toast("添加成功") } } 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 .padding(paddingValues) .padding(horizontal = 16.dp), ) { 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, ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsCategoryVm.kt ================================================ package li.songe.gkd.ui import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet import li.songe.gkd.ui.share.BaseViewModel class SubsCategoryVm(val route: SubsCategoryRoute) : BaseViewModel() { val subsRawFlow = mapSafeSubs(route.subsItemId) val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(route.subsItemId) .stateInit(emptyList()) val categoryConfigMapFlow = categoryConfigsFlow.map { it.associateBy { c -> c.categoryKey } } .stateInit(emptyMap()) val editCategoryFlow = MutableStateFlow(null) val showAddCategoryFlow = MutableStateFlow(false) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludePage.kt ================================================ package li.songe.gkd.ui import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.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.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.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.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.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 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.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.EmptyText import li.songe.gkd.ui.component.InnerDisableSwitch 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.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.itemPadding import li.songe.gkd.ui.style.scaffoldPadding import li.songe.gkd.util.AppGroupOption import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.systemAppsFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @Serializable data class SubsGlobalGroupExcludeRoute( val subsItemId: Long, val groupKey: Int, ) : NavKey @Composable 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 var searchStr by vm.searchStrFlow.asMutableState() var editable by vm.editableFlow.asMutableState() var showSearchBar by rememberSaveable { mutableStateOf(false) } LaunchedEffect(key1 = showSearchBar, block = { if (!showSearchBar) { searchStr = "" } }) 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) } }, 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++ } ) } }, 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 }), ) }, 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, ) } } } } }, ) }) }, 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 -> Row( modifier = Modifier .fillMaxWidth() .itemPadding(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { AppIcon(appId = appInfo.id) Column( modifier = Modifier.weight(1f), ) { AppNameText(appInfo = appInfo) Text( text = appInfo.id, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } val blockMatch = blockMatchAppListFlow.collectAsState().value.contains(appInfo.id) if (blockMatch) { PerfIcon( modifier = Modifier .padding(2.dp) .size(20.dp), imageVector = PerfIcon.Block, tint = MaterialTheme.colorScheme.secondary, ) } val checked = getGlobalGroupChecked( subs, excludeData, group, appInfo.id ) if (checked != null) { PerfSwitch( key = appInfo.id, checked = checked, onCheckedChange = vm.viewModelScope.launchAsFn { newChecked -> val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = subsItemId, groupKey = groupKey, )).copy( exclude = excludeData.copy( appIds = excludeData.appIds.toMutableMap() .apply { set(appInfo.id, !newChecked) }) .stringify() ) DbSet.subsConfigDao.insert(subsConfig) }, thumbContent = if (excludeData.appIds.contains(appInfo.id)) ({ PerfIcon( imageVector = ResetSettings, modifier = Modifier.size(8.dp) ) }) else null, ) } else { InnerDisableSwitch() } } } 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)) } } } } } } // null - 内置禁用 // true - 启用 // false - 禁用 fun getGlobalGroupChecked( subscription: RawSubscription, excludeData: ExcludeData, group: RawSubscription.RawGlobalGroup, appId: String, ): Boolean? { if (subscription.getGlobalGroupInnerDisabled(group, appId)) { return null } excludeData.appIds[appId]?.let { return !it } if (group.appIdEnable[appId] == true) return true if (appId == launcherAppId) { return group.matchLauncher ?: false } if (systemAppsFlow.value.contains(appId)) { return group.matchSystemApp ?: false } return group.matchAnyApp ?: true } private val tipText = """ 以换行或英文逗号分割每条禁用 示例1-禁用单个页面 appId/activityId 示例2-禁用整个应用(移除/) appId 示例3-开启此应用(前置!) !appId """.trimIndent() ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupExcludeVm.kt ================================================ package li.songe.gkd.ui import androidx.compose.runtime.mutableIntStateOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import li.songe.gkd.data.ExcludeData 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.useAppFilter import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.findOption class SubsGlobalGroupExcludeVm(val route: SubsGlobalGroupExcludeRoute) : BaseViewModel() { 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 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 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 resetKey = mutableIntStateOf(0) val excludeTextFlow = MutableStateFlow("") val editableFlow = MutableStateFlow(false).apply { launchOnChange { if (it) { excludeTextFlow.value = excludeDataFlow.value.stringify() } } } val changedValue: ExcludeData? get() { val newExclude = ExcludeData.parse(excludeTextFlow.value) return if (newExclude != excludeDataFlow.value) { newExclude } else { null } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListPage.kt ================================================ 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 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.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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 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 import li.songe.gkd.ui.component.toGroupState import li.songe.gkd.ui.component.useListScrollState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.icon.BackCloseIcon import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.share.noRippleClickable import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.scaffoldPadding 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.toast import li.songe.gkd.util.updateSubscription @Serializable data class SubsGlobalGroupListRoute(val subsItemId: Long, val focusGroupKey: Int? = null) : NavKey @Composable fun SubsGlobalGroupListPage(route: SubsGlobalGroupListRoute) { val subsItemId = route.subsItemId val focusGroupKey = route.focusGroupKey val mainVm = LocalMainViewModel.current val vm = viewModel { SubsGlobalGroupListVm(route) } val subs = vm.subsRawFlow.collectAsState().value val subsConfigs by vm.subsConfigsFlow.collectAsState() val editable = subsItemId < 0 val globalGroups = subs.globalGroups val isSelectedMode = vm.isSelectedModeFlow.collectAsState().value val selectedDataSet = vm.selectedDataSetFlow.collectAsState().value LaunchedEffect(key1 = isSelectedMode) { if (!isSelectedMode) { vm.selectedDataSetFlow.value = emptySet() } } LaunchedEffect(key1 = selectedDataSet.isEmpty()) { if (selectedDataSet.isEmpty()) { vm.isSelectedModeFlow.value = false } } BackHandler(isSelectedMode) { vm.isSelectedModeFlow.value = false } val resetKey = rememberSaveable { mutableIntStateOf(0) } val (scrollBehavior, listState) = useListScrollState(resetKey, globalGroups.isEmpty()) if (focusGroupKey != null) { LaunchedEffect(null) { if (vm.focusGroupFlow?.value != null) { val i = globalGroups.indexOfFirst { it.key == focusGroupKey } if (i >= 0) { listState.scrollToItem(i) } } } } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = throttle { if (isSelectedMode) { vm.isSelectedModeFlow.value = false } else { mainVm.popPage() } }) { BackCloseIcon(backOrClose = !isSelectedMode) } }, title = { val titleModifier = Modifier.noRippleClickable { resetKey.intValue++ } if (isSelectedMode) { Text( modifier = titleModifier, text = selectedDataSet.size.toString(), ) } else { TowLineText( modifier = titleModifier, title = subs.name, subtitle = "全局规则" ) } }, actions = { var expanded by remember { mutableStateOf(false) } AnimatedContent( targetState = isSelectedMode, transitionSpec = { getUpDownTransform() }, contentAlignment = Alignment.TopEnd, ) { if (it) { Row { BatchActionButtonGroup(vm, selectedDataSet) if (editable) { PerfIconButton( imageVector = PerfIcon.Delete, onClick = throttle( vm.viewModelScope.launchAsFn( Dispatchers.Default ) { mainVm.dialogFlow.waitResult( title = "删除规则组", text = "删除当前所选规则组?", error = true, ) val keys = selectedDataSet.mapNotNull { g -> g.groupKey } vm.isSelectedModeFlow.value = false updateSubscription( subs.copy( globalGroups = globalGroups.filterNot { g -> keys.contains(g.key) } ) ) DbSet.subsConfigDao.batchDeleteGlobalGroupConfig( subsItemId, keys ) toast("删除成功") }) ) } PerfIconButton( imageVector = PerfIcon.MoreVert, onClick = { expanded = true }) } } } if (isSelectedMode) { Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) ) { DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { DropdownMenuItem( text = { Text(text = "全选") }, onClick = { expanded = false vm.selectedDataSetFlow.value = globalGroups.map { it.toGroupState( subsId = subsItemId, ) }.toSet() } ) DropdownMenuItem( text = { Text(text = "反选") }, onClick = { expanded = false val newSelectedIds = globalGroups.map { it.toGroupState( subsId = subsItemId, ) }.toSet() - selectedDataSet vm.selectedDataSetFlow.value = newSelectedIds } ) } } } }) }, floatingActionButton = { if (editable) { AnimationFloatingActionButton( visible = !isSelectedMode, onClick = { mainVm.navigatePage( UpsertRuleGroupRoute( subsId = subsItemId, groupKey = null, appId = null, ) ) }, 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(), subs = subs, appId = null, group = group, focusGroupFlow = vm.focusGroupFlow, subsConfig = subsConfig, category = null, categoryConfig = null, isSelectedMode = isSelectedMode, isSelected = selectedDataSet.any { it.groupKey == group.key }, onLongClick = { if (globalGroups.size > 1) { vm.isSelectedModeFlow.value = true vm.selectedDataSetFlow.value = setOf( group.toGroupState(subsId = subsItemId) ) } }, onSelectedChange = { vm.selectedDataSetFlow.value = selectedDataSet.switchItem( group.toGroupState(subsId = subsItemId) ) } ) } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) if (globalGroups.isEmpty()) { EmptyText(text = "暂无规则") } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/SubsGlobalGroupListVm.kt ================================================ package li.songe.gkd.ui import kotlinx.coroutines.flow.MutableStateFlow import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.ShowGroupState import li.songe.gkd.ui.share.BaseViewModel class SubsGlobalGroupListVm(val route: SubsGlobalGroupListRoute) : BaseViewModel() { val subsRawFlow = mapSafeSubs(route.subsItemId) val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(route.subsItemId) .stateInit(emptyList()) val isSelectedModeFlow = MutableStateFlow(false) val selectedDataSetFlow = MutableStateFlow(emptySet()) val focusGroupFlow = route.focusGroupKey?.let { MutableStateFlow?>( Triple( route.subsItemId, null, route.focusGroupKey ) ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupPage.kt ================================================ package li.songe.gkd.ui import androidx.activity.compose.BackHandler 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.fillMaxWidth 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.Scaffold 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel 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.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.throttle @Serializable data class UpsertRuleGroupRoute( val subsId: Long, val groupKey: Int? = null, val appId: String? = null, val forward: Boolean = false, ) : NavKey @Composable 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 vm = viewModel { UpsertRuleGroupVm(route) } val text by vm.textFlow.collectAsState() val checkIfSaveText = throttle(mainVm.viewModelScope.launchAsFn(Dispatchers.Default) { if (vm.hasTextChanged()) { context.justHideSoftInput() mainVm.dialogFlow.waitResult( title = "提示", text = "当前内容未保存,是否放弃编辑?", ) } else { context.hideSoftInput() } mainVm.popPage() }) 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 { mainVm.navigatePage( SubsAppGroupListRoute( subsItemId = subsId, vm.addAppId ?: appId ), replaced = true ) } } else { mainVm.popPage() } }) BackHandler(true, checkIfSaveText) Scaffold(modifier = Modifier, topBar = { PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { PerfIconButton(imageVector = PerfIcon.ArrowBack, onClick = checkIfSaveText) }, title = { Text(text = if (vm.isEdit) "编辑规则组" else "添加规则组") }, actions = { PerfIconButton( imageVector = PerfIcon.Save, onClick = onClickSave, enabled = text.isNotBlank() ) } ) }) { paddingValues -> val textColors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, ) Box( modifier = Modifier .scaffoldPadding(paddingValues) .fillMaxSize(), ) { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { 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, shape = RectangleShape, colors = textColors, visualTransformation = getJson5Transformation(LocalDarkTheme.current), placeholder = { Text(text = if (vm.isApp) "请输入应用规则组\n" else "请输入全局规则组\n") }, ) } if (text.isNotEmpty()) { Text( text = text.length.toString(), modifier = Modifier .padding(8.dp) .align(Alignment.TopEnd) .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(horizontal = 2.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.tertiary, ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/UpsertRuleGroupVm.kt ================================================ package li.songe.gkd.ui import androidx.lifecycle.ViewModel 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.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(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 = subsMapFlow.value[route.subsId] subs ?: return@run null if (groupKey != null) { if (appId != null) { subs.getAppGroups(appId) } else { subs.globalGroups }.find { it.key == route.groupKey } } else { null } } private val initText = initialGroup?.cacheStr ?: "" val textFlow = MutableStateFlow(initText) 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 = subsMapFlow.value[route.subsId] ?: error("订阅不存在") val text = textFlow.value if (text.isBlank()) { error("规则不能为空") } if (text == initText) { toast("规则无变动") return } val jsonObject = runCatching { Json5.parseToJson5Element(text) }.run { if (isFailure) { error("非法格式\n${exceptionOrNull()?.message}") } getOrThrow() } if (jsonObject !is JsonObject) { error("规则应为对象格式") } if (jsonObject == initialGroup?.cacheJsonObject) { toast("规则无变动") return } if (groupKey != null) { val newGroup = try { if (appId != null) { if (jsonObject["groups"] is JsonArray) { val id = jsonObject["id"] ?: error("缺少id") if (!(id is JsonPrimitive && id.isString && id.content == appId)) { error("id与当前应用不一致") } RawSubscription.parseApp(jsonObject).let { newApp -> if (newApp.groups.isEmpty()) { error("至少输入一个规则组") } newApp.groups.first() } } else { null } ?: RawSubscription.parseAppGroup(jsonObject) } else { RawSubscription.parseGlobalGroup(jsonObject) } } catch (e: Exception) { LogUtils.d(e) error("非法规则\n${e.message}") } newGroup.errorDesc?.let(::error) if (newGroup.key != groupKey) { error("不能更改规则组的key") } val newSubs = if (appId != null) { newGroup as RawSubscription.RawAppGroup val app = subs.apps.find { a -> a.id == appId } ?: error("应用不存在") subs.copy(apps = subs.apps.toMutableList().apply { set( indexOfFirst { a -> a.id == appId }, app.copy(groups = app.groups.toMutableList().apply { set( indexOfFirst { g -> g.key == newGroup.key }, newGroup ) }) ) }) } else { newGroup as RawSubscription.RawGlobalGroup subs.copy(globalGroups = subs.globalGroups.toMutableList().apply { set(indexOfFirst { g -> g.key == newGroup.key }, newGroup) }) } updateSubscription(newSubs) } else { if (isAddAnyApp) { val newApp = try { RawSubscription.parseApp(jsonObject).apply { if (groups.isEmpty()) { error("至少输入一个规则组") } } } catch (e: Exception) { LogUtils.d(e) error("非法规则\n${e.message}") } val oldApp = subs.apps.find { it.id == newApp.id } if (oldApp != null) { newApp.groups.forEach { g -> checkGroupKeyName(oldApp.groups, g) } } val newSubs = subs.copy(apps = subs.apps.toMutableList().apply { val i = indexOfFirst { a -> a.id == newApp.id } if (i >= 0) { set( i, get(i).copy(groups = get(i).groups + newApp.groups), ) } else { add(newApp) } }) addAppId = newApp.id updateSubscription(newSubs) } else if (appId != null) { // add specified app group val newGroups = try { if (jsonObject["groups"] is JsonArray) { val id = jsonObject["id"] ?: error("缺少id") if (!(id is JsonPrimitive && id.isString && id.content == appId)) { error("id与当前应用不一致") } RawSubscription.parseApp(jsonObject).apply { if (groups.isEmpty()) { error("至少输入一个规则组") } }.groups } else { null } ?: listOf(RawSubscription.parseAppGroup(jsonObject)) } catch (e: Exception) { LogUtils.d(e) error("非法规则\n${e.message}") } val oldApp = subs.getApp(appId) newGroups.forEach { g -> checkGroupKeyName(oldApp.groups, g) g.errorDesc?.let { error(it) } } 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 } if (i >= 0) { set( i, newApp ) } else { add(newApp) } }) updateSubscription(newSubs) } else { // add global group val newGroup = try { RawSubscription.parseGlobalGroup(jsonObject) } catch (e: Exception) { LogUtils.d(e) error("非法规则\n${e.message}") } checkGroupKeyName(subs.globalGroups, newGroup) updateSubscription( subs.copy( globalGroups = subs.globalGroups + newGroup ) ) } } if (isEdit) { toast("更新成功") } else { toast("添加成功") } } init { addCloseable { clearJson5TransformationCache() } } } private fun checkGroupKeyName( groups: List, newGroup: RawSubscription.RawGroupProps ) { if (groups.any { it.name == newGroup.name }) { error("已存在同名「${newGroup.name}」规则组") } if (groups.any { it.key == newGroup.key }) { error("已存在同 key=${newGroup.key} 规则组") } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/WebViewPage.kt ================================================ package li.songe.gkd.ui import android.annotation.SuppressLint import android.graphics.Bitmap import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse 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.wrapContentSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow 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 io.ktor.client.call.body import io.ktor.client.request.get import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.MainActivity import li.songe.gkd.data.Value import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.component.PerfIconButton import li.songe.gkd.ui.component.PerfTopAppBar import li.songe.gkd.ui.component.updateDialogOptions import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.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 @Composable fun WebViewPage(route: WebViewRoute) { val initUrl = route.initUrl val mainVm = LocalMainViewModel.current val webViewState = rememberWebViewState(url = initUrl) val webViewClient = remember { GkdWebViewClient() } val webView = remember { Value(null) } Scaffold(modifier = Modifier, topBar = { PerfTopAppBar( modifier = Modifier.fillMaxWidth(), navigationIcon = { PerfIconButton( imageVector = PerfIcon.ArrowBack, onClick = { mainVm.popPage() }, ) }, title = { val loadingState = webViewState.loadingState if (loadingState is LoadingState.Loading) { CircularProgressIndicator( modifier = Modifier.iconTextSize(), ) } else { Text( // webViewState.pageTitle 在调用 reload 后会变成 null text = webViewState.pageTitle ?: webView.value?.title ?: "", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) } }, actions = { if (chromeVersion in 1.. WebView( modifier = Modifier .fillMaxSize() .scaffoldPadding(contentPadding), state = webViewState, client = webViewClient, onCreated = { webView.value = it it.addJavascriptInterface(GkdWebViewJsApi, "gkd") it.settings.apply { @SuppressLint("SetJavaScriptEnabled") javaScriptEnabled = true domStorageEnabled = true if (AndroidTarget.TIRAMISU) { setAlgorithmicDarkeningAllowed(false) } } } ) } } @Suppress("unused") private object GkdWebViewJsApi { @JavascriptInterface fun getAppId() = META.appId @JavascriptInterface fun getAppName() = META.appName @JavascriptInterface fun getVersionCode() = META.versionCode @JavascriptInterface fun getVersionName() = META.versionName @JavascriptInterface fun getChannel() = META.channel @JavascriptInterface fun getDebuggable() = META.debuggable } private const val MINI_CHROME_VERSION = 107 private val chromeVersion by lazy { WebView.getCurrentWebViewPackage()?.versionName?.run { splitToSequence('.').first().toIntOrNull() } ?: 0 } private const val DOC_CONFIG_URL = "https://registry.npmmirror.com/@gkd-kit/docs/latest/files/_config.json" private const val DEBUG_JS_TEXT = """ """ @Serializable private data class DocConfig( val mirrorBaseUrl: String, val htmlUrlMap: Map ) private class GkdWebViewClient() : AccompanistWebViewClient() { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val uri = request?.url if (uri != null && uri.host != "gkd.li") { if (uri.scheme == "gkd") { (view?.context as? MainActivity)?.mainVm?.handleGkdUri(uri) } else { openUri(uri) } return true } return super.shouldOverrideUrlLoading(view, request) } override fun shouldInterceptRequest( view: WebView?, request: WebResourceRequest? ): WebResourceResponse? { try { if (request != null && request.run { isForMainFrame && url.host == "gkd.li" && method == "GET" }) { LogUtils.d(request.method, request.url) runBlocking(Dispatchers.IO) { val docConfig = client.get(DOC_CONFIG_URL).body() val path = request.url.path.let { if (it.isNullOrEmpty()) "/" else it } val textUrl = docConfig.htmlUrlMap[path]?.let { docConfig.mirrorBaseUrl + it } if (textUrl != null) { val textContent = client.get(textUrl).body().let { if (META.debuggable) { DEBUG_JS_TEXT + it } else { it } } return@runBlocking WebResourceResponse( "text/html", "UTF-8", textContent.byteInputStream() ) } return@runBlocking null }?.let { return it } } } catch (e: Throwable) { e.printStackTrace() } return super.shouldInterceptRequest(view, request) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedBooleanContent.kt ================================================ 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AnimatedIcon.kt ================================================ package li.songe.gkd.ui.component import androidx.annotation.DrawableRes import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.material3.Icon import androidx.compose.material3.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 private fun AnimatedIcon( modifier: Modifier, @DrawableRes id: Int, atEnd: Boolean, tint: Color, contentDescription: String?, ) { val animation = AnimatedImageVector.animatedVectorResource(id) val painter = rememberAnimatedVectorPainter( animation, atEnd, ) Icon( modifier = modifier, painter = painter, contentDescription = contentDescription, tint = tint, ) } @Composable fun AnimatedIconButton( onClick: () -> Unit, @DrawableRes id: Int, modifier: Modifier = Modifier, atEnd: Boolean = false, tint: Color = LocalContentColor.current, contentDescription: String? = getIconDesc(id, atEnd), ) = TooltipIconButtonBox( contentDescription = contentDescription, ) { IconButton( onClick = onClick, ) { 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 } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/Animation.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset @Composable fun usePercentAnimatable( visible: Boolean, ): Animatable { val percent = remember { Animatable(if (visible) 1f else 0f) } LaunchedEffect(visible) { if (visible && percent.value != 1f) { percent.animateTo(targetValue = 1f, animationSpec = tween()) } else if (!visible && percent.value != 0f) { percent.animateTo(targetValue = 0f, animationSpec = tween()) } } return percent } context(scope: LazyItemScope, ) fun Modifier.animateListItem( enabled: Boolean = true, ): Modifier { if (!enabled) { return this } return scope.run { animateItem( fadeInSpec = spring(stiffness = Spring.StiffnessMediumLow), placementSpec = spring( stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold ), fadeOutSpec = spring(stiffness = Spring.StiffnessMediumLow) ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AnimationFloatingActionButton.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis import androidx.compose.animation.core.tween import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults 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.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( visible: Boolean, onClick: () -> 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() } } var innerVisible by remember { mutableStateOf(visible) } val percent = remember { Animatable(if (visible) 1f else 0f) } // https://stackoverflow.com/questions/75717579 val defaultElevation = remember { Animatable(if (visible) 1f else 0f) } LaunchedEffect(visible) { if (visible != innerVisible) { if (visible) { innerVisible = true percent.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = DefaultDurationMillis - elevationDurationMillis) ) defaultElevation.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = elevationDurationMillis) ) } else { defaultElevation.animateTo( targetValue = 0f, animationSpec = tween(durationMillis = elevationDurationMillis) ) percent.animateTo( targetValue = 0f, animationSpec = tween(durationMillis = DefaultDurationMillis - elevationDurationMillis) ) innerVisible = false } } } if (innerVisible) { 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) }, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AppBarTextField.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults.indicatorLine 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.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp /** * https://stackoverflow.com/questions/73664765 */ @Composable fun AppBarTextField( value: String, onValueChange: (String) -> Unit, hint: String, modifier: Modifier = Modifier, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { val interactionSource = remember { MutableInteractionSource() } // make sure there is no background color in the decoration box val colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ) val mergedTextStyle = LocalTextStyle.current.merge(MaterialTheme.typography.titleMedium) .merge(color = LocalContentColor.current) // set the correct cursor position when this composable is first initialized var textFieldValue by remember { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } textFieldValue = textFieldValue.copy(text = value) // make sure to keep the value updated BasicTextField( value = textFieldValue, onValueChange = { textFieldValue = it // remove newlines to avoid strange layout issues, and also because singleLine=true onValueChange(it.text.replace("\n", "")) }, modifier = modifier .fillMaxWidth() .heightIn(32.dp) .indicatorLine( enabled = true, isError = false, interactionSource = interactionSource, colors = colors ), // .focusRequester(focusRequester), textStyle = mergedTextStyle, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, interactionSource = interactionSource, singleLine = true, decorationBox = { innerTextField -> // places text field with placeholder and appropriate bottom padding TextFieldDefaults.DecorationBox( value = value, innerTextField = innerTextField, enabled = true, singleLine = true, visualTransformation = VisualTransformation.None, interactionSource = interactionSource, isError = false, placeholder = { Text(text = hint) }, colors = colors, contentPadding = PaddingValues(bottom = 4.dp), ) }, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AppCheckBoxCard.kt ================================================ 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, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AppIcon.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size 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.util.appIconMapFlow @Composable fun AppIcon( modifier: Modifier = Modifier, appId: String, ) { val icon = appIconMapFlow.collectAsState().value[appId] val iconModifier = modifier.size(32.dp) if (icon != null) { Image( painter = rememberDrawablePainter(icon), contentDescription = null, modifier = iconModifier ) } else { PerfIcon( imageVector = PerfIcon.Android, modifier = iconModifier ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AppNameText.kt ================================================ package li.songe.gkd.ui.component 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.draw.clip import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.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.text.withStyle import li.songe.gkd.data.AppInfo import li.songe.gkd.data.otherUserMapFlow 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, ) { 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, ) } 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 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 ) } ) } } else { emptyMap() } Text( modifier = modifier, text = annotatedString, inlineContent = inlineContent, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, textDecoration = textDecoration, style = style, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AuthButtonGroup.kt ================================================ package li.songe.gkd.ui.component 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 li.songe.gkd.util.throttle @Composable fun AuthButtonGroup( buttons: List Unit>>, modifier: Modifier = Modifier, ) { FlowRow( modifier = modifier, ) { buttons.forEach { (text, click) -> TextButton(onClick = throttle(click)) { Text( text = text, style = MaterialTheme.typography.bodyLarge, ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/AuthCard.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.util.throttle @Composable fun AuthCard( title: String, subtitle: String? = null, onAuthClick: () -> Unit, ) { Row( modifier = Modifier.itemPadding(), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, ) if (subtitle!=null) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Spacer(modifier = Modifier.width(8.dp)) OutlinedButton(onClick = throttle(fn = onAuthClick)) { Text(text = "授权") } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/CopyTextCard.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box 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.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.draw.clip import androidx.compose.ui.unit.dp import li.songe.gkd.util.copyText import li.songe.gkd.util.throttle @Composable fun CopyTextCard( text: String, modifier: Modifier = Modifier, ) { Box( modifier = modifier.fillMaxWidth() ) { SelectionContainer( modifier = Modifier .align(Alignment.TopStart) .fillMaxWidth() ) { Text( text = text, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) .padding(8.dp), color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodyLarge, ) } PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { copyText(text) }) .padding(4.dp) .size(24.dp), imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/CustomIconButton.kt ================================================ 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.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, ) = TooltipIconButtonBox( contentDescription = contentDescription, ) { CustomIconButton( size = size, onClickLabel = onClickLabel, onClick = onClick, ) { PerfIcon( modifier = Modifier.size(iconSize), id = id, contentDescription = contentDescription, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/CustomOutlinedTextField.kt ================================================ 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, ) } ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/DialogOptions.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults 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.graphics.Color import kotlinx.coroutines.flow.MutableStateFlow 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, val text: @Composable (() -> Unit)? = null, val onDismissRequest: (() -> Unit)? = null, val confirmButton: @Composable () -> Unit, val dismissButton: @Composable (() -> Unit)? = null, ) private fun buildDialogOptions( title: @Composable (() -> Unit), text: @Composable (() -> Unit), confirmText: String, confirmAction: () -> Unit, dismissText: String? = null, dismissAction: (() -> Unit)? = null, onDismissRequest: (() -> Unit)? = null, error: Boolean = false, ): AlertDialogOptions { return AlertDialogOptions( title = title, text = text, onDismissRequest = onDismissRequest, confirmButton = { TextButton( onClick = throttle(fn = confirmAction), colors = ButtonDefaults.textButtonColors( contentColor = if (error) MaterialTheme.colorScheme.error else Color.Unspecified ) ) { Text(text = confirmText) } }, dismissButton = if (dismissText != null && dismissAction != null) { { TextButton( onClick = throttle(fn = dismissAction), ) { Text(text = dismissText) } } } else { null }, ) } @Composable fun BuildDialog(stateFlow: MutableStateFlow) { val options by stateFlow.collectAsState() options?.let { AlertDialog( text = it.text, title = it.title, onDismissRequest = it.onDismissRequest ?: { stateFlow.value = null }, confirmButton = it.confirmButton, dismissButton = it.dismissButton, ) } } fun MutableStateFlow.updateDialogOptions( title: String, text: String? = null, textContent: (@Composable (() -> Unit))? = null, confirmText: String = DEFAULT_IK_TEXT, confirmAction: (() -> Unit)? = null, dismissText: String? = null, dismissAction: (() -> Unit)? = null, onDismissRequest: (() -> Unit)? = null, error: Boolean = false, ) { value = buildDialogOptions( title = { Text(text = title) }, text = textContent ?: { Text(text = text ?: error("miss text")) }, confirmText = confirmText, confirmAction = confirmAction ?: { value = null }, dismissText = dismissText, dismissAction = dismissAction ?: { value = null }, onDismissRequest = onDismissRequest, error = error, ) } private const val DEFAULT_IK_TEXT = "我知道了" private const val DEFAULT_CONFIRM_TEXT = "确定" private const val DEFAULT_DISMISS_TEXT = "取消" suspend fun MutableStateFlow.getResult( title: String, text: String? = null, textContent: (@Composable (() -> Unit))? = null, dismissRequest: Boolean = false, confirmText: String = DEFAULT_CONFIRM_TEXT, dismissText: String = DEFAULT_DISMISS_TEXT, error: Boolean = false, ): Boolean { return suspendCoroutine { s -> val dismiss = { s.resume(false) this.value = null } updateDialogOptions( title = title, text = text, textContent = textContent, onDismissRequest = if (dismissRequest) dismiss else ({}), confirmText = confirmText, confirmAction = { s.resume(true) this.value = null }, dismissText = dismissText, dismissAction = dismiss, error = error, ) } } suspend fun MutableStateFlow.waitResult( title: String, text: String? = null, textContent: (@Composable (() -> Unit))? = null, dismissRequest: Boolean = false, confirmText: String = DEFAULT_CONFIRM_TEXT, dismissText: String = DEFAULT_DISMISS_TEXT, error: Boolean = false, ) { val r = getResult( title = title, text = text, textContent = textContent, dismissRequest = dismissRequest, confirmText = confirmText, dismissText = dismissText, error = error, ) if (!r) { stopCoroutine() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/EmptyText.kt ================================================ package li.songe.gkd.ui.component 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.text.style.TextAlign @Composable fun EmptyText(text: String = "暂无数据") { Text( text = text, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/FixedTimeText.kt ================================================ 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 import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp val LocalNumberCharWidth = compositionLocalOf { error("not found DestinationsNavigator") } @Composable fun measureNumberTextWidth(style: TextStyle = LocalTextStyle.current): Dp { val textMeasurer = rememberTextMeasurer() val widthInPixels = "1234567890".map { c -> textMeasurer.measure(c.toString(), style).size.width }.average().toFloat() return with(LocalDensity.current) { widthInPixels.toDp() } } @Composable fun FixedTimeText( text: String, modifier: Modifier = Modifier, color: Color = Color.Unspecified, style: TextStyle = LocalTextStyle.current, charWidth: Dp = LocalNumberCharWidth.current, ) { Row(modifier = modifier) { text.forEach { c -> Text( text = c.toString(), style = style, modifier = if (c.isDigit()) { Modifier.width(charWidth) } else { Modifier }, color = color, softWrap = false, maxLines = 1, textAlign = TextAlign.Center, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/FullscreenDialog.kt ================================================ 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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/GroupNameText.kt ================================================ package li.songe.gkd.ui.component 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.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import li.songe.gkd.ui.icon.SportsBasketball @Composable fun GroupNameText( modifier: Modifier = Modifier, preText: String? = null, isGlobal: Boolean, text: String, color: Color = Color.Unspecified, style: TextStyle = LocalTextStyle.current, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, ) { if (isGlobal) { val text = remember(preText, text) { buildAnnotatedString { if (preText != null) { append(preText) } appendInlineContent("icon") append(text) } } val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } } val inlineContent = remember(style, textColor) { mapOf( "icon" to InlineTextContent( placeholder = Placeholder( width = style.fontSize, height = style.lineHeight, placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { PerfIcon( imageVector = SportsBasketball, modifier = Modifier.fillMaxSize(), tint = textColor ) } ) } Text( modifier = modifier, text = text, inlineContent = inlineContent, style = style, color = color, overflow = overflow, softWrap = softWrap, maxLines = maxLines, ) } else { Text( text = if (preText.isNullOrEmpty()) { text } else { preText + text }, style = style, color = color, overflow = overflow, softWrap = softWrap, maxLines = maxLines, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/Hooks.kt ================================================ 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.foundation.rememberScrollState import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Density import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.data.RawSubscription import li.songe.gkd.util.mapState import li.songe.gkd.util.subsMapFlow @Composable fun useSubs(subsId: Long?): RawSubscription? { val scope = rememberCoroutineScope() return remember(subsId) { subsMapFlow.mapState(scope) { it[subsId] } }.collectAsState().value } @Composable fun useSubsGroup( subs: RawSubscription?, groupKey: Int?, appId: String?, ): RawSubscription.RawGroupProps? { return remember(subs, groupKey, appId) { if (subs != null && groupKey != null) { if (appId != null) { subs.apps.find { it.id == appId }?.groups?.find { it.key == groupKey } } else { subs.globalGroups.find { it.key == groupKey } } } else { null } } } @Composable fun Modifier.autoFocus(immediateFocus: Boolean = false): Modifier { val focusRequester = remember { FocusRequester() } LaunchedEffect(null) { if (!immediateFocus) { delay(DefaultDurationMillis.toLong()) } focusRequester.requestFocus() } 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 } // key 函数的依赖变化时, compose 将重置 key 函数那行代码之后所有代码的状态, 因此需要需要将 key 作用域限定在 Composable fun 内 // 所有的 key 参数必须使用 rememberSaveable 或者 viewModel 来保存状态, 以保证正确的 restore 顺序,否则触发 ClassCastException @Composable fun useListScrollState( v1: Any?, v2: Any? = null, v3: Any? = null, canScroll: () -> Boolean = { true }, ): Pair { return key( getCompatStateValue(v1), getCompatStateValue(v2), getCompatStateValue(v3), ) { TopAppBarDefaults.enterAlwaysScrollBehavior(canScroll = canScroll) to rememberLazyListState() } } @Composable fun usePinnedScrollBehaviorState(v1: Any?): Pair { return key(getCompatStateValue(v1)) { TopAppBarDefaults.pinnedScrollBehavior() to rememberLazyListState() } } @Composable fun useScrollBehaviorState(v1: Any?): Pair { return key(getCompatStateValue(v1)) { TopAppBarDefaults.enterAlwaysScrollBehavior() to rememberScrollState() } } @Composable fun LazyListState.isAtBottom(): androidx.compose.runtime.State = remember(this) { derivedStateOf { val visibleItemsInfo = layoutInfo.visibleItemsInfo if (layoutInfo.totalItemsCount == 0) { false } else { val lastVisibleItem = visibleItemsInfo.last() val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset (lastVisibleItem.index + 1 == layoutInfo.totalItemsCount && lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight) } } } val TopAppBarScrollBehavior.isFullVisible: Boolean @Composable @ReadOnlyComposable get() = state.collapsedFraction == 0f @Composable @ReadOnlyComposable fun Modifier.textSize( style: TextStyle = LocalTextStyle.current, density: Density = LocalDensity.current, ): Modifier { val fontSizeDp = density.run { style.fontSize.toDp() } val lineHeightDp = density.run { style.lineHeight.toDp() } return height(lineHeightDp).width(fontSizeDp) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/InnerDisableSwitch.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.throttle @Composable fun InnerDisableSwitch( modifier: Modifier = Modifier, valid: Boolean = true, isSelectedMode: Boolean = false, ) { val mainVm = LocalMainViewModel.current val onClick = { if (valid) { mainVm.dialogFlow.updateDialogOptions( title = "内置禁用", text = "此规则组已经在内部配置对当前应用的禁用,就算强制开启规则组也是无意义或不生效的\n\n提示: 这种情况一般在此全局规则无法适配/跳过适配/单独适配当前应用时出现", ) } else { mainVm.dialogFlow.updateDialogOptions( title = "非法规则", text = "规则存在错误, 无法启用", ) } } PerfSwitch( checked = false, enabled = false, onCheckedChange = null, modifier = modifier.semantics { stateDescription = "已禁用" } .minimumInteractiveComponentSize().run { if (isSelectedMode) { this } else { clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Switch, onClick = throttle(onClick), onClickLabel = "打开规则禁用说明", ) } } ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/InputSubsLinkOption.kt ================================================ package li.songe.gkd.ui.component 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.material3.AlertDialog import androidx.compose.material3.OutlinedTextField 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.window.DialogProperties import kotlinx.coroutines.flow.MutableStateFlow 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 fun resume(value: String?) { showFlow.value = false valueFlow.value = "" initValueFlow.value = "" continuation?.resume(value) continuation = null } private fun submit() { val value = valueFlow.value if (!URLUtil.isNetworkUrl(value)) { toast("非法链接") return } val initValue = initValueFlow.value if (initValue.isNotEmpty() && initValue == value) { toast("未修改") resume(null) return } if (subsItemsFlow.value.any { it.updateUrl == value }) { toast("已有相同链接订阅") return } resume(value) } private fun cancel() = resume(null) suspend fun getResult(initValue: String = ""): String? { initValueFlow.value = initValue valueFlow.value = initValue showFlow.value = true return suspendCoroutine { continuation = it } } @Composable fun ContentDialog() { val show by showFlow.collectAsState() if (show) { val mainVm = LocalMainViewModel.current val value by valueFlow.collectAsState() val initValue by initValueFlow.collectAsState() AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), title = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { Text(text = if (initValue.isNotEmpty()) "修改订阅" else "添加订阅") PerfIconButton( imageVector = PerfIcon.HelpOutline, contentDescription = "订阅帮助", onClick = throttle { cancel() mainVm.navigatePage(WebViewRoute(initUrl = ShortUrlSet.URL5)) }) } }, text = { OutlinedTextField( value = value, onValueChange = { valueFlow.value = it.trim() }, maxLines = 8, modifier = Modifier .fillMaxWidth() .autoFocus(), placeholder = { Text(text = "请输入订阅链接") }, isError = value.isNotEmpty() && !URLUtil.isNetworkUrl(value), ) }, onDismissRequest = { cancel() }, confirmButton = { TextButton( enabled = value.isNotEmpty(), onClick = throttle(fn = { submit() }), ) { Text(text = "确定") } }, dismissButton = { TextButton(onClick = ::cancel) { Text(text = "取消") } }, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/ManualAuthDialog.kt ================================================ package li.songe.gkd.ui.component 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.fillMaxWidth 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.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp 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 @Composable fun ManualAuthDialog( commandText: String, show: Boolean, onUpdateShow: (Boolean) -> Unit, ) { if (show) { val mainVm = LocalMainViewModel.current AlertDialog( onDismissRequest = { onUpdateShow(false) }, title = { Text(text = "命令授权") }, text = { Column(modifier = Modifier.fillMaxWidth()) { Text(text = "1. 有一台安装了 adb 的电脑\n\n2.手机开启调试模式后连接电脑授权调试\n\n3. 在电脑 cmd/pwsh 中运行如下命令") Spacer(modifier = Modifier.height(4.dp)) Box( modifier = Modifier.fillMaxWidth() ) { SelectionContainer( modifier = Modifier .align(Alignment.TopStart) .fillMaxWidth() ) { Text( text = commandText, modifier = Modifier .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) .padding(8.dp), style = MaterialTheme.typography.bodySmall, ) } PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { copyText(commandText) }) .padding(4.dp) .size(20.dp), imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) } Spacer(modifier = Modifier.height(8.dp)) Text( modifier = Modifier .clickable(onClick = throttle { onUpdateShow(false) mainVm.navigatePage(WebViewRoute(initUrl = ShortUrlSet.URL3)) }), text = "运行后授权失败?", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) } }, confirmButton = { TextButton(onClick = { onUpdateShow(false) }) { Text(text = "关闭") } }, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/MenuExt.kt ================================================ 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, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/ModifierExt.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.ui.Modifier inline fun Modifier.runIf( enabled: Boolean, block: Modifier.() -> Modifier ) = run { if (enabled) { block() } else { this } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/MultiTextField.kt ================================================ 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/PerfCheckbox.kt ================================================ 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 ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/PerfIcon.kt ================================================ 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 } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/PerfSwitch.kt ================================================ 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, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/PerfTopAppBar.kt ================================================ 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, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/QueryPkgTipCard.kt ================================================ package li.songe.gkd.ui.component import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp 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.share.LocalMainViewModel import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.updateAppMutex @Composable 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) }) ) { Text(text = "申请权限") } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/RotatingLoadingIcon.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.animation.core.Animatable 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import kotlin.math.sin @Composable fun RotatingLoadingIcon( modifier: Modifier = Modifier, loading: Boolean, imageVector: ImageVector = PerfIcon.Autorenew, ) { val rotation = remember { Animatable(0f) } LaunchedEffect(loading) { if (loading) { rotation.animateTo( targetValue = rotation.value + 180f, animationSpec = tween( durationMillis = 250, easing = { x -> sin(Math.PI / 2 * (x - 1f)).toFloat() + 1f } ) ) rotation.animateTo( targetValue = rotation.value + 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 500, easing = LinearEasing), repeatMode = RepeatMode.Restart ) ) } else if (rotation.value != 0f) { rotation.animateTo( targetValue = rotation.value + 180f, animationSpec = tween( durationMillis = 250, easing = { x -> sin(Math.PI / 2 * x).toFloat() } ) ) } } PerfIcon( imageVector = imageVector, modifier = modifier.graphicsLayer(rotationZ = rotation.value) ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupCard.kt ================================================ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable 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.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.isActive import li.songe.gkd.appScope import li.songe.gkd.data.CategoryConfig 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.getGlobalGroupChecked import li.songe.gkd.ui.icon.ResetSettings import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.getGroupEnable import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.throttle import li.songe.gkd.util.toast import java.util.Objects @Composable fun RuleGroupCard( modifier: Modifier = Modifier, subs: RawSubscription, appId: String?, group: RawSubscription.RawGroupProps, subsConfig: SubsConfig?, category: RawSubscription.RawCategory?, categoryConfig: CategoryConfig?, focusGroupFlow: MutableStateFlow?>? = null, isSelectedMode: Boolean = false, isSelected: Boolean = false, onLongClick: () -> Unit = {}, onSelectedChange: () -> Unit = {}, ) { val mainVm = LocalMainViewModel.current val inGlobalAppPage = appId != null && group is RawSubscription.RawGlobalGroup var highlighted by remember { mutableStateOf(false) } if (focusGroupFlow != null) { val focusGroup by focusGroupFlow.collectAsState() if (subs.id == focusGroup?.first && group.key == focusGroup?.third && if (group is RawSubscription.RawAppGroup) appId == focusGroup?.second else focusGroup?.second == null) { LaunchedEffect(isSelectedMode) { if (isSelectedMode) { highlighted = false focusGroupFlow.value = null return@LaunchedEffect } delay(300) var i = 0 highlighted = true while (isActive && i < 4) { delay(400) highlighted = !highlighted i++ } highlighted = false focusGroupFlow.value = null } } } val excludeData = remember(subsConfig?.exclude) { ExcludeData.parse(subsConfig?.exclude) } val checked = if (inGlobalAppPage) { getGlobalGroupChecked( subs, excludeData, group, appId, ) } else { getGroupEnable( group, subsConfig, category, categoryConfig, ) } val onCheckedChange = appScope.launchAsFn { newChecked -> val newConfig = if (appId != null) { if (group is RawSubscription.RawGlobalGroup) { // APP 汇总页面 - 全局规则 val excludeData = ExcludeData.parse(subsConfig?.exclude) (subsConfig ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = subs.id, groupKey = group.key, )).copy( exclude = excludeData.copy( appIds = excludeData.appIds.toMutableMap().apply { set(appId, !newChecked) } ).stringify() ) } else { // 订阅详情页面 - APP 规则 (subsConfig?.copy(enable = newChecked) ?: SubsConfig( type = SubsConfig.AppGroupType, subsId = subs.id, appId = appId, groupKey = group.key, enable = newChecked )) } } else { // 订阅详情页面 - 全局规则 group as RawSubscription.RawGlobalGroup (subsConfig?.copy(enable = newChecked) ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = subs.id, groupKey = group.key, enable = newChecked )) } DbSet.subsConfigDao.insert(newConfig) } val onClick = if (isSelectedMode) (onSelectedChange) else throttle(mainVm.viewModelScope.launchAsFn(Dispatchers.Default) { group.cacheStr // load cache mainVm.ruleGroupState.showGroupFlow.value = ShowGroupState( subsId = subs.id, appId = if (group is RawSubscription.RawAppGroup) appId else null, groupKey = group.key, pageAppId = appId, ) }) val containerColor = animateColorAsState( if (isSelected || highlighted) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceContainer }, tween() ) Card( modifier = modifier .padding(horizontal = 8.dp) .combinedClickable( onClick = onClick, onLongClick = onLongClick, onClickLabel = "打开规则详情弹窗", onLongClickLabel = "进入多选模式" ), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( containerColor = containerColor.value ), ) { 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() .padding(8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column( modifier = Modifier .weight(1f) .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { GroupNameText( modifier = Modifier.fillMaxWidth(), text = group.name, style = MaterialTheme.typography.bodyLarge, isGlobal = group is RawSubscription.RawGlobalGroup, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) 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.errorDesc ?: "未知错误", modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error ) } } val percent = usePercentAnimatable(!isSelectedMode) val switchModifier = Modifier.graphicsLayer( alpha = 0.5f + (1 - 0.5f) * percent.value, ) if (!group.valid) { InnerDisableSwitch( modifier = switchModifier, valid = false, isSelectedMode = isSelectedMode, ) } else if (checked != null) { PerfSwitch( key = Objects.hash(subs.id, appId, group.key), modifier = switchModifier.minimumInteractiveComponentSize(), checked = checked, onCheckedChange = if (isSelectedMode) null else onCheckedChange, thumbContent = if (canRest) ({ PerfIcon( imageVector = ResetSettings, modifier = Modifier.size(8.dp) ) }) else null, ) } else { InnerDisableSwitch( modifier = switchModifier, isSelectedMode = isSelectedMode, ) } } 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) ) } } } } @Composable fun BatchActionButtonGroup(vm: ViewModel, selectedDataSet: Set) { val mainVm = LocalMainViewModel.current PerfIconButton( imageVector = PerfIcon.ToggleOff, contentDescription = "批量关闭规则", onClick = throttle(vm.viewModelScope.launchAsFn(Dispatchers.Default) { mainVm.dialogFlow.waitResult( title = "操作提示", 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("无规则被改变") } }) ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupDialog.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding 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.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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 kotlinx.coroutines.delay import li.songe.gkd.data.RawSubscription 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.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 @Composable fun RuleGroupDialog( subs: RawSubscription, group: RawSubscription.RawGroupProps, appId: String?, onDismissRequest: () -> Unit, onClickEdit: (() -> Unit) = {}, onClickEditExclude: () -> Unit, onClickResetSwitch: (() -> Unit)?, onClickDelete: () -> Unit = {} ) { val mainVm = LocalMainViewModel.current AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = "规则组详情") }, text = { Box( modifier = Modifier.fillMaxWidth() ) { val maxHeight = 300.dp Column( modifier = Modifier .align(Alignment.TopStart) .fillMaxWidth() .heightIn(min = 100.dp, max = maxHeight) .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.secondaryContainer) .verticalScroll(rememberScrollState()) .clearAndSetSemantics { contentDescription = "规则组内容" } ) { SelectionContainer { val textState = remember { mutableStateOf( group.cacheStr.run { // 优化: 大字符串第一次显示卡顿 if (length > 1000) substring(0, 1000) else this } ) } LaunchedEffect(group.cacheStr) { delay(50) if (group.cacheStr.length != textState.value.length) { textState.value = group.cacheStr } } val darkTheme = LocalDarkTheme.current Text( text = remember(textState.value, darkTheme) { getJson5AnnotatedString( textState.value, darkTheme ) }, modifier = Modifier.padding(4.dp), color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodySmall, ) } } PerfIcon( modifier = Modifier .align(Alignment.TopEnd) .clickable(onClick = throttle { copyText(group.cacheStr) }) .padding(4.dp) .size(24.dp), imageVector = PerfIcon.ContentCopy, tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f), ) Text( text = group.cacheStr.length.toString(), modifier = Modifier .padding(end = 4.dp, bottom = 4.dp) .align(Alignment.BottomEnd) .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(horizontal = 2.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary, ) } }, confirmButton = { Row { val currentRoute = mainVm.topRoute val targetRoute = remember(subs.id, appId, group.key) { if (group is RawSubscription.RawGlobalGroup) { SubsGlobalGroupListRoute( subsItemId = subs.id, focusGroupKey = group.key ) } else { SubsAppGroupListRoute( subsItemId = subs.id, appId = appId.toString(), focusGroupKey = group.key ) } } if (targetRoute::class != currentRoute::class) { PerfIconButton(imageVector = PerfIcon.ArrowForward, onClick = throttle { onDismissRequest() mainVm.navigatePage(targetRoute) }) } if (group.allExampleUrls.isNotEmpty()) { PerfIconButton(imageVector = PerfIcon.Image, onClick = throttle { onDismissRequest() mainVm.navigatePage( ImagePreviewRoute( title = group.name, uris = group.allExampleUrls, ) ) }) } if (subs.isLocal) { PerfIconButton(imageVector = PerfIcon.Edit, onClick = throttle(onClickEdit)) } PerfIconButton( imageVector = PerfIcon.Block, onClickLabel = "编辑规则排除名单", onClick = throttle(onClickEditExclude), ) AnimatedVisibility( visible = onClickResetSwitch != null, ) { PerfIconButton( imageVector = ResetSettings, onClickLabel = "重置开关状态至默认值", onClick = throttle(onClickResetSwitch ?: {}), ) } if (subs.isLocal) { PerfIconButton( imageVector = PerfIcon.Delete, onClick = throttle(onClickDelete), ) } } }, ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/RuleGroupState.kt ================================================ 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 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 import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import li.songe.gkd.MainViewModel import li.songe.gkd.data.CategoryConfig 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.style.scaffoldPadding import li.songe.gkd.util.getGroupEnable import li.songe.gkd.util.launchAsFn 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 data class ShowGroupState( val subsId: Long, val appId: String? = null, val groupKey: Int? = null, val pageAppId: String? = null, val addAppRule: Boolean = false, ) { val groupType: Int get() = if (appId != null) { SubsConfig.AppGroupType } else { SubsConfig.GlobalGroupType } suspend fun querySubsConfig(): SubsConfig? { groupKey ?: error("require groupKey") return if (groupType == SubsConfig.AppGroupType) { appId ?: error("require appId") DbSet.subsConfigDao.queryAppGroupTypeConfig(subsId, appId, groupKey).first() } else { DbSet.subsConfigDao.queryGlobalGroupTypeConfig(subsId, groupKey).first() } } suspend fun queryCategoryConfig(): CategoryConfig? { groupKey ?: error("require groupKey") 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) } } fun RawSubscription.RawGroupProps.toGroupState( subsId: Long, appId: String? = null, ) = when (this) { is RawSubscription.RawAppGroup -> ShowGroupState( subsId = subsId, appId = appId ?: error("require appId"), groupKey = key, pageAppId = appId, ) is RawSubscription.RawGlobalGroup -> ShowGroupState( subsId = subsId, groupKey = key, pageAppId = appId, ) } suspend fun batchUpdateGroupEnable( groups: Collection, enable: Boolean? ): List> { val diffDataList = groups.map { g -> if (g.groupKey == null) 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 } } else { globalGroups.find { it.key == g.groupKey } } } if (targetGroup?.valid != true) { return@map null } val subsConfig = g.querySubsConfig() val categoryConfig = g.queryCategoryConfig() if (enable == null && subsConfig?.enable == null && subsConfig?.exclude.isNullOrEmpty()) { return@map null } val newSubsConfig = if (g.appId != null) { targetGroup as RawSubscription.RawAppGroup val oldEnable = getGroupEnable( targetGroup, subsConfig, subscription.groupToCategoryMap[targetGroup], categoryConfig ) // app rule val newSubsConfig = (subsConfig?.copy(enable = enable) ?: SubsConfig( type = SubsConfig.AppGroupType, subsId = g.subsId, appId = g.appId, groupKey = g.groupKey, enable = enable )) val newEnable = getGroupEnable( targetGroup, newSubsConfig, subscription.groupToCategoryMap[targetGroup], categoryConfig ) if (enable == newEnable && oldEnable == newEnable) { return@map null } newSubsConfig } else { // global rule if (g.pageAppId != null) { // global rule for some app targetGroup as RawSubscription.RawGlobalGroup val excludeData = ExcludeData.parse(subsConfig?.exclude) getGlobalGroupChecked(subscription, excludeData, targetGroup, g.pageAppId).let { if (it == null) return@map null } (subsConfig ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = g.subsId, groupKey = g.groupKey, )).copy( exclude = excludeData.copy( appIds = excludeData.appIds.toMutableMap().apply { if (enable != null) { if (!contains(g.pageAppId) && enable) { return@map null } set(g.pageAppId, !enable) } else { if (!contains(g.pageAppId)) { return@map null } remove(g.pageAppId) } } ).stringify() ) } else { // full global rule val newSubsConfig = (subsConfig?.copy(enable = enable) ?: SubsConfig( type = SubsConfig.GlobalGroupType, subsId = g.subsId, groupKey = g.groupKey, enable = enable )) val oldEnable = getGroupEnable( targetGroup, subsConfig, ) val newEnable = getGroupEnable(targetGroup, newSubsConfig) if (enable == newEnable && oldEnable == newEnable) { return@map null } newSubsConfig } } if (subsConfig != newSubsConfig) { g to newSubsConfig } else { null } }.filterNotNull() val newSubsConfigs = diffDataList.map { it.second } val canDeleteList = newSubsConfigs.filter { it.type == SubsConfig.AppGroupType && it.enable == null && it.exclude.isEmpty() } DbSet.subsConfigDao.insertAndDelete( newSubsConfigs.filterNot { canDeleteList.contains(it) }, canDeleteList ) return diffDataList } class RuleGroupState( private val mainVm: MainViewModel, ) { 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 { flow { emit(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) 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 showGroupState = showGroupFlow.collectAsState().value val showSubs = useSubs(showGroupState?.subsId) val showGroup = useSubsGroup(showSubs, showGroupState?.groupKey, showGroupState?.appId) if (showGroupState?.groupKey != null && showSubs != null && showGroup != null) { val subsConfig = showSubsConfigFlow.collectAsState().value val excludeData = remember(subsConfig?.exclude) { ExcludeData.parse(subsConfig?.exclude) } RuleGroupDialog( subs = showSubs, group = showGroup, appId = showGroupState.appId, onDismissRequest = dismissGroupShow, onClickEdit = { dismissGroupShow() mainVm.navigatePage( UpsertRuleGroupRoute( subsId = showGroupState.subsId, groupKey = showGroupState.groupKey, appId = showGroupState.appId, ) ) }, onClickEditExclude = { dismissGroupShow() if (showGroupState.appId == null) { mainVm.navigatePage( SubsGlobalGroupExcludeRoute( showGroupState.subsId, showGroupState.groupKey ) ) } else { editExcludeGroupFlow.value = showGroupState } }, onClickResetSwitch = subsConfig?.let { if (showGroup is RawSubscription.RawGlobalGroup) { if (showGroupState.pageAppId != null) { if (excludeData.appIds.contains(showGroupState.pageAppId)) { mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update( subsConfig.copy( exclude = excludeData.clear( appId = showGroupState.pageAppId ).stringify() ) ) toast("已重置局部开关至初始状态") } } else { null } } else { subsConfig.enable?.let { mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update(subsConfig.copy(enable = null)) toast("已重置开关至初始状态") } } } } else { subsConfig.enable?.let { mainVm.viewModelScope.launchAsFn { DbSet.subsConfigDao.update(subsConfig.copy(enable = null)) toast("已重置开关至初始状态") } } } }, onClickDelete = mainVm.viewModelScope.launchAsFn { dismissGroupShow() val r = mainVm.dialogFlow.getResult( title = "删除规则组", text = "确定删除 ${showGroup.name} ?", error = true, ) if (!r) { showGroupFlow.value = showGroupState return@launchAsFn } if (showGroup is RawSubscription.RawGlobalGroup) { updateSubscription( showSubs.copy( globalGroups = showSubs.globalGroups.filter { g -> g.key != showGroup.key } ) ) DbSet.subsConfigDao.deleteGlobalGroupConfig( showGroupState.subsId, showGroupState.groupKey ) } else if (showGroupState.appId != null) { updateSubscription( showSubs.copy( apps = showSubs.apps.map { a -> if (a.id == showGroupState.appId) { a.copy(groups = a.groups.filter { g -> g.key != showGroup.key }) } else { a } } ) ) DbSet.subsConfigDao.deleteAppGroupConfig( showGroupState.subsId, showGroupState.appId, showGroupState.groupKey ) } toast("删除成功") } ) } val excludeGroupState = editExcludeGroupFlow.collectAsState().value val excludeSubs = useSubs(excludeGroupState?.subsId) 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每行一个", ) } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.clickable 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.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.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.util.throttle @Composable fun SettingItem( title: String, subtitle: String? = null, suffix: String? = null, suffixUnderline: Boolean = false, onSuffixClick: (() -> Unit)? = null, imageVector: ImageVector? = PerfIcon.KeyboardArrowRight, onClick: (() -> Unit)? = null, onClickLabel: String? = null, ) { Row( modifier = Modifier .let { if (onClick != null) { it.clickable( onClick = throttle(fn = onClick), onClickLabel = onClickLabel ?: "进入${title}页面" ) } else { it } } .fillMaxWidth() .itemPadding(), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = if (imageVector != null) Modifier.weight(1f) else Modifier.fillMaxWidth()) { Text( text = title, style = MaterialTheme.typography.bodyLarge, ) if (subtitle != null) { if (suffix != null) { Row { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(4.dp)) Text( 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), ) else Modifier ) } } else { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } if (imageVector != null) { PerfIcon( imageVector = imageVector, contentDescription = null, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/ShareDataDialog.kt ================================================ package li.songe.gkd.ui.component import androidx.activity.compose.LocalActivity 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.runtime.collectAsState 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.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) { val context = LocalActivity.current as MainActivity Dialog(onDismissRequest = { showShareDataIdsFlow.value = null }) { 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 { showShareDataIdsFlow.value = null vm.viewModelScope.launchTry(Dispatchers.IO) { val file = exportData(showShareDataIds) context.shareFile(file, "分享数据文件") } }) .then(modifier) ) Text( text = "保存到下载", modifier = Modifier .clickable(onClick = throttle { showShareDataIdsFlow.value = null vm.viewModelScope.launchTry(Dispatchers.IO) { val file = exportData(showShareDataIds) context.saveFileToDownloads(file) } }) .then(modifier) ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/SubsAppCard.kt ================================================ 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.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.store.blockMatchAppListFlow import li.songe.gkd.ui.SubsAppInfoItem import li.songe.gkd.ui.style.appItemPadding @Composable fun SubsAppCard( data: SubsAppInfoItem, onClick: (() -> Unit), onValueChange: ((Boolean) -> Unit), ) { val rawApp = data.rawApp Row( modifier = Modifier .clickable(onClick = onClick) .appItemPadding(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { AppIcon(appId = data.id) Column( modifier = Modifier .weight(1f), verticalArrangement = Arrangement.Center ) { AppNameText(appInfo = data.appInfo, fallbackName = data.rawApp.name) if (rawApp.groups.isNotEmpty()) { val enableDesc = when (data.enableSize) { 0 -> "${rawApp.groups.size}组规则/${rawApp.groups.size}关闭" rawApp.groups.size -> "${rawApp.groups.size}组规则" else -> "${rawApp.groups.size}组规则/${data.enableSize}启用/${rawApp.groups.size - data.enableSize}关闭" } Text( text = enableDesc, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } 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, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/SubsItemCard.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState 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.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.share.LocalMainViewModel import li.songe.gkd.util.formatTimeAgo import li.songe.gkd.util.mapState import li.songe.gkd.util.subsLoadErrorsFlow import li.songe.gkd.util.subsRefreshErrorsFlow import li.songe.gkd.util.throttle import li.songe.gkd.util.updateSubsMutex @Composable fun SubsItemCard( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource, subsItem: SubsItem, subscription: RawSubscription?, index: Int, isSelectedMode: Boolean, isSelected: Boolean, onCheckedChange: ((Boolean) -> Unit), onSelectedChange: (() -> Unit)? = null, ) { val mainVm = LocalMainViewModel.current val vm = viewModel() val subsLoadError by remember(subsItem.id) { subsLoadErrorsFlow.mapState(vm.viewModelScope) { it[subsItem.id] } }.collectAsState() val subsRefreshError by remember(subsItem.id) { subsRefreshErrorsFlow.mapState(vm.viewModelScope) { it[subsItem.id] } }.collectAsState() val subsRefreshing by updateSubsMutex.state.collectAsState() val dragged by interactionSource.collectIsDraggedAsState() val onClick = { if (!dragged) { if (isSelectedMode) { onSelectedChange?.invoke() } else if (!updateSubsMutex.mutex.isLocked) { mainVm.sheetSubsIdFlow.value = subsItem.id } } } val containerColor = animateColorAsState( if (isSelected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceContainer }, tween() ) Card( onClick = onClick, 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( containerColor = containerColor.value ), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp), ) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (subscription != null) { Text( modifier = Modifier.semantics { contentDescription = "订阅顺序:$index, 订阅名称 ${subscription.name}" }, text = "$index. ${subscription.name}", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge, ) Text( text = subscription.numText, style = MaterialTheme.typography.bodyMedium, color = if (subscription.groupsSize == 0) { LocalContentColor.current.copy(alpha = 0.5f) } else { LocalContentColor.current } ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { 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( modifier = Modifier.semantics { contentDescription = "更新时间 $timeStr" }, text = timeStr, style = MaterialTheme.typography.labelSmall, ) } } else { Text( text = "id=${subsItem.id}", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) val color = if (subsLoadError != null) { MaterialTheme.colorScheme.error } else { Color.Unspecified } Text( text = subsLoadError?.message ?: if (subsRefreshing) "加载中..." else "文件不存在", style = MaterialTheme.typography.bodyMedium, color = color ) } if (subsRefreshError != null) { Text( text = "更新错误: ${subsRefreshError?.message}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error ) } } Spacer(modifier = Modifier.width(4.dp)) val percent = usePercentAnimatable(!isSelectedMode) val switchModifier = Modifier.graphicsLayer( alpha = 0.5f + (1 - 0.5f) * percent.value, ).run { if (isSelectedMode) { minimumInteractiveComponentSize() } else { this } } PerfSwitch( key = subsItem.id, modifier = switchModifier, checked = subsItem.enable, onCheckedChange = if (isSelectedMode) null else throttle(fn = onCheckedChange), ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/SubsSheet.kt ================================================ 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.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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import li.songe.gkd.META 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 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.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 @Composable fun SubsSheet( vm: ViewModel, sheetSubsIdFlow: MutableStateFlow ) { val subsItems by subsItemsFlow.collectAsState() val (subsId, setSubsId) = remember { mutableStateOf(sheetSubsIdFlow.value) } val subsItem = subsItems.find { it.id == subsId } if (subsItem == null) { LaunchedEffect(null) { sheetSubsIdFlow.collect { setSubsId(it) } } } else { val mainVm = LocalMainViewModel.current val subsIdToRaw by subsMapFlow.collectAsState() var swipeEnabled by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, confirmValueChange = { swipeEnabled } ) LaunchedEffect(null) { sheetSubsIdFlow.collect { if (it == null && sheetState.isVisible) { launch { sheetState.hide() }.invokeOnCompletion { if (!sheetState.isVisible) { setSubsId(null) } } } else { setSubsId(it) } } } val scrollState = rememberScrollState() remember { derivedStateOf { scrollState.value == 0 } }.let { a -> LaunchedEffect(a.value) { swipeEnabled = a.value } } ModalBottomSheet( onDismissRequest = { sheetSubsIdFlow.value = null }, sheetState = sheetState ) { val subscription = subsIdToRaw[subsItem.id] val showName = subscription?.name ?: "id=${subsItem.id}" val childModifier = remember { Modifier .fillMaxWidth() .padding(horizontal = itemHorizontalPadding, vertical = 8.dp) } Column( modifier = Modifier .verticalScroll( state = scrollState, enabled = sheetState.currentValue == SheetValue.Expanded ) .fillMaxWidth(), ) { Text( text = showName, style = MaterialTheme.typography.titleLarge, modifier = childModifier ) if (subscription != null) { Column( modifier = childModifier.clearAndSetSemantics { contentDescription = "作者:${subscription.author ?: "未知"}, 版本号:v${subscription.version}, 更新时间:${subsItem.mtimeStr}" } ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "作者", style = MaterialTheme.typography.labelLarge, ) Text( text = "v${subscription.version}", 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(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { if (!subsItem.isLocal) { Text( text = subscription.author ?: "未知", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.let { if (subscription.author == null) { it.copy(alpha = 0.5f) } else { it } }, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) } else { Text( text = META.appName, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, ) } Text( text = subsItem.mtimeStr, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } if (subscription.globalGroups.isNotEmpty() || subsItem.isLocal) { Row( modifier = Modifier .clickable(onClickLabel = "查看全局规则组列表", onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(SubsGlobalGroupListRoute(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(1f) ) { Text( text = "全局规则", style = MaterialTheme.typography.labelLarge, ) Text( text = if (subscription.globalGroups.isNotEmpty()) "共 ${subscription.globalGroups.size} 全局规则组" else "暂无", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.let { if (subscription.globalGroups.isEmpty()) { it.copy(alpha = 0.5f) } else { it } }, ) } PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, ) } } if (subscription.appGroups.isNotEmpty() || subsItem.isLocal) { Row( modifier = Modifier .clickable(onClickLabel = "查看应用规则组列表", onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(SubsAppListRoute(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(1f) ) { Text( text = "应用规则", style = MaterialTheme.typography.labelLarge, ) Text( 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()) { it.copy(alpha = 0.5f) } else { it } }, ) } PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, ) } } if (subscription.categories.isNotEmpty() || subsItem.isLocal) { Row( modifier = Modifier .clickable(onClickLabel = "查看规则类别列表", onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(SubsCategoryRoute(subsItem.id)) }) .then(childModifier), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(1f) ) { Text( text = "规则类别", style = MaterialTheme.typography.labelLarge, ) Text( text = if (subscription.categories.isNotEmpty()) "共 ${subscription.categories.size} 类别" else "暂无", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.let { if (subscription.categories.isEmpty()) { it.copy(alpha = 0.5f) } else { it } }, ) } PerfIcon( imageVector = PerfIcon.KeyboardArrowRight, ) } } if (!subsItem.isLocal && subsItem.updateUrl != null) { Row( modifier = Modifier .clickable(onClickLabel = "编辑订阅链接", onClick = throttle { if (updateSubsMutex.mutex.isLocked) { toast("正在刷新订阅,请稍后操作") return@throttle } mainVm.viewModelScope.launchTry { val url = mainVm.inputSubsLinkOption.getResult(initValue = subsItem.updateUrl) ?: return@launchTry mainVm.addOrModifySubs(url, subsItem) } }) .then(childModifier), verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(1f), ) { Text( text = "订阅链接", style = MaterialTheme.typography.labelLarge, ) Text( text = subsItem.updateUrl, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, softWrap = false, overflow = TextOverflow.MiddleEllipsis, modifier = Modifier .clearAndSetSemantics {} .clickable(onClickLabel = "查看订阅链接", onClick = { mainVm.textFlow.value = subsItem.updateUrl }) ) } Spacer(modifier = Modifier.width(8.dp)) PerfIcon( imageVector = PerfIcon.Edit, ) } } } else { val loading by updateSubsMutex.state.collectAsState() Column( modifier = Modifier .fillMaxWidth() .height(150.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(EmptyHeight)) if (loading) { CircularProgressIndicator() } else { Text( text = "文件加载错误或不存在", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.error, ) TextButton(onClick = throttle { checkSubsUpdate(showToast = true) }) { Text(text = "重新加载") } } } } Row( modifier = childModifier, horizontalArrangement = Arrangement.End ) { if (!subsItem.isLocal && subscription?.supportUri != null) { PerfIconButton( imageVector = PerfIcon.HelpOutline, onClick = throttle { mainVm.textFlow.value = subscription.supportUri }, ) } PerfIconButton(imageVector = PerfIcon.History, onClick = throttle { setSubsId(null) sheetSubsIdFlow.value = null mainVm.navigatePage(ActionLogRoute(subsId = subsItem.id)) }) if (subscription != null || !subsItem.isLocal) { PerfIconButton(imageVector = PerfIcon.Share, onClick = throttle { mainVm.showShareDataIdsFlow.value = setOf(subsItem.id) }) } if (subsItem.id != LOCAL_SUBS_ID) { 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)) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/TermsAcceptDialog.kt ================================================ package li.songe.gkd.ui.component import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.withLink import li.songe.gkd.MainActivity import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.throttle @Composable fun TermsAcceptDialog() { val mainVm = LocalMainViewModel.current val context = LocalActivity.current as MainActivity val modifier = Modifier.fillMaxWidth() val stepDataList = remember { arrayOf( "使用声明" to @Composable { val linkStyles = TextLinkStyles( style = SpanStyle( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) ) Text( modifier = modifier, text = buildAnnotatedString { append("感谢使用 GKD!您需要阅读并同意「") withLink( LinkAnnotation.Url( ShortUrlSet.URL12, linkStyles ) ) { append("用户协议") } append("」和「") withLink( LinkAnnotation.Url( ShortUrlSet.URL11, linkStyles ) ) { append("隐私政策") } append("」才能继续使用, 请仔细阅读相关内容") }, ) }, "关于无障碍" to @Composable { Text( modifier = modifier, text = "GKD 请求使用系统「无障碍 API」获取屏幕信息, 以此基于用户自定义订阅规则执行自动化操作", ) } ) } var step by rememberSaveable { mutableIntStateOf(0) } AlertDialog( onDismissRequest = {}, title = { Text(text = stepDataList[step].first) }, text = stepDataList[step].second, confirmButton = { TextButton(onClick = throttle { if (step < stepDataList.size - 1) { step++ } else { mainVm.termsAcceptedFlow.value = true } }) { Text(text = "同意") } }, dismissButton = { TextButton(onClick = throttle { context.finish() }) { Text(text = "不同意") } } ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/TextDialog.kt ================================================ 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 = "关闭") } } }, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/TextMenu.kt ================================================ 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.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.Alignment 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.OptionMenuLabel @Composable fun TextMenu( modifier: Modifier = Modifier, title: String, option: Option, onOptionChange: ((Option) -> Unit), ) { var expanded by remember { mutableStateOf(false) } Row( modifier = Modifier .clickable { expanded = true } .fillMaxWidth().let { if (modifier == Modifier) { it.itemPadding() } else { it.then(modifier) } }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = title, style = MaterialTheme.typography.bodyLarge, ) Row( verticalAlignment = Alignment.CenterVertically ) { Text( text = option.label, style = MaterialTheme.typography.bodyMedium, ) PerfIcon( imageVector = PerfIcon.UnfoldMore, ) DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { 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) ({ PerfIcon( imageVector = otherOption.icon, ) }) else null, text = { val text = if (otherOption is OptionMenuLabel) { otherOption.menuLabel } else { otherOption.label } Text(text = text) }, onClick = { expanded = false if (otherOption != option) { onOptionChange(otherOption) } }, ) } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt ================================================ 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.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 import li.songe.gkd.util.throttle @Composable 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 = if (paddingDisabled) topModifier else topModifier.itemPadding(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, ) if (subtitle != null) { if (suffix != null) { FlowRow( modifier = Modifier.fillMaxWidth(), ) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(4.dp)) Text( 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), ) else Modifier ) } } else { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } suffixIcon?.invoke() PerfSwitch( checked = checked, enabled = enabled, onCheckedChange = onCheckedChange?.let { throttle(fn = it) }, modifier = Modifier.semantics { this.stateDescription = title + if (checked) "已开启" else "已关闭" } ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/TooltipIconButtonBox.kt ================================================ 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 androidx.compose.runtime.collectAsState import li.songe.gkd.ui.share.LocalIsTalkbackEnabled @Composable fun TooltipIconButtonBox(contentDescription: String?, content: @Composable () -> Unit) { // 视障用户使用 TalkBack 朗读 contentDescription,不需要 Tooltip if (contentDescription.isNullOrEmpty() || LocalIsTalkbackEnabled.current.collectAsState().value) { content() } else { TooltipBox( tooltip = { PlainTooltip { Text(text = contentDescription) } }, state = rememberTooltipState(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider( TooltipAnchorPosition.Start ), content = content, ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @Composable fun TowLineText( title: String, subtitle: String, modifier: Modifier = Modifier, showApp: Boolean = false, ) { Column( modifier = modifier, ) { Text( text = title, maxLines = 1, softWrap = false, overflow = TextOverflow.MiddleEllipsis, style = MaterialTheme.typography.titleMedium, ) CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleSmall) { if (showApp) { AppNameText(appId = subtitle) } else { Text( text = subtitle, maxLines = 1, overflow = TextOverflow.MiddleEllipsis, ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/component/UploadOptions.kt ================================================ package li.songe.gkd.ui.component import androidx.compose.animation.AnimatedContent import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow 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 import java.io.File class UploadOptions( private val mainVm: MainViewModel, ) { private val statusFlow = MutableStateFlow?>(null) private var job: Job? = null private fun buildTask( cookie: String, getFile: suspend () -> File, onSuccessResult: (suspend (GithubPoliciesAsset) -> Unit)? ) = mainVm.viewModelScope.launchTry(Dispatchers.IO) { statusFlow.value = LoadStatus.Loading() try { val policiesAsset = uploadFileToGithub(cookie, getFile()) { if (statusFlow.value is LoadStatus.Loading) { statusFlow.value = LoadStatus.Loading(it) } } statusFlow.value = LoadStatus.Success(policiesAsset) onSuccessResult?.invoke(policiesAsset) } catch (e: Exception) { LogUtils.d(e) statusFlow.value = LoadStatus.Failure(e) } finally { job = null } } private var showHref: (GithubPoliciesAsset) -> String = { it.shortHref } fun startTask( getFile: suspend () -> File, showHref: (GithubPoliciesAsset) -> String = { it.shortHref }, onSuccessResult: (suspend (GithubPoliciesAsset) -> Unit)? = null ) { val cookie = mainVm.githubCookieFlow.value if (cookie.isEmpty()) { toast("请先设置 cookie 后再上传") mainVm.showEditCookieDlgFlow.value = true return } if (job != null || statusFlow.value is LoadStatus.Loading) { return } this.showHref = showHref job = buildTask(cookie, getFile, onSuccessResult) } private fun stopTask() { if (statusFlow.value is LoadStatus.Loading && job != null) { job?.cancel("上传已取消") job = null } } @Composable fun ShowDialog() { when (val status = statusFlow.collectAsState().value) { null -> {} is LoadStatus.Loading -> { AlertDialog( title = { Text(text = "上传文件中") }, text = { val showExactProgress = 0f < status.progress && status.progress < 1f AnimatedContent(showExactProgress) { showExact -> if (showExact) { LinearProgressIndicator( progress = { status.progress }, ) } else { LinearProgressIndicator() } } }, onDismissRequest = { }, confirmButton = { TextButton(onClick = { stopTask() }) { Text(text = "终止上传") } }, ) } is LoadStatus.Success -> { val href = showHref(status.result) AlertDialog( title = { Text(text = "上传完成") }, text = { CopyTextCard(text = href) }, onDismissRequest = {}, confirmButton = { TextButton(onClick = { statusFlow.value = null }) { Text(text = "关闭") } } ) } is LoadStatus.Failure -> { AlertDialog( title = { Text(text = "上传失败") }, text = { Text(text = status.exception.let { it.message ?: it.toString() }) }, onDismissRequest = { statusFlow.value = null }, dismissButton = if (status.exception is GithubCookieException) ({ TextButton(onClick = { statusFlow.value = null mainVm.showEditCookieDlgFlow.value = true }) { Text(text = "更换 Cookie") } }) else { null }, confirmButton = { TextButton(onClick = { statusFlow.value = null }) { Text(text = "关闭") } }, ) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt ================================================ 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.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.LocalSaveableStateRegistry 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.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 kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import li.songe.gkd.MainActivity 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.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.util.AppGroupOption import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.LogUtils 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 import li.songe.gkd.util.updateAllAppInfo import li.songe.gkd.util.updateAppMutex @Composable fun useAppListPage(): ScaffoldExt { 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() val ruleSummary by ruleSummaryFlow.collectAsState() val globalDesc = if (ruleSummary.globalGroups.isNotEmpty()) { "${ruleSummary.globalGroups.size}全局" } else { null } val showSearchBar by vm.showSearchBarFlow.collectAsState() val refreshing by updateAppMutex.state.collectAsState() val pullToRefreshState = rememberPullToRefreshState() val editWhiteListMode by vm.editWhiteListModeFlow.collectAsState() val savedStateRegistry = LocalSaveableStateRegistry.current if (savedStateRegistry != null) { LogUtils.d(savedStateRegistry.performSave()) } 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 = BottomNavItem.AppList, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { DisposableEffect(null) { onDispose { if (vm.searchStrFlow.value.isEmpty()) { vm.showSearchBarFlow.value = false } vm.editWhiteListModeFlow.value = false } } PerfTopAppBar(scrollBehavior = scrollBehavior, title = { val firstShowSearchBar = remember { showSearchBar } if (showSearchBar) { BackHandler { if (!context.justHideSoftInput()) { vm.showSearchBarFlow.value = false } } AppBarTextField( value = searchStr, onValueChange = { newValue -> vm.searchStrFlow.value = newValue.trim() }, hint = "请输入应用名称/ID", modifier = if (firstShowSearchBar) Modifier else Modifier.autoFocus(), ) } else { val titleModifier = Modifier .noRippleClickable( onClick = throttle { scrollKey.intValue++ } ) if (editWhiteListMode) { BackHandler { vm.editWhiteListModeFlow.value = false } } AnimatedContent( targetState = editWhiteListMode, transitionSpec = { getUpDownTransform() }, ) { localEditWhiteListMode -> if (localEditWhiteListMode) { Text( modifier = titleModifier, text = "应用白名单", ) } else { Text( modifier = titleModifier, text = BottomNavItem.AppList.label, ) } } } }, actions = { 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) ) { 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.showBlockAppFlow, ) } } } }) }, floatingActionButton = { AnimationFloatingActionButton( visible = editWhiteListMode, contentDescription = "编辑白名单", onClick = { mainVm.navigatePage(EditBlockAppListRoute) }, imageVector = PerfIcon.Edit, ) } ) { contentPadding -> val canQueryPkg by canQueryPkgState.stateFlow.collectAsState() PullToRefreshBox( modifier = Modifier.padding(contentPadding), state = pullToRefreshState, isRefreshing = refreshing, onRefresh = { updateAllAppInfo() } ) { LazyColumn( modifier = Modifier.fillMaxSize(), state = listState ) { if (!canQueryPkg) { item(key = 1, contentType = 1) { QueryPkgAuthCard() } } items(appInfos, { it.id }) { appInfo -> val desc = run { if (editWhiteListMode) return@run null val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList() val appDesc = if (appGroups.isNotEmpty()) { when (val disabledCount = appGroups.count { g -> !g.enable }) { 0 -> "${appGroups.size}组规则" appGroups.size -> "${appGroups.size}组规则/${disabledCount}关闭" else -> { "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" } } } else { null } if (globalDesc != null) { if (appDesc != null) { "$globalDesc/$appDesc" } else { globalDesc } } else { appDesc } } 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)) } } } } } } @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 = "未加入白名单" } 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, ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt ================================================ package li.songe.gkd.ui.home 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 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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 kotlinx.coroutines.Dispatchers import li.songe.gkd.MainActivity 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.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.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.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 @Composable fun useControlPage(): ScaffoldExt { val context = LocalActivity.current as MainActivity val mainVm = LocalMainViewModel.current val vm = viewModel() val scrollKey = rememberSaveable { mutableIntStateOf(0) } val (scrollBehavior, scrollState) = useScrollBehaviorState(scrollKey) LaunchedEffect(null) { mainVm.resetPageScrollEvent.collect { if (it == BottomNavItem.Control) { scrollKey.intValue++ } } } return ScaffoldExt( navItem = BottomNavItem.Control, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, title = { Text( text = stringResource(R.string.app_name) ) }, actions = { PerfIconButton( imageVector = PerfIcon.RocketLaunch, onClickLabel = "前往工作模式页面", contentDescription = "工作模式", onClick = throttle { mainVm.navigatePage(AuthA11yRoute) }, ) }) }) { contentPadding -> val store by storeFlow.collectAsState() val a11yRunning by A11yService.isRunning.collectAsState() 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) ) { 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() }, ) } PageSwitchItemCard( imageVector = PerfIcon.Notifications, title = "常驻通知", subtitle = "显示运行状态及统计数据", checked = manageRunning && store.enableStatusService, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { StatusService.requestStart(context) } else { StatusService.stop() storeFlow.value = store.copy( enableStatusService = false ) } }, ) ServerStatusCard() PageItemCard( title = "触发记录", subtitle = "规则误触可定位关闭", imageVector = PerfIcon.History, onClickLabel = "打开触发记录页面", onClick = { mainVm.navigatePage(ActionLogRoute()) }) if (ActivityService.isRunning.collectAsState().value) { PageItemCard( title = "界面日志", subtitle = "记录打开的应用及界面", imageVector = PerfIcon.Layers, onClickLabel = "打开界面日志页面", onClick = { mainVm.navigatePage(ActivityLogRoute) }) } PageItemCard( title = "了解 GKD", subtitle = "查阅规则文档和常见问题", imageVector = PerfIcon.HelpOutline, onClickLabel = "打开 GKD 文档页面", onClick = { mainVm.navigatePage(WebViewRoute(initUrl = HOME_PAGE_URL)) }) Spacer(modifier = Modifier.height(EmptyHeight)) } } } @Composable private fun PageItemCard( imageVector: ImageVector, title: String, subtitle: String, onClickLabel: String, onClick: () -> Unit, ) { Card( modifier = Modifier .fillMaxWidth() .semantics { this.onClick(label = onClickLabel, action = null) }, shape = MaterialTheme.shapes.large, colors = surfaceCardColors, onClick = throttle(fn = 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, ) } } } } @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 ) { Row( modifier = Modifier .fillMaxWidth() .padding(itemVerticalPadding), verticalAlignment = Alignment.CenterVertically ) { PerfIcon( imageVector = imageVector, modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer) .padding(8.dp) .size(24.dp), tint = MaterialTheme.colorScheme.primary, contentDescription = null, ) Spacer(modifier = Modifier.width(itemHorizontalPadding)) content() } } @Composable private fun ServerStatusCard() { val mainVm = LocalMainViewModel.current val vm = viewModel() Card( modifier = Modifier .fillMaxWidth() .semantics { onClick(label = "不执行操作", action = null) }, shape = RoundedCornerShape(20.dp), colors = surfaceCardColors, onClick = {}) { Row( modifier = Modifier .fillMaxWidth() .padding( start = itemVerticalPadding, end = itemVerticalPadding, top = itemVerticalPadding, bottom = itemVerticalPadding / 2 ), verticalAlignment = Alignment.CenterVertically ) { PerfIcon( imageVector = PerfIcon.Equalizer, modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer) .padding(8.dp) .size(24.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(itemHorizontalPadding)) Column( modifier = Modifier.weight(1f) ) { Text( text = "数据概览", style = MaterialTheme.typography.bodyLarge, ) val usedSubsItemCount by vm.usedSubsItemCountFlow.collectAsState() AnimatedVisibility(usedSubsItemCount > 0) { Text( text = "已开启 $usedSubsItemCount 条订阅", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = itemVerticalPadding) ) { val subsStatus by vm.subsStatusFlow.collectAsState() AnimatedVisibility(subsStatus.isNotEmpty()) { Text( modifier = Modifier.padding(horizontal = 8.dp), text = subsStatus, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } val latestRecordDesc by latestRecordDescFlow.collectAsState() if (latestRecordDesc != null) { Row( modifier = Modifier .padding(horizontal = 4.dp) .clip(MaterialTheme.shapes.extraSmall) .clickable(onClickLabel = "前往应用的规则汇总页面", onClick = throttle { latestRecordFlow.value?.let { mainVm.navigatePage( AppConfigRoute( appId = it.appId, focusLog = it ) ) } }) .fillMaxWidth() .padding(horizontal = 4.dp) ) { 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, ) } } Spacer(modifier = Modifier.height(itemVerticalPadding)) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt ================================================ package li.songe.gkd.ui.home import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem 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.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable import li.songe.gkd.ui.component.PerfIcon import li.songe.gkd.ui.share.LocalMainViewModel 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 @Composable fun HomePage() { val mainVm = LocalMainViewModel.current viewModel() // init state val tab by mainVm.tabFlow.collectAsState() val pages = arrayOf(useControlPage(), useSubsManagePage(), useAppListPage(), useSettingsPage()) val page = pages.find { p -> p.navItem.key == tab } ?: pages.first() Scaffold( modifier = page.modifier, topBar = page.topBar, floatingActionButton = page.floatingActionButton, bottomBar = { NavigationBar { pages.forEach { page -> NavigationBarItem( selected = page.navItem.key == tab, modifier = Modifier, onClick = { mainVm.handleClickTab(page.navItem) }, icon = { PerfIcon( imageVector = page.navItem.icon, contentDescription = null, ) }, label = { Text(text = page.navItem.label) }) } } }, content = page.content ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt ================================================ package li.songe.gkd.ui.home import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine 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.findOption import li.songe.gkd.util.getSubsStatus import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.usedSubsEntriesFlow class HomeVm : BaseViewModel() { val subsStatusFlow by lazy { combine(ruleSummaryFlow, actionCountFlow) { ruleSummary, count -> getSubsStatus(ruleSummary, count) }.stateInit(EMPTY_RULE_TIP) } val usedSubsItemCountFlow = usedSubsEntriesFlow.mapNew { it.size } 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 editWhiteListModeFlow = MutableStateFlow(false) val blockAppListFlow = MutableStateFlow(blockMatchAppListFlow.value).also { stateFlow -> combine(blockMatchAppListFlow, editWhiteListModeFlow) { it }.launchCollect { if (!editWhiteListModeFlow.value) { stateFlow.value = blockMatchAppListFlow.value } } } val appFilter = useAppFilter( appGroupTypeFlow = appGroupTypeFlow, sortTypeFlow = sortTypeFlow, showBlockAppFlow = showBlockAppFlow, blockAppListFlow = blockAppListFlow, ) val searchStrFlow = appFilter.searchStrFlow val showSearchBarFlow = MutableStateFlow(false).apply { launchCollect { if (!it) { searchStrFlow.value = "" } } } val appInfosFlow = appFilter.appListFlow val showToastInputDlgFlow = MutableStateFlow(false) val showNotifTextInputDlgFlow = MutableStateFlow(false) val showToastSettingsDlgFlow = MutableStateFlow(false) val showA11yBlockDlgFlow = MutableStateFlow(false) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt ================================================ package li.songe.gkd.ui.home import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Text 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 = { PerfTopAppBar(title = { Text( text = navItem.label, ) }) }, val floatingActionButton: @Composable () -> Unit = {}, val content: @Composable (PaddingValues) -> Unit ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt ================================================ package li.songe.gkd.ui.home import android.view.KeyEvent import androidx.activity.compose.LocalActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import li.songe.gkd.MainActivity import li.songe.gkd.R import li.songe.gkd.app import li.songe.gkd.permission.ignoreBatteryOptimizationsState import li.songe.gkd.permission.requiredPermission import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.A11yService import li.songe.gkd.service.StatusService import li.songe.gkd.service.fixRestartAutomatorService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow 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.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.share.LocalMainViewModel import li.songe.gkd.ui.share.asMutableState import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.iconTextSize import li.songe.gkd.ui.style.itemHorizontalPadding import li.songe.gkd.ui.style.titleItemPadding import li.songe.gkd.util.AndroidTarget import li.songe.gkd.util.DarkThemeOption import li.songe.gkd.util.findOption import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.mapState import li.songe.gkd.util.openA11ySettings import li.songe.gkd.util.openAppDetailsSettings import li.songe.gkd.util.throttle import li.songe.gkd.util.toast @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 vm.showToastInputDlgFlow.asMutableState() if (showToastInputDlg) { var value by remember { mutableStateOf(store.actionToast) } val maxCharLen = 64 AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), 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, placeholder = { Text(text = "请输入提示内容") }, onValueChange = { value = it.take(maxCharLen) }, supportingText = { Text( text = "${value.length} / $maxCharLen", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End, ) }, modifier = Modifier .fillMaxWidth() .autoFocus() ) }, onDismissRequest = { showToastInputDlg = false }, confirmButton = { TextButton(enabled = value.isNotEmpty(), onClick = { if (value != storeFlow.value.actionToast) { storeFlow.update { it.copy(actionToast = value) } toast("更新成功") } showToastInputDlg = false }) { Text(text = "确认") } }, dismissButton = { TextButton(onClick = { showToastInputDlg = false }) { Text(text = "取消") } } ) } var showNotifTextInputDlg by vm.showNotifTextInputDlgFlow.asMutableState() if (showNotifTextInputDlg) { var titleValue by remember { mutableStateOf(store.customNotifTitle) } var textValue by remember { mutableStateOf(store.customNotifText) } AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), title = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { Text(text = "通知文案") 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 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(onClick = { context.justHideSoftInput() if (store.customNotifTitle != textValue || store.customNotifText != textValue) { storeFlow.update { it.copy( customNotifTitle = titleValue, customNotifText = textValue ) } toast("更新成功") } showNotifTextInputDlg = false }) { Text( text = "确认", ) } }, dismissButton = { TextButton(onClick = { showNotifTextInputDlg = false }) { Text( text = "取消", ) } }) } var showToastSettingsDlg by vm.showToastSettingsDlgFlow.asMutableState() if (showToastSettingsDlg) { AlertDialog( onDismissRequest = { showToastSettingsDlg = false }, title = { Text("提示设置") }, text = { TextSwitch( paddingDisabled = true, title = "系统提示", subtitle = "系统样式触发提示", suffix = "查看限制", onSuffixClick = { showToastSettingsDlg = false val confirmAction = { mainVm.dialogFlow.value = null showToastSettingsDlg = true } mainVm.dialogFlow.updateDialogOptions( title = "限制说明", text = "系统 Toast 存在频率限制, 触发过于频繁会被系统强制不显示\n\n如果只使用开屏一类低频率规则可使用系统提示, 否则建议关闭此项使用自定义样式提示", confirmAction = confirmAction, onDismissRequest = confirmAction, ) }, checked = store.useSystemToast, onCheckedChange = { storeFlow.value = store.copy( useSystemToast = it ) }) }, confirmButton = { TextButton(onClick = { showToastSettingsDlg = false }) { Text("关闭") } } ) } var showA11yBlockDlg by vm.showA11yBlockDlgFlow.asMutableState() if (showA11yBlockDlg) { BlockA11yDialog(onDismissRequest = { showA11yBlockDlg = false }) } val scrollKey = rememberSaveable { mutableIntStateOf(0) } val (scrollBehavior, scrollState) = useScrollBehaviorState(scrollKey) LaunchedEffect(null) { mainVm.resetPageScrollEvent.collect { if (it == BottomNavItem.Settings) { scrollKey.intValue++ } } } return ScaffoldExt( navItem = BottomNavItem.Settings, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar( scrollBehavior = scrollBehavior, title = { Text( text = BottomNavItem.Settings.label, ) }, ) }, ) { contentPadding -> Column( modifier = Modifier .verticalScroll(scrollState) .padding(contentPadding) ) { Text( text = "常规", modifier = Modifier.titleItemPadding(showTop = false), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) TextSwitch( title = "触发提示", subtitle = store.actionToast, checked = store.toastWhenClick, onClickLabel = "打开触发提示弹窗", onClick = { showToastInputDlg = true }, suffixIcon = { PerfCustomIconButton( size = 32.dp, iconSize = 20.dp, onClickLabel = "打开提示设置弹窗", onClick = throttle { showToastSettingsDlg = true }, id = R.drawable.ic_page_info, contentDescription = "提示设置", ) }, onCheckedChange = { storeFlow.value = store.copy( toastWhenClick = it ) }) val subsStatus by vm.subsStatusFlow.collectAsState() TextSwitch( title = "通知文案", subtitle = if (store.useCustomNotifText) { store.customNotifTitle + " / " + store.customNotifText } else { subsStatus }, checked = store.useCustomNotifText, onClickLabel = "打开修改通知文案弹窗", onClick = { showNotifTextInputDlg = true }, onCheckedChange = { storeFlow.value = store.copy( useCustomNotifText = it ) }) TextSwitch( title = "后台隐藏", subtitle = "在「最近任务」隐藏卡片", checked = store.excludeFromRecents, onCheckedChange = vm.viewModelScope.launchAsFn { if (it) { mainVm.dialogFlow.waitResult( title = "后台隐藏", text = "隐藏卡片后可能导致部分设备无法给任务卡片加锁后台,建议先加锁后再隐藏,若已加锁或没有锁后台机制请继续", confirmText = "继续", ) } storeFlow.value = store.copy( excludeFromRecents = !store.excludeFromRecents ) }) val scope = rememberCoroutineScope() val lazyOn = remember { storeFlow.mapState(scope) { it.enableBlockA11yAppList }.debounce(300) .stateIn(scope, SharingStarted.Eagerly, store.enableBlockA11yAppList) }.collectAsState() AnimatedVisibility(visible = lazyOn.value) { Text( modifier = Modifier .fillMaxWidth() .titleItemPadding(), text = "无障碍", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) } TextSwitch( title = "局部关闭", subtitle = "白名单内关闭服务", checked = store.enableBlockA11yAppList && 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 = "外观", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) TextMenu( title = "深色模式", option = DarkThemeOption.objects.findOption(store.enableDarkTheme), onOptionChange = { storeFlow.update { s -> s.copy(enableDarkTheme = it.value) } } ) if (AndroidTarget.S) { TextSwitch( title = "动态配色", checked = store.enableDynamicColor, onCheckedChange = { storeFlow.update { s -> s.copy(enableDynamicColor = it) } } ) } Text( text = "其他", modifier = Modifier.titleItemPadding(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) SettingItem(title = "高级设置", onClick = { mainVm.navigatePage(AdvancedPageRoute) }) SettingItem(title = "关于", onClick = { 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 hasOtherA11y by mainVm.hasOtherA11yFlow.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 && !hasOtherA11y, onClick = mainVm.viewModelScope.launchAsFn { onDismissRequest() delay(200) storeFlow.update { it.copy(enableBlockA11yAppList = true) } } ) { Text(text = "继续") } Spacer(modifier = Modifier.width(itemHorizontalPadding)) } }, ) { contentPadding -> Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(contentPadding) .padding(horizontal = itemHorizontalPadding) ) { CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { Text(text = "「局部关闭」可在白名单应用内关闭服务,来解决界面异常,游戏掉帧或无障碍检测的问题") Spacer(modifier = Modifier.height(16.dp)) Text(text = "使用须知", style = MaterialTheme.typography.titleMedium) Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { RequiredTextItem(text = "切换服务会造成短暂触摸卡顿,请自行测试后再编辑白名单") RequiredTextItem(text = "使用其它无障碍应用会导致优化无效,因为无障碍不会被完全关闭") RequiredTextItem(text = "必须确保服务关闭后的持续后台运行,否则会被系统暂停或结束运行导致重启失败") } Spacer(modifier = Modifier.height(16.dp)) Text(text = "使用条件", style = MaterialTheme.typography.titleMedium) Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { RequiredTextItem( text = "Shizuku 授权", enabled = !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 = hasOtherA11y, imageVector = if (!hasOtherA11y) PerfIcon.Check else PerfIcon.ArrowForward, onClick = { if (writeSecureSettingsState.updateAndGet()) { if (A11yService.isRunning.value) { setOf(A11yService.a11yCn) } else { emptySet() }.let { app.putSecureA11yServices(it) } toast("关闭成功") } else { openA11ySettings() } }, ) 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(), ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt ================================================ 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 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.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed 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.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.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState 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.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.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.dylanc.activityresult.launcher.launchForResult 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.SubsItemCard import li.songe.gkd.ui.component.TextMenu import li.songe.gkd.ui.component.usePinnedScrollBehaviorState import li.songe.gkd.ui.component.waitResult import li.songe.gkd.ui.share.ListPlaceholder import li.songe.gkd.ui.share.LocalMainViewModel import li.songe.gkd.ui.style.EmptyHeight import li.songe.gkd.ui.style.itemVerticalPadding import li.songe.gkd.util.LOCAL_SUBS_ID import li.songe.gkd.util.ShortUrlSet import li.songe.gkd.util.UpdateTimeOption import li.songe.gkd.util.checkSubsUpdate import li.songe.gkd.util.deleteSubscription 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.mapState import li.songe.gkd.util.ruleSummaryFlow 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 import li.songe.gkd.util.usedSubsEntriesFlow import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @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 subsMapFlow.collectAsState() var orderSubItems by remember { mutableStateOf(subItems) } LaunchedEffect(subItems) { orderSubItems = subItems } val refreshing by updateSubsMutex.state.collectAsState() val pullToRefreshState = rememberPullToRefreshState() var isSelectedMode by remember { mutableStateOf(false) } var selectedIds by remember { mutableStateOf(emptySet()) } val draggedFlag = remember { Value(false) } LaunchedEffect(key1 = isSelectedMode) { if (!isSelectedMode && selectedIds.isNotEmpty()) { selectedIds = emptySet() } } BackHandler(isSelectedMode) { isSelectedMode = false } LaunchedEffect(key1 = subItems.size) { if (subItems.size <= 1) { isSelectedMode = false } } var showSettingsDlg by remember { mutableStateOf(false) } if (showSettingsDlg) { AlertDialog( onDismissRequest = { showSettingsDlg = false }, title = { Text("订阅设置") }, text = { val store by storeFlow.collectAsState() Column { TextMenu( modifier = Modifier.padding(0.dp, itemVerticalPadding), title = "更新订阅", option = UpdateTimeOption.objects.findOption(store.updateSubsInterval) ) { storeFlow.update { s -> s.copy(updateSubsInterval = it.value) } } val updateValue = throttle { storeFlow.update { it.copy(subsPowerWarn = !it.subsPowerWarn) } } Row( modifier = Modifier .padding(0.dp, itemVerticalPadding) .clickable( onClickLabel = if (store.subsPowerWarn) "关闭警告" else "开启警告", onClick = updateValue ) .semantics(mergeDescendants = true) { stateDescription = if (store.subsPowerWarn) "已开启" else "已关闭" }, 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 = null, ) } } }, confirmButton = { TextButton(onClick = { showSettingsDlg = false }, modifier = Modifier.semantics { onClick(label = "关闭弹窗", action = null) }) { Text("关闭") } } ) } val scrollKey = rememberSaveable { mutableIntStateOf(0) } val (scrollBehavior, lazyListState) = usePinnedScrollBehaviorState(scrollKey) LaunchedEffect(null) { mainVm.resetPageScrollEvent.collect { if (it == BottomNavItem.SubsManage) { scrollKey.intValue++ } } } return ScaffoldExt( navItem = BottomNavItem.SubsManage, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { PerfTopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { if (isSelectedMode) { PerfIconButton( imageVector = PerfIcon.Close, contentDescription = "取消选择", onClick = { isSelectedMode = false }, ) } }, title = { if (isSelectedMode) { Text( text = if (selectedIds.isNotEmpty()) selectedIds.size.toString() else "", ) } else { Text( text = BottomNavItem.SubsManage.label, ) } }, actions = { var expanded by remember { mutableStateOf(false) } AnimatedContent( targetState = isSelectedMode, transitionSpec = { getUpDownTransform() }, contentAlignment = Alignment.TopEnd, ) { Row { if (it) { PerfIconButton( imageVector = PerfIcon.Share, contentDescription = "分享选中订阅", onClick = { mainVm.showShareDataIdsFlow.value = selectedIds }) val canDeleteIds = if (selectedIds.contains(LOCAL_SUBS_ID)) { selectedIds - LOCAL_SUBS_ID } else { selectedIds } if (canDeleteIds.isNotEmpty()) { val text = "确定删除所选 ${canDeleteIds.size} 个订阅?".let { s -> if (selectedIds.contains(LOCAL_SUBS_ID)) "$s\n\n注: 不包含本地订阅" else s } 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() AnimatedVisibility( visible = ruleSummary.slowGroupCount > 0, enter = scaleIn(), exit = scaleOut(), ) { 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 }) } } } PerfIconButton( imageVector = PerfIcon.MoreVert, contentDescription = "更多操作", onClick = { if (updateSubsMutex.mutex.isLocked) { toast("正在刷新订阅,请稍后操作") } else { expanded = true } }) Box( modifier = Modifier.wrapContentSize(Alignment.TopStart) ) { key(isSelectedMode) { DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { if (isSelectedMode) { DropdownMenuItem( text = { Text(text = "全选") }, onClick = { expanded = false selectedIds = subItems.map { it.id }.toSet() } ) DropdownMenuItem( text = { Text(text = "反选") }, onClick = { expanded = false val newSelectedIds = subItems.map { it.id }.toSet() - selectedIds if (newSelectedIds.isEmpty()) { isSelectedMode = false } selectedIds = newSelectedIds } ) } 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( UpsertRuleGroupRoute( subsId = LOCAL_SUBS_ID, groupKey = null, appId = "", forward = true, ) ) }, ) DropdownMenuItem( text = { Text(text = "添加全局规则") }, onClick = throttle { expanded = false mainVm.navigatePage( UpsertRuleGroupRoute( subsId = LOCAL_SUBS_ID, groupKey = null, appId = null, forward = true, ) ) }, ) } } } } }) }, floatingActionButton = { AnimationFloatingActionButton( contentDescription = "添加订阅", onClickLabel = "打开添加订阅弹窗", visible = !isSelectedMode, onClick = { if (updateSubsMutex.mutex.isLocked) { toast("正在刷新订阅,请稍后操作") } else { mainVm.viewModelScope.launchTry { val url = mainVm.inputSubsLinkOption.getResult() ?: return@launchTry mainVm.addOrModifySubs(url) } } }, imageVector = PerfIcon.Add, ) }, ) { contentPadding -> val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to -> orderSubItems = orderSubItems.toMutableList().apply { add(to.index, removeAt(from.index)) forEachIndexed { index, subsItem -> if (subsItem.order != index) { this[index] = subsItem.copy(order = index) } } } draggedFlag.value = true } PullToRefreshBox( modifier = Modifier.padding(contentPadding), state = pullToRefreshState, isRefreshing = refreshing, onRefresh = { checkSubsUpdate(true) } ) { LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), ) { itemsIndexed(orderSubItems, { _, subItem -> subItem.id }) { index, subItem -> val canDrag = !refreshing && orderSubItems.size > 1 ReorderableItem( state = reorderableLazyColumnState, key = subItem.id, enabled = canDrag, ) { val interactionSource = remember { MutableInteractionSource() } SubsItemCard( modifier = Modifier.longPressDraggableHandle( enabled = canDrag, interactionSource = interactionSource, onDragStarted = { if (orderSubItems.size > 1 && !isSelectedMode) { isSelectedMode = true selectedIds = setOf(subItem.id) } }, onDragStopped = { if (draggedFlag.value) { draggedFlag.value = false isSelectedMode = false selectedIds = emptySet() } val changeItems = orderSubItems.filter { newItem -> subItems.find { oldItem -> oldItem.id == newItem.id }?.order != newItem.order } if (changeItems.isNotEmpty()) { vm.viewModelScope.launchTry { DbSet.subsItemDao.batchUpdateOrder(changeItems) } } }, ), interactionSource = interactionSource, subsItem = subItem, subscription = subsIdToRaw[subItem.id], index = index + 1, isSelectedMode = isSelectedMode, isSelected = selectedIds.contains(subItem.id), onCheckedChange = mainVm.viewModelScope.launchAsFn { checked -> if (checked && storeFlow.value.subsPowerWarn && !subItem.isLocal && usedSubsEntriesFlow.value.any { !it.subsItem.isLocal }) { mainVm.dialogFlow.waitResult( title = "耗电警告", textContent = { Column { Text(text = "启用多个远程订阅可能导致执行大量重复规则, 这可能造成规则执行卡顿以及多余耗电\n\n请认真考虑后再确认开启!!!\n") Text( text = "查看耗电说明", modifier = Modifier.clickable(onClick = throttle { mainVm.dialogFlow.value = null mainVm.navigatePage( WebViewRoute( initUrl = ShortUrlSet.URL6 ) ) }), textDecoration = TextDecoration.Underline, color = MaterialTheme.colorScheme.primary, ) } }, confirmText = "仍然启用", error = true ) } DbSet.subsItemDao.updateEnable(subItem.id, checked) }, onSelectedChange = { val newSelectedIds = if (selectedIds.contains(subItem.id)) { selectedIds.toMutableSet().apply { remove(subItem.id) } } else { selectedIds + subItem.id } selectedIds = newSelectedIds if (newSelectedIds.isEmpty()) { isSelectedMode = false } }, ) } } item(ListPlaceholder.KEY, ListPlaceholder.TYPE) { Spacer(modifier = Modifier.height(EmptyHeight)) } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/icon/BackCloseIcon.kt ================================================ package li.songe.gkd.ui.icon import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalContentColor 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.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics 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 } @Composable 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) } LaunchedEffect(backOrClose) { if (backOrClose && percent.value != 1f) { percent.animateTo(targetValue = 1f, animationSpec = tween()) } else if (!backOrClose && percent.value != 0f) { percent.animateTo(targetValue = 0f, animationSpec = tween()) } } Canvas( modifier = modifier .size(24.dp) .semantics { this.contentDescription = contentDescription this.role = Role.Image }, ) { val x = size.width val halfX = x * 0.5f val closeOffset = 0.2375f * x val otherCloseOffset = x - closeOffset val closeStrokeWidth = x * 0.08202f val backStrokeWidth = x * 0.08316f val strokeWidth = percent.calc(closeStrokeWidth, backStrokeWidth) if (backOrClose) { drawLine( color = tint, start = Offset( percent.calc(closeOffset, x * 0.208f), percent.calc(closeOffset, halfX) ), end = Offset( percent.calc(otherCloseOffset, x * 0.8335f), percent.calc(otherCloseOffset, halfX) ), strokeWidth = strokeWidth, ) drawLine( color = tint, start = Offset( percent.calc(otherCloseOffset, x * 0.529675f), percent.calc(closeOffset, x * 0.1861f) ), end = Offset( percent.calc(halfX, x * 0.196f), percent.calc(halfX, x * 0.5295f) ), strokeWidth = strokeWidth, ) drawLine( color = tint, start = Offset( percent.calc(closeOffset, x * 0.5295f), percent.calc(otherCloseOffset, x * 0.804f) ), end = Offset( percent.calc(halfX, x * 0.196f), percent.calc(halfX, x * 0.4705f) ), strokeWidth = strokeWidth, ) } else { drawLine( color = tint, start = Offset( percent.calc(closeOffset, x * 0.208f), percent.calc(otherCloseOffset, halfX) ), end = Offset( percent.calc(otherCloseOffset, x * 0.8335f), percent.calc(closeOffset, halfX) ), strokeWidth = strokeWidth, ) drawLine( color = tint, start = Offset( percent.calc(closeOffset, x * 0.529675f), percent.calc(closeOffset, x * 0.1861f) ), end = Offset( percent.calc(halfX, x * 0.196f), percent.calc(halfX, x * 0.5295f) ), strokeWidth = strokeWidth, ) drawLine( color = tint, start = Offset( percent.calc(otherCloseOffset, x * 0.5295f), percent.calc(otherCloseOffset, x * 0.804f) ), end = Offset( percent.calc(halfX, x * 0.196f), percent.calc(halfX, x * 0.4705f) ), strokeWidth = strokeWidth, ) } } } @Preview( showBackground = true, heightDp = 48, widthDp = 48 ) @Composable fun PreviewBackCloseIcon() { var backOrClose by remember { mutableStateOf(true) } LaunchedEffect(null) { delay(100) while (isActive) { backOrClose = !backOrClose delay(1000) } } BackCloseIcon( backOrClose = backOrClose, modifier = Modifier.fillMaxSize(), ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/icon/DragPan.kt ================================================ 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 ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/icon/LockOpenRight.kt ================================================ 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 ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/icon/ResetSettings.kt ================================================ 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 ResetSettings: 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(520f, 630f) verticalLineToRelative(-60f) horizontalLineToRelative(160f) verticalLineToRelative(60f) lineTo(520f, 630f) close() moveTo(580f, 840f) verticalLineToRelative(-50f) horizontalLineToRelative(-60f) verticalLineToRelative(-60f) horizontalLineToRelative(60f) verticalLineToRelative(-50f) horizontalLineToRelative(60f) verticalLineToRelative(160f) horizontalLineToRelative(-60f) close() moveTo(680f, 790f) verticalLineToRelative(-60f) horizontalLineToRelative(160f) verticalLineToRelative(60f) lineTo(680f, 790f) close() moveTo(720f, 680f) verticalLineToRelative(-160f) horizontalLineToRelative(60f) verticalLineToRelative(50f) horizontalLineToRelative(60f) verticalLineToRelative(60f) horizontalLineToRelative(-60f) verticalLineToRelative(50f) horizontalLineToRelative(-60f) close() moveTo(831f, 400f) horizontalLineToRelative(-83f) quadToRelative(-26f, -88f, -99f, -144f) reflectiveQuadToRelative(-169f, -56f) quadToRelative(-117f, 0f, -198.5f, 81.5f) reflectiveQuadTo(200f, 480f) quadToRelative(0f, 72f, 32.5f, 132f) reflectiveQuadToRelative(87.5f, 98f) verticalLineToRelative(-110f) horizontalLineToRelative(80f) verticalLineToRelative(240f) lineTo(160f, 840f) verticalLineToRelative(-80f) horizontalLineToRelative(94f) quadToRelative(-62f, -50f, -98f, -122.5f) reflectiveQuadTo(120f, 480f) quadToRelative(0f, -75f, 28.5f, -140.5f) reflectiveQuadToRelative(77f, -114f) quadToRelative(48.5f, -48.5f, 114f, -77f) reflectiveQuadTo(480f, 120f) quadToRelative(129f, 0f, 226.5f, 79.5f) reflectiveQuadTo(831f, 400f) close() } }.build() return _IconName!! } @Suppress("ObjectPropertyName") private var _IconName: ImageVector? = null ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/icon/SportsBasketball.kt ================================================ package li.songe.gkd.ui.icon import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType 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 SportsBasketball: ImageVector get() { if (_SportsBasketball != null) { return _SportsBasketball!! } _SportsBasketball = ImageVector.Builder( name = "SportsBasketball", defaultWidth = 32.dp, defaultHeight = 32.dp, viewportWidth = 24f, viewportHeight = 24f ).apply { path( fill = SolidColor(Color(0xFF000000)), pathFillType = PathFillType.EvenOdd ) { moveTo(8.367f, 3.492f) curveToRelative(0.499f, 0.396f, 1.172f, 0.95f, 1.905f, 1.607f) curveToRelative(0.702f, 0.63f, 1.473f, 1.366f, 2.203f, 2.161f) curveToRelative(1.403f, -1.191f, 2.486f, -2.535f, 3.044f, -3.815f) arcToRelative(9.3f, 9.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2.414f, -0.63f) arcToRelative(9.2f, 9.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -4.738f, 0.677f) moveToRelative(8.491f, 0.634f) curveToRelative(-0.678f, 1.509f, -1.901f, 2.993f, -3.405f, 4.271f) arcToRelative(14f, 14f, 0f, isMoreThanHalf = false, isPositiveArc = true, 1.197f, 1.728f) curveToRelative(0.41f, 0.71f, 0.774f, 1.533f, 1.095f, 2.396f) curveToRelative(1.812f, -0.683f, 3.717f, -1.021f, 5.502f, -0.805f) arcToRelative(9.2f, 9.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.236f, -4.341f) arcToRelative(9.2f, 9.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -3.153f, -3.249f) moveToRelative(4.312f, 9.093f) curveToRelative(-1.536f, -0.209f, -3.255f, 0.08f, -4.944f, 0.724f) curveToRelative(0.29f, 0.937f, 0.535f, 1.876f, 0.733f, 2.729f) arcToRelative(52f, 52f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.563f, 2.75f) arcToRelative(9.2f, 9.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2.984f, -3.788f) curveToRelative(0.33f, -0.771f, 0.553f, -1.585f, 0.664f, -2.415f) moveToRelative(-5.026f, 7.05f) arcToRelative(51f, 51f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.645f, -3.257f) arcToRelative(39f, 39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.655f, -2.461f) quadToRelative(-0.614f, 0.307f, -1.207f, 0.667f) curveToRelative(-2.412f, 1.468f, -4.342f, 3.47f, -5.156f, 5.337f) arcToRelative(9.24f, 9.24f, 0f, isMoreThanHalf = false, isPositiveArc = false, 7.663f, -0.285f) moveToRelative(-9.002f, -0.395f) curveToRelative(1.001f, -2.227f, 3.19f, -4.401f, 5.715f, -5.937f) quadToRelative(0.73f, -0.445f, 1.508f, -0.822f) curveToRelative(-0.302f, -0.823f, -0.64f, -1.593f, -1.014f, -2.24f) arcTo(13f, 13f, 0f, isMoreThanHalf = false, isPositiveArc = false, 12.27f, 9.32f) arcToRelative(18f, 18f, 0f, isMoreThanHalf = false, isPositiveArc = true, -1.064f, 0.706f) curveToRelative(-2.57f, 1.578f, -5.658f, 2.597f, -8.454f, 2.259f) arcToRelative(9.2f, 9.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.237f, 4.34f) arcToRelative(9.2f, 9.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 3.153f, 3.25f) moveTo(2.83f, 10.78f) arcToRelative(9.24f, 9.24f, 0f, isMoreThanHalf = false, isPositiveArc = true, 4.092f, -6.513f) quadToRelative(0.188f, 0.143f, 0.466f, 0.363f) curveToRelative(0.486f, 0.383f, 1.154f, 0.931f, 1.883f, 1.585f) curveToRelative(0.66f, 0.592f, 1.358f, 1.26f, 2.012f, 1.965f) quadToRelative(-0.42f, 0.293f, -0.862f, 0.566f) curveTo(7.973f, 10.25f, 5.187f, 11.1f, 2.83f, 10.78f) moveToRelative(3.795f, -8.09f) arcToRelative(10.7f, 10.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, 6.66f, -1.364f) arcToRelative(10.74f, 10.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.025f, 5.299f) arcToRelative(10.74f, 10.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0.576f, 9.598f) arcToRelative(10.7f, 10.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.511f, 5.087f) curveToRelative(-5.142f, 2.968f, -11.716f, 1.206f, -14.685f, -3.935f) curveTo(-0.278f, 12.233f, 1.483f, 5.658f, 6.625f, 2.69f) } }.build() return _SportsBasketball!! } @Suppress("ObjectPropertyName") private var _SportsBasketball: ImageVector? = null ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/AppFilter.kt ================================================ 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.store.blockMatchAppListFlow import li.songe.gkd.util.AppGroupOption import li.songe.gkd.util.AppSortOption import li.songe.gkd.util.visibleAppInfosFlow 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, ) } class AppFilter( val searchStrFlow: MutableStateFlow, val appListFlow: StateFlow>, val showAllAppFlow: StateFlow, ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/BaseViewModel.kt ================================================ 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() ) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/FixedWindowInsets.kt ================================================ 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 } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/ListPlaceholder.kt ================================================ package li.songe.gkd.ui.share import kotlin.math.E import kotlin.math.PI object ListPlaceholder { const val KEY = PI const val TYPE = E } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/LocalExt.kt ================================================ package li.songe.gkd.ui.share import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import li.songe.gkd.MainViewModel val LocalMainViewModel = compositionLocalOf { error("not found MainViewModel") } val LocalDarkTheme = compositionLocalOf { false } val LocalIsTalkbackEnabled = compositionLocalOf> { MutableStateFlow(false) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/ModifierExt.kt ================================================ 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, ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/share/StateExt.kt ================================================ 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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/style/Color.kt ================================================ package li.songe.gkd.ui.style import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import li.songe.json5.Json5 import li.songe.json5.Json5Token val surfaceCardColors: CardColors @Composable get() = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) private fun getDarkJson5TokenColor(json5Token: Json5Token?): Color = when (json5Token) { null -> Color(0xFFFF00FF) // unknown token color Json5Token.Comment -> Color(0xFF75715E) Json5Token.LeftBrace, Json5Token.RightBrace -> Color(0xFFFFA07A) Json5Token.LeftBracket, Json5Token.RightBracket -> Color(0xFFFFA07A) Json5Token.Colon -> Color(0xFFE1E4E8) Json5Token.Comma -> Color(0xFFE1E4E8) Json5Token.BooleanLiteral -> Color(0xFF79B8FF) Json5Token.NullLiteral -> Color(0xFFB22222) Json5Token.NumberLiteral -> Color(0xFF2E8B57) Json5Token.StringLiteral -> Color(0xFFE6DB74) Json5Token.Property -> Color(0xFFBCBEC4) Json5Token.Whitespace -> Color.Transparent } private fun getLightJson5TokenColor(json5Token: Json5Token?): Color = when (json5Token) { null -> Color(0xFFFF0000) Json5Token.Comment -> Color(0xFF6A9955) Json5Token.LeftBrace, Json5Token.RightBrace -> Color(0xFFAF00DB) Json5Token.LeftBracket, Json5Token.RightBracket -> Color(0xFFAF00DB) Json5Token.Colon -> Color(0xFF000000) Json5Token.Comma -> Color(0xFF000000) Json5Token.BooleanLiteral -> Color(0xFF0000FF) Json5Token.NullLiteral -> Color(0xFFA31515) Json5Token.NumberLiteral -> Color(0xFF098658) Json5Token.StringLiteral -> Color(0xFF669900) Json5Token.Property -> Color(0xFF001080) Json5Token.Whitespace -> Color.Transparent } private val json5LightStyleCache = HashMap() private val json5DarkStyleCache = HashMap() fun getJson5AnnotatedString(source: String, dark: Boolean): AnnotatedString = buildAnnotatedString { append(source) val styleCache = if (dark) { json5DarkStyleCache } else { json5LightStyleCache } Json5.parseToJson5LooseRanges(source).forEach { range -> if (range.token is Json5Token.Whitespace) { return@forEach } val style = styleCache[range.token] ?: SpanStyle( color = if (dark) { getDarkJson5TokenColor(range.token) } else { getLightJson5TokenColor(range.token) }, ).apply { styleCache[range.token] = this } addStyle( style = style, range.start, range.end ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt ================================================ package li.songe.gkd.ui.style import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalTextStyle import androidx.compose.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 = 80.dp val cardHorizontalPadding = 12.dp fun Modifier.itemPadding() = this.padding(itemHorizontalPadding, itemVerticalPadding) fun Modifier.titleItemPadding(showTop: Boolean = true) = this.padding( itemHorizontalPadding, if (showTop) itemVerticalPadding + itemVerticalPadding / 2 else 0.dp, itemHorizontalPadding, itemVerticalPadding - itemVerticalPadding / 2 ) fun Modifier.appItemPadding() = this.padding(itemHorizontalPadding, itemVerticalPadding) fun Modifier.scaffoldPadding(values: PaddingValues): Modifier { return padding( top = values.calculateTopPadding(), // 被 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/style/TextTransformation.kt ================================================ package li.songe.gkd.ui.style import androidx.collection.LruCache import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation private const val HIGHLIGHT_JSON5_MAX_LENGTH = 10000 private class Json5VisualTransformation(val dark: Boolean) : VisualTransformation { val cache = LruCache(0xF) override fun filter(text: AnnotatedString): TransformedText { if (text.text.isBlank() || text.text.length > HIGHLIGHT_JSON5_MAX_LENGTH) { return VisualTransformation.None.filter(text) } cache[text.text]?.let { return it } return TransformedText( text = getJson5AnnotatedString(text.text, dark), offsetMapping = OffsetMapping.Identity, ).apply { cache.put(text.text, this) } } } private val darkVisualTransformation = Json5VisualTransformation(true) private val lightVisualTransformation = Json5VisualTransformation(false) fun getJson5Transformation(dark: Boolean): VisualTransformation = if (dark) { darkVisualTransformation } else { lightVisualTransformation } fun clearJson5TransformationCache() { darkVisualTransformation.cache.evictAll() lightVisualTransformation.cache.evictAll() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/ui/style/Theme.kt ================================================ package li.songe.gkd.ui.style import android.view.accessibility.AccessibilityManager import androidx.activity.compose.LocalActivity import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme 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.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.view.WindowInsetsControllerCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted 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() @Composable fun AppTheme( invertedTheme: Boolean = false, content: @Composable () -> Unit, ) { 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).let { if (invertedTheme) !it else it } val colorScheme = when { AndroidTarget.S && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app) AndroidTarget.S && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(app) darkTheme -> DarkColorScheme else -> LightColorScheme } 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) } } val isTalkbackEnabledFlow = remember { MutableStateFlow(app.a11yManager.isTouchExplorationEnabled) } DisposableEffect(null) { val listener = AccessibilityManager.TouchExplorationStateChangeListener { isTalkbackEnabledFlow.value = it } app.a11yManager.addTouchExplorationStateChangeListener(listener) onDispose { app.a11yManager.removeTouchExplorationStateChangeListener(listener) } } CompositionLocalProvider( LocalDarkTheme provides darkTheme, LocalIsTalkbackEnabled provides isTalkbackEnabledFlow ) { MaterialTheme( colorScheme = colorScheme.animation(), content = content, ) } } @Composable private fun Color.animation() = animateColorAsState( targetValue = this, animationSpec = tween(durationMillis = 500), label = "animation" ).value @Composable private fun ColorScheme.animation(): ColorScheme { return copy( primary = primary.animation(), onPrimary = onPrimary.animation(), primaryContainer = primaryContainer.animation(), onPrimaryContainer = onPrimaryContainer.animation(), inversePrimary = inversePrimary.animation(), secondary = secondary.animation(), onSecondary = onSecondary.animation(), secondaryContainer = secondaryContainer.animation(), onSecondaryContainer = onSecondaryContainer.animation(), tertiary = tertiary.animation(), onTertiary = onTertiary.animation(), tertiaryContainer = tertiaryContainer.animation(), onTertiaryContainer = onTertiaryContainer.animation(), background = background.animation(), onBackground = onBackground.animation(), surface = surface.animation(), onSurface = onSurface.animation(), surfaceVariant = surfaceVariant.animation(), onSurfaceVariant = onSurfaceVariant.animation(), surfaceTint = surfaceTint.animation(), inverseSurface = inverseSurface.animation(), inverseOnSurface = inverseOnSurface.animation(), error = error.animation(), onError = onError.animation(), errorContainer = errorContainer.animation(), onErrorContainer = onErrorContainer.animation(), outline = outline.animation(), outlineVariant = outlineVariant.animation(), scrim = scrim.animation(), surfaceBright = surfaceBright.animation(), surfaceDim = surfaceDim.animation(), surfaceContainer = surfaceContainer.animation(), surfaceContainerHigh = surfaceContainerHigh.animation(), surfaceContainerHighest = surfaceContainerHighest.animation(), surfaceContainerLow = surfaceContainerLow.animation(), surfaceContainerLowest = surfaceContainerLowest.animation(), ) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/AndroidTarget.kt ================================================ 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 } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt ================================================ package li.songe.gkd.util import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.graphics.drawable.Drawable import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import li.songe.gkd.App import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo 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 li.songe.gkd.shizuku.currentUserId import li.songe.gkd.shizuku.shizukuContextFlow val userAppInfoMapFlow = MutableStateFlow(emptyMap()) val userAppIconMapFlow = MutableStateFlow(emptyMap()) val otherUserAppInfoMapFlow = MutableStateFlow(emptyMap()) val otherUserAppIconMapFlow = MutableStateFlow(emptyMap()) val 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 { appInfoMapFlow.mapState(appScope) { c -> c.filter { a -> a.value.isSystem } } } val systemAppsFlow by lazy { systemAppInfoCacheFlow.mapState(appScope) { c -> c.keys } } val visibleAppInfosFlow by lazy { appInfoMapFlow.mapState(appScope) { c -> c.values.filterNot { it.hidden }.sortedWith { a, b -> collator.compare(a.name, b.name) } } } private val willUpdateAppIds by lazy { MutableStateFlow(emptySet()) } private val packageReceiver by lazy { object : BroadcastReceiver() { val actions = arrayOf( Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_REPLACED, Intent.ACTION_PACKAGE_REMOVED ) override fun onReceive(context: Context?, intent: Intent?) { // PACKAGE_REMOVED->PACKAGE_ADDED->PACKAGE_REPLACED val appId = intent?.data?.schemeSpecificPart ?: return willUpdateAppIds.update { it + appId } } }.apply { val intentFilter = IntentFilter().apply { actions.forEach { addAction(it) } addDataScheme("package") } ContextCompat.registerReceiver( app, this, intentFilter, ContextCompat.RECEIVER_EXPORTED ) } } const val PKG_FLAGS = PackageManager.MATCH_UNINSTALLED_PACKAGES val updateAppMutex = MutexState() 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 } } } } otherUserMapFlow.value = otherUsers.associateBy { it.id } otherUserAppInfoMapFlow.value = newAppMap otherUserAppIconMapFlow.value = newIconMap } private fun updatePartAppInfo( appIds: Set, ) = updateAppMutex.launchTry(appScope, Dispatchers.IO) { willUpdateAppIds.update { it - appIds } val newAppMap = HashMap(userAppInfoMapFlow.value) val newIconMap = HashMap(userAppIconMapFlow.value) 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 } } 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).map { action -> try { // DeadObjectException BadParcelableException app.packageManager.queryIntentActivities( Intent(action), PackageManager.MATCH_DISABLED_COMPONENTS ) } catch (_: Throwable) { emptyList() } }.flatten() .map { it.activityInfo.packageName }.toSet() .filter { !newAppMap.contains(it) }.mapNotNull { app.getPkgInfo(it) } visiblePkgList.forEach { pkgInfo -> val (appInfo, appIcon) = pkgInfo.toAppInfoAndIcon(hidden = false) newAppMap[pkgInfo.packageName] = appInfo if (appIcon != null) { newIconMap[pkgInfo.packageName] = appIcon } } } } 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 updateAllAppInfo() appScope.launchTry { shizukuContextFlow.drop(1).collect { updateAppMutex.launchTry(appScope, Dispatchers.IO) { updateOtherUserAppInfo() } } } appScope.launchTry { willUpdateAppIds.debounce(3000) .filter { it.isNotEmpty() } .collect { updatePartAppInfo(it) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/BarUtils.kt ================================================ 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 } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/CollectionExt.kt ================================================ package li.songe.gkd.util fun Set.switchItem(t: T): Set { return if (contains(t)) { minus(t) } else { plus(t) } } inline fun Iterable.filterIfNotAll(predicate: (T) -> Boolean): List { return if (count() > 0 && !all(predicate)) { filter(predicate) } else { this as? List ?: toList() } } inline fun Iterable.distinctByIfAny(selector: (T) -> K): List { return if (count() > 1 && any { v1 -> any { v2 -> v1 !== v2 && selector(v1) == selector(v2) } }) { distinctBy(selector) } else { this as? List ?: toList() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Constants.kt ================================================ package li.songe.gkd.util const val FILE_SHORT_URL = "https://f.gkd.li/" const val IMPORT_SHORT_URL = "https://i.gkd.li/i/" const val SERVER_SCRIPT_URL = "https://registry.npmmirror.com/@gkd-kit/config/latest/files/dist/server.js" const val REPOSITORY_URL = "https://github.com/gkd-kit/gkd" const val ISSUES_URL = "${REPOSITORY_URL}/issues" const val HOME_PAGE_URL = "https://gkd.li" const val LOCAL_SUBS_ID = -2L const val LOCAL_HTTP_SUBS_ID = -1L val LOCAL_SUBS_IDS = arrayOf(LOCAL_SUBS_ID, LOCAL_HTTP_SUBS_ID) const val EMPTY_RULE_TIP = "暂无规则" object ShortUrlSet { const val URL1 = "https://gkd.li?r=1" const val URL2 = "https://gkd.li?r=2" const val URL3 = "https://gkd.li?r=3" 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 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 PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=li.songe.gkd" const val systemUiAppId = "com.android.systemui" ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/CoroutineExt.kt ================================================ package li.songe.gkd.util import androidx.compose.runtime.Composable 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 fun CoroutineScope.launchTry( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, silent: Boolean = false, block: suspend CoroutineScope.() -> Unit, ) = launch(context, start) { try { block() } catch (e: CancellationException) { e.printStackTrace() } catch (_: InterruptRuleMatchException) { } catch (e: Throwable) { LogUtils.d(e) if (!silent) { toast(e.message ?: e.stackTraceToString(), loc = "", forced = e is RpcError) } } } @Composable fun CoroutineScope.launchAsFn( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, ): () -> Unit = { launch(context, start) { try { block() } catch (e: CancellationException) { e.printStackTrace() } catch (e: Throwable) { LogUtils.d(e) toast(e.message ?: e.stackTraceToString(), loc = "") } } } @Composable fun CoroutineScope.launchAsFn( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.(T) -> Unit, ): (T) -> Unit = { launch(context, start) { try { block(it) } catch (e: CancellationException) { e.printStackTrace() } catch (e: Throwable) { LogUtils.d(e) toast(e.message ?: e.stackTraceToString(), loc = "") } } } suspend fun stopCoroutine(): Nothing { currentCoroutineContext()[Job]?.cancel() yield() // the following code will not be run throw CancellationException("Coroutine stopped") } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/FlowExt.kt ================================================ package li.songe.gkd.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn fun StateFlow.mapState( coroutineScope: CoroutineScope, mapper: (value: T) -> M, ): StateFlow = map { mapper(it) }.stateIn( coroutineScope, SharingStarted.Eagerly, mapper(value) ) ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt ================================================ package li.songe.gkd.util import android.text.format.DateUtils import androidx.annotation.WorkerThread import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.data.AppInfo import li.songe.gkd.data.UserInfo import li.songe.gkd.data.otherUserMapFlow import li.songe.gkd.permission.allPermissionStates import li.songe.gkd.shizuku.currentUserId import li.songe.gkd.shizuku.shizukuContextFlow import java.io.File fun File.autoMk(): File { if (!exists()) { mkdirs() } return this } private val filesDir: File by lazy { app.getExternalFilesDir(null) ?: error("failed getExternalFilesDir") } 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 privateStoreFolder: File get() = app.filesDir.resolve("store").autoMk() private val cacheDir by lazy { app.externalCacheDir ?: app.cacheDir } val coilCacheDir: File get() = cacheDir.resolve("coil").autoMk() val sharedDir: File get() = cacheDir.resolve("shared").autoMk() private val tempDir: File get() = cacheDir.resolve("temp").autoMk() fun createGkdTempDir(): File { return tempDir .resolve(System.currentTimeMillis().toString()) .apply { mkdirs() } } private fun removeExpired(dir: File) { dir.listFiles()?.forEach { f -> if (System.currentTimeMillis() - f.lastModified() > DateUtils.HOUR_IN_MILLIS) { if (f.isDirectory) { f.deleteRecursively() } else if (f.isFile) { f.delete() } } } } fun clearCache() { removeExpired(sharedDir) 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 = createGkdTempDir() val files = mutableListOf(dbFolder, storeFolder, subsFolder, logFolder) tempDir.resolve("meta.json").also { it.writeText(toJson5String(META)) files.add(it) } 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) } tempDir.resolve("gkd-${META.versionCode}-v${META.versionName}.json").also { it.writeText(json.encodeToString(META)) files.add(it) } val logZipFile = sharedDir.resolve("log-${System.currentTimeMillis()}.zip") ZipUtils.zipFiles(files, logZipFile) tempDir.deleteRecursively() return logZipFile } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Github.kt ================================================ 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.material3.AlertDialog import androidx.compose.material3.OutlinedTextField 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.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.window.DialogProperties import io.ktor.client.call.body import io.ktor.client.plugins.onUpload import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.HttpMessageBuilder import io.ktor.http.HttpStatusCode import kotlinx.coroutines.delay 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.share.LocalMainViewModel import li.songe.json5.Json5 import java.io.File private fun HttpMessageBuilder.setCommonHeaders(cookie: String) { header("Cookie", cookie) header("Referer", "https://github.com/gkd-kit/inspect/issues/46") header("Origin", "https://github.com") header( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0" ) } private fun String.json5ToJsonString(): String { return json.encodeToString(Json5.parseToJson5Element(this)) } @Suppress("PropertyName") @Serializable private data class UploadPoliciesAssetsResponse( val upload_url: String, val asset_upload_url: String, val asset_upload_authenticity_token: String, val asset: GithubPoliciesAsset, val form: Map, ) data class GithubCookieException(override val message: String) : Exception(message) private suspend fun graphqlFetch( cookie: String, data: String, ): HttpResponse { return client.post("https://github.com/_graphql") { setCommonHeaders(cookie) header("Accept", "application/json") header("Content-Type", "text/plain;charset=UTF-8") header("GitHub-Verified-Fetch", "true") setBody(data) } } // https://github.com/lisonge/user-attachments suspend fun uploadFileToGithub( cookie: String, file: File, listener: ((progress: Float) -> Unit) ): GithubPoliciesAsset { // prepare upload asset val policiesRawResp = client.post("https://github.com/upload/policies/assets") { setCommonHeaders(cookie) header("GitHub-Verified-Fetch", "true") header("X-Requested-With", "XMLHttpRequest") setBody(MultiPartFormDataContent(formData { append("repository_id", "661952005") append("name", "file.zip") append("size", file.length().toString()) append("content_type", "application/x-zip-compressed") })) } if (policiesRawResp.status == HttpStatusCode.Unauthorized) { throw GithubCookieException("检测到 cookie 失效, 请更换") } val policiesResp = policiesRawResp.body() // upload to s3 val byteArray = file.readBytes() client.post(policiesResp.upload_url) { setCommonHeaders(cookie) setBody(MultiPartFormDataContent(formData { policiesResp.form.forEach { (key, value) -> append(key, value) } append("file", byteArray, Headers.build { append(HttpHeaders.ContentType, "application/x-zip-compressed") append(HttpHeaders.ContentDisposition, "filename=\"file.zip\"") }) })) onUpload { bytesSentTotal, contentLength -> listener(bytesSentTotal / (contentLength ?: byteArray.size).toFloat()) } } // check assets client.put("https://github.com" + policiesResp.asset_upload_url) { setCommonHeaders(cookie) header("Accept", "application/json") setBody(MultiPartFormDataContent(formData { append("authenticity_token", policiesResp.asset_upload_authenticity_token) })) } // send file url text to github comment val commentResultResp = graphqlFetch( cookie, """ { query: '50e7774b5a519b88858e02e46e0348da', variables: { connections: [ 'client:I_kwDOJ3SWBc6viUWN:__Issue__backTimelineItems_connection(visibleEventsOnly:true)', ], input: { body: '${policiesResp.asset.href}', subjectId: 'I_kwDOJ3SWBc6viUWN', }, }, } """.json5ToJsonString() ) val commentResult = json.decodeFromString(commentResultResp.bodyAsText()) val commentId = (commentResult.jsonObject["data"] ?.jsonObject["addComment"] ?.jsonObject["timelineEdge"] ?.jsonObject["node"] ?.jsonObject["id"]?.toString() ?: error("commentId not found")) // delay is needed delay(1000) // unsubscribe the comment graphqlFetch( cookie, """ { query: 'd0752b2e49295017f67c84f21bfe41a3', variables: { input: { state: 'UNSUBSCRIBED', subscribableId: 'I_kwDOJ3SWBc6viUWN' }, }, } """.json5ToJsonString() ) // delete the comment graphqlFetch( cookie, """ { query: 'b0f125991160e607a64d9407db9c01b3', variables: { connections: [], input: { id: $commentId }, }, } """.json5ToJsonString() ) return policiesResp.asset } @Composable fun EditGithubCookieDlg() { val mainVm = LocalMainViewModel.current val showEditCookieDlg by mainVm.showEditCookieDlgFlow.collectAsState() if (showEditCookieDlg) { var value by remember { mutableStateOf(mainVm.githubCookieFlow.value) } AlertDialog( properties = DialogProperties(dismissOnClickOutside = false), onDismissRequest = { mainVm.showEditCookieDlgFlow.value = false }, title = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { Text(text = "Github Cookie") PerfIconButton( imageVector = PerfIcon.HelpOutline, onClick = throttle { mainVm.showEditCookieDlgFlow.value = false mainVm.navigatePage(WebViewRoute(initUrl = ShortUrlSet.URL1)) }) } }, text = { OutlinedTextField( value = value, onValueChange = { value = it.filter { c -> c != '\n' && c != '\r' } }, placeholder = { Text(text = "请输入 Github Cookie") }, modifier = Modifier .fillMaxWidth() .autoFocus(), maxLines = 10, ) }, confirmButton = { TextButton(onClick = { mainVm.showEditCookieDlgFlow.value = false mainVm.githubCookieFlow.value = value.trim() toast("更新成功") }) { Text(text = "确认") } }, dismissButton = { TextButton(onClick = { mainVm.showEditCookieDlgFlow.value = false }) { Text(text = "取消") } } ) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/ImageUtils.kt ================================================ 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() } } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt ================================================ 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.Environment import android.provider.MediaStore import android.provider.Settings import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri 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 import li.songe.gkd.permission.requiredPermission import java.io.File import kotlin.reflect.KClass fun MainActivity.shareFile(file: File, title: String) { val uri = FileProvider.getUriForFile( app, "${app.packageName}.provider", file ) val intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, uri) type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } tryStartActivity( Intent.createChooser( intent, title ) ) } suspend fun MainActivity.saveFileToDownloads(file: File) { if (AndroidTarget.Q) { val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } withContext(Dispatchers.IO) { val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: error("创建URI失败") contentResolver.openOutputStream(uri)?.use { outputStream -> outputStream.write(file.readBytes()) outputStream.flush() } } } else { requiredPermission(this, canWriteExternalStorage) val targetFile = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), file.name ) targetFile.writeBytes(file.readBytes()) } toast("已保存 ${file.name} 到下载") } fun Context.tryStartActivity(intent: Intent) { try { startActivity(intent) } catch (e: Exception) { e.printStackTrace() LogUtils.d("tryStartActivity", e) toast("跳转失败\n" + (e.message ?: e.stackTraceToString())) } } fun openWeChatScaner() { val intent = app.packageManager.getLaunchIntentForPackage("com.tencent.mm")?.apply { putExtra("LauncherUI.From.Scaner.Shortcut", true) } if (intent == null) { toast("请检查微信是否安装或禁用") return } app.tryStartActivity(intent) } fun openA11ySettings() { val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK 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() } catch (e: Exception) { e.printStackTrace() toast("非法链接") return } openUri(u) } fun openUri(uri: Uri) { val intent = Intent(Intent.ACTION_VIEW, uri) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) app.tryStartActivity(intent) } fun openApp(appId: String) { val intent = app.packageManager.getLaunchIntentForPackage(appId) if (intent != null) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) app.tryStartActivity(intent) } else { toast("请检查此应用是否安装或禁用") } } fun stopServiceByClass(clazz: KClass) { val intent = Intent(app, clazz.java) app.stopService(intent) } fun startForegroundServiceByClass(clazz: KClass) { if (!notificationState.checkOrToast()) return if (!foregroundServiceSpecialUseState.checkOrToast()) return val intent = Intent(app, clazz.java) try { app.startForegroundService(intent) } catch (e: Throwable) { LogUtils.d(e) 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? } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/KeyboardUtils.kt ================================================ 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/LifecycleCallbacks.kt ================================================ package li.songe.gkd.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import li.songe.loc.Loc import java.util.WeakHashMap private val cbMap = WeakHashMap>>() typealias CbFn = () -> Unit @Suppress("UNCHECKED_CAST") private fun OnSimpleLife.cbs(method: Int): MutableList = synchronized(cbMap) { return cbMap.getOrPut(this) { hashMapOf() } .getOrPut(method) { mutableListOf() } as MutableList } interface OnSimpleLife { fun onCreated(f: CbFn) = cbs(1).add(f) fun onCreated() = cbs(1).forEach { it() } fun onDestroyed(f: CbFn) = cbs(2).add(f) fun onDestroyed() = cbs(2).forEach { it() } @Loc 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) } } } val scope: CoroutineScope fun useScope(): CoroutineScope = MainScope().apply { onDestroyed { cancel() } } fun useAliveFlow(stateFlow: MutableStateFlow) { onCreated { stateFlow.value = true } onDestroyed { stateFlow.value = false } } @Loc fun useAliveToast( name: String, delayMillis: Long = 0L, @Loc loc: String = "", ) { onCreated { toast("${name}已启动", loc = loc, delayMillis = delayMillis) } onDestroyed { toast("${name}已关闭", loc = loc) } } } interface OnA11yLife : OnSimpleLife { fun onA11yConnected(f: CbFn) = cbs(3).add(f) fun onA11yConnected() = cbs(3).forEach { it() } } interface OnTileLife : OnSimpleLife { fun onStartListened(f: CbFn) = cbs(4).add(f) fun onStartListened() = cbs(4).forEach { it() } 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() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/LinkLoad.kt ================================================ package li.songe.gkd.util ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/LoadStatus.kt ================================================ package li.songe.gkd.util sealed class LoadStatus { data class Loading(val progress: Float = 0f) : LoadStatus() data class Failure(val exception: Exception) : LoadStatus() data class Success(val result: T) : LoadStatus() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/LogUtils.kt ================================================ 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 { @Loc 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 private val deviceInfoText by lazy { val deviceInfos = listOf( android.os.Build.MANUFACTURER, android.os.Build.MODEL, DeviceBrand.getBrandName(), DeviceOs.getOsName() + DeviceOs.getOsVersionName() + DeviceOs.getOsBigVersionCode(), DeviceMarketName.getMarketName(app) ) buildString { append("Android: ${android.os.Build.VERSION.RELEASE} (${android.os.Build.VERSION.SDK_INT})\n") append("Device: ${deviceInfos.joinToString("/")}\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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/MutexState.kt ================================================ 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 = 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 { intState.update { it - 1 } } } suspend inline fun whenUnLock(block: () -> Unit) { if (mutex.isLocked) return withStateLock(block) } fun launchTry( scope: CoroutineScope, context: CoroutineContext, block: suspend () -> Unit, ) = scope.launchTry(context = context) { withStateLock { block() } }.let { } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/NetworkExt.kt ================================================ package li.songe.gkd.util import java.net.NetworkInterface import java.net.ServerSocket fun getIpAddressInLocalNetwork(): List { val networkInterfaces = try { NetworkInterface.getNetworkInterfaces().asSequence() } catch (e: Exception) { // android.system.ErrnoException: getifaddrs failed: EACCES (Permission denied) toast("获取HOST失败:" + e.message) return emptyList() } val localAddresses = networkInterfaces.flatMap { it.inetAddresses.asSequence().filter { inetAddress -> inetAddress.isSiteLocalAddress && !(inetAddress.hostAddress?.contains(":") ?: false) && inetAddress.hostAddress != "127.0.0.1" }.map { inetAddress -> inetAddress.hostAddress } } return localAddresses.toList() } fun isPortAvailable(port: Int): Boolean { var serverSocket: ServerSocket? = null return try { serverSocket = ServerSocket(port) serverSocket.reuseAddress = true true } catch (_: Exception) { false } finally { serverSocket?.close() } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/NetworkUtils.kt ================================================ package li.songe.gkd.util import java.net.InetAddress object NetworkUtils { fun isAvailable(): Boolean = try { InetAddress.getByName("www.baidu.com") != null } catch (_: Throwable) { false } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Option.kt ================================================ package li.songe.gkd.util 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> } sealed interface OptionIcon { val icon: ImageVector } sealed interface OptionMenuLabel { val menuLabel: String } fun > Iterable.findOption(value: V): T { return find { it.value == value } ?: first() } fun Option.toToggleableState() = when (value) { true -> ToggleableState.On false -> ToggleableState.Off null -> ToggleableState.Indeterminate } sealed class AppSortOption(override val value: Int, override val label: String) : Option { override val options get() = objects data object ByAppName : AppSortOption(0, "按应用名称") data object ByActionTime : AppSortOption(2, "按最近触发") data object ByUsedTime : AppSortOption(3, "按最近使用") companion object { val objects by lazy { listOf(ByAppName, ByUsedTime, ByActionTime) } } } 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 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, 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 objects by lazy { listOf(FollowSystem, AlwaysEnable, AlwaysDisable) } } } 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 objects by lazy { listOf(FollowSubs, AllEnable, AllDisable) } } } sealed class RuleSortOption(override val value: Int, override val label: String) : Option { override val options get() = objects data object ByDefault : RuleSortOption(0, "按默认顺序") data object ByActionTime : RuleSortOption(1, "按最近触发") data object ByRuleName : RuleSortOption(2, "按规则名称") companion object { val objects by lazy { listOf(ByDefault, ByActionTime, ByRuleName) } } } sealed class UpdateChannelOption( override val value: Int, override val label: String, val url: String ) : Option { 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" ) companion object { val objects by lazy { listOf(Stable, Beta) } } } 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 objects by lazy { listOf(A11yMode, AutomationMode) } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Others.kt ================================================ package li.songe.gkd.util import android.app.Activity import android.content.ComponentName import android.content.Intent import android.content.pm.PackageInfo import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.drawable.Drawable import android.os.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 import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.ui.unit.sp import androidx.core.graphics.get 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.File import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName private val componentNameCache by lazy { HashMap() } val KClass<*>.componentName get() = componentNameCache.getOrPut(jvmName) { ComponentName(META.appId, jvmName) } fun Bitmap.isFullTransparent(): Boolean { repeat(width) { x -> repeat(height) { y -> if (this[x, y] != Color.TRANSPARENT) { return false } } } return true } class InterruptRuleMatchException() : Exception() fun getShowActivityId(appId: String, activityId: String?): String? { return if (activityId != null) { if (activityId.startsWith(appId) && activityId.getOrNull(appId.length) == '.') { activityId.substring(appId.length) } else { activityId } } else { null } } fun MainActivity.fixSomeProblems() { fixTransparentNavigationBar() } private fun Activity.fixTransparentNavigationBar() { // 修复在浅色主题下导航栏背景不透明的问题 if (AndroidTarget.Q) { window.isNavigationBarContrastEnforced = false } else { @Suppress("DEPRECATION") window.navigationBarColor = Color.TRANSPARENT } } fun > AnimatedContentTransitionScope.getUpDownTransform(): ContentTransform { return if (targetState > initialState) { slideInVertically { height -> height } + fadeIn() togetherWith slideOutVertically { height -> -height } + fadeOut() } else { slideInVertically { height -> -height } + fadeIn() togetherWith slideOutVertically { height -> height } + fadeOut() }.using( SizeTransform(clip = false) ) } 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) } fun drawTextToBitmap(text: String, bitmap: Bitmap) { val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize = 32.sp.px color = Color.BLUE textAlign = Paint.Align.CENTER } val canvas = Canvas(bitmap) val strList = text.split('\n') strList.forEachIndexed { i, str -> canvas.drawText( str, bitmap.width / 2f, (bitmap.height / 2f) + (i - strList.size / 2f) * (paint.textSize + 4.sp.px), paint ) } } // 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/ScreenUtils.kt ================================================ package li.songe.gkd.util import android.content.res.Configuration import android.content.res.Resources import android.graphics.Point import li.songe.gkd.app @Suppress("DEPRECATION") object ScreenUtils { fun getScreenWidth(): Int = Point().apply { app.windowManager.defaultDisplay.getRealSize(this) }.x fun getScreenHeight(): Int = Point().apply { app.windowManager.defaultDisplay.getRealSize(this) }.y fun getScreenDensityDpi(): Int = Resources.getSystem().displayMetrics.densityDpi fun isLandscape(): Boolean { return app.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } fun isScreenLock(): Boolean = app.keyguardManager.inKeyguardRestrictedInputMode() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/ScreenshotUtil.kt ================================================ package li.songe.gkd.util import android.app.Activity import android.app.Activity.RESULT_OK import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.PixelFormat import android.hardware.display.DisplayManager import android.hardware.display.VirtualDisplay import android.media.Image import android.media.ImageReader import android.media.projection.MediaProjection import android.media.projection.MediaProjectionManager import android.os.Handler import android.os.Looper import androidx.core.graphics.createBitmap 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 class ScreenshotUtil( private val context: Context, private val screenshotIntent: Intent ) { private val handler by lazy { Handler(Looper.getMainLooper()) } private var virtualDisplay: VirtualDisplay? = null private var imageReader: ImageReader? = null private var mediaProjection: MediaProjection? = null private val mediaProjectionManager by lazy { context.getSystemService( Activity.MEDIA_PROJECTION_SERVICE ) as MediaProjectionManager } private val width: Int get() = ScreenUtils.getScreenWidth() private val height: Int get() = ScreenUtils.getScreenHeight() private val dpi: Int get() = ScreenUtils.getScreenDensityDpi() fun destroy() { imageReader?.setOnImageAvailableListener(null, null) virtualDisplay?.release() imageReader?.close() mediaProjection?.stop() } // TODO android13 上一半概率获取到全透明图片, android12 暂无此问题 suspend fun execute() = suspendCoroutine { block -> imageReader = ImageReader.newInstance( width, height, PixelFormat.RGBA_8888, 2 ) if (mediaProjection == null) { mediaProjection = mediaProjectionManager.getMediaProjection( RESULT_OK, screenshotIntent ) } virtualDisplay = mediaProjection!!.createVirtualDisplay( "screenshot", width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, imageReader!!.surface, null, handler ) var resumed = false imageReader!!.setOnImageAvailableListener({ reader -> if (resumed) return@setOnImageAvailableListener var image: Image? = null var bitmapWithStride: Bitmap? = null val bitmap: Bitmap? try { image = reader.acquireLatestImage() if (image != null) { val planes = image.planes val buffer = planes[0].buffer val pixelStride = planes[0].pixelStride val rowStride = planes[0].rowStride bitmapWithStride = createBitmap(rowStride / pixelStride, height) bitmapWithStride.copyPixelsFromBuffer(buffer) bitmap = Bitmap.createBitmap(bitmapWithStride, 0, 0, width, height) if (!bitmap.isFullTransparent()) { imageReader?.setOnImageAvailableListener(null, null) block.resume(bitmap) resumed = true } } } catch (e: Exception) { e.printStackTrace() imageReader?.setOnImageAvailableListener(null, null) block.resumeWithException(e) } finally { bitmapWithStride?.recycle() image?.close() } }, handler) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Singleton.kt ================================================ package li.songe.gkd.util 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 java.text.Collator import java.util.Locale val json by lazy { Json { ignoreUnknownKeys = true explicitNulls = false encodeDefaults = true } } val keepNullJson by lazy { Json(from = json) { explicitNulls = true } } val client by lazy { HttpClient(OkHttp) { install(ContentNegotiation) { json(json, ContentType.Any) } engine { clientCacheSize = 0 } } } val collator by lazy { Collator.getInstance(Locale.CHINESE)!! } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/SnapshotExt.kt ================================================ package li.songe.gkd.util import android.graphics.Bitmap import androidx.core.graphics.createBitmap import androidx.core.graphics.set 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.ScreenshotService import li.songe.gkd.shizuku.shizukuContextFlow import li.songe.gkd.store.storeFlow import java.io.File import kotlin.math.min 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( snapshotId: Long, appId: String? = null, activityId: String? = null ): File { val filename = if (appId != null) { val name = appInfoMapFlow.value[appId]?.name?.filterNot { c -> c in "\\/:*?\"<>|" || c <= ' ' } if (activityId != null) { "${(name ?: appId).take(20)}_${ activityId.split('.').last().take(40) }-${snapshotId}.zip" } else { "${(name ?: appId).take(20)}-${snapshotId}.zip" } } else { "${snapshotId}.zip" } val file = sharedDir.resolve(filename) if (file.exists()) { file.delete() } withContext(Dispatchers.IO) { ZipUtils.zipFiles( listOf( snapshotFile(snapshotId), screenshotFile(snapshotId) ), file ) } return file } fun removeSnapshot(id: Long) { snapshotParentPath(id).apply { if (exists()) { deleteRecursively() } } } private fun emptyScreenBitmap(text: String): Bitmap { return createBitmap(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight()).apply { drawTextToBitmap(text, this) } } private fun cropBitmapStatusBar(bitmap: Bitmap): Bitmap { val tempBp = bitmap.run { if (!isMutable || config == Bitmap.Config.HARDWARE) { 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 barHeight) { tempBp[x, y] = 0 } } return tempBp } private val captureLoading = MutableStateFlow(false) suspend fun captureSnapshot( skipScreenshot: Boolean = false, forcedCropStatusBar: Boolean = false, ): ComplexSnapshot { if (A11yRuleEngine.instance == null) { throw RpcError("服务不可用,请先授权") } if (captureLoading.value) { throw RpcError("正在保存快照,不可重复操作") } captureLoading.value = true try { val rootNode = A11yRuleEngine.instance?.safeActiveWindow ?: throw RpcError("当前应用没有无障碍信息,捕获失败") if (storeFlow.value.showSaveSnapshotToast) { 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 = appId, activityId = activityId, screenHeight = ScreenUtils.getScreenHeight(), screenWidth = ScreenUtils.getScreenWidth(), isLandscape = ScreenUtils.isLandscape(), nodes = info2nodeList(rootNode) ) } val d2 = async(Dispatchers.IO) { if (skipScreenshot) { emptyScreenBitmap("跳过截图\n请自行替换") } else { A11yRuleEngine.screenshot() ?: ScreenshotService.screenshot() ?: emptyScreenBitmap("无截图权限\n请自行替换") }.let { if (storeFlow.value.hideSnapshotStatusBar && (forcedCropStatusBar || BarUtils.checkStatusBarVisible() == true)) { cropBitmapStatusBar(it) } else { it } } } d1.await() to d2.await() } withContext(Dispatchers.IO) { snapshotParentPath(snapshot.id).autoMk() screenshotFile(snapshot.id).outputStream().use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) } snapshotFile(snapshot.id).writeText(keepNullJson.encodeToString(snapshot)) minSnapshotFile(snapshot.id).writeText( keepNullJson.encodeToString( snapshot.copy( nodes = emptyList() ) ) ) DbSet.snapshotDao.insert(snapshot.toSnapshot()) } toast("快照成功", forced = true) val desc = snapshot.appInfo?.name ?: snapshot.appId snapshotNotif.copy(text = "快照「$desc」已保存至记录").notifySelf() return snapshot } finally { captureLoading.value = false } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/SubsState.kt ================================================ package li.songe.gkd.util import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import li.songe.gkd.appScope import li.songe.gkd.data.AppRule import li.songe.gkd.data.CategoryConfig import li.songe.gkd.data.GlobalRule import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.ResolvedAppGroup import li.songe.gkd.data.ResolvedGlobalGroup import li.songe.gkd.data.SubsConfig import li.songe.gkd.data.SubsItem import li.songe.gkd.data.SubsVersion import li.songe.gkd.db.DbSet import li.songe.json5.decodeFromJson5String import java.net.URI val subsItemsFlow by lazy { DbSet.subsItemDao.query().stateIn(appScope, SharingStarted.Eagerly, emptyList()) } private fun getCheckUpdateUrl( subsItem: SubsItem, subscription: RawSubscription?, ): String? { val checkUpdateUrl = subscription?.checkUpdateUrl ?: return null val updateUrl = subscription.updateUrl ?: subsItem.updateUrl ?: return checkUpdateUrl try { return URI(updateUrl).resolve(checkUpdateUrl).toString() } catch (e: Exception) { e.printStackTrace() } return null } sealed class SubsEntryType { abstract val subsItem: SubsItem abstract val subscription: RawSubscription? val checkUpdateUrl by lazy { getCheckUpdateUrl(subsItem, subscription) } } data class SubsEntry( override val subsItem: SubsItem, override val subscription: RawSubscription?, ) : SubsEntryType() data class UsedSubsEntry( override val subsItem: SubsItem, override val subscription: RawSubscription, ) : SubsEntryType() val subsLoadErrorsFlow = MutableStateFlow>(emptyMap()) val subsRefreshErrorsFlow = MutableStateFlow>(emptyMap()) val subsMapFlow = MutableStateFlow>(emptyMap()) val latestRecordFlow by lazy { DbSet.actionLogDao.queryLatest().stateIn(appScope, SharingStarted.Eagerly, null) } val latestRecordDescFlow by lazy { combine( latestRecordFlow, subsMapFlow, appInfoMapFlow, ) { record, subsMap, appMap -> if (record == null) return@combine null val isAppRule = record.groupType == SubsConfig.AppGroupType val groupName = if (isAppRule) { subsMap[record.subsId]?.apps?.find { a -> a.id == record.appId }?.groups?.find { g -> g.key == record.groupKey }?.name } else { subsMap[record.subsId]?.globalGroups?.find { g -> g.key == record.groupKey }?.name } val appName = appMap[record.appId]?.name val appShowName = appName ?: record.appId if (groupName != null) { if (groupName.startsWith(appShowName)) { groupName } else { if (isAppRule) { "$appShowName/$groupName" } else { "$groupName/$appShowName" } } } else { appShowName } }.stateIn(appScope, SharingStarted.Eagerly, null) } val subsEntriesFlow by lazy { combine( subsItemsFlow, subsMapFlow, ) { subsItems, subsIdToRaw -> subsItems.map { s -> SubsEntry( subsItem = s, subscription = subsIdToRaw[s.id], ) } }.stateIn(appScope, SharingStarted.Eagerly, emptyList()) } val usedSubsEntriesFlow by lazy { 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.withStateLock { val subsId = subscription.id val subsName = subscription.name val newMap = subsMapFlow.value.toMutableMap() val nextSubsRaw: RawSubscription if (subsId < 0 && newMap[subsId]?.version == subscription.version) { nextSubsRaw = subscription.run { copy( version = version + 1, apps = apps.filterIfNotAll { it.groups.isNotEmpty() } .distinctByIfAny { it.id }, ) } } else { nextSubsRaw = subscription } newMap[subsId] = nextSubsRaw subsMapFlow.value = newMap if (subsLoadErrorsFlow.value.contains(subsId)) { subsLoadErrorsFlow.update { it.toMutableMap().apply { remove(subsId) } } } withContext(Dispatchers.IO) { cleanupSubsConfig(subsId, nextSubsRaw) DbSet.subsItemDao.updateMtime(subsId, System.currentTimeMillis()) subsFolder.resolve("${subsId}.json") .writeText(json.encodeToString(nextSubsRaw)) } LogUtils.d("更新订阅文件:id=${subsId},name=${subsName}") } } } fun deleteSubscription(vararg subsIds: Long) { appScope.launchTry(Dispatchers.IO) { updateSubsMutex.mutex.withLock { val deleteSize = DbSet.subsItemDao.deleteById(*subsIds) if (deleteSize > 0) { DbSet.subsConfigDao.deleteBySubsId(*subsIds) DbSet.actionLogDao.deleteBySubsId(*subsIds) DbSet.categoryConfigDao.deleteBySubsId(*subsIds) val newMap = subsMapFlow.value.toMutableMap() subsIds.forEach { id -> newMap.remove(id) subsFolder.resolve("$id.json").apply { if (exists()) { delete() } } } subsMapFlow.value = newMap toast("删除成功") LogUtils.d("deleteSubscription", subsIds) } } } } fun getCategoryEnable( category: RawSubscription.RawCategory?, categoryConfig: CategoryConfig?, ): Boolean? = if (categoryConfig != null) { // 批量配置 categoryConfig.enable } else { // 批量默认 category?.enable } fun getGroupEnable( group: RawSubscription.RawGroupProps, subsConfig: SubsConfig?, category: RawSubscription.RawCategory? = null, categoryConfig: CategoryConfig? = null, ): Boolean = group.valid && when (group) { // 优先级: 规则用户配置 > 批量配置 > 批量默认 > 规则默认 is RawSubscription.RawAppGroup -> { subsConfig?.enable ?: getCategoryEnable(category, categoryConfig) ?: group.enable ?: true } is RawSubscription.RawGlobalGroup -> { subsConfig?.enable ?: group.enable ?: true } } data class RuleSummary( val globalRules: List = emptyList(), val globalGroups: List = emptyList(), val appIdToRules: Map> = emptyMap(), val appIdToGroups: Map> = emptyMap(), val appIdToAllGroups: Map> = emptyMap(), ) { val appSize = appIdToRules.keys.size val appGroupSize = appIdToGroups.values.sumOf { s -> s.size } val numText = if (globalGroups.size + appGroupSize > 0) { if (globalGroups.isNotEmpty()) { "${globalGroups.size}全局" + if (appGroupSize > 0) { "/" } else { "" } } else { "" } + if (appGroupSize > 0) { "${appSize}应用/${appGroupSize}规则组" } else { "" } } else { EMPTY_RULE_TIP } val slowGlobalGroups = globalRules.filter { r -> r.isSlow }.distinctBy { r -> r.group } .map { r -> r.group to r } val slowAppGroups = appIdToRules.values.flatten().filter { r -> r.isSlow }.distinctBy { r -> r.group } .map { r -> r.group to r } val slowGroupCount = slowGlobalGroups.size + slowAppGroups.size } val ruleSummaryFlow by lazy { combine( usedSubsEntriesFlow, appInfoMapFlow, DbSet.appConfigDao.queryUsedList(), DbSet.subsConfigDao.queryUsedList(), DbSet.categoryConfigDao.queryUsedList(), ) { subsEntries, appInfoCache, appConfigs, subsConfigs, categoryConfigs -> val globalSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.GlobalGroupType } val groupSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.AppGroupType } val appRules = HashMap>() val appGroups = HashMap>() val appAllGroups = HashMap>() val globalRules = mutableListOf() val globalGroups = mutableListOf() subsEntries.forEach { (subsItem, rawSubs) -> // global scope val subGlobalSubsConfigs = globalSubsConfigs.filter { c -> c.subsId == subsItem.id } val subGlobalGroupToRules = mutableMapOf>() rawSubs.globalGroups.filter { g -> (subGlobalSubsConfigs.find { c -> c.groupKey == g.key }?.enable ?: g.enable ?: true) && g.valid }.forEach { groupRaw -> val config = subGlobalSubsConfigs.find { c -> c.groupKey == groupRaw.key } val g = ResolvedGlobalGroup( group = groupRaw, subscription = rawSubs, subsItem = subsItem, config = config ) globalGroups.add(g) val subRules = groupRaw.rules.map { ruleRaw -> GlobalRule( rule = ruleRaw, g = g, appInfoCache = appInfoCache, ) } subGlobalGroupToRules[groupRaw] = subRules globalRules.addAll(subRules) } subGlobalGroupToRules.values.forEach { it.forEach { r -> r.groupToRules = subGlobalGroupToRules } } subGlobalGroupToRules.clear() // app scope val subAppConfigs = appConfigs.filter { c -> c.subsId == subsItem.id } val subGroupSubsConfigs = groupSubsConfigs.filter { c -> c.subsId == subsItem.id } val subCategoryConfigs = categoryConfigs.filter { c -> c.subsId == subsItem.id } rawSubs.apps.filter { appRaw -> // 筛选 当前启用的 app 订阅规则 appRaw.groups.isNotEmpty() && (subAppConfigs.find { c -> c.appId == appRaw.id }?.enable ?: (appInfoCache[appRaw.id] != null)) }.forEach { appRaw -> val subAppGroups = mutableListOf() val appGroupConfigs = subGroupSubsConfigs.filter { c -> c.appId == appRaw.id } val subAppGroupToRules = mutableMapOf>() val groupAndEnables = appRaw.groups.map { group -> val config = appGroupConfigs.find { c -> c.groupKey == group.key } val category = rawSubs.groupToCategoryMap[group] val categoryConfig = subCategoryConfigs.find { c -> c.categoryKey == category?.key } val enable = getGroupEnable( group, config, category, categoryConfig ) && group.valid ResolvedAppGroup( group = group, subscription = rawSubs, subsItem = subsItem, config = config, app = appRaw, enable = enable, ) } appAllGroups[appRaw.id] = (appAllGroups[appRaw.id] ?: emptyList()) + groupAndEnables groupAndEnables.forEach { g -> if (g.enable) { subAppGroups.add(g.group) val subRules = g.group.rules.map { ruleRaw -> AppRule( rule = ruleRaw, g = g, appInfo = appInfoCache[appRaw.id] ) }.filter { r -> r.enable } subAppGroupToRules[g.group] = subRules if (subRules.isNotEmpty()) { val rules = appRules[appRaw.id] ?: mutableListOf() appRules[appRaw.id] = rules rules.addAll(subRules) } } } if (subAppGroups.isNotEmpty()) { appGroups[appRaw.id] = subAppGroups } subAppGroupToRules.values.forEach { it.forEach { r -> r.groupToRules = subAppGroupToRules } } } } RuleSummary( globalRules = globalRules, globalGroups = globalGroups, appIdToRules = appRules, appIdToGroups = appGroups, appIdToAllGroups = appAllGroups ) }.flowOn(Dispatchers.Default).stateIn(appScope, SharingStarted.Eagerly, RuleSummary()) } fun getSubsStatus(ruleSummary: RuleSummary, count: Long): String { return if (count > 0) { "${ruleSummary.numText}/${count}触发" } else { ruleSummary.numText } } private fun loadSubs(id: Long): RawSubscription { val file = subsFolder.resolve("${id}.json") if (!file.exists()) { // 某些设备出现这种情况 if (id == LOCAL_SUBS_ID) { return RawSubscription( id = LOCAL_SUBS_ID, name = "本地订阅", version = 0 ) } if (id == LOCAL_HTTP_SUBS_ID) { return RawSubscription( id = LOCAL_HTTP_SUBS_ID, name = "内存订阅", version = 0 ) } error("订阅文件不存在") } val subscription = try { RawSubscription.parse(file.readText(), json5 = false) } catch (e: Exception) { throw Exception("订阅文件解析失败", e) } if (subscription.id != id) { error("订阅文件id不一致") } return subscription } private fun refreshRawSubsList(items: List): Boolean { if (items.isEmpty()) return false val subscriptions = subsMapFlow.value.toMutableMap() val errors = subsLoadErrorsFlow.value.toMutableMap() var changed = false items.forEach { s -> try { subscriptions[s.id] = loadSubs(s.id) errors.remove(s.id) changed = true } catch (e: Exception) { errors[s.id] = e } } subsMapFlow.value = subscriptions subsLoadErrorsFlow.value = errors return changed } fun initSubsState() { subsItemsFlow.value appScope.launchTry(Dispatchers.IO) { 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? { val subsItem = subsEntry.subsItem val subsRaw = subsEntry.subscription if (subsItem.updateUrl == null || subsItem.id < 0) return null val checkUpdateUrl = subsEntry.checkUpdateUrl if (checkUpdateUrl != null && subsRaw != null) { try { val subsVersion = json.decodeFromJson5String( client.get(checkUpdateUrl).bodyAsText() ) if (subsVersion.id == subsRaw.id && subsVersion.version <= subsRaw.version) { return null } } catch (e: Exception) { LogUtils.d("快速检测更新失败", subsItem, e.message) } } val updateUrl = subsRaw?.updateUrl ?: subsItem.updateUrl val text = try { client.get(updateUrl).bodyAsText() } catch (e: Exception) { throw Exception("请求更新链接失败", e) } val newSubsRaw = try { RawSubscription.parse(text) } catch (e: Exception) { throw Exception("解析文本失败", e) } if (newSubsRaw.id != subsItem.id) { error("新id=${newSubsRaw.id}不匹配旧id=${subsItem.id}") } if (subsRaw != null && newSubsRaw.version <= subsRaw.version) { LogUtils.d( "版本号不满足条件:id=${subsItem.id}", "${subsRaw.version} -> ${newSubsRaw.version}" ) return null } return newSubsRaw } fun checkSubsUpdate(showToast: Boolean = false) = appScope.launchTry(Dispatchers.IO) { if (updateSubsMutex.mutex.isLocked) { return@launchTry } updateSubsMutex.withStateLock { if (subsEntriesFlow.value.any { !it.subsItem.isLocal } && !NetworkUtils.isAvailable()) { if (showToast) { toast("网络不可用") } return@withStateLock } LogUtils.d("开始检测更新") // 文件不存在, 重新加载 val changed = refreshRawSubsList(subsEntriesFlow.value.filter { it.subscription == null } .map { it.subsItem }) if (changed) { delay(500) } var successNum = 0 subsEntriesFlow.value.filter { !it.subsItem.isLocal }.forEach { subsEntry -> try { val newSubsRaw = updateSubs(subsEntry) if (newSubsRaw != null) { updateSubscription(newSubsRaw) successNum++ } if (subsRefreshErrorsFlow.value.contains(subsEntry.subsItem.id)) { subsRefreshErrorsFlow.update { it.toMutableMap().apply { remove(subsEntry.subsItem.id) } } } } catch (e: Exception) { subsRefreshErrorsFlow.update { it.toMutableMap().apply { set(subsEntry.subsItem.id, e) } } LogUtils.d("检测更新失败", e.message) } } if (showToast) { if (successNum > 0) { toast("更新 $successNum 条订阅") } else { toast("暂无更新") } } LogUtils.d("结束检测更新") delay(500) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt ================================================ package li.songe.gkd.util import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import java.text.SimpleDateFormat import java.util.Locale import java.util.concurrent.TimeUnit fun formatTimeAgo(timestamp: Long): String { val currentTime = System.currentTimeMillis() val timeDifference = currentTime - timestamp val minutes = TimeUnit.MILLISECONDS.toMinutes(timeDifference) val hours = TimeUnit.MILLISECONDS.toHours(timeDifference) val days = TimeUnit.MILLISECONDS.toDays(timeDifference) val weeks = days / 7 val months = (days / 30) val years = (days / 365) return when { years > 0 -> "${years}年前" months > 0 -> "${months}月前" weeks > 0 -> "${weeks}周前" days > 0 -> "${days}天前" hours > 0 -> "${hours}小时前" minutes > 0 -> "${minutes}分钟前" else -> "刚刚" } } private val formatDateMap by lazy { hashMapOf() } fun Long.format(formatStr: String): String { var df = formatDateMap[formatStr] if (df == null) { df = SimpleDateFormat(formatStr, Locale.getDefault()) formatDateMap[formatStr] = df } return df.format(this) } data class ThrottleTimer( private val interval: Long = 500L, ) { private var lastAccessTime: Long = 0L fun expired(): Boolean { val t = System.currentTimeMillis() if (t - lastAccessTime > interval) { lastAccessTime = t return true } return false } } @Composable fun throttle( fn: (() -> Unit), ): (() -> Unit) { val timer = remember { ThrottleTimer() } return remember(fn) { { if (timer.expired()) { fn.invoke() } } } } @Composable fun throttle( fn: ((T) -> Unit), ): ((T) -> Unit) { val timer = remember { ThrottleTimer() } return remember(fn) { { if (timer.expired()) { fn.invoke(it) } } } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Toast.kt ================================================ package li.songe.gkd.util import android.content.ClipData import android.content.Context import android.content.res.Configuration import android.graphics.Color import android.graphics.Outline import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable import android.graphics.text.LineBreaker import android.util.TypedValue import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.ViewOutlineProvider import android.view.WindowManager import android.widget.TextView import android.widget.Toast import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt import com.hjq.toast.Toaster import com.hjq.toast.style.WhiteToastStyle import li.songe.gkd.app 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 @Loc 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 get() = storeFlow.value.enableDarkTheme ?: app.resources.configuration.let { it.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES } private val toastYOffset: Int get() = (ScreenUtils.getScreenHeight() * 0.12f).toInt() private val circleOutlineProvider by lazy { object : ViewOutlineProvider() { override fun getOutline(view: View?, outline: Outline?) { if (view != null && outline != null) { // 20.sp : line height, 12.dp : top/bottom padding outline.setRoundRect( 0, 0, view.width, view.height, (12.dp.px * 2 + 20.sp.px) / 2f ) } } } } private fun View.updateToastView() { setPaddingRelative( 16.dp.px.toInt(), 12.dp.px.toInt(), 16.dp.px.toInt(), 12.dp.px.toInt(), ) layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) 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()) } outlineProvider = circleOutlineProvider clipToOutline = true elevation = 2.dp.px outlineProvider = circleOutlineProvider clipToOutline = true } private fun setReactiveToastStyle() { Toaster.setStyle(object : WhiteToastStyle() { override fun getGravity() = Gravity.BOTTOM override fun getYOffset() = toastYOffset override fun getTranslationZ(context: Context?) = 0f override fun createView(context: Context?): View { return super.createView(context).apply { updateToastView() } } }) } private var triggerTime = 0L private const val triggerInterval = 2000L fun showActionToast(rule: ResolvedRule) { if (!storeFlow.value.toastWhenClick) return 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(text) } else { showA11yToast(text) } } } } private var cacheToast: Toast? = null private fun showSystemToast(message: CharSequence) { cacheToast?.cancel() 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 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 = if (wm == app.windowManager) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY } format = PixelFormat.TRANSLUCENT 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 y = toastYOffset windowAnimations = android.R.style.Animation_Toast } wm.addView(textView, layoutParams) runMainPost(triggerInterval) { try { wm.removeViewImmediate(textView) } catch (_: Exception) { } } } 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() } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Unit.kt ================================================ package li.songe.gkd.util import android.util.TypedValue import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import li.songe.gkd.app /** * px -> dp */ val Dp.px: Float get() = value * app.resources.displayMetrics.density /** * sp -> px */ val TextUnit.px: Float get() = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, value, app.resources.displayMetrics ) ///** // * px -> dp // */ //val Int.calcDp: Float // get() = this / app.resources.displayMetrics.density ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/Upgrade.kt ================================================ package li.songe.gkd.util import android.content.Intent import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.LinearProgressIndicator 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 androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import io.ktor.client.call.body import io.ktor.client.plugins.onDownload import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel import io.ktor.util.cio.writeChannel import io.ktor.utils.io.copyAndClose import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import li.songe.gkd.META import li.songe.gkd.app import li.songe.gkd.store.createAnyFlow import li.songe.gkd.store.storeFlow import java.io.File import java.net.URI private val UPDATE_URL: String get() = UpdateChannelOption.objects.findOption(storeFlow.value.updateChannel).url @Serializable data class NewVersion( val versionCode: Int, val versionName: String, val changelog: String, val downloadUrl: String, val fileSize: Long, val versionLogs: List = emptyList(), ) @Serializable data class VersionLog( val name: String, val code: Int, val desc: String, ) class UpdateStatus(val scope: CoroutineScope) { private val checkUpdatingMutex = MutexState() val checkUpdatingFlow get() = checkUpdatingMutex.state private val newVersionFlow = MutableStateFlow(null) private val downloadStatusFlow = MutableStateFlow?>(null) private var downloadJob: Job? = null private val ignoreVersionListFlow by lazy { createAnyFlow( key = "ignore_version_list", default = { emptySet() }, scope = scope, ) } private var lastManual = false fun checkUpdate(manual: Boolean = false) = scope.launchTry(Dispatchers.IO, silent = manual) { lastManual = manual checkUpdatingMutex.whenUnLock { if (!NetworkUtils.isAvailable()) { error("网络不可用") } val newVersion = client.get(UPDATE_URL).body() if (newVersion.versionCode <= META.versionCode) { if (manual) toast("暂无更新") return@launchTry } if (!manual && ignoreVersionListFlow.value.contains(newVersion.versionCode)) return@launchTry newVersionFlow.value = newVersion } }.let { } private fun startDownload(newVersion: NewVersion) { if (downloadStatusFlow.value is LoadStatus.Loading) return downloadStatusFlow.value = LoadStatus.Loading(0f) val apkFile = sharedDir.resolve("gkd-v${newVersion.versionCode}.apk").apply { if (exists()) { delete() } } downloadJob = scope.launch(Dispatchers.IO) { try { val channel = client.get(URI(UPDATE_URL).resolve(newVersion.downloadUrl).toString()) { onDownload { bytesSentTotal, _ -> val downloadStatus = downloadStatusFlow.value if (downloadStatus is LoadStatus.Loading) { downloadStatusFlow.value = LoadStatus.Loading( bytesSentTotal.toFloat() / (newVersion.fileSize) ) } else if (downloadStatus is LoadStatus.Failure) { // 提前终止下载 downloadJob?.cancel() } } }.bodyAsChannel() if (downloadStatusFlow.value is LoadStatus.Loading) { channel.copyAndClose(apkFile.writeChannel()) downloadStatusFlow.value = LoadStatus.Success(apkFile) } } catch (e: Exception) { if (downloadStatusFlow.value is LoadStatus.Loading) { downloadStatusFlow.value = LoadStatus.Failure(e) } } finally { downloadJob = null } } } @Composable fun UpgradeDialog() { newVersionFlow.collectAsState().value?.let { newVersionVal -> val text = remember { val logs = newVersionVal.versionLogs.takeWhile { v -> v.code > META.versionCode } "v${META.versionName} -> v${newVersionVal.versionName}\n\n${ if (logs.size > 1) { logs.joinToString("\n\n") { v -> "v${v.name}\n${v.desc}" } } else if (logs.isNotEmpty()) { logs.first().desc } else { "" } }".trimEnd() } AlertDialog( title = { Text(text = "新版本") }, text = { Text( text = text, modifier = Modifier .fillMaxWidth() .heightIn(max = 400.dp) .verticalScroll(rememberScrollState()) ) }, onDismissRequest = { }, confirmButton = { TextButton(onClick = { newVersionFlow.value = null startDownload(newVersionVal) }) { Text(text = "下载更新") } }, dismissButton = { TextButton(onClick = { newVersionFlow.value = null }) { Text(text = "取消") } if (!lastManual) { TextButton(onClick = { newVersionFlow.value = null ignoreVersionListFlow.update { it + newVersionVal.versionCode } toast("已忽略此版本") }) { Text(text = "忽略") } } }, ) } downloadStatusFlow.collectAsState().value?.let { downloadStatusVal -> when (downloadStatusVal) { is LoadStatus.Loading -> { AlertDialog( title = { Text(text = "下载中") }, text = { LinearProgressIndicator( progress = { downloadStatusVal.progress }, ) }, onDismissRequest = {}, confirmButton = { TextButton(onClick = { downloadStatusFlow.value = LoadStatus.Failure( Exception("终止下载") ) }) { Text(text = "终止下载") } }, ) } is LoadStatus.Failure -> { AlertDialog( title = { Text(text = "下载失败") }, text = { Text(text = downloadStatusVal.exception.let { it.message ?: it.toString() }) }, onDismissRequest = { downloadStatusFlow.value = null }, confirmButton = { TextButton(onClick = { downloadStatusFlow.value = null }) { Text(text = "关闭") } }, ) } is LoadStatus.Success -> { AlertDialog( title = { Text(text = "下载完毕") }, text = { Text(text = "可继续选择安装新版本") }, onDismissRequest = {}, dismissButton = { TextButton(onClick = { downloadStatusFlow.value = null }) { Text(text = "关闭") } }, confirmButton = { TextButton(onClick = throttle { installApk(downloadStatusVal.result) }) { Text(text = "安装") } }) } } } } } private fun installApk(file: File) { val uri = FileProvider.getUriForFile( app, "${app.packageName}.provider", file ) val intent = Intent(Intent.ACTION_VIEW).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setDataAndType(uri, "application/vnd.android.package-archive") } app.tryStartActivity(intent) } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/UriUtils.kt ================================================ 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) } } ================================================ FILE: app/src/main/kotlin/li/songe/gkd/util/ZipUtils.kt ================================================ 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) } } } } } } } ================================================ FILE: app/src/main/res/drawable/ic_anim_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_anim_search_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_capture.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_event_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_flash_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_flash_on.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_http.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_layers.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_page_info.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_radio_button.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_status.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #FCFCFC #111111 @color/better_white @color/better_black ================================================ FILE: app/src/main/res/values/strings.xml ================================================ GKD 基于高级选择器和订阅规则的屏幕自定义点击服务\n\n通过自定义选择器和订阅规则,能帮助你实现点击任意位置控件,自定义快捷操作等高级功能 导入数据 捕获快照 HTTP服务 快照按钮 规则匹配 界面服务 事件服务 ================================================ FILE: app/src/main/res/values/themes.xml ================================================