├── .github ├── 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 │ │ ├── 2.json │ │ ├── 3.json │ │ ├── 4.json │ │ ├── 5.json │ │ └── 6.json └── src │ ├── androidTest │ └── kotlin │ │ └── li │ │ └── songe │ │ └── gkd │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── aidl │ │ └── li │ │ │ └── songe │ │ │ └── gkd │ │ │ └── shizuku │ │ │ └── IUserService.aidl │ ├── kotlin │ │ ├── androidx │ │ │ └── compose │ │ │ │ └── material3 │ │ │ │ └── pullrefresh │ │ │ │ ├── PullRefresh.kt │ │ │ │ ├── PullRefreshIndicator.kt │ │ │ │ ├── PullRefreshIndicatorPatch.kt │ │ │ │ ├── PullRefreshIndicatorTransform.kt │ │ │ │ └── PullRefreshState.kt │ │ └── li │ │ │ └── songe │ │ │ └── gkd │ │ │ ├── App.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── OpenFileActivity.kt │ │ │ ├── OpenSchemeActivity.kt │ │ │ ├── composition │ │ │ ├── CanConfigBubble.kt │ │ │ ├── CanOnAccessibilityEvent.kt │ │ │ ├── CanOnConfigurationChanged.kt │ │ │ ├── CanOnDestroy.kt │ │ │ ├── CanOnInterrupt.kt │ │ │ ├── CanOnKeyEvent.kt │ │ │ ├── CanOnServiceConnected.kt │ │ │ ├── CanOnStartCommand.kt │ │ │ ├── CompositionAbService.kt │ │ │ ├── CompositionActivity.kt │ │ │ ├── CompositionExt.kt │ │ │ ├── CompositionFbService.kt │ │ │ ├── CompositionService.kt │ │ │ ├── InvokeMessage.kt │ │ │ └── Typealias.kt │ │ │ ├── data │ │ │ ├── AppInfo.kt │ │ │ ├── AppRule.kt │ │ │ ├── AttrInfo.kt │ │ │ ├── BaseSnapshot.kt │ │ │ ├── CategoryConfig.kt │ │ │ ├── ClickLog.kt │ │ │ ├── ComplexSnapshot.kt │ │ │ ├── DeviceInfo.kt │ │ │ ├── GithubPoliciesAsset.kt │ │ │ ├── GkdAction.kt │ │ │ ├── GlobalRule.kt │ │ │ ├── NodeInfo.kt │ │ │ ├── RawSubscription.kt │ │ │ ├── ResolvedRule.kt │ │ │ ├── RpcError.kt │ │ │ ├── Snapshot.kt │ │ │ ├── SubsConfig.kt │ │ │ ├── SubsItem.kt │ │ │ ├── SubsVersion.kt │ │ │ ├── TransferData.kt │ │ │ ├── Tuple.kt │ │ │ └── Value.kt │ │ │ ├── db │ │ │ ├── AppDb.kt │ │ │ └── DbSet.kt │ │ │ ├── debug │ │ │ ├── FloatingService.kt │ │ │ ├── HttpService.kt │ │ │ ├── KtorCorsPlugin.kt │ │ │ ├── KtorErrorPlugin.kt │ │ │ ├── ScreenshotService.kt │ │ │ ├── SnapshotActionService.kt │ │ │ ├── SnapshotExt.kt │ │ │ └── SnapshotTileService.kt │ │ │ ├── notif │ │ │ ├── Notif.kt │ │ │ ├── NotifChannel.kt │ │ │ └── NotifManager.kt │ │ │ ├── permission │ │ │ ├── PermissionDialog.kt │ │ │ └── PermissionState.kt │ │ │ ├── service │ │ │ ├── AbEvent.kt │ │ │ ├── AbExt.kt │ │ │ ├── AbState.kt │ │ │ ├── GkdAbService.kt │ │ │ └── ManageService.kt │ │ │ ├── shizuku │ │ │ ├── AutoStartReceiver.kt │ │ │ ├── CommandResult.kt │ │ │ ├── ShizukuApi.kt │ │ │ └── UserService.kt │ │ │ ├── ui │ │ │ ├── AboutPage.kt │ │ │ ├── AdvancedPage.kt │ │ │ ├── AdvancedVm.kt │ │ │ ├── AppConfigPage.kt │ │ │ ├── AppConfigVm.kt │ │ │ ├── AppItemPage.kt │ │ │ ├── AppItemVm.kt │ │ │ ├── CategoryPage.kt │ │ │ ├── CategoryVm.kt │ │ │ ├── ClickLogPage.kt │ │ │ ├── ClickLogVm.kt │ │ │ ├── GlobalRuleExcludePage.kt │ │ │ ├── GlobalRuleExcludeVm.kt │ │ │ ├── GlobalRulePage.kt │ │ │ ├── GlobalRuleVm.kt │ │ │ ├── GroupImagePage.kt │ │ │ ├── ImagePreviewPage.kt │ │ │ ├── SlowGroupPage.kt │ │ │ ├── SnapshotPage.kt │ │ │ ├── SnapshotVm.kt │ │ │ ├── SubsPage.kt │ │ │ ├── SubsVm.kt │ │ │ ├── component │ │ │ │ ├── AppBarTextField.kt │ │ │ │ ├── AuthCard.kt │ │ │ │ ├── ConfirmDialog.kt │ │ │ │ ├── RotatingLoadingIcon.kt │ │ │ │ ├── SettingItem.kt │ │ │ │ ├── StartEllipsisText.kt │ │ │ │ ├── SubsAppCard.kt │ │ │ │ ├── SubsItemCard.kt │ │ │ │ ├── TextMenu.kt │ │ │ │ ├── TextSwitch.kt │ │ │ │ └── TowLineText.kt │ │ │ ├── home │ │ │ │ ├── AppListPage.kt │ │ │ │ ├── ControlPage.kt │ │ │ │ ├── HomePage.kt │ │ │ │ ├── HomeVm.kt │ │ │ │ ├── ScaffoldExt.kt │ │ │ │ ├── SettingsPage.kt │ │ │ │ └── SubsManagePage.kt │ │ │ ├── style │ │ │ │ └── Padding.kt │ │ │ └── theme │ │ │ │ └── Theme.kt │ │ │ └── util │ │ │ ├── AppInfoState.kt │ │ │ ├── BitmapExt.kt │ │ │ ├── ComposeExt.kt │ │ │ ├── Constants.kt │ │ │ ├── CoroutineExt.kt │ │ │ ├── FlowExt.kt │ │ │ ├── FolderExt.kt │ │ │ ├── IntentExt.kt │ │ │ ├── Json5.kt │ │ │ ├── LoadStatus.kt │ │ │ ├── NavExt.kt │ │ │ ├── NetworkExt.kt │ │ │ ├── Option.kt │ │ │ ├── PackageExt.kt │ │ │ ├── ProfileTransitions.kt │ │ │ ├── ResolvedGroup.kt │ │ │ ├── SafeR.kt │ │ │ ├── ScreenshotUtil.kt │ │ │ ├── Singleton.kt │ │ │ ├── Store.kt │ │ │ ├── SubsState.kt │ │ │ ├── TimeExt.kt │ │ │ ├── Toast.kt │ │ │ ├── Upgrade.kt │ │ │ └── Zip.kt │ └── res │ │ ├── drawable │ │ ├── ic_capture.xml │ │ ├── ic_launcher.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ └── ic_status.xml │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── ab_desc.xml │ │ └── file_paths.xml │ └── test │ └── kotlin │ └── li │ └── songe │ └── gkd │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── hidden_api ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ ├── aidl │ └── android │ │ ├── accessibilityservice │ │ ├── IAccessibilityServiceClient.aidl │ │ └── IAccessibilityServiceConnection.aidl │ │ └── app │ │ └── IUiAutomationConnection.aidl │ └── java │ └── android │ ├── accessibilityservice │ └── AccessibilityServiceHidden.java │ ├── app │ ├── ActivityManagerNative.java │ ├── ActivityThread.java │ ├── ContextImpl.java │ ├── IActivityManager.java │ ├── IActivityTaskManager.java │ ├── UiAutomationConnection.java │ └── UiAutomationHidden.java │ ├── content │ └── pm │ │ └── IPackageManager.java │ └── hardware │ └── input │ └── IInputManager.java ├── selector ├── .gitignore ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── li │ │ └── songe │ │ └── selector │ │ ├── GkdSyntaxError.kt │ │ ├── MultiplatformSelector.kt │ │ ├── MultiplatformTransform.kt │ │ ├── Selector.kt │ │ ├── Transform.kt │ │ ├── data │ │ ├── BinaryExpression.kt │ │ ├── CompareOperator.kt │ │ ├── ConnectExpression.kt │ │ ├── ConnectOperator.kt │ │ ├── ConnectSegment.kt │ │ ├── ConnectWrapper.kt │ │ ├── Expression.kt │ │ ├── LogicalExpression.kt │ │ ├── LogicalOperator.kt │ │ ├── PolynomialExpression.kt │ │ ├── PrimitiveValue.kt │ │ ├── PropertySegment.kt │ │ ├── PropertyWrapper.kt │ │ └── TupleExpression.kt │ │ ├── parser │ │ ├── Parser.kt │ │ ├── ParserResult.kt │ │ └── ParserSet.kt │ │ └── toMatches.kt │ ├── jsMain │ └── kotlin │ │ └── li │ │ └── songe │ │ └── selector │ │ └── toMatches.js.kt │ ├── jvmMain │ └── kotlin │ │ └── li │ │ └── songe │ │ └── selector │ │ └── toMatches.jvm.kt │ └── jvmTest │ └── kotlin │ └── li │ └── songe │ └── selector │ ├── ParserTest.kt │ ├── TestNode.kt │ └── TestSnapshot.kt ├── settings.gradle.kts ├── stability_config.conf └── wasm_matches ├── .gitignore ├── build.gradle.kts └── src └── commonMain └── kotlin └── li └── songe └── matches └── toMatches.kt /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 问题反馈 / Bug report 2 | title: "[BUG] " 3 | description: 反馈你遇到的问题 / Report the issue you are experiencing 4 | labels: ["bug"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | 感谢您花时间填写这个 Bug 报告 11 | - type: checkboxes 12 | id: checkboxes 13 | attributes: 14 | label: 一些验证 15 | description: 在提交问题之前,请确保您完成以下操作 16 | options: 17 | - label: 请 **确保** 您已经查阅了 [GKD 官方文档](https://gkd.li) 以及 [常见问题](https://gkd.li/faq/) 18 | required: true 19 | - label: 请 **确保** [已有的问题](https://github.com/gkd-kit/gkd/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论 20 | required: true 21 | - label: 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索 22 | required: true 23 | - label: 请 **确保** 提供下列的日志和BUG描述及其复现步骤, 否则你的问题将会被直接关闭 24 | required: true 25 | - type: textarea 26 | id: log-file 27 | attributes: 28 | label: | 29 | 日志文件-无论什么问题不包含日志将会被直接关闭 30 | description: | 31 | 主页-设置-日志, 上传日志文件或者生成链接并粘贴到下面的输入框\ 32 | 无论什么问题,你都需要提供日志文件. 没有日志, 纯发文字/截图/视频都是没有用的\ 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: bug-1 37 | attributes: 38 | label: | 39 | BUG描述(文字/截图/视频) 40 | description: | 41 | 请使用尽量准确的描述, 否则你的问题将会被直接关闭 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: bug-2 46 | attributes: 47 | label: | 48 | 期望行为(文字/截图/视频) 49 | description: | 50 | 请使用尽量准确的描述, 否则你的问题将会被直接关闭 51 | validations: 52 | required: true 53 | - type: textarea 54 | id: bug-3 55 | attributes: 56 | label: | 57 | 实际行为(文字/截图/视频) 58 | description: | 59 | 请使用尽量准确的描述, 否则你的问题将会被直接关闭 60 | validations: 61 | required: true 62 | 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 讨论交流 / Discussions 4 | url: https://github.com/gkd-kit/gkd/discussions 5 | about: Use GitHub discussions for message-board style questions and discussions. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 / Feature request 2 | title: "[Feature] " 3 | description: 提出你的功能请求 / Propose your feature request 4 | labels: ["enhancement"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | 感谢您对该项目的兴趣,并花点时间填写此功能请求报告 11 | - type: checkboxes 12 | id: checkboxes 13 | attributes: 14 | label: 一些验证 15 | description: 在提交问题之前,请确保您完成以下操作 16 | options: 17 | - label: GKD 默认不提供任何规则, 你可以查看 [GKD 官方文档](https://gkd.li) 后自行编写规则或者导入远程订阅, 请不要再提出类似想要XXX规则这种问题 18 | required: true 19 | - label: 请 **确保** 您已经查阅了 [GKD 官方文档](https://gkd.li) 以及 [常见问题](https://gkd.li/faq/) 20 | required: true 21 | - label: 请 **确保** [已有的问题](https://github.com/gkd-kit/gkd/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论 22 | required: true 23 | - label: 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索 24 | required: true 25 | - type: textarea 26 | id: feature-description 27 | attributes: 28 | label: | 29 | 新功能描述 30 | description: | 31 | 例如: 我希望在 GKD 中的什么页面添加什么功能, 以及这个功能的作用是什么\ 32 | 或者在规则定义中添加某个字段, 以及这个字段的作用是什么\ 33 | 请使用准确的描述, 否则你的问题将会被直接关闭 34 | validations: 35 | required: true 36 | -------------------------------------------------------------------------------- /.github/workflows/Build-Apk.yml: -------------------------------------------------------------------------------- 1 | name: Build-Apk 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | push: 7 | branches: 8 | - '**' 9 | paths-ignore: 10 | - 'LICENSE' 11 | - '*.md' 12 | - '.github/**' 13 | 14 | jobs: 15 | build: 16 | if: ${{ !startsWith(github.event.head_commit.message, 'chore:') }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-java@v3 22 | with: 23 | distribution: 'adopt' 24 | java-version: '17' 25 | 26 | - name: write secrets info 27 | run: | 28 | echo ${{ secrets.GKD_STORE_FILE_BASE64 }} | base64 --decode > ${{ github.workspace }}/key.jks 29 | echo GKD_STORE_FILE='${{ github.workspace }}/key.jks' >> gradle.properties 30 | echo GKD_STORE_PASSWORD='${{ secrets.GKD_STORE_PASSWORD }}' >> gradle.properties 31 | echo GKD_KEY_ALIAS='${{ secrets.GKD_KEY_ALIAS }}' >> gradle.properties 32 | echo GKD_KEY_PASSWORD='${{ secrets.GKD_KEY_PASSWORD }}' >> gradle.properties 33 | 34 | - run: chmod 777 ./gradlew 35 | - run: ./gradlew app:assemble 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | name: outputs 40 | path: app/build/outputs 41 | 42 | - uses: actions/upload-artifact@v4 43 | with: 44 | name: release 45 | path: app/build/outputs/apk/release 46 | 47 | - uses: actions/upload-artifact@v4 48 | with: 49 | name: debug 50 | path: app/build/outputs/apk/debug 51 | -------------------------------------------------------------------------------- /.github/workflows/Build-Release.yml: -------------------------------------------------------------------------------- 1 | name: Build-Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-java@v3 15 | with: 16 | distribution: 'adopt' 17 | java-version: '17' 18 | 19 | - name: write secrets info 20 | run: | 21 | echo ${{ secrets.GKD_STORE_FILE_BASE64 }} | base64 --decode > ${{ github.workspace }}/key.jks 22 | echo GKD_STORE_FILE='${{ github.workspace }}/key.jks' >> gradle.properties 23 | echo GKD_STORE_PASSWORD='${{ secrets.GKD_STORE_PASSWORD }}' >> gradle.properties 24 | echo GKD_KEY_ALIAS='${{ secrets.GKD_KEY_ALIAS }}' >> gradle.properties 25 | echo GKD_KEY_PASSWORD='${{ secrets.GKD_KEY_PASSWORD }}' >> gradle.properties 26 | 27 | - run: chmod 777 ./gradlew 28 | - run: ./gradlew app:assemble 29 | 30 | - uses: actions/upload-artifact@v4 31 | with: 32 | name: outputs 33 | path: app/build/outputs 34 | 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: release 38 | path: app/build/outputs/apk/release 39 | 40 | - uses: actions/upload-artifact@v4 41 | with: 42 | name: debug 43 | path: app/build/outputs/apk/debug 44 | 45 | - uses: actions/upload-artifact@v4 46 | with: 47 | name: CHANGELOG.md 48 | path: CHANGELOG.md 49 | 50 | release: 51 | needs: build 52 | permissions: write-all 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/download-artifact@v4 56 | with: 57 | name: outputs 58 | path: outputs 59 | 60 | - uses: actions/download-artifact@v4 61 | with: 62 | name: CHANGELOG.md 63 | 64 | - run: ls -R 65 | 66 | - id: create_release 67 | uses: actions/create-release@v1 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | tag_name: ${{ github.ref }} 72 | release_name: Release ${{ github.ref }} 73 | body_path: ./CHANGELOG.md 74 | 75 | - uses: actions/upload-release-asset@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | asset_path: outputs/apk/release/app-release.apk 81 | asset_name: gkd-${{ github.ref_name }}.apk 82 | asset_content_type: application/vnd.android.package-archive 83 | 84 | - run: zip -r outputs.zip outputs 85 | 86 | - uses: actions/upload-release-asset@v1 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | with: 90 | upload_url: ${{ steps.create_release.outputs.upload_url }} 91 | asset_path: outputs.zip 92 | asset_name: outputs-${{ github.ref_name }}.zip 93 | asset_content_type: application/zip 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | .idea 17 | /kotlin-js-store 18 | .vscode 19 | 20 | *.jks 21 | *.keystore 22 | 23 | /_assets 24 | /.kotlin 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v1.8.0-beta.2 2 | 3 | 请注意这是一个测试版本(可能包含BUG), 正式版本暂时不会收到更新 4 | 5 | 如果您在使用的过程中遇到BUG, 请到 [issues](https://github.com/gkd-kit/gkd/issues) 提交, 记得带上日志 6 | 7 | 以下是本次更新的主要内容 8 | 9 | ## 优化和修复 10 | 11 | - 应用的规则列表界面现在显示订阅名称 12 | - 订阅新增了 anyMatches 字段, 如果存在任意一个选择器能匹配上节点, 那么点击这个节点 13 | - 修复了在调用系统分享时状态栏区域塌陷导致应用界面整体上移的问题 14 | - 改进了一些字体样式和间距 15 | - 其它优化和错误修复 16 | 17 | ## v1.8.0-beta.1 18 | 19 | - 优化了很多界面UI 20 | - 新增导入导出规则/配置数据 21 | - 优化应用搜索支持忽略大小写 22 | - 适配国产ROM应用列表权限 23 | - 应用规则组的编辑框新增支持输入 App 类型 24 | - 修复全局规则 matchSystemApp=false 不生效的问题 25 | - 修复规则 resetMatch=app 在某些情况下无效的问题 26 | - 修复订阅列表拖动排序错乱的问题 27 | - 修复在某些机型上无故重启进程导致匹配范围短时间失效的错误点击问题 28 | - 隐藏 Android>=12 上截图服务开关 29 | - 其它优化和错误修复 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gkd 2 | 3 | 基于 **无障碍** + **高级选择器** + **订阅规则** 的自定义屏幕点击 APP 4 | 5 | ## 安装 6 | 7 | - [gkd.li](https://gkd.li/guide/) 8 | - [releases](https://github.com/gkd-kit/gkd/releases) 9 | 10 | 如遇到问题可查看 [疑难解答](https://gkd.li/faq/) 11 | 12 | ## 功能 13 | 14 | 基于 [高级选择器](https://gkd.li/selector/) + [订阅规则](https://gkd.li/subscription/) + [快照审查](https://github.com/gkd-kit/inspect) 实现根据屏幕上下文信息自定义点击目标控件 15 | 16 | | | | | | 17 | | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | 18 | | ![img](https://github.com/gkd-kit/gkd/assets/38517192/79b8a829-4106-415f-9659-2920f7b5ccb5) | ![img](https://github.com/gkd-kit/gkd/assets/38517192/6755a005-33c2-4db9-acda-bac1e7a3632d) | ![img](https://github.com/gkd-kit/gkd/assets/38517192/91ea9329-e943-4ea8-bb6e-987c22ac7b4d) | ![img](https://github.com/gkd-kit/gkd/assets/38517192/1d672345-cf3e-4b2c-a606-53a53642abda) | 19 | | ![img](https://github.com/gkd-kit/gkd/assets/38517192/b600fa5d-284d-4dc8-9f8b-095826a73d95) | ![img](https://github.com/gkd-kit/gkd/assets/38517192/aad60a98-ffa2-4c23-a934-92e65f6018ec) | ![img](https://github.com/gkd-kit/gkd/assets/38517192/544c6aad-e2ee-42d6-9a1a-967d9d426bc9) | ![img](https://github.com/gkd-kit/gkd/assets/38517192/dd262506-b1d3-4c25-b52c-765ad6de6a1e) | 20 | 21 | ## 订阅 22 | 23 | 本应用 **默认不携带任何规则**,需自行添加本地规则,或者通过订阅链接的方式获取规则 24 | 25 | 也可通过 [subscription-template](https://github.com/gkd-kit/subscription-template) 快速构建自己的远程订阅 26 | 27 | 第三方订阅列表可在 查看 28 | 29 | 要加入此列表, 需点击仓库主页右上角设置图标后在 Topics 中添加 `gkd-subscription` 30 | 31 |
32 | 示例图片 - 添加至 Topics 33 | 34 | ![image](https://github.com/gkd-kit/gkd/assets/38517192/b7a2548d-c499-4db3-a2a4-dab81f0d312e) 35 |
36 | 37 | ## 选择器 38 | 39 | 40 | 41 | [@[vid=\"menu\"] < [vid=\"menu_container\"] - [vid=\"dot_text_layout\"] > [text^=\"广告\"]](https://i.gkd.li/i/14881985?gkd=QFt2aWQ9Im1lbnUiXSA8IFt2aWQ9Im1lbnVfY29udGFpbmVyIl0gLSBbdmlkPSJkb3RfdGV4dF9sYXlvdXQiXSA-IFt0ZXh0Xj0i5bm_5ZGKIl0) 42 | 43 | ![image](https://github.com/gkd-kit/gkd/assets/38517192/980db09f-2c50-4ca0-a8e3-43dce10e38f0) 44 | 45 | ## Star History 46 | 47 | [![Stargazers over time](https://starchart.cc/gkd-kit/gkd.svg?variant=adaptive)](https://starchart.cc/gkd-kit/gkd) 48 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /app/src/androidTest/kotlin/li/songe/gkd/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("li.songe.gkd", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/aidl/li/songe/gkd/shizuku/IUserService.aidl: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.shizuku; 2 | 3 | interface IUserService { 4 | void destroy() = 16777114; // Destroy method defined by Shizuku server 5 | 6 | void exit() = 1; // Exit method defined by user 7 | 8 | String execCommand(String command) = 2; 9 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorPatch.kt: -------------------------------------------------------------------------------- 1 | package androidx.compose.material3.pullrefresh 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.surfaceColorAtElevation 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.unit.Dp 8 | 9 | @Composable 10 | internal fun surfaceColorAtElevation(color: Color, elevation: Dp): Color = when (color) { 11 | MaterialTheme.colorScheme.surface -> MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) 12 | else -> color 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package androidx.compose.material3.pullrefresh 18 | 19 | import androidx.compose.animation.core.LinearOutSlowInEasing 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.drawWithContent 22 | import androidx.compose.ui.graphics.drawscope.clipRect 23 | import androidx.compose.ui.graphics.graphicsLayer 24 | import androidx.compose.ui.platform.debugInspectorInfo 25 | import androidx.compose.ui.platform.inspectable 26 | 27 | /** 28 | * A modifier for translating the position and scaling the size of a pull-to-refresh indicator 29 | * based on the given [PullRefreshState]. 30 | * 31 | * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample 32 | * 33 | * @param state The [PullRefreshState] which determines the position of the indicator. 34 | * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. 35 | */ 36 | // TODO: Consider whether the state parameter should be replaced with lambdas. 37 | fun Modifier.pullRefreshIndicatorTransform( 38 | state: PullRefreshState, 39 | scale: Boolean = false, 40 | ) = inspectable( 41 | inspectorInfo = debugInspectorInfo { 42 | name = "pullRefreshIndicatorTransform" 43 | properties["state"] = state 44 | properties["scale"] = scale 45 | }, 46 | ) { 47 | Modifier 48 | // Essentially we only want to clip the at the top, so the indicator will not appear when 49 | // the position is 0. It is preferable to clip the indicator as opposed to the layout that 50 | // contains the indicator, as this would also end up clipping shadows drawn by items in a 51 | // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE 52 | // for the other dimensions to allow for more room for elevation / arbitrary indicators - we 53 | // only ever really want to clip at the top edge. 54 | .drawWithContent { 55 | clipRect( 56 | top = 0f, 57 | left = -Float.MAX_VALUE, 58 | right = Float.MAX_VALUE, 59 | bottom = Float.MAX_VALUE, 60 | ) { 61 | this@drawWithContent.drawContent() 62 | } 63 | } 64 | .graphicsLayer { 65 | translationY = state.position - size.height 66 | 67 | if (scale && !state.refreshing) { 68 | val scaleFraction = LinearOutSlowInEasing 69 | .transform(state.position / state.threshold) 70 | .coerceIn(0f, 1f) 71 | scaleX = scaleFraction 72 | scaleY = scaleFraction 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/App.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.os.Build 6 | import com.blankj.utilcode.util.LogUtils 7 | import com.hjq.toast.Toaster 8 | import com.tencent.mmkv.MMKV 9 | import dagger.hilt.android.HiltAndroidApp 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.MainScope 12 | import li.songe.gkd.debug.clearHttpSubs 13 | import li.songe.gkd.notif.initChannel 14 | import li.songe.gkd.util.GIT_COMMIT_URL 15 | import li.songe.gkd.util.initAppState 16 | import li.songe.gkd.util.initFolder 17 | import li.songe.gkd.util.initStore 18 | import li.songe.gkd.util.initSubsState 19 | import li.songe.gkd.util.launchTry 20 | import org.lsposed.hiddenapibypass.HiddenApiBypass 21 | 22 | 23 | val appScope by lazy { MainScope() } 24 | 25 | private lateinit var innerApp: Application 26 | val app: Application 27 | get() = innerApp 28 | 29 | 30 | @HiltAndroidApp 31 | class App : Application() { 32 | override fun attachBaseContext(base: Context?) { 33 | super.attachBaseContext(base) 34 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 35 | HiddenApiBypass.addHiddenApiExemptions("L") 36 | } 37 | } 38 | 39 | override fun onCreate() { 40 | super.onCreate() 41 | innerApp = this 42 | 43 | val errorHandler = Thread.getDefaultUncaughtExceptionHandler() 44 | Thread.setDefaultUncaughtExceptionHandler { t, e -> 45 | LogUtils.d("UncaughtExceptionHandler", t, e) 46 | errorHandler?.uncaughtException(t, e) 47 | } 48 | Toaster.init(this) 49 | MMKV.initialize(this) 50 | LogUtils.getConfig().apply { 51 | setConsoleSwitch(BuildConfig.DEBUG) 52 | saveDays = 7 53 | isLog2FileSwitch = true 54 | } 55 | LogUtils.d( 56 | "GIT_COMMIT_URL: $GIT_COMMIT_URL", 57 | "VERSION_CODE: ${BuildConfig.VERSION_CODE}", 58 | "VERSION_NAME: ${BuildConfig.VERSION_NAME}", 59 | ) 60 | 61 | initFolder() 62 | appScope.launchTry(Dispatchers.IO) { 63 | initStore() 64 | initAppState() 65 | initSubsState() 66 | initChannel() 67 | clearHttpSubs() 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.blankj.utilcode.util.LogUtils 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.debounce 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.flow.stateIn 11 | import kotlinx.coroutines.launch 12 | import li.songe.gkd.data.RawSubscription 13 | import li.songe.gkd.data.SubsItem 14 | import li.songe.gkd.db.DbSet 15 | import li.songe.gkd.permission.authReasonFlow 16 | import li.songe.gkd.util.LOCAL_SUBS_ID 17 | import li.songe.gkd.util.checkUpdate 18 | import li.songe.gkd.util.clearCache 19 | import li.songe.gkd.util.launchTry 20 | import li.songe.gkd.util.map 21 | import li.songe.gkd.util.storeFlow 22 | import li.songe.gkd.util.updateSubscription 23 | 24 | class MainViewModel : ViewModel() { 25 | init { 26 | viewModelScope.launchTry(Dispatchers.IO) { 27 | val subsItems = DbSet.subsItemDao.queryAll() 28 | if (!subsItems.any { s -> s.id == LOCAL_SUBS_ID }) { 29 | updateSubscription( 30 | RawSubscription( 31 | id = LOCAL_SUBS_ID, 32 | name = "本地订阅", 33 | version = 0 34 | ) 35 | ) 36 | DbSet.subsItemDao.insert( 37 | SubsItem( 38 | id = LOCAL_SUBS_ID, 39 | order = subsItems.minByOrNull { it.order }?.order ?: 0, 40 | ) 41 | ) 42 | } 43 | } 44 | 45 | viewModelScope.launchTry(Dispatchers.IO) { 46 | // 每次进入删除缓存 47 | clearCache() 48 | } 49 | 50 | if (storeFlow.value.autoCheckAppUpdate) { 51 | appScope.launch { 52 | try { 53 | checkUpdate() 54 | } catch (e: Exception) { 55 | e.printStackTrace() 56 | LogUtils.d(e) 57 | } 58 | } 59 | } 60 | 61 | viewModelScope.launch { 62 | storeFlow.map(viewModelScope) { s -> s.log2FileSwitch }.collect { 63 | LogUtils.getConfig().isLog2FileSwitch = it 64 | } 65 | } 66 | } 67 | 68 | val enableDarkThemeFlow = storeFlow.debounce(200).map { s -> s.enableDarkTheme }.stateIn( 69 | viewModelScope, 70 | SharingStarted.Eagerly, 71 | storeFlow.value.enableDarkTheme 72 | ) 73 | val enableDynamicColorFlow = storeFlow.debounce(300).map { s -> s.enableDynamicColor }.stateIn( 74 | viewModelScope, 75 | SharingStarted.Eagerly, 76 | storeFlow.value.enableDynamicColor 77 | ) 78 | 79 | 80 | override fun onCleared() { 81 | super.onCleared() 82 | authReasonFlow.value = null 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/OpenFileActivity.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | 6 | class OpenFileActivity : Activity() { 7 | override fun onCreate(savedInstanceState: Bundle?) { 8 | super.onCreate(savedInstanceState) 9 | navToMainActivity() 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/OpenSchemeActivity.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | 6 | class OpenSchemeActivity : Activity() { 7 | override fun onCreate(savedInstanceState: Bundle?) { 8 | super.onCreate(savedInstanceState) 9 | navToMainActivity() 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanConfigBubble.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | interface CanConfigBubble { 4 | fun configBubble(f: ConfigBubbleHook) 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnAccessibilityEvent.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.view.accessibility.AccessibilityEvent 4 | 5 | interface CanOnAccessibilityEvent { 6 | fun onAccessibilityEvent(f: (AccessibilityEvent) -> Unit): Boolean 7 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnConfigurationChanged.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.content.res.Configuration 4 | 5 | interface CanOnConfigurationChanged { 6 | fun onConfigurationChanged(f: (newConfig: Configuration) -> Unit):Boolean 7 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnDestroy.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | interface CanOnDestroy { 4 | fun onDestroy(f: () -> Unit): Boolean 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnInterrupt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | interface CanOnInterrupt { 4 | fun onInterrupt(f: () -> Unit):Boolean 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnKeyEvent.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.view.KeyEvent 4 | 5 | interface CanOnKeyEvent { 6 | fun onKeyEvent(f: (KeyEvent?) -> Unit): Unit 7 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnServiceConnected.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | interface CanOnServiceConnected { 4 | fun onServiceConnected(f: () -> Unit):Boolean 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CanOnStartCommand.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | interface CanOnStartCommand { 4 | fun onStartCommand(f: StartCommandHook): Boolean 5 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CompositionAbService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.accessibilityservice.AccessibilityService 4 | import android.content.Intent 5 | import android.view.KeyEvent 6 | import android.view.accessibility.AccessibilityEvent 7 | 8 | open class CompositionAbService( 9 | private val block: CompositionAbService.() -> Unit, 10 | ) : AccessibilityService(), CanOnDestroy, CanOnStartCommand, CanOnAccessibilityEvent, 11 | CanOnServiceConnected, CanOnInterrupt, CanOnKeyEvent { 12 | override fun onCreate() { 13 | super.onCreate() 14 | block(this) 15 | } 16 | 17 | private val onStartCommandHooks by lazy { linkedSetOf() } 18 | override fun onStartCommand(f: StartCommandHook) = onStartCommandHooks.add(f) 19 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 20 | onStartCommandHooks.forEach { f -> f(intent, flags, startId) } 21 | return super.onStartCommand(intent, flags, startId) 22 | } 23 | 24 | private val destroyHooks by lazy { linkedSetOf<() -> Unit>() } 25 | override fun onDestroy(f: () -> Unit) = destroyHooks.add(f) 26 | override fun onDestroy() { 27 | super.onDestroy() 28 | destroyHooks.forEach { f -> f() } 29 | } 30 | 31 | private val onAccessibilityEventHooks by lazy { linkedSetOf<(AccessibilityEvent) -> Unit>() } 32 | private val interestedEvents = 33 | AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED 34 | 35 | override fun onAccessibilityEvent(f: (AccessibilityEvent) -> Unit) = 36 | onAccessibilityEventHooks.add(f) 37 | 38 | override fun onAccessibilityEvent(event: AccessibilityEvent?) { 39 | if (event != null && 40 | event.packageName != null && 41 | event.className != null && 42 | event.eventType.and(interestedEvents) != 0 43 | ) { 44 | onAccessibilityEventHooks.forEach { f -> f(event) } 45 | } 46 | } 47 | 48 | 49 | private val onInterruptHooks by lazy { linkedSetOf<() -> Unit>() } 50 | override fun onInterrupt(f: () -> Unit) = onInterruptHooks.add(f) 51 | override fun onInterrupt() { 52 | onInterruptHooks.forEach { f -> f() } 53 | } 54 | 55 | private val onServiceConnectedHooks by lazy { linkedSetOf<() -> Unit>() } 56 | override fun onServiceConnected(f: () -> Unit) = onServiceConnectedHooks.add(f) 57 | override fun onServiceConnected() { 58 | super.onServiceConnected() 59 | onServiceConnectedHooks.forEach { f -> f() } 60 | } 61 | 62 | 63 | private val onKeyEventHooks by lazy { linkedSetOf<(KeyEvent?) -> Unit>() } 64 | override fun onKeyEvent(event: KeyEvent?): Boolean { 65 | onKeyEventHooks.forEach { f -> f(event) } 66 | return super.onKeyEvent(event) 67 | } 68 | 69 | override fun onKeyEvent(f: (KeyEvent?) -> Unit) { 70 | onKeyEventHooks.add(f) 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CompositionActivity.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.content.res.Configuration 4 | import android.graphics.Color 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 9 | import androidx.core.view.ViewCompat 10 | import androidx.core.view.WindowInsetsCompat 11 | import com.blankj.utilcode.util.BarUtils 12 | 13 | open class CompositionActivity( 14 | private val block: CompositionActivity.(Bundle?) -> Unit, 15 | ) : ComponentActivity(), CanOnDestroy, CanOnConfigurationChanged { 16 | 17 | private val destroyHooks by lazy { linkedSetOf<() -> Unit>() } 18 | override fun onDestroy(f: () -> Unit) = destroyHooks.add(f) 19 | override fun onDestroy() { 20 | super.onDestroy() 21 | destroyHooks.forEach { f -> f() } 22 | } 23 | 24 | private val finishHooks by lazy { linkedSetOf<(fs: () -> Unit) -> Unit>() } 25 | fun onFinish(f: (fs: () -> Unit) -> Unit) = finishHooks.add(f) 26 | override fun finish() { 27 | if (finishHooks.isEmpty()) { 28 | super.finish() 29 | } 30 | val fs = { super.finish() } 31 | finishHooks.forEach { f -> f(fs) } 32 | } 33 | 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | installSplashScreen() 36 | enableEdgeToEdge() 37 | fixTopPadding() 38 | super.onCreate(savedInstanceState) 39 | block(savedInstanceState) 40 | } 41 | 42 | private val configurationChangedHooks by lazy { linkedSetOf<(newConfig: Configuration) -> Unit>() } 43 | override fun onConfigurationChanged(f: (newConfig: Configuration) -> Unit) = 44 | configurationChangedHooks.add(f) 45 | 46 | override fun onConfigurationChanged(newConfig: Configuration) { 47 | super.onConfigurationChanged(newConfig) 48 | configurationChangedHooks.forEach { f -> f(newConfig) } 49 | } 50 | } 51 | 52 | fun ComponentActivity.fixTopPadding() { 53 | // 当调用系统分享时, 会导致状态栏区域消失, 应用整体上移, 设置一个 top padding 保证不上移 54 | var tempTop: Int? = null 55 | ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, windowInsets -> 56 | view.setBackgroundColor(Color.TRANSPARENT) 57 | val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) 58 | if (statusBars.top == 0) { 59 | view.setPadding( 60 | statusBars.left, 61 | tempTop ?: BarUtils.getStatusBarHeight(), 62 | statusBars.right, 63 | statusBars.bottom 64 | ) 65 | } else { 66 | tempTop = statusBars.top 67 | view.setPadding(statusBars.left, 0, statusBars.right, statusBars.bottom) 68 | } 69 | ViewCompat.onApplyWindowInsets(view, windowInsets) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CompositionExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.app.Activity 4 | import android.app.Service 5 | import android.content.Context 6 | import com.blankj.utilcode.util.LogUtils 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.cancel 10 | import kotlin.coroutines.CoroutineContext 11 | 12 | object CompositionExt { 13 | fun CanOnDestroy.useScope(context: CoroutineContext = Dispatchers.Default): CoroutineScope { 14 | val scope = CoroutineScope(context) 15 | onDestroy { scope.cancel() } 16 | return scope 17 | } 18 | 19 | fun Context.useLifeCycleLog() { 20 | val simpleName = this::class.simpleName 21 | when (this) { 22 | is Activity, is Service -> { 23 | LogUtils.d(simpleName, "onCreate") 24 | } 25 | 26 | else -> { 27 | LogUtils.w("current context is not the one of Activity, Service", this) 28 | } 29 | } 30 | 31 | if (this is CanOnDestroy) { 32 | onDestroy { 33 | LogUtils.d(simpleName, "onDestroy") 34 | } 35 | } 36 | if (this is CanOnInterrupt) { 37 | onInterrupt { 38 | LogUtils.d(simpleName, "onInterrupt") 39 | } 40 | } 41 | 42 | if (this is CanOnServiceConnected) { 43 | onServiceConnected { 44 | LogUtils.d(simpleName, "onServiceConnected") 45 | } 46 | } 47 | 48 | if (this is CanOnConfigurationChanged) { 49 | onConfigurationChanged { 50 | LogUtils.d(simpleName, "onConfigurationChanged", it) 51 | } 52 | } 53 | 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CompositionFbService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import com.torrydo.floatingbubbleview.service.expandable.BubbleBuilder 4 | import com.torrydo.floatingbubbleview.service.expandable.ExpandableBubbleService 5 | 6 | open class CompositionFbService( 7 | private val block: CompositionFbService.() -> Unit, 8 | ) : ExpandableBubbleService(), CanOnDestroy, CanConfigBubble { 9 | 10 | 11 | override fun configExpandedBubble() = null 12 | 13 | override fun onCreate() { 14 | block() 15 | super.onCreate() 16 | } 17 | 18 | private val destroyHooks by lazy { linkedSetOf<() -> Unit>() } 19 | override fun onDestroy(f: () -> Unit) = destroyHooks.add(f) 20 | override fun onDestroy() { 21 | super.onDestroy() 22 | destroyHooks.forEach { f -> f() } 23 | } 24 | 25 | 26 | private val configBubbleHooks by lazy { linkedSetOf() } 27 | override fun configBubble(f: ConfigBubbleHook) { 28 | configBubbleHooks.add(f) 29 | } 30 | 31 | override fun configBubble(): BubbleBuilder? { 32 | var result: BubbleBuilder? = null 33 | configBubbleHooks.forEach { f -> 34 | f { 35 | result = it 36 | } 37 | } 38 | return result 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/CompositionService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.app.Service 4 | import android.content.Intent 5 | import android.os.IBinder 6 | 7 | open class CompositionService( 8 | private val block: CompositionService.() -> Unit, 9 | ) : Service(), CanOnDestroy, CanOnStartCommand { 10 | override fun onBind(intent: Intent?): IBinder? = null 11 | override fun onCreate() { 12 | super.onCreate() 13 | block() 14 | } 15 | 16 | private val onStartCommandHooks by lazy { linkedSetOf() } 17 | override fun onStartCommand(f: StartCommandHook) = onStartCommandHooks.add(f) 18 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 19 | onStartCommandHooks.forEach { f -> f(intent, flags, startId) } 20 | return super.onStartCommand(intent, flags, startId) 21 | } 22 | 23 | private val destroyHooks by lazy { linkedSetOf<() -> Unit>() } 24 | override fun onDestroy(f: () -> Unit) = destroyHooks.add(f) 25 | override fun onDestroy() { 26 | super.onDestroy() 27 | destroyHooks.forEach { f -> f() } 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/InvokeMessage.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Parcelize 9 | @Serializable 10 | data class InvokeMessage( 11 | @SerialName("name") val name: String?, 12 | @SerialName("method") val method: String, 13 | ) : Parcelable 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/composition/Typealias.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.composition 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.torrydo.floatingbubbleview.service.expandable.BubbleBuilder 6 | 7 | typealias StartCommandHook = (intent: Intent?, flags: Int, startId: Int) -> Unit 8 | 9 | typealias onReceiveType = (Context?, Intent?) -> Unit 10 | 11 | typealias ConfigBubbleHook = ((BubbleBuilder) -> Unit) -> Unit -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import android.content.pm.ApplicationInfo 4 | import android.content.pm.PackageInfo 5 | import android.graphics.drawable.Drawable 6 | import android.os.Build 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.Transient 9 | import li.songe.gkd.app 10 | 11 | @Serializable 12 | data class AppInfo( 13 | val id: String, 14 | val name: String, 15 | @Transient 16 | val icon: Drawable? = null, 17 | val versionCode: Long, 18 | val versionName: String?, 19 | val isSystem: Boolean, 20 | val mtime: Long, 21 | val hidden: Boolean, 22 | ) 23 | 24 | val selfAppInfo by lazy { 25 | app.packageManager.getPackageInfo(app.packageName, 0).toAppInfo()!! 26 | } 27 | 28 | /** 29 | * 平均单次调用时间 11ms 30 | */ 31 | fun PackageInfo.toAppInfo(): AppInfo? { 32 | applicationInfo ?: return null 33 | return AppInfo( 34 | id = packageName, 35 | name = applicationInfo.loadLabel(app.packageManager).toString(), 36 | icon = applicationInfo.loadIcon(app.packageManager), 37 | versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 38 | longVersionCode 39 | } else { 40 | @Suppress("DEPRECATION") 41 | versionCode.toLong() 42 | }, 43 | versionName = versionName, 44 | isSystem = (ApplicationInfo.FLAG_SYSTEM and applicationInfo.flags) != 0, 45 | mtime = lastUpdateTime, 46 | hidden = app.packageManager.getLaunchIntentForPackage(packageName) == null 47 | ) 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/AppRule.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import li.songe.gkd.util.ResolvedAppGroup 4 | 5 | class AppRule( 6 | rule: RawSubscription.RawAppRule, 7 | g: ResolvedAppGroup, 8 | val appInfo: AppInfo?, 9 | ) : ResolvedRule( 10 | rule = rule, 11 | g = g, 12 | ) { 13 | val group = g.group 14 | val app = g.app 15 | val enable = appInfo?.let { 16 | if ((rule.excludeVersionCodes 17 | ?: group.excludeVersionCodes)?.contains(appInfo.versionCode) == true 18 | ) { 19 | return@let false 20 | } 21 | if ((rule.excludeVersionNames 22 | ?: group.excludeVersionNames)?.contains(appInfo.versionName) == true 23 | ) { 24 | return@let false 25 | } 26 | (rule.versionCodes ?: group.versionCodes)?.apply { 27 | return@let contains(appInfo.versionCode) 28 | } 29 | (rule.versionNames ?: group.versionNames)?.apply { 30 | return@let contains(appInfo.versionName) 31 | } 32 | 33 | null 34 | } ?: true 35 | val appId = app.id 36 | private val activityIds = getFixActivityIds(app.id, rule.activityIds ?: group.activityIds) 37 | private val excludeActivityIds = 38 | (getFixActivityIds( 39 | app.id, 40 | rule.excludeActivityIds ?: group.excludeActivityIds 41 | ) + (excludeData.activityIds.filter { e -> e.first == appId } 42 | .map { e -> e.second })).distinct() 43 | 44 | override val type = "app" 45 | override fun matchActivity(appId: String, activityId: String?): Boolean { 46 | if (!enable) return false 47 | if (appId != app.id) return false 48 | activityId ?: return true 49 | if (excludeActivityIds.any { activityId.startsWith(it) }) return false 50 | return activityIds.isEmpty() || activityIds.any { activityId.startsWith(it) } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/AttrInfo.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import android.graphics.Rect 4 | import android.view.accessibility.AccessibilityNodeInfo 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class AttrInfo( 9 | val id: String?, 10 | val vid: String?, 11 | val name: String?, 12 | val text: String?, 13 | val desc: String?, 14 | 15 | val clickable: Boolean, 16 | val focusable: Boolean, 17 | val checkable: Boolean, 18 | val checked: Boolean, 19 | val editable: Boolean, 20 | val longClickable: Boolean, 21 | val visibleToUser: Boolean, 22 | 23 | val left: Int, 24 | val top: Int, 25 | val right: Int, 26 | val bottom: Int, 27 | 28 | val width: Int, 29 | val height: Int, 30 | 31 | val childCount: Int, 32 | 33 | val index: Int, 34 | val depth: Int, 35 | ) { 36 | companion object { 37 | /** 38 | * 不要在多线程中使用 39 | */ 40 | private val rect = Rect() 41 | fun info2data( 42 | node: AccessibilityNodeInfo, 43 | index: Int, 44 | depth: Int, 45 | ): AttrInfo { 46 | node.getBoundsInScreen(rect) 47 | val appId = node.packageName?.toString() ?: "" 48 | val id: String? = node.viewIdResourceName 49 | val idPrefix = "$appId:id/" 50 | val vid = if (id != null && id.startsWith(idPrefix)) { 51 | id.substring(idPrefix.length) 52 | } else { 53 | // 此处不使用 id 是因为某些节点的 id 没有 appId:id/ 前缀 54 | null 55 | } 56 | return AttrInfo( 57 | id = id, 58 | vid = vid, 59 | name = node.className?.toString(), 60 | text = node.text?.toString(), 61 | desc = node.contentDescription?.toString(), 62 | 63 | clickable = node.isClickable, 64 | focusable = node.isFocusable, 65 | checkable = node.isCheckable, 66 | checked = node.isChecked, 67 | editable = node.isEditable, 68 | longClickable = node.isLongClickable, 69 | visibleToUser = node.isVisibleToUser, 70 | 71 | left = rect.left, 72 | top = rect.top, 73 | right = rect.right, 74 | bottom = rect.bottom, 75 | 76 | width = rect.width(), 77 | height = rect.height(), 78 | 79 | childCount = node.childCount, 80 | 81 | index = index, 82 | depth = depth, 83 | ) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/BaseSnapshot.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | interface BaseSnapshot { 4 | val id: Long 5 | 6 | val appId: String? 7 | val activityId: String? 8 | val appName: String? 9 | val appVersionCode: Long? 10 | val appVersionName: String? 11 | 12 | val screenHeight: Int 13 | val screenWidth: Int 14 | val isLandscape: Boolean 15 | 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/CategoryConfig.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Entity 7 | import androidx.room.Insert 8 | import androidx.room.OnConflictStrategy 9 | import androidx.room.PrimaryKey 10 | import androidx.room.Query 11 | import androidx.room.Update 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.serialization.Serializable 14 | 15 | @Serializable 16 | @Entity( 17 | tableName = "category_config", 18 | ) 19 | data class CategoryConfig( 20 | @PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(), 21 | @ColumnInfo(name = "enable") val enable: Boolean? = null, 22 | @ColumnInfo(name = "subs_item_id") val subsItemId: Long, 23 | @ColumnInfo(name = "category_key") val categoryKey: Int, 24 | ) { 25 | @Dao 26 | interface CategoryConfigDao { 27 | 28 | @Update 29 | suspend fun update(vararg objects: CategoryConfig): Int 30 | 31 | @Insert(onConflict = OnConflictStrategy.REPLACE) 32 | suspend fun insert(vararg objects: CategoryConfig): List 33 | 34 | @Insert(onConflict = OnConflictStrategy.IGNORE) 35 | suspend fun insertOrIgnore(vararg objects: CategoryConfig): List 36 | 37 | @Delete 38 | suspend fun delete(vararg objects: CategoryConfig): Int 39 | 40 | @Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId") 41 | suspend fun deleteBySubsItemId(subsItemId: Long): Int 42 | 43 | @Query("DELETE FROM category_config WHERE subs_item_id IN (:subsIds)") 44 | suspend fun deleteBySubsId(vararg subsIds: Long): Int 45 | 46 | @Query("DELETE FROM category_config WHERE subs_item_id=:subsItemId AND category_key=:categoryKey") 47 | suspend fun deleteByCategoryKey(subsItemId: Long, categoryKey: Int): Int 48 | 49 | @Query("SELECT * FROM category_config WHERE subs_item_id IN (SELECT si.id FROM subs_item si WHERE si.enable = 1)") 50 | fun queryUsedList(): Flow> 51 | 52 | @Query("SELECT * FROM category_config WHERE subs_item_id=:subsItemId") 53 | fun queryConfig(subsItemId: Long): Flow> 54 | 55 | @Query("SELECT * FROM category_config WHERE subs_item_id IN (:subsItemIds)") 56 | suspend fun querySubsItemConfig(subsItemIds: List): List 57 | 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/ComplexSnapshot.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import com.blankj.utilcode.util.ScreenUtils 4 | import kotlinx.serialization.Serializable 5 | import li.songe.gkd.app 6 | import li.songe.gkd.service.GkdAbService 7 | import li.songe.gkd.service.getAndUpdateCurrentRules 8 | import li.songe.gkd.service.safeActiveWindow 9 | 10 | @Serializable 11 | data class ComplexSnapshot( 12 | override val id: Long, 13 | 14 | override val appId: String?, 15 | override val activityId: String?, 16 | 17 | override val screenHeight: Int, 18 | override val screenWidth: Int, 19 | override val isLandscape: Boolean, 20 | 21 | val appInfo: AppInfo? = appId?.let { app.packageManager.getPackageInfo(appId, 0)?.toAppInfo() }, 22 | val gkdAppInfo: AppInfo? = selfAppInfo, 23 | val device: DeviceInfo = DeviceInfo.instance, 24 | 25 | @Deprecated("use appInfo") 26 | override val appName: String? = appInfo?.name, 27 | @Deprecated("use appInfo") 28 | override val appVersionCode: Long? = appInfo?.versionCode, 29 | @Deprecated("use appInfo") 30 | override val appVersionName: String? = appInfo?.versionName, 31 | 32 | val nodes: List, 33 | ) : BaseSnapshot 34 | 35 | 36 | fun createComplexSnapshot(): ComplexSnapshot { 37 | val currentAbNode = GkdAbService.service?.safeActiveWindow 38 | val appId = currentAbNode?.packageName?.toString() 39 | val currentActivityId = getAndUpdateCurrentRules().topActivity.activityId 40 | 41 | return ComplexSnapshot( 42 | id = System.currentTimeMillis(), 43 | 44 | appId = appId, 45 | activityId = currentActivityId, 46 | 47 | screenHeight = ScreenUtils.getScreenHeight(), 48 | screenWidth = ScreenUtils.getScreenWidth(), 49 | isLandscape = ScreenUtils.isLandscape(), 50 | 51 | nodes = NodeInfo.info2nodeList(currentAbNode) 52 | ) 53 | } 54 | 55 | fun ComplexSnapshot.toSnapshot(): Snapshot { 56 | return Snapshot( 57 | id = id, 58 | 59 | appId = appId, 60 | activityId = activityId, 61 | 62 | screenHeight = screenHeight, 63 | screenWidth = screenWidth, 64 | isLandscape = isLandscape, 65 | 66 | appName = appInfo?.name, 67 | appVersionCode = appInfo?.versionCode, 68 | appVersionName = appInfo?.versionName, 69 | ) 70 | } 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/DeviceInfo.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import android.os.Build 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class DeviceInfo( 8 | val device: String, 9 | val model: String, 10 | val manufacturer: String, 11 | val brand: String, 12 | val sdkInt: Int, 13 | val release: String, 14 | ) { 15 | companion object { 16 | val instance by lazy { 17 | DeviceInfo( 18 | device = Build.DEVICE, 19 | model = Build.MODEL, 20 | manufacturer = Build.MANUFACTURER, 21 | brand = Build.BRAND, 22 | sdkInt = Build.VERSION.SDK_INT, 23 | release = Build.VERSION.RELEASE, 24 | ) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/GithubPoliciesAsset.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import kotlinx.serialization.Serializable 4 | import li.songe.gkd.util.FILE_SHORT_URL 5 | 6 | @Serializable 7 | data class GithubPoliciesAsset( 8 | val id: Int, 9 | val href: String, 10 | ) { 11 | val shortHref: String 12 | get() = FILE_SHORT_URL + id 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import kotlinx.collections.immutable.ImmutableMap 4 | import li.songe.gkd.service.launcherAppId 5 | import li.songe.gkd.util.ResolvedGlobalGroup 6 | import li.songe.gkd.util.systemAppsFlow 7 | 8 | data class GlobalApp( 9 | val id: String, 10 | val enable: Boolean, 11 | val activityIds: List, 12 | val excludeActivityIds: List, 13 | ) 14 | 15 | class GlobalRule( 16 | rule: RawSubscription.RawGlobalRule, 17 | g: ResolvedGlobalGroup, 18 | appInfoCache: ImmutableMap, 19 | ) : ResolvedRule( 20 | rule = rule, 21 | g = g, 22 | ) { 23 | val group = g.group 24 | private val matchAnyApp = rule.matchAnyApp ?: group.matchAnyApp ?: true 25 | private val matchLauncher = rule.matchLauncher ?: group.matchLauncher ?: false 26 | private val matchSystemApp = rule.matchSystemApp ?: group.matchSystemApp ?: false 27 | val apps = mutableMapOf().apply { 28 | (rule.apps ?: group.apps ?: emptyList()).filter { a -> 29 | // https://github.com/gkd-kit/gkd/issues/619 30 | appInfoCache.isEmpty() || appInfoCache.containsKey(a.id) // 过滤掉未安装应用 31 | }.forEach { a -> 32 | val enable = a.enable ?: appInfoCache[a.id]?.let { appInfo -> 33 | if (a.excludeVersionCodes?.contains(appInfo.versionCode) == true) { 34 | return@let false 35 | } 36 | if (a.excludeVersionNames?.contains(appInfo.versionName) == true) { 37 | return@let false 38 | } 39 | a.versionCodes?.apply { 40 | return@let contains(appInfo.versionCode) 41 | } 42 | a.versionNames?.apply { 43 | return@let contains(appInfo.versionName) 44 | } 45 | null 46 | } ?: true 47 | this[a.id] = GlobalApp( 48 | id = a.id, 49 | enable = enable, 50 | activityIds = getFixActivityIds(a.id, a.activityIds), 51 | excludeActivityIds = getFixActivityIds(a.id, a.excludeActivityIds), 52 | ) 53 | } 54 | } 55 | 56 | override val type = "global" 57 | 58 | private val excludeAppIds = apps.filter { e -> !e.value.enable }.keys 59 | private val enableApps = apps.filter { e -> e.value.enable } 60 | 61 | /** 62 | * 内置禁用>用户配置>规则自带 63 | * 范围越精确优先级越高 64 | */ 65 | override fun matchActivity(appId: String, activityId: String?): Boolean { 66 | // 规则自带禁用 67 | if (excludeAppIds.contains(appId)) { 68 | return false 69 | } 70 | 71 | // 用户自定义禁用 72 | if (excludeData.excludeAppIds.contains(appId)) { 73 | return false 74 | } 75 | if (activityId != null && excludeData.activityIds.contains(appId to activityId)) { 76 | return false 77 | } 78 | if (excludeData.includeAppIds.contains(appId)) { 79 | activityId ?: return true 80 | val app = enableApps[appId] ?: return true 81 | // 规则自带页面的禁用 82 | return !app.excludeActivityIds.any { e -> e.startsWith(activityId) } 83 | } 84 | 85 | // 范围比较 86 | val app = enableApps[appId] 87 | if (app != null) { // 规则自定义启用 88 | activityId ?: return true 89 | return app.activityIds.isEmpty() || app.activityIds.any { e -> e.startsWith(activityId) } 90 | } else { 91 | if (!matchLauncher && appId == launcherAppId) { 92 | return false 93 | } 94 | if (!matchSystemApp && systemAppsFlow.value.contains(appId)) { 95 | return false 96 | } 97 | return matchAnyApp 98 | } 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/RpcError.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class RpcError( 8 | override val message: String, 9 | @SerialName("__error") val error: Boolean = true, 10 | val unknown: Boolean = false, 11 | ) : Exception(message) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/Snapshot.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Entity 7 | import androidx.room.Insert 8 | import androidx.room.OnConflictStrategy 9 | import androidx.room.PrimaryKey 10 | import androidx.room.Query 11 | import androidx.room.Update 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.serialization.Serializable 14 | import li.songe.gkd.debug.SnapshotExt 15 | import li.songe.gkd.util.format 16 | import java.io.File 17 | 18 | @Entity( 19 | tableName = "snapshot", 20 | ) 21 | @Serializable 22 | data class Snapshot( 23 | @PrimaryKey @ColumnInfo(name = "id") override val id: Long, 24 | 25 | @ColumnInfo(name = "app_id") override val appId: String?, 26 | @ColumnInfo(name = "activity_id") override val activityId: String?, 27 | @ColumnInfo(name = "app_name") override val appName: String?, 28 | @ColumnInfo(name = "app_version_code") override val appVersionCode: Long?, 29 | @ColumnInfo(name = "app_version_name") override val appVersionName: String?, 30 | 31 | @ColumnInfo(name = "screen_height") override val screenHeight: Int, 32 | @ColumnInfo(name = "screen_width") override val screenWidth: Int, 33 | @ColumnInfo(name = "is_landscape") override val isLandscape: Boolean, 34 | 35 | @ColumnInfo(name = "github_asset_id") val githubAssetId: Int? = null, 36 | 37 | ) : BaseSnapshot { 38 | 39 | val date by lazy { id.format("MM-dd HH:mm:ss") } 40 | 41 | val screenshotFile by lazy { 42 | File( 43 | SnapshotExt.getScreenshotPath( 44 | id 45 | ) 46 | ) 47 | } 48 | 49 | @Dao 50 | interface SnapshotDao { 51 | @Update 52 | suspend fun update(vararg objects: Snapshot): Int 53 | 54 | @Insert 55 | suspend fun insert(vararg users: Snapshot): List 56 | 57 | @Insert(onConflict = OnConflictStrategy.IGNORE) 58 | suspend fun insertOrIgnore(vararg users: Snapshot): List 59 | 60 | @Query("DELETE FROM snapshot") 61 | suspend fun deleteAll() 62 | 63 | @Delete 64 | suspend fun delete(vararg users: Snapshot): Int 65 | 66 | @Query("SELECT * FROM snapshot ORDER BY id DESC") 67 | fun query(): Flow> 68 | 69 | @Query("UPDATE snapshot SET github_asset_id=null WHERE id = :id") 70 | suspend fun deleteGithubAssetId(id: Long) 71 | 72 | @Query("SELECT COUNT(*) FROM snapshot") 73 | fun count(): Flow 74 | } 75 | } 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/SubsItem.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Entity 7 | import androidx.room.Insert 8 | import androidx.room.OnConflictStrategy 9 | import androidx.room.PrimaryKey 10 | import androidx.room.Query 11 | import androidx.room.Transaction 12 | import androidx.room.Update 13 | import kotlinx.collections.immutable.toImmutableMap 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.flow.Flow 16 | import kotlinx.serialization.Serializable 17 | import li.songe.gkd.appScope 18 | import li.songe.gkd.db.DbSet 19 | import li.songe.gkd.util.isSafeUrl 20 | import li.songe.gkd.util.launchTry 21 | import li.songe.gkd.util.subsFolder 22 | import li.songe.gkd.util.subsIdToRawFlow 23 | 24 | @Serializable 25 | @Entity( 26 | tableName = "subs_item", 27 | ) 28 | data class SubsItem( 29 | @PrimaryKey @ColumnInfo(name = "id") val id: Long, 30 | 31 | @ColumnInfo(name = "ctime") val ctime: Long = System.currentTimeMillis(), 32 | @ColumnInfo(name = "mtime") val mtime: Long = System.currentTimeMillis(), 33 | @ColumnInfo(name = "enable") val enable: Boolean = true, 34 | @ColumnInfo(name = "enable_update") val enableUpdate: Boolean = true, 35 | @ColumnInfo(name = "order") val order: Int, 36 | @ColumnInfo(name = "update_url") val updateUrl: String? = null, 37 | 38 | ) { 39 | 40 | private val isSafeRemote by lazy { 41 | if (updateUrl != null) { 42 | isSafeUrl(updateUrl) 43 | } else { 44 | false 45 | } 46 | } 47 | 48 | val sourceText by lazy { 49 | if (id < 0) { 50 | "本地来源" 51 | } else if (isSafeRemote) { 52 | "可信来源" 53 | } else { 54 | "未知来源" 55 | } 56 | } 57 | 58 | @Dao 59 | interface SubsItemDao { 60 | @Update 61 | suspend fun update(vararg objects: SubsItem): Int 62 | 63 | @Query("UPDATE subs_item SET enable=:enable WHERE id=:id") 64 | suspend fun updateEnable(id: Long, enable: Boolean): Int 65 | 66 | @Query("UPDATE subs_item SET `order`=:order WHERE id=:id") 67 | suspend fun updateOrder(id: Long, order: Int): Int 68 | 69 | @Transaction 70 | suspend fun batchUpdateOrder(subsItems: List) { 71 | subsItems.forEach { subsItem -> 72 | updateOrder(subsItem.id, subsItem.order) 73 | } 74 | } 75 | 76 | @Insert(onConflict = OnConflictStrategy.REPLACE) 77 | suspend fun insert(vararg users: SubsItem): List 78 | 79 | @Insert(onConflict = OnConflictStrategy.IGNORE) 80 | suspend fun insertOrIgnore(vararg users: SubsItem): List 81 | 82 | @Delete 83 | suspend fun delete(vararg users: SubsItem): Int 84 | 85 | @Query("UPDATE subs_item SET mtime=:mtime WHERE id=:id") 86 | suspend fun updateMtime(id: Long, mtime: Long = System.currentTimeMillis()): Int 87 | 88 | @Query("SELECT * FROM subs_item ORDER BY `order`") 89 | fun query(): Flow> 90 | 91 | @Query("SELECT * FROM subs_item ORDER BY `order`") 92 | fun queryAll(): List 93 | 94 | @Query("DELETE FROM subs_item WHERE id IN (:ids)") 95 | suspend fun deleteById(vararg ids: Long) 96 | } 97 | 98 | } 99 | 100 | 101 | fun deleteSubscription(vararg subsIds: Long) { 102 | appScope.launchTry(Dispatchers.IO) { 103 | DbSet.subsItemDao.deleteById(*subsIds) 104 | DbSet.subsConfigDao.deleteBySubsId(*subsIds) 105 | DbSet.clickLogDao.deleteBySubsId(*subsIds) 106 | DbSet.categoryConfigDao.deleteBySubsId(*subsIds) 107 | val newMap = subsIdToRawFlow.value.toMutableMap() 108 | subsIds.forEach { id -> 109 | newMap.remove(id) 110 | subsFolder.resolve("$id.json").apply { 111 | if (exists()) { 112 | delete() 113 | } 114 | } 115 | } 116 | subsIdToRawFlow.value = newMap.toImmutableMap() 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/SubsVersion.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SubsVersion(val id: Long, val version: Int) 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/TransferData.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import kotlinx.serialization.Serializable 4 | import li.songe.gkd.db.DbSet 5 | import li.songe.gkd.util.LOCAL_SUBS_IDS 6 | import li.songe.gkd.util.subsIdToRawFlow 7 | import li.songe.gkd.util.subsItemsFlow 8 | import li.songe.gkd.util.updateSubscription 9 | 10 | @Serializable 11 | data class TransferData( 12 | val type: String = TYPE, 13 | val ctime: Long = System.currentTimeMillis(), 14 | 15 | val subsItems: List = emptyList(), 16 | val subscriptions: List = emptyList(), 17 | val subsConfigs: List = emptyList(), 18 | val categoryConfigs: List = emptyList(), 19 | ) { 20 | companion object { 21 | const val TYPE = "transfer_data" 22 | } 23 | } 24 | 25 | suspend fun exportTransferData(subsItemIds: Collection): TransferData { 26 | return TransferData( 27 | subsItems = subsItemsFlow.value.filter { subsItemIds.contains(it.id) }, 28 | subscriptions = subsIdToRawFlow.value.values.filter { it.id < 0 && subsItemIds.contains(it.id) }, 29 | subsConfigs = DbSet.subsConfigDao.querySubsItemConfig(subsItemIds.toList()), 30 | categoryConfigs = DbSet.categoryConfigDao.querySubsItemConfig(subsItemIds.toList()), 31 | ) 32 | } 33 | 34 | suspend fun importTransferData(transferData: TransferData): Boolean { 35 | // TODO transaction 36 | val maxOrder = (subsItemsFlow.value.maxOfOrNull { it.order } ?: -1) + 1 37 | val subsItems = 38 | transferData.subsItems.filter { s -> s.id >= 0 || LOCAL_SUBS_IDS.contains(s.id) } 39 | .mapIndexed { i, s -> 40 | s.copy(order = maxOrder + i) 41 | } 42 | val hasNewSubsItem = 43 | subsItems.any { newSubs -> newSubs.id >= 0 && subsItemsFlow.value.all { oldSubs -> oldSubs.id != newSubs.id } } 44 | DbSet.subsItemDao.insertOrIgnore(*subsItems.toTypedArray()) 45 | DbSet.subsConfigDao.insertOrIgnore(*transferData.subsConfigs.toTypedArray()) 46 | DbSet.categoryConfigDao.insertOrIgnore(*transferData.categoryConfigs.toTypedArray()) 47 | transferData.subscriptions.forEach { subscription -> 48 | if (LOCAL_SUBS_IDS.contains(subscription.id)) { 49 | updateSubscription(subscription) 50 | } 51 | } 52 | return hasNewSubsItem 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/Tuple.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | data class Tuple3( 7 | val t0: T0, 8 | val t1: T1, 9 | val t2: T2, 10 | ) { 11 | override fun toString() = "($t0, $t1, $t2)" 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/data/Value.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.data 2 | 3 | class Value(var value: T) -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/db/AppDb.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.db 2 | 3 | import androidx.room.AutoMigration 4 | import androidx.room.Database 5 | import androidx.room.RoomDatabase 6 | import li.songe.gkd.data.CategoryConfig 7 | import li.songe.gkd.data.ClickLog 8 | import li.songe.gkd.data.Snapshot 9 | import li.songe.gkd.data.SubsConfig 10 | import li.songe.gkd.data.SubsItem 11 | 12 | @Database( 13 | version = 6, 14 | entities = [SubsItem::class, Snapshot::class, SubsConfig::class, ClickLog::class, CategoryConfig::class], 15 | autoMigrations = [ 16 | AutoMigration(from = 1, to = 2), 17 | AutoMigration(from = 2, to = 3), 18 | AutoMigration(from = 3, to = 4), 19 | AutoMigration(from = 4, to = 5), 20 | AutoMigration(from = 5, to = 6), 21 | ] 22 | ) 23 | abstract class AppDb : RoomDatabase() { 24 | abstract fun subsItemDao(): SubsItem.SubsItemDao 25 | abstract fun snapshotDao(): Snapshot.SnapshotDao 26 | abstract fun subsConfigDao(): SubsConfig.SubsConfigDao 27 | abstract fun clickLogDao(): ClickLog.TriggerLogDao 28 | abstract fun categoryConfigDao(): CategoryConfig.CategoryConfigDao 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/db/DbSet.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.db 2 | 3 | import androidx.room.Room 4 | import li.songe.gkd.app 5 | import li.songe.gkd.util.dbFolder 6 | 7 | object DbSet { 8 | 9 | private fun buildDb(): AppDb { 10 | return Room.databaseBuilder( 11 | app, AppDb::class.java, dbFolder.resolve("gkd.db").absolutePath 12 | ).fallbackToDestructiveMigration().build() 13 | } 14 | 15 | private val db by lazy { buildDb() } 16 | val subsItemDao 17 | get() = db.subsItemDao() 18 | val subsConfigDao 19 | get() = db.subsConfigDao() 20 | val snapshotDao 21 | get() = db.snapshotDao() 22 | val clickLogDao 23 | get() = db.clickLogDao() 24 | val categoryConfigDao 25 | get() = db.categoryConfigDao() 26 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/debug/FloatingService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.debug 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.view.ViewConfiguration 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.CenterFocusWeak 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.unit.dp 13 | import com.torrydo.floatingbubbleview.FloatingBubbleListener 14 | import com.torrydo.floatingbubbleview.service.expandable.BubbleBuilder 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import li.songe.gkd.app 18 | import li.songe.gkd.appScope 19 | import li.songe.gkd.composition.CompositionExt.useLifeCycleLog 20 | import li.songe.gkd.composition.CompositionFbService 21 | import li.songe.gkd.data.Tuple3 22 | import li.songe.gkd.notif.createNotif 23 | import li.songe.gkd.notif.floatingChannel 24 | import li.songe.gkd.notif.floatingNotif 25 | import li.songe.gkd.util.launchTry 26 | import li.songe.gkd.util.toast 27 | import kotlin.math.sqrt 28 | 29 | class FloatingService : CompositionFbService({ 30 | useLifeCycleLog() 31 | configBubble { resolve -> 32 | val builder = BubbleBuilder(this).bubbleCompose { 33 | Icon( 34 | imageVector = Icons.Default.CenterFocusWeak, 35 | contentDescription = "capture", 36 | modifier = Modifier.size(40.dp), 37 | tint = Color.Red 38 | ) 39 | }.enableAnimateToEdge(false) 40 | 41 | // https://github.com/gkd-kit/gkd/issues/62 42 | // https://github.com/gkd-kit/gkd/issues/61 43 | val defaultFingerData = Tuple3(0L, 0f, 0f) 44 | var fingerDownData = defaultFingerData 45 | val maxDistanceOffset = 50 46 | builder.addFloatingBubbleListener(object : FloatingBubbleListener { 47 | override fun onFingerDown(x: Float, y: Float) { 48 | fingerDownData = Tuple3(System.currentTimeMillis(), x, y) 49 | } 50 | 51 | override fun onFingerMove(x: Float, y: Float) { 52 | if (fingerDownData === defaultFingerData) { 53 | return 54 | } 55 | val dx = fingerDownData.t1 - x 56 | val dy = fingerDownData.t2 - y 57 | val distance = sqrt(dx * dx + dy * dy) 58 | if (distance > maxDistanceOffset) { 59 | // reset 60 | fingerDownData = defaultFingerData 61 | } 62 | } 63 | 64 | override fun onFingerUp(x: Float, y: Float) { 65 | if (System.currentTimeMillis() - fingerDownData.t0 < ViewConfiguration.getTapTimeout()) { 66 | // is onClick 67 | appScope.launchTry(Dispatchers.IO) { 68 | SnapshotExt.captureSnapshot() 69 | toast("快照成功") 70 | } 71 | } 72 | } 73 | }) 74 | resolve(builder) 75 | } 76 | 77 | isRunning.value = true 78 | onDestroy { 79 | isRunning.value = false 80 | } 81 | }) { 82 | 83 | override fun onCreate() { 84 | super.onCreate() 85 | minimize() 86 | } 87 | 88 | override fun startNotificationForeground() { 89 | createNotif(this, floatingChannel.id, floatingNotif) 90 | } 91 | 92 | companion object { 93 | val isRunning = MutableStateFlow(false) 94 | fun stop(context: Context = app) { 95 | context.stopService(Intent(context, FloatingService::class.java)) 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/debug/KtorCorsPlugin.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.debug 2 | 3 | import io.ktor.http.HttpHeaders 4 | import io.ktor.http.HttpMethod 5 | import io.ktor.server.application.createApplicationPlugin 6 | import io.ktor.server.request.httpMethod 7 | import io.ktor.server.response.header 8 | import io.ktor.server.response.respond 9 | 10 | // allow all cors 11 | val KtorCorsPlugin = createApplicationPlugin(name = "KtorCorsPlugin") { 12 | onCallRespond { call, _ -> 13 | call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") 14 | call.response.header(HttpHeaders.AccessControlAllowMethods, "*") 15 | call.response.header(HttpHeaders.AccessControlAllowHeaders, "*") 16 | call.response.header(HttpHeaders.AccessControlExposeHeaders, "*") 17 | call.response.header("Access-Control-Allow-Private-Network", "true") 18 | } 19 | onCall { call -> 20 | if (call.request.httpMethod == HttpMethod.Options) { 21 | call.respond("all-cors-ok") 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/debug/KtorErrorPlugin.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.debug 2 | 3 | import android.util.Log 4 | import com.blankj.utilcode.util.LogUtils 5 | import io.ktor.server.application.createApplicationPlugin 6 | import io.ktor.server.application.hooks.CallFailed 7 | import io.ktor.server.plugins.origin 8 | import io.ktor.server.request.uri 9 | import io.ktor.server.response.respond 10 | import li.songe.gkd.data.RpcError 11 | 12 | val KtorErrorPlugin = createApplicationPlugin(name = "KtorErrorPlugin") { 13 | onCall { call -> 14 | // TODO 在局域网会被扫描工具批量请求多个路径 15 | if (call.request.uri == "/" || call.request.uri.startsWith("/api/")) { 16 | Log.d("Ktor", "onCall: ${call.request.origin.remoteAddress} -> ${call.request.uri}") 17 | } 18 | } 19 | on(CallFailed) { call, cause -> 20 | when (cause) { 21 | is RpcError -> { 22 | // 主动抛出的错误 23 | LogUtils.d(call.request.uri, cause.message) 24 | call.respond(cause) 25 | } 26 | 27 | is Exception -> { 28 | // 未知错误 29 | LogUtils.d(call.request.uri, cause.message) 30 | cause.printStackTrace() 31 | call.respond(RpcError(message = cause.message ?: "unknown error", unknown = true)) 32 | } 33 | 34 | else -> { 35 | cause.printStackTrace() 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/debug/ScreenshotService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.debug 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.ComponentName 5 | import android.content.Context 6 | import android.content.Intent 7 | import com.blankj.utilcode.util.LogUtils 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import li.songe.gkd.app 10 | import li.songe.gkd.composition.CompositionExt.useLifeCycleLog 11 | import li.songe.gkd.composition.CompositionService 12 | import li.songe.gkd.notif.createNotif 13 | import li.songe.gkd.notif.screenshotChannel 14 | import li.songe.gkd.notif.screenshotNotif 15 | import li.songe.gkd.util.ScreenshotUtil 16 | 17 | class ScreenshotService : CompositionService({ 18 | useLifeCycleLog() 19 | createNotif(this, screenshotChannel.id, screenshotNotif) 20 | 21 | onStartCommand { intent, _, _ -> 22 | if (intent == null) return@onStartCommand 23 | screenshotUtil?.destroy() 24 | screenshotUtil = ScreenshotUtil(this, intent) 25 | LogUtils.d("screenshot restart") 26 | } 27 | onDestroy { 28 | screenshotUtil?.destroy() 29 | screenshotUtil = null 30 | } 31 | 32 | isRunning.value = true 33 | onDestroy { 34 | isRunning.value = false 35 | } 36 | }) { 37 | companion object { 38 | suspend fun screenshot() = screenshotUtil?.execute() 39 | 40 | @SuppressLint("StaticFieldLeak") 41 | private var screenshotUtil: ScreenshotUtil? = null 42 | 43 | fun start(context: Context = app, intent: Intent) { 44 | intent.component = ComponentName(context, ScreenshotService::class.java) 45 | context.startForegroundService(intent) 46 | } 47 | 48 | val isRunning = MutableStateFlow(false) 49 | fun stop(context: Context = app) { 50 | context.stopService(Intent(context, ScreenshotService::class.java)) 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/debug/SnapshotActionService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.debug 2 | 3 | import android.app.Service 4 | import android.content.Intent 5 | import android.os.Binder 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.launch 8 | import li.songe.gkd.appScope 9 | import li.songe.gkd.util.launchTry 10 | 11 | /** 12 | * https://github.com/gkd-kit/gkd/issues/253 13 | */ 14 | class SnapshotActionService : Service() { 15 | override fun onBind(intent: Intent?): Binder? = null 16 | override fun onCreate() { 17 | super.onCreate() 18 | appScope.launch { 19 | delay(1000) 20 | stopSelf() 21 | } 22 | appScope.launchTry { 23 | SnapshotExt.captureSnapshot() 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/debug/SnapshotTileService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.debug 2 | 3 | import android.accessibilityservice.AccessibilityService 4 | import android.service.quicksettings.TileService 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.delay 7 | import li.songe.gkd.appScope 8 | import li.songe.gkd.debug.SnapshotExt.captureSnapshot 9 | import li.songe.gkd.service.GkdAbService 10 | import li.songe.gkd.service.GkdAbService.Companion.eventExecutor 11 | import li.songe.gkd.service.GkdAbService.Companion.shizukuTopActivityGetter 12 | import li.songe.gkd.service.TopActivity 13 | import li.songe.gkd.service.getAndUpdateCurrentRules 14 | import li.songe.gkd.service.safeActiveWindow 15 | import li.songe.gkd.service.topActivityFlow 16 | import li.songe.gkd.util.launchTry 17 | import li.songe.gkd.util.toast 18 | 19 | class SnapshotTileService : TileService() { 20 | override fun onClick() { 21 | super.onClick() 22 | val service = GkdAbService.service 23 | if (service == null) { 24 | toast("无障碍没有开启") 25 | return 26 | } 27 | appScope.launchTry(Dispatchers.IO) { 28 | val oldAppId = service.safeActiveWindow?.packageName?.toString() 29 | ?: return@launchTry toast("获取界面信息根节点失败") 30 | val interval = 500L 31 | val waitTime = 3000L 32 | var i = 0 33 | while (true) { 34 | val latestAppId = 35 | service.safeActiveWindow?.packageName?.toString() ?: return@launchTry 36 | if (latestAppId != oldAppId) { 37 | eventExecutor.execute { 38 | topActivityFlow.value = 39 | shizukuTopActivityGetter?.invoke() ?: TopActivity(appId = latestAppId) 40 | getAndUpdateCurrentRules() 41 | appScope.launchTry(Dispatchers.IO) { 42 | captureSnapshot() 43 | } 44 | } 45 | return@launchTry 46 | } else { 47 | service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) 48 | delay(interval) 49 | i++ 50 | if (i * interval > waitTime) { 51 | toast("没有检测到界面切换,捕获失败") 52 | return@launchTry 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/notif/Notif.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.notif 2 | 3 | import li.songe.gkd.app 4 | import li.songe.gkd.util.SafeR 5 | 6 | data class Notif( 7 | val id: Int, 8 | val smallIcon: Int = SafeR.ic_status, 9 | val title: String = app.getString(SafeR.app_name), 10 | val text: String, 11 | val ongoing: Boolean, 12 | val autoCancel: Boolean, 13 | ) 14 | 15 | val abNotif by lazy { 16 | Notif( 17 | id = 100, 18 | text = "无障碍正在运行", 19 | ongoing = true, 20 | autoCancel = false, 21 | ) 22 | } 23 | 24 | val screenshotNotif by lazy { 25 | Notif( 26 | id = 101, 27 | text = "截屏服务正在运行", 28 | ongoing = true, 29 | autoCancel = false, 30 | ) 31 | } 32 | 33 | val floatingNotif by lazy { 34 | Notif( 35 | id = 102, 36 | text = "悬浮窗按钮正在显示", 37 | ongoing = true, 38 | autoCancel = false, 39 | ) 40 | } 41 | 42 | val httpNotif by lazy { 43 | Notif( 44 | id = 103, 45 | text = "HTTP服务正在运行", 46 | ongoing = true, 47 | autoCancel = false, 48 | ) 49 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/notif/NotifChannel.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.notif 2 | 3 | import li.songe.gkd.app 4 | 5 | data class NotifChannel( 6 | val id: String, 7 | val name: String, 8 | val desc: String, 9 | ) 10 | 11 | val defaultChannel by lazy { 12 | NotifChannel( 13 | id = "default", name = "GKD", desc = "显示服务运行状态" 14 | ) 15 | } 16 | 17 | val floatingChannel by lazy { 18 | NotifChannel( 19 | id = "floating", name = "悬浮窗按钮服务", desc = "用于主动捕获屏幕快照的悬浮窗按钮" 20 | ) 21 | } 22 | val screenshotChannel by lazy { 23 | NotifChannel( 24 | id = "screenshot", name = "截屏服务", desc = "用于捕获屏幕截屏生成快照" 25 | ) 26 | } 27 | val httpChannel by lazy { 28 | NotifChannel( 29 | id = "http", name = "HTTP服务", desc = "用于连接Web端工具调试" 30 | ) 31 | } 32 | 33 | fun initChannel() { 34 | createChannel(app, defaultChannel) 35 | createChannel(app, floatingChannel) 36 | createChannel(app, screenshotChannel) 37 | createChannel(app, httpChannel) 38 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/notif/NotifManager.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.notif 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.app.PendingIntent 6 | import android.app.Service 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.content.pm.ServiceInfo 10 | import android.os.Build 11 | import androidx.core.app.NotificationCompat 12 | import androidx.core.app.NotificationManagerCompat 13 | import li.songe.gkd.MainActivity 14 | 15 | fun createChannel(context: Context, notifChannel: NotifChannel) { 16 | val importance = NotificationManager.IMPORTANCE_LOW 17 | val channel = NotificationChannel(notifChannel.id, notifChannel.name, importance) 18 | channel.description = notifChannel.desc 19 | val notificationManager = NotificationManagerCompat.from(context) 20 | notificationManager.createNotificationChannel(channel) 21 | } 22 | 23 | fun createNotif(context: Service, channelId: String, notif: Notif) { 24 | val intent = Intent(context, MainActivity::class.java).apply { 25 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 26 | } 27 | val pendingIntent = PendingIntent.getActivity( 28 | context, notif.id, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT 29 | ) 30 | 31 | val builder = NotificationCompat.Builder(context, channelId) 32 | .setSmallIcon(notif.smallIcon) 33 | .setContentTitle(notif.title).setContentText(notif.text).setContentIntent(pendingIntent) 34 | .setPriority(NotificationCompat.PRIORITY_DEFAULT).setOngoing(notif.ongoing) 35 | .setAutoCancel(notif.autoCancel) 36 | 37 | val notification = builder.build() 38 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 39 | context.startForeground( 40 | notif.id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST 41 | ) 42 | } else { 43 | context.startForeground(notif.id, notification) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.permission 2 | 3 | import android.app.Activity 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Text 6 | import androidx.compose.material3.TextButton 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.collectAsState 9 | import com.hjq.permissions.OnPermissionCallback 10 | import com.hjq.permissions.XXPermissions 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlin.coroutines.resume 13 | import kotlin.coroutines.suspendCoroutine 14 | 15 | data class AuthReason( 16 | val text: String, 17 | val confirm: () -> Unit 18 | ) 19 | 20 | val authReasonFlow = MutableStateFlow(null) 21 | 22 | @Composable 23 | fun AuthDialog() { 24 | val authAction = authReasonFlow.collectAsState().value 25 | if (authAction != null) { 26 | AlertDialog( 27 | title = { 28 | Text(text = "权限请求") 29 | }, 30 | text = { 31 | Text(text = authAction.text) 32 | }, 33 | onDismissRequest = { authReasonFlow.value = null }, 34 | confirmButton = { 35 | TextButton(onClick = { 36 | authReasonFlow.value = null 37 | authAction.confirm() 38 | }) { 39 | Text(text = "确认") 40 | } 41 | }, 42 | dismissButton = { 43 | TextButton(onClick = { authReasonFlow.value = null }) { 44 | Text(text = "取消") 45 | } 46 | } 47 | ) 48 | } 49 | } 50 | 51 | sealed class PermissionResult { 52 | data object Granted : PermissionResult() 53 | data class Denied(val doNotAskAgain: Boolean) : PermissionResult() 54 | } 55 | 56 | suspend fun asyncRequestPermission( 57 | context: Activity, 58 | permission: String, 59 | ): PermissionResult { 60 | if (XXPermissions.isGranted(context, permission)) { 61 | return PermissionResult.Granted 62 | } 63 | return suspendCoroutine { continuation -> 64 | XXPermissions.with(context) 65 | .unchecked() 66 | .permission(permission) 67 | .request(object : OnPermissionCallback { 68 | override fun onGranted(permissions: MutableList, allGranted: Boolean) { 69 | if (allGranted) { 70 | continuation.resume(PermissionResult.Granted) 71 | } else { 72 | continuation.resume(PermissionResult.Denied(false)) 73 | } 74 | } 75 | 76 | override fun onDenied(permissions: MutableList, doNotAskAgain: Boolean) { 77 | continuation.resume(PermissionResult.Denied(doNotAskAgain)) 78 | } 79 | }) 80 | } 81 | } 82 | 83 | suspend fun checkOrRequestPermission( 84 | context: Activity, 85 | permissionState: PermissionState 86 | ): Boolean { 87 | if (!permissionState.updateAndGet()) { 88 | val result = permissionState.request?.let { it(context) } ?: return false 89 | if (result is PermissionResult.Denied) { 90 | if (result.doNotAskAgain) { 91 | authReasonFlow.value = permissionState.reason 92 | } 93 | return false 94 | } 95 | } 96 | return true 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/service/AbEvent.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.service 2 | 3 | import android.view.accessibility.AccessibilityEvent 4 | 5 | data class AbEvent( 6 | val type: Int, 7 | val time: Long, 8 | val appId: String, 9 | val className: String, 10 | ) 11 | 12 | fun AccessibilityEvent.toAbEvent(): AbEvent? { 13 | return AbEvent( 14 | type = eventType, 15 | time = System.currentTimeMillis(), 16 | appId = packageName?.toString() ?: return null, 17 | className = className?.toString() ?: return null 18 | ) 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/service/ManageService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.service 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.core.app.NotificationManagerCompat 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.flow.stateIn 10 | import kotlinx.coroutines.launch 11 | import li.songe.gkd.app 12 | import li.songe.gkd.composition.CompositionExt.useLifeCycleLog 13 | import li.songe.gkd.composition.CompositionExt.useScope 14 | import li.songe.gkd.composition.CompositionService 15 | import li.songe.gkd.notif.abNotif 16 | import li.songe.gkd.notif.createNotif 17 | import li.songe.gkd.notif.defaultChannel 18 | import li.songe.gkd.util.clickCountFlow 19 | import li.songe.gkd.util.map 20 | import li.songe.gkd.util.ruleSummaryFlow 21 | import li.songe.gkd.util.storeFlow 22 | 23 | class ManageService : CompositionService({ 24 | useLifeCycleLog() 25 | val context = this 26 | createNotif(context, defaultChannel.id, abNotif) 27 | val scope = useScope() 28 | scope.launch { 29 | combine( 30 | ruleSummaryFlow, 31 | clickCountFlow, 32 | storeFlow.map(scope) { it.enableService }, 33 | GkdAbService.isRunning 34 | ) { allRules, clickCount, enableService, abRunning -> 35 | if (!abRunning) return@combine "无障碍未授权" 36 | if (!enableService) return@combine "服务已暂停" 37 | allRules.numText + if (clickCount > 0) { 38 | "/${clickCount}点击" 39 | } else { 40 | "" 41 | } 42 | }.stateIn(scope, SharingStarted.Eagerly, "").collect { text -> 43 | createNotif( 44 | context, defaultChannel.id, abNotif.copy( 45 | text = text 46 | ) 47 | ) 48 | } 49 | } 50 | isRunning.value = true 51 | onDestroy { 52 | isRunning.value = false 53 | } 54 | }) { 55 | companion object { 56 | fun start(context: Context = app) { 57 | context.startForegroundService(Intent(context, ManageService::class.java)) 58 | } 59 | 60 | val isRunning = MutableStateFlow(false) 61 | 62 | fun stop(context: Context = app) { 63 | context.stopService(Intent(context, ManageService::class.java)) 64 | } 65 | 66 | fun autoStart(context: Context) { 67 | // 在[系统重启]/[被其它高权限应用重启]时自动打开通知栏状态服务 68 | if (storeFlow.value.enableStatusService && 69 | NotificationManagerCompat.from(context).areNotificationsEnabled() && 70 | !isRunning.value 71 | ) { 72 | start(context) 73 | } 74 | } 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/shizuku/AutoStartReceiver.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.shizuku 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import rikka.shizuku.Shizuku 7 | 8 | class AutoStartReceiver : BroadcastReceiver() { 9 | override fun onReceive(context: Context?, intent: Intent?) { 10 | if (intent?.action == Intent.ACTION_BOOT_COMPLETED || intent?.action == Intent.ACTION_LOCKED_BOOT_COMPLETED) { 11 | Shizuku.addBinderReceivedListenerSticky(oneShotBinderReceivedListener) 12 | } 13 | } 14 | 15 | private val oneShotBinderReceivedListener = object : Shizuku.OnBinderReceivedListener { 16 | override fun onBinderReceived() { 17 | Shizuku.removeBinderReceivedListener(this) 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/shizuku/CommandResult.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.shizuku 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class CommandResult( 7 | val code: Int, 8 | val result: String, 9 | val error: String? 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/shizuku/UserService.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.shizuku 2 | 3 | import android.content.Context 4 | import android.os.RemoteException 5 | import android.util.Log 6 | import androidx.annotation.Keep 7 | import kotlinx.serialization.encodeToString 8 | import li.songe.gkd.util.json 9 | import java.io.DataOutputStream 10 | 11 | 12 | class UserService : IUserService.Stub { 13 | /** 14 | * Constructor is required. 15 | */ 16 | constructor() { 17 | Log.i("UserService", "constructor") 18 | } 19 | 20 | @Keep 21 | constructor(context: Context) { 22 | Log.i("UserService", "constructor with Context: context=$context") 23 | } 24 | 25 | /** 26 | * Reserved destroy method 27 | */ 28 | override fun destroy() { 29 | Log.i("UserService", "destroy") 30 | } 31 | 32 | override fun exit() { 33 | destroy() 34 | } 35 | 36 | @Throws(RemoteException::class) 37 | override fun execCommand(command: String): String { 38 | val process = Runtime.getRuntime().exec("sh") 39 | val outputStream = DataOutputStream(process.outputStream) 40 | val commandResult = try { 41 | command.split('\n').filter { it.isNotBlank() }.forEach { 42 | outputStream.write(it.toByteArray()) 43 | outputStream.writeBytes('\n'.toString()) 44 | outputStream.flush() 45 | } 46 | outputStream.writeBytes("exit\n") 47 | outputStream.flush() 48 | CommandResult( 49 | code = process.waitFor(), 50 | result = process.inputStream.bufferedReader().readText(), 51 | error = process.errorStream.bufferedReader().readText(), 52 | ) 53 | } catch (e: Exception) { 54 | e.printStackTrace() 55 | val message = e.message 56 | val aimErrStr = "error=" 57 | val index = message?.indexOf(aimErrStr) 58 | val code = if (index != null) { 59 | message.substring(index + aimErrStr.length) 60 | .takeWhile { c -> c.isDigit() } 61 | .toIntOrNull() 62 | } else { 63 | null 64 | } ?: 1 65 | CommandResult( 66 | code = code, 67 | result = "", 68 | error = e.message, 69 | ) 70 | } finally { 71 | outputStream.close() 72 | process.inputStream.close() 73 | process.outputStream.close() 74 | process.destroy() 75 | } 76 | return json.encodeToString(commandResult) 77 | } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/AdvancedVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.stateIn 9 | import li.songe.gkd.db.DbSet 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class AdvancedVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { 14 | val snapshotCountFlow = 15 | DbSet.snapshotDao.count().stateIn(viewModelScope, SharingStarted.Eagerly, 0) 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.combine 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.stateIn 12 | import li.songe.gkd.data.SubsConfig 13 | import li.songe.gkd.db.DbSet 14 | import li.songe.gkd.ui.destinations.AppConfigPageDestination 15 | import li.songe.gkd.util.RuleSortOption 16 | import li.songe.gkd.util.collator 17 | import li.songe.gkd.util.ruleSummaryFlow 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class AppConfigVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { 22 | private val args = AppConfigPageDestination.argsFrom(stateHandle) 23 | 24 | private val latestGlobalLogsFlow = DbSet.clickLogDao.queryAppLatest( 25 | args.appId, 26 | SubsConfig.GlobalGroupType 27 | ) 28 | private val latestAppLogsFlow = DbSet.clickLogDao.queryAppLatest( 29 | args.appId, 30 | SubsConfig.AppGroupType 31 | ) 32 | 33 | val ruleSortTypeFlow = MutableStateFlow(RuleSortOption.Default) 34 | 35 | val globalGroupsFlow = combine( 36 | ruleSummaryFlow.map { r -> r.globalGroups }, 37 | ruleSortTypeFlow, 38 | latestGlobalLogsFlow 39 | ) { list, type, logs -> 40 | when (type) { 41 | RuleSortOption.Default -> list 42 | RuleSortOption.ByName -> list.sortedWith { a, b -> 43 | collator.compare( 44 | a.group.name, 45 | b.group.name 46 | ) 47 | } 48 | 49 | RuleSortOption.ByTime -> list.sortedBy { a -> 50 | -(logs.find { c -> c.groupKey == a.group.key && c.subsId == a.subsItem.id }?.id 51 | ?: 0) 52 | } 53 | } 54 | }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 55 | 56 | val appGroupsFlow = combine( 57 | ruleSummaryFlow.map { r -> r.appIdToAllGroups[args.appId] ?: emptyList() }, 58 | ruleSortTypeFlow, 59 | latestAppLogsFlow 60 | ) { list, type, logs -> 61 | when (type) { 62 | RuleSortOption.Default -> list 63 | RuleSortOption.ByName -> list.sortedWith { a, b -> 64 | collator.compare( 65 | a.group.name, 66 | b.group.name 67 | ) 68 | } 69 | 70 | RuleSortOption.ByTime -> list.sortedBy { a -> 71 | -(logs.find { c -> c.groupKey == a.group.key && c.subsId == a.subsItem.id }?.id 72 | ?: 0) 73 | } 74 | } 75 | }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 76 | 77 | val innerDisabledDlgFlow = MutableStateFlow(false) 78 | 79 | } 80 | 81 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/AppItemVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.coroutines.flow.stateIn 10 | import li.songe.gkd.data.RawSubscription 11 | import li.songe.gkd.db.DbSet 12 | import li.songe.gkd.ui.destinations.AppItemPageDestination 13 | import li.songe.gkd.util.map 14 | import li.songe.gkd.util.subsIdToRawFlow 15 | import li.songe.gkd.util.subsItemsFlow 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class AppItemVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { 20 | private val args = AppItemPageDestination.argsFrom(stateHandle) 21 | 22 | val subsItemFlow = 23 | subsItemsFlow.map { subsItems -> subsItems.find { s -> s.id == args.subsItemId } } 24 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 25 | 26 | val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } 27 | 28 | val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(args.subsItemId, args.appId) 29 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 30 | 31 | val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) 32 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 33 | 34 | val subsAppFlow = 35 | subsIdToRawFlow.map(viewModelScope) { subsIdToRaw -> 36 | subsIdToRaw[args.subsItemId]?.apps?.find { it.id == args.appId } 37 | ?: RawSubscription.RawApp(id = args.appId, name = null) 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/CategoryVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.stateIn 9 | import li.songe.gkd.db.DbSet 10 | import li.songe.gkd.ui.destinations.CategoryPageDestination 11 | import li.songe.gkd.util.map 12 | import li.songe.gkd.util.subsIdToRawFlow 13 | import li.songe.gkd.util.subsItemsFlow 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class CategoryVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { 18 | private val args = CategoryPageDestination.argsFrom(stateHandle) 19 | 20 | val subsItemFlow = 21 | subsItemsFlow.map(viewModelScope) { subsItems -> subsItems.find { s -> s.id == args.subsItemId } } 22 | 23 | val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { m -> m[args.subsItemId] } 24 | 25 | val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) 26 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/ClickLogVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.Pager 6 | import androidx.paging.PagingConfig 7 | import androidx.paging.cachedIn 8 | import androidx.paging.map 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.SharingStarted 11 | import kotlinx.coroutines.flow.combine 12 | import kotlinx.coroutines.flow.stateIn 13 | import li.songe.gkd.data.SubsConfig 14 | import li.songe.gkd.data.Tuple3 15 | import li.songe.gkd.db.DbSet 16 | import li.songe.gkd.util.subsIdToRawFlow 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class ClickLogVm @Inject constructor() : ViewModel() { 21 | 22 | val pagingDataFlow = Pager(PagingConfig(pageSize = 100)) { DbSet.clickLogDao.pagingSource() } 23 | .flow.cachedIn(viewModelScope) 24 | .combine(subsIdToRawFlow) { pagingData, subsIdToRaw -> 25 | pagingData.map { c -> 26 | val group = if (c.groupType == SubsConfig.AppGroupType) { 27 | val app = subsIdToRaw[c.subsId]?.apps?.find { a -> a.id == c.appId } 28 | app?.groups?.find { g -> g.key == c.groupKey } 29 | } else { 30 | subsIdToRaw[c.subsId]?.globalGroups?.find { g -> g.key == c.groupKey } 31 | } 32 | val rule = group?.rules?.run { 33 | if (c.ruleKey != null) { 34 | find { r -> r.key == c.ruleKey } 35 | } else { 36 | getOrNull(c.ruleIndex) 37 | } 38 | } 39 | Tuple3(c, group, rule) 40 | } 41 | } 42 | 43 | val clickLogCountFlow = 44 | DbSet.clickLogDao.count().stateIn(viewModelScope, SharingStarted.Eagerly, 0) 45 | 46 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.combine 10 | import kotlinx.coroutines.flow.debounce 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.stateIn 13 | import li.songe.gkd.data.ExcludeData 14 | import li.songe.gkd.db.DbSet 15 | import li.songe.gkd.ui.destinations.GlobalRuleExcludePageDestination 16 | import li.songe.gkd.util.SortTypeOption 17 | import li.songe.gkd.util.map 18 | import li.songe.gkd.util.orderedAppInfosFlow 19 | import li.songe.gkd.util.subsIdToRawFlow 20 | import javax.inject.Inject 21 | 22 | @HiltViewModel 23 | class GlobalRuleExcludeVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { 24 | private val args = GlobalRuleExcludePageDestination.argsFrom(stateHandle) 25 | 26 | val rawSubsFlow = subsIdToRawFlow.map(viewModelScope) { it[args.subsItemId] } 27 | 28 | val groupFlow = 29 | rawSubsFlow.map(viewModelScope) { r -> r?.globalGroups?.find { g -> g.key == args.groupKey } } 30 | 31 | val subsConfigFlow = 32 | DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId, args.groupKey) 33 | .map { it.firstOrNull() } 34 | .stateIn(viewModelScope, SharingStarted.Eagerly, null) 35 | 36 | val excludeDataFlow = subsConfigFlow.map(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } 37 | 38 | val searchStrFlow = MutableStateFlow("") 39 | private val debounceSearchStrFlow = searchStrFlow.debounce(200) 40 | .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) 41 | 42 | private val appIdToOrderFlow = 43 | DbSet.clickLogDao.queryLatestUniqueAppIds(args.subsItemId, args.groupKey).map { appIds -> 44 | appIds.mapIndexed { index, appId -> appId to index }.toMap() 45 | } 46 | val sortTypeFlow = MutableStateFlow(SortTypeOption.SortByName) 47 | val showSystemAppFlow = MutableStateFlow(true) 48 | val showHiddenAppFlow = MutableStateFlow(false) 49 | val showAppInfosFlow = 50 | combine(orderedAppInfosFlow.combine(showHiddenAppFlow) { appInfos, showHiddenApp -> 51 | if (showHiddenApp) { 52 | appInfos 53 | } else { 54 | appInfos.filter { a -> !a.hidden } 55 | } 56 | }.combine(showSystemAppFlow) { apps, showSystemApp -> 57 | if (showSystemApp) { 58 | apps 59 | } else { 60 | apps.filter { a -> !a.isSystem } 61 | } 62 | }, sortTypeFlow, appIdToOrderFlow) { apps, sortType, appIdToOrder -> 63 | when (sortType) { 64 | SortTypeOption.SortByAppMtime -> { 65 | apps.sortedBy { a -> -a.mtime } 66 | } 67 | 68 | SortTypeOption.SortByTriggerTime -> { 69 | apps.sortedBy { a -> appIdToOrder[a.id] ?: Int.MAX_VALUE } 70 | } 71 | 72 | SortTypeOption.SortByName -> { 73 | apps 74 | } 75 | } 76 | }.combine(debounceSearchStrFlow) { apps, str -> 77 | if (str.isBlank()) { 78 | apps 79 | } else { 80 | (apps.filter { a -> a.name.contains(str, true) } + apps.filter { a -> 81 | a.id.contains( 82 | str, 83 | true 84 | ) 85 | }).distinct() 86 | } 87 | }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 88 | 89 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.stateIn 9 | import li.songe.gkd.db.DbSet 10 | import li.songe.gkd.ui.destinations.GlobalRulePageDestination 11 | import li.songe.gkd.util.map 12 | import li.songe.gkd.util.subsIdToRawFlow 13 | import li.songe.gkd.util.subsItemsFlow 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class GlobalRuleVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { 18 | private val args = GlobalRulePageDestination.argsFrom(stateHandle) 19 | val subsItemFlow = 20 | subsItemsFlow.map(viewModelScope) { s -> s.find { v -> v.id == args.subsItemId } } 21 | val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] } 22 | 23 | val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId) 24 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 25 | 26 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/ImagePreviewPage.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.IconButton 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TopAppBar 16 | import androidx.compose.material3.TopAppBarDefaults 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.zIndex 20 | import coil.compose.AsyncImage 21 | import com.ramcosta.composedestinations.annotation.Destination 22 | import com.ramcosta.composedestinations.annotation.RootNavGraph 23 | import li.songe.gkd.util.LocalNavController 24 | import li.songe.gkd.util.ProfileTransitions 25 | 26 | @RootNavGraph 27 | @Destination(style = ProfileTransitions::class) 28 | @Composable 29 | fun ImagePreviewPage( 30 | filePath: String, 31 | title: String? = null, 32 | ) { 33 | val navController = LocalNavController.current 34 | Box(modifier = Modifier.fillMaxSize()) { 35 | TopAppBar( 36 | navigationIcon = { 37 | IconButton(onClick = { 38 | navController.popBackStack() 39 | }) { 40 | Icon( 41 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 42 | contentDescription = null, 43 | ) 44 | } 45 | }, 46 | title = { 47 | if (title != null) { 48 | Text(text = title) 49 | } 50 | }, 51 | actions = {}, 52 | modifier = Modifier.zIndex(1f), 53 | colors = TopAppBarDefaults.topAppBarColors( 54 | containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f) 55 | ) 56 | ) 57 | 58 | Column( 59 | modifier = Modifier 60 | .fillMaxSize() 61 | .verticalScroll(rememberScrollState()) 62 | ) { 63 | AsyncImage( 64 | model = filePath, contentDescription = null, modifier = Modifier.fillMaxWidth() 65 | ) 66 | } 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/SnapshotVm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import io.ktor.client.call.body 7 | import io.ktor.client.plugins.onUpload 8 | import io.ktor.client.request.forms.formData 9 | import io.ktor.client.request.forms.submitFormWithBinaryData 10 | import io.ktor.client.statement.bodyAsText 11 | import io.ktor.http.Headers 12 | import io.ktor.http.HttpHeaders 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.Job 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.SharingStarted 17 | import kotlinx.coroutines.flow.stateIn 18 | import li.songe.gkd.data.GithubPoliciesAsset 19 | import li.songe.gkd.data.RpcError 20 | import li.songe.gkd.data.Snapshot 21 | import li.songe.gkd.db.DbSet 22 | import li.songe.gkd.debug.SnapshotExt.getSnapshotZipFile 23 | import li.songe.gkd.util.FILE_UPLOAD_URL 24 | import li.songe.gkd.util.LoadStatus 25 | import li.songe.gkd.util.client 26 | import li.songe.gkd.util.launchTry 27 | import javax.inject.Inject 28 | 29 | 30 | @HiltViewModel 31 | class SnapshotVm @Inject constructor() : ViewModel() { 32 | val snapshotsState = DbSet.snapshotDao.query() 33 | .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) 34 | 35 | val uploadStatusFlow = MutableStateFlow?>(null) 36 | var uploadJob: Job? = null 37 | 38 | fun uploadZip(snapshot: Snapshot) { 39 | uploadJob = viewModelScope.launchTry(Dispatchers.IO) { 40 | val zipFile = getSnapshotZipFile(snapshot.id) 41 | uploadStatusFlow.value = LoadStatus.Loading() 42 | try { 43 | val response = 44 | client.submitFormWithBinaryData(url = FILE_UPLOAD_URL, formData = formData { 45 | append("\"file\"", zipFile.readBytes(), Headers.build { 46 | append(HttpHeaders.ContentType, "application/x-zip-compressed") 47 | append(HttpHeaders.ContentDisposition, "filename=\"file.zip\"") 48 | }) 49 | }) { 50 | onUpload { bytesSentTotal, contentLength -> 51 | if (uploadStatusFlow.value is LoadStatus.Loading) { 52 | uploadStatusFlow.value = 53 | LoadStatus.Loading(bytesSentTotal / contentLength.toFloat()) 54 | } 55 | } 56 | } 57 | if (response.headers["X_RPC_OK"] == "true") { 58 | val policiesAsset = response.body() 59 | uploadStatusFlow.value = LoadStatus.Success(policiesAsset) 60 | DbSet.snapshotDao.update(snapshot.copy(githubAssetId = policiesAsset.id)) 61 | } else if (response.headers["X_RPC_OK"] == "false") { 62 | uploadStatusFlow.value = LoadStatus.Failure(response.body()) 63 | } else { 64 | uploadStatusFlow.value = LoadStatus.Failure(Exception(response.bodyAsText())) 65 | } 66 | } catch (e: Exception) { 67 | uploadStatusFlow.value = LoadStatus.Failure(e) 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/AuthCard.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.OutlinedButton 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import li.songe.gkd.ui.style.itemPadding 15 | 16 | @Composable 17 | fun AuthCard( 18 | title: String, 19 | desc: String, 20 | onAuthClick: () -> Unit, 21 | ) { 22 | Row( 23 | modifier = Modifier.itemPadding(), 24 | verticalAlignment = Alignment.CenterVertically 25 | ) { 26 | Column(modifier = Modifier.weight(1f)) { 27 | MaterialTheme.typography.bodyLarge 28 | Text( 29 | text = title, 30 | style = MaterialTheme.typography.bodyLarge, 31 | ) 32 | Text( 33 | text = desc, 34 | style = MaterialTheme.typography.bodyMedium, 35 | color = MaterialTheme.colorScheme.onSurfaceVariant, 36 | ) 37 | } 38 | Spacer(modifier = Modifier.width(10.dp)) 39 | OutlinedButton(onClick = onAuthClick) { 40 | Text(text = "授权") 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/ConfirmDialog.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.material3.TextButton 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.DisposableEffect 9 | import androidx.compose.runtime.collectAsState 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlin.coroutines.resume 12 | import kotlin.coroutines.suspendCoroutine 13 | 14 | 15 | private data class DialogParams( 16 | val title: String, 17 | val text: String? = null, 18 | val resolve: () -> Unit, 19 | val reject: () -> Unit 20 | ) 21 | 22 | private val dialogParamsFlow = MutableStateFlow(null) 23 | 24 | @Composable 25 | fun ConfirmDialog() { 26 | val dialogParams = dialogParamsFlow.collectAsState().value 27 | if (dialogParams != null) { 28 | AlertDialog( 29 | onDismissRequest = { }, 30 | title = { Text(text = dialogParams.title) }, 31 | text = if (dialogParams.text != null) { 32 | { 33 | Text(text = dialogParams.text) 34 | } 35 | } else null, 36 | confirmButton = { 37 | TextButton(onClick = dialogParams.resolve) { 38 | Text(text = "是", color = MaterialTheme.colorScheme.error) 39 | } 40 | }, 41 | dismissButton = { 42 | TextButton(onClick = dialogParams.reject) { 43 | Text(text = "否") 44 | } 45 | } 46 | ) 47 | } 48 | DisposableEffect(key1 = null, effect = { 49 | onDispose { 50 | val d = dialogParamsFlow.value 51 | if (d != null) { 52 | d.reject.invoke() 53 | dialogParamsFlow.value = null 54 | } 55 | } 56 | }) 57 | } 58 | 59 | suspend fun getDialogResult(title: String, text: String? = null): Boolean { 60 | return suspendCoroutine { s -> 61 | dialogParamsFlow.value = DialogParams( 62 | title = title, 63 | text = text, 64 | resolve = { s.resume(true);dialogParamsFlow.value = null }, 65 | reject = { s.resume(false);dialogParamsFlow.value = null } 66 | ) 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/RotatingLoadingIcon.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.animation.core.LinearEasing 5 | import androidx.compose.animation.core.RepeatMode 6 | import androidx.compose.animation.core.infiniteRepeatable 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.Autorenew 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.graphicsLayer 16 | import kotlin.math.sin 17 | 18 | @Composable 19 | fun RotatingLoadingIcon(loading: Boolean) { 20 | val rotation = remember { Animatable(0f) } 21 | LaunchedEffect(loading) { 22 | if (loading) { 23 | rotation.animateTo( 24 | targetValue = rotation.value + 180f, 25 | animationSpec = tween( 26 | durationMillis = 250, 27 | easing = { x -> sin(Math.PI / 2 * (x - 1f)).toFloat() + 1f } 28 | ) 29 | ) 30 | rotation.animateTo( 31 | targetValue = rotation.value + 360f, 32 | animationSpec = infiniteRepeatable( 33 | animation = tween(durationMillis = 500, easing = LinearEasing), 34 | repeatMode = RepeatMode.Restart 35 | ) 36 | ) 37 | } else if (rotation.value != 0f) { 38 | rotation.animateTo( 39 | targetValue = rotation.value + 180f, 40 | animationSpec = tween( 41 | durationMillis = 250, 42 | easing = { x -> sin(Math.PI / 2 * x).toFloat() } 43 | ) 44 | ) 45 | } 46 | } 47 | Icon( 48 | imageVector = Icons.Default.Autorenew, 49 | contentDescription = null, 50 | modifier = Modifier.graphicsLayer(rotationZ = rotation.value) 51 | ) 52 | } 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/SettingItem.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.vector.ImageVector 16 | import li.songe.gkd.ui.style.itemPadding 17 | 18 | @Composable 19 | fun SettingItem( 20 | title: String, 21 | imageVector: ImageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, 22 | onClick: () -> Unit, 23 | ) { 24 | Row( 25 | modifier = Modifier 26 | .clickable( 27 | onClick = onClick 28 | ) 29 | .fillMaxWidth() 30 | .itemPadding(), 31 | horizontalArrangement = Arrangement.SpaceBetween, 32 | verticalAlignment = Alignment.CenterVertically, 33 | ) { 34 | Text( 35 | text = title, 36 | style = MaterialTheme.typography.bodyLarge, 37 | ) 38 | Icon(imageVector = imageVector, contentDescription = title) 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/TextMenu.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.UnfoldMore 9 | import androidx.compose.material3.DropdownMenu 10 | import androidx.compose.material3.DropdownMenuItem 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import li.songe.gkd.ui.style.itemPadding 22 | import li.songe.gkd.util.Option 23 | import li.songe.gkd.util.allSubObject 24 | 25 | @Composable 26 | fun TextMenu( 27 | title: String, 28 | option: Option, 29 | onOptionChange: ((Option) -> Unit), 30 | ) { 31 | var expanded by remember { mutableStateOf(false) } 32 | Row( 33 | modifier = Modifier 34 | .clickable { 35 | expanded = true 36 | } 37 | .fillMaxWidth() 38 | .itemPadding(), 39 | verticalAlignment = Alignment.CenterVertically, 40 | horizontalArrangement = Arrangement.SpaceBetween 41 | ) { 42 | Text( 43 | text = title, 44 | style = MaterialTheme.typography.bodyLarge, 45 | ) 46 | Row( 47 | verticalAlignment = Alignment.CenterVertically 48 | ) { 49 | Text( 50 | text = option.label, 51 | style = MaterialTheme.typography.bodyMedium, 52 | ) 53 | Icon( 54 | imageVector = Icons.Default.UnfoldMore, 55 | contentDescription = null 56 | ) 57 | DropdownMenu( 58 | expanded = expanded, 59 | onDismissRequest = { expanded = false } 60 | ) { 61 | option.allSubObject.forEach { otherOption -> 62 | DropdownMenuItem( 63 | text = { 64 | Text(text = otherOption.label) 65 | }, 66 | onClick = { 67 | expanded = false 68 | if (otherOption != option) { 69 | onOptionChange(otherOption) 70 | } 71 | }, 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Switch 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import li.songe.gkd.ui.style.itemPadding 15 | 16 | @Composable 17 | fun TextSwitch( 18 | modifier: Modifier = Modifier, 19 | name: String, 20 | desc: String? = null, 21 | checked: Boolean = true, 22 | enabled: Boolean = true, 23 | onCheckedChange: ((Boolean) -> Unit)? = null, 24 | ) { 25 | Row( 26 | modifier = modifier.itemPadding(), 27 | verticalAlignment = Alignment.CenterVertically 28 | ) { 29 | if (desc != null) { 30 | Column(modifier = Modifier.weight(1f)) { 31 | Text( 32 | text = name, 33 | style = MaterialTheme.typography.bodyLarge, 34 | ) 35 | Text( 36 | text = desc, 37 | style = MaterialTheme.typography.bodyMedium, 38 | color = MaterialTheme.colorScheme.onSurfaceVariant, 39 | ) 40 | } 41 | } else { 42 | Text( 43 | text = name, 44 | style = MaterialTheme.typography.bodyLarge, 45 | ) 46 | } 47 | Spacer(modifier = Modifier.width(10.dp)) 48 | Switch( 49 | checked = checked, 50 | enabled = enabled, 51 | onCheckedChange = onCheckedChange, 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/component/TowLineText.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.component 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.text.style.TextOverflow 8 | 9 | @Composable 10 | fun TowLineText( 11 | title: String, 12 | subTitle: String 13 | ) { 14 | Column { 15 | Text( 16 | text = title, 17 | maxLines = 1, 18 | overflow = TextOverflow.Ellipsis, 19 | style = MaterialTheme.typography.titleMedium 20 | ) 21 | Text( 22 | text = subTitle, 23 | maxLines = 1, 24 | overflow = TextOverflow.Ellipsis, 25 | style = MaterialTheme.typography.titleSmall 26 | ) 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.home 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TopAppBar 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | 9 | data class ScaffoldExt( 10 | val navItem: BottomNavItem, 11 | val modifier: Modifier = Modifier, 12 | val topBar: @Composable () -> Unit = { 13 | TopAppBar(title = { 14 | Text( 15 | text = navItem.label, 16 | ) 17 | }) 18 | }, 19 | val floatingActionButton: @Composable () -> Unit = {}, 20 | val content: @Composable (PaddingValues) -> Unit 21 | ) 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/style/Padding.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.style 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.MenuDefaults 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | 8 | val itemHorizontalPadding = 16.dp 9 | val itemVerticalPadding = 12.dp 10 | 11 | fun Modifier.itemPadding() = this then padding(itemHorizontalPadding, itemVerticalPadding) 12 | 13 | fun Modifier.titleItemPadding() = 14 | this then padding( 15 | itemHorizontalPadding, 16 | itemVerticalPadding + itemVerticalPadding / 2, 17 | itemHorizontalPadding, 18 | itemVerticalPadding - itemVerticalPadding / 2 19 | ) 20 | 21 | fun Modifier.appItemPadding() = this then padding(10.dp, 10.dp) 22 | 23 | fun Modifier.menuPadding() = 24 | this then padding(MenuDefaults.DropdownMenuItemContentPadding).padding(vertical = 8.dp) 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.core.view.WindowInsetsControllerCompat 16 | import li.songe.gkd.MainActivity 17 | import li.songe.gkd.util.updateToastStyle 18 | 19 | val LightColorScheme = lightColorScheme() 20 | val DarkColorScheme = darkColorScheme() 21 | val supportDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 22 | 23 | @Composable 24 | fun AppTheme( 25 | content: @Composable () -> Unit, 26 | ) { 27 | // https://developer.android.com/jetpack/compose/designsystems/material3?hl=zh-cn 28 | val context = LocalContext.current as MainActivity 29 | val enableDarkTheme by context.mainVm.enableDarkThemeFlow.collectAsState() 30 | val enableDynamicColor by context.mainVm.enableDynamicColorFlow.collectAsState() 31 | val systemInDarkTheme = isSystemInDarkTheme() 32 | val darkTheme = enableDarkTheme ?: systemInDarkTheme 33 | val colorScheme = when { 34 | supportDynamicColor && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(context) 35 | supportDynamicColor && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(context) 36 | darkTheme -> DarkColorScheme 37 | else -> LightColorScheme 38 | } 39 | 40 | // https://github.com/gkd-kit/gkd/pull/421 41 | LaunchedEffect(darkTheme) { 42 | WindowInsetsControllerCompat(context.window, context.window.decorView).apply { 43 | isAppearanceLightStatusBars = !darkTheme 44 | } 45 | updateToastStyle(darkTheme) 46 | } 47 | MaterialTheme( 48 | colorScheme = colorScheme, content = content 49 | ) 50 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/BitmapExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import android.graphics.Bitmap 4 | 5 | fun Bitmap.isEmptyBitmap(): Boolean { 6 | val emptyBitmap = Bitmap.createBitmap(width, height, config) 7 | return this.sameAs(emptyBitmap) 8 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/ComposeExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import com.dylanc.activityresult.launcher.PickContentLauncher 5 | import com.dylanc.activityresult.launcher.StartActivityLauncher 6 | 7 | 8 | val LocalLauncher = 9 | compositionLocalOf { error("not found StartActivityLauncher") } 10 | 11 | val LocalPickContentLauncher = 12 | compositionLocalOf { error("not found LocalPickContentLauncher") } 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import android.webkit.URLUtil 4 | import li.songe.gkd.BuildConfig 5 | 6 | const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION" 7 | 8 | const val FILE_UPLOAD_URL = "https://u.gkd.li/" 9 | const val FILE_SHORT_URL = "https://f.gkd.li/" 10 | const val IMPORT_BASE_URL = "https://i.gkd.li/i/" 11 | 12 | const val UPDATE_URL = "https://registry.npmmirror.com/@gkd-kit/app/latest/files/index.json" 13 | 14 | const val SERVER_SCRIPT_URL = 15 | "https://registry-direct.npmmirror.com/@gkd-kit/config/latest/files/dist/server.js" 16 | 17 | const val REPOSITORY_URL = "https://github.com/gkd-kit/gkd" 18 | 19 | const val HOME_PAGE_URL = "https://gkd.li" 20 | 21 | @Suppress("SENSELESS_COMPARISON") 22 | val GIT_COMMIT_URL = if (BuildConfig.GIT_COMMIT_ID != null) { 23 | "https://github.com/gkd-kit/gkd/commit/${BuildConfig.GIT_COMMIT_ID}" 24 | } else { 25 | null 26 | } 27 | 28 | private val safeRemoteBaseUrls = arrayOf( 29 | "https://registry.npmmirror.com/@gkd-kit/", 30 | "https://raw.githubusercontent.com/gkd-kit/", 31 | 32 | "https://cdn.jsdelivr.net/gh/gkd-kit/", 33 | "https://cdn.jsdelivr.net/npm/@gkd-kit/", 34 | "https://fastly.jsdelivr.net/gh/gkd-kit/", 35 | "https://fastly.jsdelivr.net/npm/@gkd-kit/", 36 | ) 37 | 38 | fun isSafeUrl(url: String): Boolean { 39 | if (!URLUtil.isHttpsUrl(url)) return false 40 | return safeRemoteBaseUrls.any { u -> url.startsWith(u) } 41 | } 42 | 43 | const val LOCAL_SUBS_ID = -2L 44 | const val LOCAL_HTTP_SUBS_ID = -1L 45 | val LOCAL_SUBS_IDS = arrayOf(LOCAL_SUBS_ID, LOCAL_HTTP_SUBS_ID) 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/CoroutineExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import com.blankj.utilcode.util.LogUtils 4 | import com.hjq.toast.Toaster 5 | import kotlinx.coroutines.CancellationException 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.CoroutineStart 8 | import kotlinx.coroutines.launch 9 | import kotlin.coroutines.CoroutineContext 10 | import kotlin.coroutines.EmptyCoroutineContext 11 | 12 | fun CoroutineScope.launchTry( 13 | context: CoroutineContext = EmptyCoroutineContext, 14 | start: CoroutineStart = CoroutineStart.DEFAULT, 15 | block: suspend CoroutineScope.() -> Unit, 16 | ) = launch(context, start) { 17 | try { 18 | block() 19 | } catch (e: CancellationException) { 20 | e.printStackTrace() 21 | } catch (e: Exception) { 22 | e.printStackTrace() 23 | LogUtils.d(e) 24 | Toaster.show(e.message ?: e.stackTraceToString()) 25 | } 26 | } 27 | 28 | fun CoroutineScope.launchAsFn( 29 | context: CoroutineContext = EmptyCoroutineContext, 30 | start: CoroutineStart = CoroutineStart.DEFAULT, 31 | block: suspend CoroutineScope.() -> Unit, 32 | ): () -> Unit { 33 | return { 34 | launch(context, start) { 35 | try { 36 | block() 37 | } catch (e: CancellationException) { 38 | e.printStackTrace() 39 | } catch (e: Exception) { 40 | e.printStackTrace() 41 | Toaster.show(e.message ?: e.stackTraceToString()) 42 | } 43 | } 44 | } 45 | } 46 | 47 | fun CoroutineScope.launchAsFn( 48 | context: CoroutineContext = EmptyCoroutineContext, 49 | start: CoroutineStart = CoroutineStart.DEFAULT, 50 | block: suspend CoroutineScope.(T) -> Unit, 51 | ): (T) -> Unit { 52 | return { 53 | launch(context, start) { 54 | try { 55 | block(it) 56 | } catch (e: CancellationException) { 57 | e.printStackTrace() 58 | } catch (e: Exception) { 59 | e.printStackTrace() 60 | Toaster.show(e.message ?: e.stackTraceToString()) 61 | } 62 | } 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/FlowExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.SharingStarted 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.map 7 | import kotlinx.coroutines.flow.stateIn 8 | 9 | // https://github.com/Kotlin/kotlinx.coroutines/issues/2514 10 | fun StateFlow.map( 11 | coroutineScope: CoroutineScope, 12 | mapper: (value: T) -> M, 13 | ): StateFlow = map { mapper(it) }.stateIn( 14 | coroutineScope, SharingStarted.Eagerly, mapper(value) 15 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/FolderExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import com.blankj.utilcode.util.LogUtils 4 | import com.blankj.utilcode.util.ZipUtils 5 | import kotlinx.serialization.encodeToString 6 | import li.songe.gkd.app 7 | import java.io.File 8 | 9 | private val filesDir by lazy { 10 | app.getExternalFilesDir(null) ?: app.filesDir 11 | } 12 | val dbFolder by lazy { filesDir.resolve("db") } 13 | val subsFolder by lazy { filesDir.resolve("subscription") } 14 | val snapshotFolder by lazy { filesDir.resolve("snapshot") } 15 | 16 | private val cacheDir by lazy { 17 | app.externalCacheDir ?: app.cacheDir 18 | } 19 | val snapshotZipDir by lazy { cacheDir.resolve("snapshotZip") } 20 | val newVersionApkDir by lazy { cacheDir.resolve("newVersionApk") } 21 | val logZipDir by lazy { cacheDir.resolve("logZip") } 22 | val imageCacheDir by lazy { cacheDir.resolve("imageCache") } 23 | val exportZipDir by lazy { cacheDir.resolve("exportZip") } 24 | 25 | fun initFolder() { 26 | listOf( 27 | dbFolder, 28 | subsFolder, 29 | snapshotFolder, 30 | snapshotZipDir, 31 | newVersionApkDir, 32 | logZipDir, 33 | imageCacheDir, 34 | exportZipDir 35 | ).forEach { f -> 36 | if (!f.exists()) { 37 | // TODO 在某些机型上无法创建目录 用户反馈重启手机后解决 是否存在其它解决方式? 38 | f.mkdirs() 39 | } 40 | } 41 | } 42 | 43 | fun clearCache() { 44 | listOf( 45 | snapshotZipDir, 46 | newVersionApkDir, 47 | logZipDir, 48 | imageCacheDir, 49 | exportZipDir 50 | ).forEach { dir -> 51 | if (dir.isDirectory && dir.exists()) { 52 | dir.listFiles()?.forEach { file -> 53 | if (file.isFile) { 54 | file.delete() 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | 62 | fun buildLogFile(): File { 63 | val files = mutableListOf(dbFolder, subsFolder) 64 | LogUtils.getLogFiles().firstOrNull()?.parentFile?.let { files.add(it) } 65 | val appListFile = logZipDir 66 | .resolve("appList.json") 67 | appListFile.writeText(json.encodeToString(appInfoCacheFlow.value.values.toList())) 68 | files.add(appListFile) 69 | val logZipFile = logZipDir.resolve("log-${System.currentTimeMillis()}.zip") 70 | ZipUtils.zipFiles(files, logZipFile) 71 | return logZipFile 72 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.webkit.MimeTypeMap 7 | import androidx.core.content.FileProvider 8 | import com.blankj.utilcode.util.LogUtils 9 | import java.io.File 10 | 11 | fun Context.shareFile(file: File, tile: String) { 12 | val uri = FileProvider.getUriForFile( 13 | this, "${packageName}.provider", file 14 | ) 15 | val intent = Intent().apply { 16 | action = Intent.ACTION_SEND 17 | putExtra(Intent.EXTRA_STREAM, uri) 18 | type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension) 19 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 20 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 21 | } 22 | tryStartActivity( 23 | Intent.createChooser( 24 | intent, tile 25 | ) 26 | ) 27 | } 28 | 29 | fun Context.tryStartActivity(intent: Intent) { 30 | try { 31 | startActivity(intent) 32 | } catch (e: Exception) { 33 | e.printStackTrace() 34 | LogUtils.d("tryStartActivity", e) 35 | // 在某些模拟器上/特定设备 ActivityNotFoundException 36 | toast(e.message ?: e.stackTraceToString()) 37 | } 38 | } 39 | 40 | fun Context.openUri(uri: String) { 41 | val u = try { 42 | Uri.parse(uri) 43 | } catch (e: Exception) { 44 | e.printStackTrace() 45 | toast("非法链接") 46 | return 47 | } 48 | val intent = Intent(Intent.ACTION_VIEW, u) 49 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 50 | tryStartActivity(intent) 51 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Json5.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import blue.endless.jankson.Jankson 4 | import kotlinx.serialization.json.Json 5 | import kotlinx.serialization.json.JsonArray 6 | import kotlinx.serialization.json.JsonElement 7 | import kotlinx.serialization.json.JsonObject 8 | import kotlinx.serialization.json.JsonPrimitive 9 | import kotlinx.serialization.serializer 10 | 11 | private val json5IdentifierReg = Regex("[a-zA-Z_][a-zA-Z0-9_]*") 12 | 13 | /** 14 | * https://spec.json5.org/#strings 15 | */ 16 | private fun escapeString(value: String): String { 17 | val wrapChar = '\'' 18 | val sb = StringBuilder() 19 | sb.append(wrapChar) 20 | value.forEach { c -> 21 | val escapeChar = when (c) { 22 | wrapChar -> wrapChar 23 | '\n' -> 'n' 24 | '\r' -> 'r' 25 | '\t' -> 't' 26 | '\b' -> 'b' 27 | '\\' -> '\\' 28 | else -> null 29 | } 30 | if (escapeChar != null) { 31 | sb.append("\\" + escapeChar) 32 | } else { 33 | when (c.code) { 34 | in 0..0xf -> { 35 | sb.append("\\x0" + c.code.toString(16)) 36 | } 37 | 38 | in 0..0x1f -> { 39 | sb.append("\\x" + c.code.toString(16)) 40 | } 41 | 42 | else -> { 43 | sb.append(c) 44 | } 45 | } 46 | } 47 | } 48 | sb.append(wrapChar) 49 | return sb.toString() 50 | } 51 | 52 | fun convertJsonElementToJson5(element: JsonElement, indent: Int = 2): String { 53 | val spaces = "\u0020".repeat(indent) 54 | return when (element) { 55 | is JsonPrimitive -> { 56 | val content = element.content 57 | if (element.isString) { 58 | escapeString(content) 59 | } else { 60 | content 61 | } 62 | } 63 | 64 | is JsonObject -> { 65 | if (element.isEmpty()) { 66 | "{}" 67 | } else { 68 | val entries = element.entries.joinToString(",\n") { (key, value) -> 69 | // If key is a valid identifier, no quotes are needed 70 | if (key.matches(json5IdentifierReg)) { 71 | "$key: ${convertJsonElementToJson5(value, indent)}" 72 | } else { 73 | "${escapeString(key)}: ${convertJsonElementToJson5(value, indent)}" 74 | } 75 | }.lineSequence().map { l -> spaces + l }.joinToString("\n") 76 | "{\n$entries\n}" 77 | } 78 | } 79 | 80 | is JsonArray -> { 81 | if (element.isEmpty()) { 82 | "[]" 83 | } else { 84 | val elements = 85 | element.joinToString(",\n") { convertJsonElementToJson5(it, indent) } 86 | .lineSequence().map { l -> spaces + l }.joinToString("\n") 87 | "[\n$elements\n]" 88 | } 89 | } 90 | } 91 | } 92 | 93 | inline fun Json.encodeToJson5String(value: T): String { 94 | return convertJsonElementToJson5(encodeToJsonElement(serializersModule.serializer(), value)) 95 | } 96 | 97 | fun json5ToJson(source: String): String { 98 | return Jankson.builder().build().load(source).toJson() 99 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/LoadStatus.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | sealed class LoadStatus { 4 | data class Loading(val progress: Float = 0f) : LoadStatus() 5 | data class Failure(val exception: Exception) : LoadStatus() 6 | data class Success(val result: T) : LoadStatus() 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/NavExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import androidx.navigation.NavController 5 | import androidx.navigation.NavHostController 6 | import androidx.navigation.NavOptionsBuilder 7 | import com.ramcosta.composedestinations.spec.Direction 8 | 9 | 10 | val LocalNavController = 11 | compositionLocalOf { error("not found DestinationsNavigator") } 12 | 13 | private val navThrottle = useThrottle() 14 | fun NavController.navigate( 15 | direction: Direction, 16 | navOptionsBuilder: NavOptionsBuilder.() -> Unit = {}, 17 | ) { 18 | navThrottle { 19 | navigate(direction.route, navOptionsBuilder) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/NetworkExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import java.net.NetworkInterface 4 | 5 | fun getIpAddressInLocalNetwork(): List { 6 | val networkInterfaces = try { 7 | NetworkInterface.getNetworkInterfaces().asSequence() 8 | } catch (e: Exception) { 9 | // android.system.ErrnoException: getifaddrs failed: EACCES (Permission denied) 10 | toast("获取host失败:" + e.message) 11 | return emptyList() 12 | } 13 | val localAddresses = networkInterfaces.flatMap { 14 | it.inetAddresses.asSequence().filter { inetAddress -> 15 | inetAddress.isSiteLocalAddress && !(inetAddress.hostAddress?.contains(":") 16 | ?: false) && inetAddress.hostAddress != "127.0.0.1" 17 | }.map { inetAddress -> inetAddress.hostAddress } 18 | } 19 | return localAddresses.toList() 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Option.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | sealed interface Option { 4 | val value: T 5 | val label: String 6 | } 7 | 8 | fun > Array.findOption(value: V): T { 9 | return find { it.value == value } ?: first() 10 | } 11 | 12 | @Suppress("UNCHECKED_CAST") 13 | val Option.allSubObject: Array> 14 | get() = when (this) { 15 | is SortTypeOption -> SortTypeOption.allSubObject 16 | is UpdateTimeOption -> UpdateTimeOption.allSubObject 17 | is DarkThemeOption -> DarkThemeOption.allSubObject 18 | is EnableGroupOption -> EnableGroupOption.allSubObject 19 | is RuleSortOption -> RuleSortOption.allSubObject 20 | } as Array> 21 | 22 | sealed class SortTypeOption(override val value: Int, override val label: String) : Option { 23 | data object SortByName : SortTypeOption(0, "按名称") 24 | data object SortByAppMtime : SortTypeOption(1, "按更新时间") 25 | data object SortByTriggerTime : SortTypeOption(2, "按触发时间") 26 | 27 | companion object { 28 | // https://stackoverflow.com/questions/47648689 29 | val allSubObject by lazy { arrayOf(SortByName, SortByAppMtime, SortByTriggerTime) } 30 | } 31 | } 32 | 33 | sealed class UpdateTimeOption(override val value: Long, override val label: String) : Option { 34 | data object Pause : UpdateTimeOption(-1, "暂停") 35 | data object Everyday : UpdateTimeOption(24 * 60 * 60_000, "每天") 36 | data object Every3Days : UpdateTimeOption(24 * 60 * 60_000 * 3, "每3天") 37 | data object Every7Days : UpdateTimeOption(24 * 60 * 60_000 * 7, "每7天") 38 | 39 | companion object { 40 | val allSubObject by lazy { arrayOf(Pause, Everyday, Every3Days, Every7Days) } 41 | } 42 | } 43 | 44 | sealed class DarkThemeOption(override val value: Boolean?, override val label: String) : 45 | Option { 46 | data object FollowSystem : DarkThemeOption(null, "跟随系统") 47 | data object AlwaysEnable : DarkThemeOption(true, "总是启用") 48 | data object AlwaysDisable : DarkThemeOption(false, "总是关闭") 49 | 50 | companion object { 51 | val allSubObject by lazy { arrayOf(FollowSystem, AlwaysEnable, AlwaysDisable) } 52 | } 53 | } 54 | 55 | sealed class EnableGroupOption(override val value: Boolean?, override val label: String) : 56 | Option { 57 | data object FollowSubs : DarkThemeOption(null, "跟随订阅") 58 | data object AllEnable : DarkThemeOption(true, "全部启用") 59 | data object AllDisable : DarkThemeOption(false, "全部关闭") 60 | 61 | companion object { 62 | val allSubObject by lazy { arrayOf(FollowSubs, AllEnable, AllDisable) } 63 | } 64 | } 65 | 66 | sealed class RuleSortOption(override val value: Int, override val label: String) : Option { 67 | data object Default : RuleSortOption(0, "按订阅顺序") 68 | data object ByTime : RuleSortOption(1, "按触发时间") 69 | data object ByName : RuleSortOption(2, "按名称") 70 | 71 | companion object { 72 | val allSubObject by lazy { arrayOf(Default, ByTime, ByName) } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/PackageExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import android.content.Intent 4 | import android.content.pm.PackageManager 5 | 6 | fun PackageManager.getDefaultLauncherAppId(): String? { 7 | val intent = Intent(Intent.ACTION_MAIN) 8 | intent.addCategory(Intent.CATEGORY_HOME) 9 | val defaultLauncher = this.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) 10 | return defaultLauncher?.activityInfo?.packageName 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/ProfileTransitions.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import androidx.compose.animation.AnimatedContentTransitionScope 4 | import androidx.compose.animation.EnterTransition 5 | import androidx.compose.animation.ExitTransition 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.animation.fadeOut 8 | import androidx.compose.animation.slideInHorizontally 9 | import androidx.compose.animation.slideOutHorizontally 10 | import androidx.navigation.NavBackStackEntry 11 | import com.ramcosta.composedestinations.spec.DestinationStyle 12 | 13 | object ProfileTransitions : DestinationStyle.Animated { 14 | override fun AnimatedContentTransitionScope.enterTransition(): EnterTransition? { 15 | return slideInHorizontally(tween()) { it } 16 | } 17 | 18 | override fun AnimatedContentTransitionScope.exitTransition(): ExitTransition? { 19 | return slideOutHorizontally(tween()) { -it } + fadeOut(tween()) 20 | } 21 | 22 | override fun AnimatedContentTransitionScope.popEnterTransition(): EnterTransition? { 23 | return slideInHorizontally(tween()) { -it } 24 | } 25 | 26 | override fun AnimatedContentTransitionScope.popExitTransition(): ExitTransition? { 27 | return slideOutHorizontally(tween()) { it } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/ResolvedGroup.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import li.songe.gkd.data.RawSubscription 4 | import li.songe.gkd.data.SubsConfig 5 | import li.songe.gkd.data.SubsItem 6 | 7 | sealed class ResolvedGroup( 8 | open val group: RawSubscription.RawGroupProps, 9 | val subscription: RawSubscription, 10 | val subsItem: SubsItem, 11 | val config: SubsConfig?, 12 | ) 13 | 14 | class ResolvedAppGroup( 15 | override val group: RawSubscription.RawAppGroup, 16 | subscription: RawSubscription, 17 | subsItem: SubsItem, 18 | config: SubsConfig?, 19 | val app: RawSubscription.RawApp, 20 | val enable: Boolean, 21 | ) : ResolvedGroup(group, subscription, subsItem, config) 22 | 23 | class ResolvedGlobalGroup( 24 | override val group: RawSubscription.RawGlobalGroup, 25 | subscription: RawSubscription, 26 | subsItem: SubsItem, 27 | config: SubsConfig?, 28 | ) : ResolvedGroup(group, subscription, subsItem, config) -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/SafeR.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import li.songe.gkd.R 4 | 5 | /** 6 | * ![image](https://github.com/gkd-kit/gkd/assets/38517192/c9325110-d90f-4041-a01d-404d14c5d34d) 7 | */ 8 | @Suppress("UNRESOLVED_REFERENCE") 9 | object SafeR { 10 | val app_name: Int = R.string.app_name 11 | val ic_status: Int = R.drawable.ic_status 12 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Singleton.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import android.os.Build 4 | import coil.ImageLoader 5 | import coil.decode.GifDecoder 6 | import coil.decode.ImageDecoderDecoder 7 | import coil.disk.DiskCache 8 | import com.tencent.mmkv.MMKV 9 | import io.ktor.client.HttpClient 10 | import io.ktor.client.engine.okhttp.OkHttp 11 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 12 | import io.ktor.http.ContentType 13 | import io.ktor.serialization.kotlinx.json.json 14 | import kotlinx.serialization.ExperimentalSerializationApi 15 | import kotlinx.serialization.json.Json 16 | import li.songe.gkd.app 17 | import okhttp3.OkHttpClient 18 | import java.text.Collator 19 | import java.time.Duration 20 | import java.util.Locale 21 | 22 | 23 | val kv by lazy { MMKV.mmkvWithID("kv")!! } 24 | 25 | @OptIn(ExperimentalSerializationApi::class) 26 | val json by lazy { 27 | Json { 28 | isLenient = true 29 | ignoreUnknownKeys = true 30 | explicitNulls = false 31 | encodeDefaults = true 32 | } 33 | } 34 | 35 | val keepNullJson by lazy { 36 | Json { 37 | isLenient = true 38 | ignoreUnknownKeys = true 39 | encodeDefaults = true 40 | } 41 | } 42 | 43 | val client by lazy { 44 | HttpClient(OkHttp) { 45 | install(ContentNegotiation) { 46 | json(json, ContentType.Any) 47 | } 48 | engine { 49 | clientCacheSize = 0 50 | } 51 | } 52 | } 53 | 54 | val imageLoader by lazy { 55 | ImageLoader.Builder(app) 56 | .okHttpClient( 57 | OkHttpClient.Builder() 58 | .connectTimeout(Duration.ofSeconds(30)) 59 | .readTimeout(Duration.ofSeconds(30)) 60 | .writeTimeout(Duration.ofSeconds(30)) 61 | .build() 62 | ) 63 | .components { 64 | if (Build.VERSION.SDK_INT >= 28) { 65 | add(ImageDecoderDecoder.Factory()) 66 | } else { 67 | add(GifDecoder.Factory()) 68 | } 69 | }.diskCache { 70 | DiskCache.Builder().directory(imageCacheDir).build() 71 | }.build() 72 | } 73 | 74 | 75 | val collator by lazy { Collator.getInstance(Locale.CHINESE)!! } 76 | 77 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Store.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import com.blankj.utilcode.util.LogUtils 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.drop 7 | import kotlinx.coroutines.flow.update 8 | import kotlinx.coroutines.launch 9 | import kotlinx.coroutines.withContext 10 | import kotlinx.serialization.Serializable 11 | import kotlinx.serialization.encodeToString 12 | import li.songe.gkd.appScope 13 | 14 | private inline fun createStorageFlow( 15 | key: String, 16 | crossinline init: () -> T, 17 | ): MutableStateFlow { 18 | val str = kv.getString(key, null) 19 | val initValue = if (str != null) { 20 | try { 21 | json.decodeFromString(str) 22 | } catch (e: Exception) { 23 | e.printStackTrace() 24 | LogUtils.d(e) 25 | null 26 | } 27 | } else { 28 | null 29 | } 30 | val stateFlow = MutableStateFlow(initValue ?: init()) 31 | appScope.launch { 32 | stateFlow.drop(1).collect { 33 | withContext(Dispatchers.IO) { 34 | kv.encode(key, json.encodeToString(it)) 35 | } 36 | } 37 | } 38 | return stateFlow 39 | } 40 | 41 | @Serializable 42 | data class Store( 43 | val enableService: Boolean = true, 44 | val enableStatusService: Boolean = true, 45 | val excludeFromRecents: Boolean = false, 46 | val captureScreenshot: Boolean = false, 47 | val httpServerPort: Int = 8888, 48 | val updateSubsInterval: Long = UpdateTimeOption.Everyday.value, 49 | val captureVolumeChange: Boolean = false, 50 | val autoCheckAppUpdate: Boolean = true, 51 | val toastWhenClick: Boolean = true, 52 | val clickToast: String = "GKD", 53 | val autoClearMemorySubs: Boolean = true, 54 | val hideSnapshotStatusBar: Boolean = false, 55 | val enableShizukuActivity: Boolean = false, 56 | val enableShizukuClick: Boolean = false, 57 | val log2FileSwitch: Boolean = true, 58 | val enableDarkTheme: Boolean? = null, 59 | val enableDynamicColor: Boolean = true, 60 | val enableAbFloatWindow: Boolean = true, 61 | val sortType: Int = SortTypeOption.SortByName.value, 62 | val showSystemApp: Boolean = true, 63 | val showHiddenApp: Boolean = false, 64 | ) 65 | 66 | val storeFlow by lazy { 67 | createStorageFlow("store-v2") { Store() }.apply { 68 | if (UpdateTimeOption.allSubObject.all { it.value != value.updateSubsInterval }) { 69 | update { 70 | it.copy( 71 | updateSubsInterval = UpdateTimeOption.Everyday.value 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | 78 | @Serializable 79 | data class RecordStore( 80 | val clickCount: Int = 0, 81 | ) 82 | 83 | val recordStoreFlow by lazy { 84 | createStorageFlow("record_store-v2") { RecordStore() } 85 | } 86 | 87 | val clickCountFlow by lazy { 88 | recordStoreFlow.map(appScope) { r -> r.clickCount } 89 | } 90 | 91 | fun increaseClickCount(n: Int = 1) { 92 | recordStoreFlow.update { 93 | it.copy( 94 | clickCount = it.clickCount + n 95 | ) 96 | } 97 | } 98 | 99 | fun initStore() { 100 | storeFlow.value 101 | recordStoreFlow.value 102 | } 103 | 104 | -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/TimeExt.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.Locale 5 | import java.util.concurrent.TimeUnit 6 | 7 | fun formatTimeAgo(timestamp: Long): String { 8 | val currentTime = System.currentTimeMillis() 9 | val timeDifference = currentTime - timestamp 10 | 11 | val minutes = TimeUnit.MILLISECONDS.toMinutes(timeDifference) 12 | val hours = TimeUnit.MILLISECONDS.toHours(timeDifference) 13 | val days = TimeUnit.MILLISECONDS.toDays(timeDifference) 14 | val weeks = days / 7 15 | val months = (days / 30) 16 | val years = (days / 365) 17 | return when { 18 | years > 0 -> "${years}年前" 19 | months > 0 -> "${months}月前" 20 | weeks > 0 -> "${weeks}周前" 21 | days > 0 -> "${days}天前" 22 | hours > 0 -> "${hours}小时前" 23 | minutes > 0 -> "${minutes}分钟前" 24 | else -> "刚刚" 25 | } 26 | } 27 | 28 | private val formatDateMap = mutableMapOf() 29 | 30 | fun Long.format(formatStr: String): String { 31 | var df = formatDateMap[formatStr] 32 | if (df == null) { 33 | df = SimpleDateFormat(formatStr, Locale.getDefault()) 34 | formatDateMap[formatStr] = df 35 | } 36 | return df.format(this) 37 | } 38 | 39 | fun useThrottle(interval: Long = 500L): (fn: () -> Unit) -> Unit { 40 | var lastTriggerTime = 0L 41 | return { fn -> 42 | val t = System.currentTimeMillis() 43 | if (t - lastTriggerTime > interval) { 44 | lastTriggerTime = t 45 | fn() 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Toast.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.graphics.drawable.Drawable 6 | import android.graphics.drawable.GradientDrawable 7 | import android.view.Gravity 8 | import android.widget.Toast 9 | import com.blankj.utilcode.util.ConvertUtils 10 | import com.blankj.utilcode.util.LogUtils 11 | import com.blankj.utilcode.util.ScreenUtils 12 | import com.hjq.toast.Toaster 13 | import com.hjq.toast.style.WhiteToastStyle 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.launch 16 | import li.songe.gkd.activityVisibleFlow 17 | import li.songe.gkd.app 18 | import li.songe.gkd.appScope 19 | 20 | 21 | private var lastToast: Toast? = null 22 | 23 | fun toast(text: CharSequence) { 24 | appScope.launch(Dispatchers.Main) { 25 | try { 26 | lastToast?.cancel() 27 | lastToast = null 28 | } catch (e: Exception) { 29 | LogUtils.d("lastToast?.cancel", e) 30 | } 31 | try { 32 | Toaster.cancel() 33 | } catch (e: Exception) { 34 | LogUtils.d("Toaster.cancel", e) 35 | } 36 | try { 37 | if (activityVisibleFlow.value <= 0) { 38 | lastToast = Toast.makeText(app, text, Toast.LENGTH_SHORT).apply { 39 | show() 40 | } 41 | } else { 42 | Toaster.show(text) 43 | } 44 | } catch (e: Exception) { 45 | LogUtils.d("toast失败", e) 46 | } 47 | } 48 | } 49 | 50 | fun updateToastStyle(darkTheme: Boolean) { 51 | Toaster.setStyle(object : WhiteToastStyle() { 52 | override fun getGravity() = Gravity.BOTTOM 53 | override fun getYOffset() = (ScreenUtils.getScreenHeight() * 0.12f).toInt() 54 | override fun getTranslationZ(context: Context?) = ConvertUtils.dp2px(1f).toFloat() 55 | override fun getTextColor(context: Context?): Int { 56 | return if (darkTheme) Color.WHITE else Color.BLACK 57 | } 58 | 59 | override fun getBackgroundDrawable(context: Context?): Drawable { 60 | return (super.getBackgroundDrawable(context) as GradientDrawable).apply { 61 | setColor(if (!darkTheme) Color.WHITE else Color.parseColor("#191a1c")) 62 | } 63 | } 64 | }) 65 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/li/songe/gkd/util/Zip.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd.util 2 | 3 | import java.io.BufferedReader 4 | import java.io.ByteArrayInputStream 5 | import java.io.InputStreamReader 6 | import java.util.zip.ZipEntry 7 | import java.util.zip.ZipInputStream 8 | 9 | fun readFileZipByteArray(zipByteArray: ByteArray, fileName: String): String? { 10 | val byteArrayInputStream = ByteArrayInputStream(zipByteArray) 11 | val zipInputStream = ZipInputStream(byteArrayInputStream) 12 | zipInputStream.use { 13 | var zipEntry: ZipEntry? = zipInputStream.nextEntry 14 | while (zipEntry != null) { 15 | if (zipEntry.name == fileName) { 16 | val reader = BufferedReader(InputStreamReader(zipInputStream)) 17 | val content = reader.use { it.readText() } 18 | return content 19 | } 20 | zipEntry = zipInputStream.nextEntry 21 | } 22 | } 23 | return null 24 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_capture.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_status.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | GKD 4 | 基于高级选择器+订阅规则的屏幕自定义点击服务\n\n通过自定义选择器和订阅规则,能帮助你实现点击任意位置控件,自定义快捷操作等高级功能 5 | 捕获快照 6 | GKD-导入数据 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 13 | 14 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/xml/ab_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/test/kotlin/li/songe/gkd/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package li.songe.gkd 2 | 3 | import org.junit.Test 4 | 5 | /** 6 | * Example local unit test, which will execute on the development machine (host). 7 | * 8 | * See [testing documentation](http://d.android.com/tools/testing). 9 | */ 10 | class ExampleUnitTest { 11 | 12 | @Test 13 | fun check_parser_selector() { 14 | 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | repositories { 4 | mavenLocal() 5 | mavenCentral() 6 | google() 7 | maven("https://jitpack.io") 8 | } 9 | dependencies { 10 | classpath(libs.android.gradle) 11 | classpath(libs.kotlin.gradle.plugin) 12 | classpath(libs.kotlin.serialization) 13 | } 14 | } 15 | 16 | plugins { 17 | alias(libs.plugins.google.ksp) apply false 18 | alias(libs.plugins.google.hilt) apply false 19 | 20 | alias(libs.plugins.android.library) apply false 21 | alias(libs.plugins.android.application) apply false 22 | 23 | alias(libs.plugins.kotlin.serialization) apply false 24 | alias(libs.plugins.kotlin.parcelize) apply false 25 | 26 | alias(libs.plugins.kotlin.multiplatform) apply false 27 | alias(libs.plugins.kotlin.android) apply false 28 | alias(libs.plugins.kotlin.compose) apply false 29 | 30 | alias(libs.plugins.rikka.refine) apply false 31 | } 32 | 33 | // can not work with Kotlin Multiplatform 34 | // https://youtrack.jetbrains.com/issue/KT-33191/ 35 | //tasks.register("clean").configure { 36 | // delete(rootProject.buildDir) 37 | //} 38 | 39 | project.gradle.taskGraph.whenReady { 40 | allTasks.forEach { task -> 41 | // error: The binary version of its metadata is 1.8.0, expected version is 1.6.0. 42 | // I don't know how to solve it, so just disable these tasks 43 | if (task.name.contains("lintAnalyzeDebug") || task.name.contains("lintVitalAnalyzeRelease")) { 44 | task.enabled = false 45 | } 46 | } 47 | } 48 | 49 | // https://kotlinlang.org/docs/js-project-setup.html#use-pre-installed-node-js 50 | rootProject.plugins.withType { 51 | rootProject.the().download = 52 | false 53 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | #org.gradle.java.home=D\:/User/Documents/lisonge/.jdks/corretto-11.0.13 23 | #android.experimental.legacyTransform.forceNonIncremental=true 24 | android.debug.obsoleteApi=true 25 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyb1989/gkd/b2403e70bd4945725f4d0478aaf87df2f706198b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 3 | distributionPath=wrapper/dists 4 | zipStorePath=wrapper/dists 5 | zipStoreBase=GRADLE_USER_HOME 6 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /hidden_api/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /hidden_api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | } 4 | 5 | android { 6 | namespace = "li.songe.gkd" 7 | compileSdk = libs.versions.compileSdk.get().toInt() 8 | buildToolsVersion = libs.versions.buildToolsVersion.get() 9 | 10 | defaultConfig { 11 | minSdk = libs.versions.minSdk.get().toInt() 12 | } 13 | 14 | buildTypes { 15 | release { 16 | isMinifyEnabled = false 17 | } 18 | } 19 | compileOptions { 20 | sourceCompatibility = JavaVersion.VERSION_17 21 | targetCompatibility = JavaVersion.VERSION_17 22 | } 23 | buildFeatures { 24 | aidl = true 25 | buildConfig = false 26 | } 27 | } 28 | 29 | dependencies { 30 | annotationProcessor(libs.rikka.processor) 31 | compileOnly(libs.rikka.annotation) 32 | } -------------------------------------------------------------------------------- /hidden_api/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceClient.aidl: -------------------------------------------------------------------------------- 1 | // IAccessibilityServiceClient.aidl 2 | package android.accessibilityservice; 3 | 4 | interface IAccessibilityServiceClient { 5 | } 6 | -------------------------------------------------------------------------------- /hidden_api/src/main/aidl/android/accessibilityservice/IAccessibilityServiceConnection.aidl: -------------------------------------------------------------------------------- 1 | // IAccessibilityServiceConnection.aidl 2 | package android.accessibilityservice; 3 | 4 | // Declare any non-default types here with import statements 5 | 6 | interface IAccessibilityServiceConnection { 7 | } -------------------------------------------------------------------------------- /hidden_api/src/main/aidl/android/app/IUiAutomationConnection.aidl: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | import android.accessibilityservice.IAccessibilityServiceClient; 4 | import android.graphics.Bitmap; 5 | import android.graphics.Rect; 6 | import android.view.InputEvent; 7 | import android.os.ParcelFileDescriptor; 8 | 9 | interface IUiAutomationConnection { 10 | void connect(IAccessibilityServiceClient client, int flags); 11 | void disconnect(); 12 | boolean injectInputEvent(in InputEvent event, boolean sync); 13 | void syncInputTransactions(); 14 | boolean setRotation(int rotation); 15 | Bitmap takeScreenshot(in Rect crop, int rotation); 16 | void executeShellCommand(String command, in ParcelFileDescriptor sink, 17 | in ParcelFileDescriptor source); 18 | oneway void shutdown(); 19 | } 20 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/accessibilityservice/AccessibilityServiceHidden.java: -------------------------------------------------------------------------------- 1 | 2 | package android.accessibilityservice; 3 | 4 | import android.graphics.Region; 5 | import android.os.IBinder; 6 | import android.view.KeyEvent; 7 | import android.view.accessibility.AccessibilityEvent; 8 | 9 | import dev.rikka.tools.refine.RefineAs; 10 | 11 | @SuppressWarnings("unused") 12 | @RefineAs(AccessibilityService.class) 13 | public class AccessibilityServiceHidden { 14 | 15 | public interface Callbacks { 16 | void onAccessibilityEvent(AccessibilityEvent event); 17 | 18 | void onInterrupt(); 19 | 20 | void onServiceConnected(); 21 | 22 | void init(int connectionId, IBinder windowToken); 23 | 24 | boolean onGesture(int gestureId); 25 | 26 | boolean onKeyEvent(KeyEvent event); 27 | 28 | void onMagnificationChanged(int displayId, Region region, 29 | float scale, float centerX, float centerY); 30 | 31 | void onSoftKeyboardShowModeChanged(int showMode); 32 | 33 | void onPerformGestureResult(int sequence, boolean completedSuccessfully); 34 | 35 | void onFingerprintCapturingGesturesChanged(boolean active); 36 | 37 | void onFingerprintGesture(int gesture); 38 | 39 | void onAccessibilityButtonClicked(); 40 | 41 | void onAccessibilityButtonAvailabilityChanged(boolean available); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/ActivityManagerNative.java: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | import android.os.IBinder; 4 | 5 | @SuppressWarnings("unused") 6 | public class ActivityManagerNative { 7 | 8 | public static IActivityManager asInterface(IBinder obj) { 9 | throw new RuntimeException("Stub!"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/ActivityThread.java: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | @SuppressWarnings("unused") 4 | public class ActivityThread { 5 | 6 | public static ActivityThread currentActivityThread() { 7 | throw new RuntimeException("Stub!"); 8 | } 9 | 10 | public ContextImpl getSystemContext() { 11 | throw new RuntimeException("Stub!"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/ContextImpl.java: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | import android.content.Context; 4 | 5 | @SuppressWarnings("unused") 6 | public abstract class ContextImpl extends Context { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/IActivityManager.java: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | import android.os.Binder; 4 | import android.os.IBinder; 5 | import android.os.IInterface; 6 | import android.os.RemoteException; 7 | 8 | import java.util.List; 9 | 10 | @SuppressWarnings("unused") 11 | public interface IActivityManager extends IInterface { 12 | List getRunningAppProcesses() throws RemoteException; 13 | 14 | List getTasks(int maxNum) throws RemoteException; 15 | 16 | List getFilteredTasks(int maxNum, int ignoreActivityType, int ignoreWindowingMode) throws RemoteException; 17 | 18 | void forceStopPackage(String packageName, int userId); 19 | 20 | abstract class Stub extends Binder implements IActivityManager { 21 | 22 | public static IActivityManager asInterface(IBinder obj) { 23 | throw new RuntimeException("Stub!"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/IActivityTaskManager.java: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | import android.os.Binder; 4 | import android.os.IBinder; 5 | import android.os.IInterface; 6 | 7 | import java.util.List; 8 | 9 | @SuppressWarnings("unused") 10 | public interface IActivityTaskManager extends IInterface { 11 | // XIAOMI 12 | List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra); 13 | 14 | // https://github.com/gkd-kit/gkd/issues/58#issuecomment-1736843795 15 | List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); 16 | 17 | // https://github.com/gkd-kit/gkd/issues/58#issuecomment-1732245703 18 | List getTasks(int maxNum); 19 | 20 | abstract class Stub extends Binder implements IActivityTaskManager { 21 | public static IActivityTaskManager asInterface(IBinder obj) { 22 | throw new RuntimeException("Stub!"); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/UiAutomationConnection.java: -------------------------------------------------------------------------------- 1 | 2 | package android.app; 3 | 4 | @SuppressWarnings("unused") 5 | public class UiAutomationConnection extends IUiAutomationConnection.Default { 6 | public UiAutomationConnection() { 7 | throw new RuntimeException("Stub!"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/app/UiAutomationHidden.java: -------------------------------------------------------------------------------- 1 | package android.app; 2 | 3 | import android.os.Looper; 4 | import android.os.ParcelFileDescriptor; 5 | 6 | import dev.rikka.tools.refine.RefineAs; 7 | 8 | @SuppressWarnings("unused") 9 | @RefineAs(UiAutomation.class) 10 | public class UiAutomationHidden { 11 | 12 | public UiAutomationHidden(Looper looper, IUiAutomationConnection connection) { 13 | throw new RuntimeException("Stub!"); 14 | } 15 | 16 | public void connect() { 17 | throw new RuntimeException("Stub!"); 18 | } 19 | 20 | public void connect(int flag) { 21 | throw new RuntimeException("Stub!"); 22 | } 23 | 24 | public void disconnect() { 25 | throw new RuntimeException("Stub!"); 26 | } 27 | 28 | public ParcelFileDescriptor[] executeShellCommandRwe(String command) { 29 | throw new RuntimeException("Stub!"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/content/pm/IPackageManager.java: -------------------------------------------------------------------------------- 1 | package android.content.pm; 2 | 3 | import android.content.ComponentName; 4 | import android.os.IBinder; 5 | import android.os.IInterface; 6 | 7 | import java.util.List; 8 | 9 | @SuppressWarnings("unused") 10 | public interface IPackageManager extends IInterface { 11 | ComponentName getHomeActivities(List outHomeCandidates); 12 | 13 | void setComponentEnabledSetting(ComponentName componentName, int newState, int flags, int userId); 14 | 15 | ApplicationInfo getApplicationInfo(String packageName, long flags, int userId); 16 | 17 | PackageInfo getPackageInfo(String packageName, long flags, int userId); 18 | 19 | abstract class Stub { 20 | 21 | public static IPackageManager asInterface(IBinder obj) { 22 | throw new RuntimeException("Stub!"); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /hidden_api/src/main/java/android/hardware/input/IInputManager.java: -------------------------------------------------------------------------------- 1 | package android.hardware.input; 2 | 3 | import android.os.Binder; 4 | import android.os.IBinder; 5 | import android.os.IInterface; 6 | import android.os.RemoteException; 7 | import android.view.InputEvent; 8 | 9 | @SuppressWarnings("unused") 10 | public interface IInputManager extends IInterface { 11 | boolean injectInputEvent(InputEvent event, int mode) throws RemoteException; 12 | 13 | abstract class Stub extends Binder implements IInputManager { 14 | 15 | public static IInputManager asInterface(IBinder obj) { 16 | throw new RuntimeException("Stub!"); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /selector/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /selector/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | alias(libs.plugins.kotlin.serialization) 6 | } 7 | 8 | kotlin { 9 | jvm { 10 | compilerOptions { 11 | jvmTarget.set(JvmTarget.JVM_17) 12 | } 13 | } 14 | // https://kotlinlang.org/docs/js-to-kotlin-interop.html#kotlin-types-in-javascript 15 | js(IR) { 16 | binaries.executable() 17 | useEsModules() 18 | generateTypeScriptDefinitions() 19 | browser {} 20 | } 21 | sourceSets { 22 | all { 23 | languageSettings.optIn("kotlin.js.ExperimentalJsExport") 24 | } 25 | commonMain { 26 | dependencies { 27 | implementation(libs.kotlin.stdlib.common) 28 | } 29 | } 30 | jvmTest { 31 | dependencies { 32 | implementation(libs.kotlinx.serialization.json) 33 | implementation(libs.junit) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/GkdSyntaxError.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlin.js.JsExport 4 | 5 | @JsExport 6 | data class GkdSyntaxError internal constructor( 7 | val expectedValue: String, 8 | val position: Int, 9 | val source: String, 10 | override val cause: Exception? = null 11 | ) : Exception( 12 | "expected $expectedValue in selector at position $position, but got ${ 13 | source.getOrNull( 14 | position 15 | ) 16 | }" 17 | ) 18 | 19 | internal fun gkdAssert( 20 | source: String, 21 | offset: Int, 22 | value: String = "", 23 | expectedValue: String? = null 24 | ) { 25 | if (offset >= source.length || (value.isNotEmpty() && !value.contains(source[offset]))) { 26 | throw GkdSyntaxError(expectedValue ?: value, offset, source) 27 | } 28 | } 29 | 30 | internal fun gkdError( 31 | source: String, 32 | offset: Int, 33 | expectedValue: String = "", 34 | cause: Exception? = null 35 | ): Nothing { 36 | throw GkdSyntaxError(expectedValue, offset, source, cause) 37 | } -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/MultiplatformSelector.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlin.js.JsExport 4 | 5 | @JsExport 6 | @Suppress("UNUSED", "UNCHECKED_CAST") 7 | class MultiplatformSelector private constructor( 8 | internal val selector: Selector, 9 | ) { 10 | val tracks = selector.tracks 11 | val trackIndex = selector.trackIndex 12 | val connectKeys = selector.connectKeys 13 | val propertyNames = selector.propertyNames 14 | 15 | val qfIdValue = selector.qfIdValue 16 | val qfVidValue = selector.qfVidValue 17 | val qfTextValue = selector.qfTextValue 18 | val canQf = selector.canQf 19 | val isMatchRoot = selector.isMatchRoot 20 | 21 | // [name,operator,value][] 22 | val binaryExpressions = selector.binaryExpressions.map { e -> 23 | arrayOf( 24 | e.name, 25 | e.operator.key, 26 | e.value.type, 27 | e.value.toString() 28 | ) 29 | }.toTypedArray() 30 | 31 | fun match(node: T, transform: MultiplatformTransform): T? { 32 | return selector.match(node, transform.transform) 33 | } 34 | 35 | fun matchTrack(node: T, transform: MultiplatformTransform): Array? { 36 | return selector.matchTracks(node, transform.transform)?.toTypedArray() as Array? 37 | } 38 | 39 | override fun toString() = selector.toString() 40 | 41 | companion object { 42 | fun parse(source: String) = MultiplatformSelector(Selector.parse(source)) 43 | fun parseOrNull(source: String) = Selector.parseOrNull(source)?.let(::MultiplatformSelector) 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/MultiplatformTransform.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlin.js.JsExport 4 | 5 | @JsExport 6 | @Suppress("UNCHECKED_CAST", "UNUSED") 7 | class MultiplatformTransform( 8 | getAttr: (T, String) -> Any?, 9 | getName: (T) -> String?, 10 | getChildren: (T) -> Array, 11 | getParent: (T) -> T?, 12 | ) { 13 | internal val transform = Transform( 14 | getAttr = getAttr, 15 | getName = getName, 16 | getChildren = { node -> getChildren(node).asSequence() }, 17 | getParent = getParent, 18 | ) 19 | 20 | val querySelectorAll: (T, MultiplatformSelector) -> Array = { node, selector -> 21 | val result = 22 | transform.querySelectorAll(node, selector.selector).toList().toTypedArray() 23 | result as Array 24 | } 25 | 26 | val querySelector: (T, MultiplatformSelector) -> T? = { node, selector -> 27 | transform.querySelectorAll(node, selector.selector).firstOrNull() 28 | } 29 | 30 | val querySelectorTrackAll: (T, MultiplatformSelector) -> Array> = { node, selector -> 31 | val result = transform.querySelectorTrackAll(node, selector.selector) 32 | .map { it.toTypedArray() as Array }.toList().toTypedArray() 33 | result as Array> 34 | } 35 | 36 | val querySelectorTrack: (T, MultiplatformSelector) -> Array? = { node, selector -> 37 | transform.querySelectorTrackAll(node, selector.selector).firstOrNull() 38 | ?.toTypedArray() as Array? 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/Selector.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import li.songe.selector.data.BinaryExpression 4 | import li.songe.selector.data.CompareOperator 5 | import li.songe.selector.data.ConnectOperator 6 | import li.songe.selector.data.PrimitiveValue 7 | import li.songe.selector.data.PropertyWrapper 8 | import li.songe.selector.parser.ParserSet 9 | 10 | class Selector internal constructor(private val propertyWrapper: PropertyWrapper) { 11 | override fun toString(): String { 12 | return propertyWrapper.toString() 13 | } 14 | 15 | val tracks = run { 16 | val list = mutableListOf(propertyWrapper) 17 | while (true) { 18 | list.add(list.last().to?.to ?: break) 19 | } 20 | list.map { p -> p.propertySegment.tracked }.toTypedArray() 21 | } 22 | 23 | val trackIndex = tracks.indexOfFirst { it }.let { i -> 24 | if (i < 0) 0 else i 25 | } 26 | 27 | fun match( 28 | node: T, 29 | transform: Transform, 30 | trackNodes: MutableList = ArrayList(tracks.size), 31 | ): T? { 32 | val trackTempNodes = matchTracks(node, transform, trackNodes) ?: return null 33 | return trackTempNodes[trackIndex] 34 | } 35 | 36 | fun matchTracks( 37 | node: T, 38 | transform: Transform, 39 | trackNodes: MutableList = ArrayList(tracks.size), 40 | ): List? { 41 | return propertyWrapper.matchTracks(node, transform, trackNodes) 42 | } 43 | 44 | val qfIdValue = propertyWrapper.propertySegment.expressions.firstOrNull().let { e -> 45 | if (e is BinaryExpression && e.name == "id" && e.operator == CompareOperator.Equal && e.value is PrimitiveValue.StringValue) { 46 | e.value.value 47 | } else { 48 | null 49 | } 50 | } 51 | 52 | val qfVidValue = propertyWrapper.propertySegment.expressions.firstOrNull().let { e -> 53 | if (e is BinaryExpression && e.name == "vid" && e.operator == CompareOperator.Equal && e.value is PrimitiveValue.StringValue) { 54 | e.value.value 55 | } else { 56 | null 57 | } 58 | } 59 | 60 | val qfTextValue = propertyWrapper.propertySegment.expressions.firstOrNull().let { e -> 61 | if (e is BinaryExpression && e.name == "text" && (e.operator == CompareOperator.Equal || e.operator == CompareOperator.Start || e.operator == CompareOperator.Include || e.operator == CompareOperator.End) && e.value is PrimitiveValue.StringValue) { 62 | e.value.value 63 | } else { 64 | null 65 | } 66 | } 67 | 68 | val canQf = qfIdValue != null || qfVidValue != null || qfTextValue != null 69 | 70 | // 主动查询 71 | val isMatchRoot = propertyWrapper.propertySegment.expressions.firstOrNull().let { e -> 72 | e is BinaryExpression && e.name == "depth" && e.operator == CompareOperator.Equal && e.value.value == 0 73 | } 74 | 75 | val connectKeys = run { 76 | var c = propertyWrapper.to 77 | val keys = mutableListOf() 78 | while (c != null) { 79 | c.apply { 80 | keys.add(connectSegment.operator.key) 81 | } 82 | c = c.to.to 83 | } 84 | keys.toTypedArray() 85 | } 86 | 87 | val binaryExpressions = run { 88 | var p: PropertyWrapper? = propertyWrapper 89 | val expressions = mutableListOf() 90 | while (p != null) { 91 | val s = p.propertySegment 92 | expressions.addAll(s.binaryExpressions) 93 | p = p.to?.to 94 | } 95 | expressions.distinct().toTypedArray() 96 | } 97 | 98 | val propertyNames = run { 99 | binaryExpressions.map { e -> e.name }.distinct().toTypedArray() 100 | } 101 | 102 | val canCacheIndex = 103 | connectKeys.contains(ConnectOperator.BeforeBrother.key) || connectKeys.contains( 104 | ConnectOperator.AfterBrother.key 105 | ) || propertyNames.contains("index") 106 | 107 | companion object { 108 | fun parse(source: String) = ParserSet.selectorParser(source) 109 | fun parseOrNull(source: String) = try { 110 | ParserSet.selectorParser(source) 111 | } catch (e: Exception) { 112 | null 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/BinaryExpression.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | data class BinaryExpression( 6 | val name: String, 7 | val operator: CompareOperator, 8 | val value: PrimitiveValue 9 | ) : Expression() { 10 | override fun match(node: T, transform: Transform) = 11 | operator.compare(transform.getAttr(node, name), value) 12 | 13 | override val binaryExpressions 14 | get() = arrayOf(this) 15 | 16 | override fun toString() = "${name}${operator.key}${value}" 17 | } 18 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/ConnectExpression.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | sealed class ConnectExpression { 4 | abstract val minOffset: Int 5 | abstract val maxOffset: Int? 6 | abstract fun checkOffset(offset: Int): Boolean 7 | abstract fun getOffset(i: Int): Int 8 | } 9 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/ConnectOperator.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | sealed class ConnectOperator(val key: String) { 6 | abstract fun traversal( 7 | node: T, transform: Transform, connectExpression: ConnectExpression 8 | ): Sequence 9 | 10 | companion object { 11 | // https://stackoverflow.com/questions/47648689 12 | val allSubClasses by lazy { 13 | listOf( 14 | BeforeBrother, AfterBrother, Ancestor, Child, Descendant 15 | ).sortedBy { -it.key.length } 16 | } 17 | } 18 | 19 | /** 20 | * A + B, 1,2,3,A,B,7,8 21 | */ 22 | data object BeforeBrother : ConnectOperator("+") { 23 | override fun traversal( 24 | node: T, transform: Transform, connectExpression: ConnectExpression 25 | ) = transform.getBeforeBrothers(node, connectExpression) 26 | 27 | } 28 | 29 | /** 30 | * A - B, 1,2,3,B,A,7,8 31 | */ 32 | data object AfterBrother : ConnectOperator("-") { 33 | override fun traversal( 34 | node: T, transform: Transform, connectExpression: ConnectExpression 35 | ) = transform.getAfterBrothers(node, connectExpression) 36 | } 37 | 38 | /** 39 | * A > B, A is the ancestor of B 40 | */ 41 | data object Ancestor : ConnectOperator(">") { 42 | override fun traversal( 43 | node: T, transform: Transform, connectExpression: ConnectExpression 44 | ) = transform.getAncestors(node, connectExpression) 45 | 46 | } 47 | 48 | /** 49 | * A < B, A is the child of B 50 | */ 51 | data object Child : ConnectOperator("<") { 52 | override fun traversal( 53 | node: T, transform: Transform, connectExpression: ConnectExpression 54 | ) = transform.getChildrenX(node, connectExpression) 55 | } 56 | 57 | /** 58 | * A << B, A is the descendant of B 59 | */ 60 | data object Descendant : ConnectOperator("<<") { 61 | override fun traversal( 62 | node: T, transform: Transform, connectExpression: ConnectExpression 63 | ) = transform.getDescendantsX(node, connectExpression) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/ConnectSegment.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | data class ConnectSegment( 6 | val operator: ConnectOperator = ConnectOperator.Ancestor, 7 | val connectExpression: ConnectExpression = PolynomialExpression(), 8 | ) { 9 | override fun toString(): String { 10 | if (operator == ConnectOperator.Ancestor && connectExpression is PolynomialExpression && connectExpression.a == 1 && connectExpression.b == 0) { 11 | return "" 12 | } 13 | return operator.key + connectExpression.toString() 14 | } 15 | 16 | fun traversal(node: T, transform: Transform): Sequence { 17 | return operator.traversal(node, transform, connectExpression) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/ConnectWrapper.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | data class ConnectWrapper( 6 | val connectSegment: ConnectSegment, 7 | val to: PropertyWrapper, 8 | ) { 9 | override fun toString(): String { 10 | return (to.toString() + "\u0020" + connectSegment.toString()).trim() 11 | } 12 | 13 | fun matchTracks( 14 | node: T, transform: Transform, 15 | trackNodes: MutableList, 16 | ): List? { 17 | connectSegment.traversal(node, transform).forEach { 18 | if (it == null) return@forEach 19 | val r = to.matchTracks(it, transform, trackNodes) 20 | if (r != null) return r 21 | } 22 | return null 23 | } 24 | } -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/Expression.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | sealed class Expression { 6 | internal abstract fun match(node: T, transform: Transform): Boolean 7 | 8 | abstract val binaryExpressions: Array 9 | } 10 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/LogicalExpression.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | data class LogicalExpression( 6 | val left: Expression, 7 | val operator: LogicalOperator, 8 | val right: Expression, 9 | ) : Expression() { 10 | override fun match(node: T, transform: Transform): Boolean { 11 | return operator.compare(node, transform, left, right) 12 | } 13 | 14 | override val binaryExpressions 15 | get() = left.binaryExpressions + right.binaryExpressions 16 | 17 | override fun toString(): String { 18 | val leftStr = if (left is LogicalExpression && left.operator != operator) { 19 | "($left)" 20 | } else { 21 | left.toString() 22 | } 23 | val rightStr = if (right is LogicalExpression && right.operator != operator) { 24 | "($right)" 25 | } else { 26 | right.toString() 27 | } 28 | return "$leftStr\u0020${operator.key}\u0020$rightStr" 29 | } 30 | } -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/LogicalOperator.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | sealed class LogicalOperator(val key: String) { 6 | companion object { 7 | // https://stackoverflow.com/questions/47648689 8 | val allSubClasses by lazy { 9 | listOf( 10 | AndOperator, OrOperator 11 | ).sortedBy { -it.key.length } 12 | } 13 | } 14 | 15 | abstract fun compare( 16 | node: T, 17 | transform: Transform, 18 | left: Expression, 19 | right: Expression, 20 | ): Boolean 21 | 22 | data object AndOperator : LogicalOperator("&&") { 23 | override fun compare( 24 | node: T, 25 | transform: Transform, 26 | left: Expression, 27 | right: Expression, 28 | ): Boolean { 29 | return left.match(node, transform) && right.match(node, transform) 30 | } 31 | } 32 | 33 | data object OrOperator : LogicalOperator("||") { 34 | override fun compare( 35 | node: T, 36 | transform: Transform, 37 | left: Expression, 38 | right: Expression, 39 | ): Boolean { 40 | return left.match(node, transform) || right.match(node, transform) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/PrimitiveValue.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | sealed class PrimitiveValue(open val value: Any?, open val type: String) { 4 | data object NullValue : PrimitiveValue(null, "null") { 5 | override fun toString() = "null" 6 | } 7 | 8 | data class BooleanValue(override val value: Boolean) : PrimitiveValue(value, TYPE_NAME) { 9 | override fun toString() = value.toString() 10 | 11 | companion object { 12 | const val TYPE_NAME = "boolean" 13 | } 14 | } 15 | 16 | data class IntValue(override val value: Int) : PrimitiveValue(value, TYPE_NAME) { 17 | override fun toString() = value.toString() 18 | 19 | companion object { 20 | const val TYPE_NAME = "int" 21 | } 22 | } 23 | 24 | data class StringValue( 25 | override val value: String, 26 | val matches: ((CharSequence) -> Boolean)? = null 27 | ) : PrimitiveValue(value, TYPE_NAME) { 28 | 29 | val outMatches: (value: CharSequence) -> Boolean = run { 30 | matches ?: return@run { false } 31 | getMatchValue(value, "(?is)", ".*")?.let { startsWithValue -> 32 | return@run { value -> value.startsWith(startsWithValue, ignoreCase = true) } 33 | } 34 | getMatchValue(value, "(?is).*", ".*")?.let { containsValue -> 35 | return@run { value -> value.contains(containsValue, ignoreCase = true) } 36 | } 37 | getMatchValue(value, "(?is).*", "")?.let { endsWithValue -> 38 | return@run { value -> value.endsWith(endsWithValue, ignoreCase = true) } 39 | } 40 | return@run matches 41 | } 42 | 43 | companion object { 44 | const val TYPE_NAME = "string" 45 | private const val REG_SPECIAL_STRING = "\\^$.?*|+()[]{}" 46 | private fun getMatchValue(value: String, prefix: String, suffix: String): String? { 47 | if (value.startsWith(prefix) && value.endsWith(suffix) && value.length >= (prefix.length + suffix.length)) { 48 | for (i in prefix.length until value.length - suffix.length) { 49 | if (value[i] in REG_SPECIAL_STRING) { 50 | return null 51 | } 52 | } 53 | return value.subSequence(prefix.length, value.length - suffix.length).toString() 54 | } 55 | return null 56 | } 57 | } 58 | 59 | override fun toString(): String { 60 | val wrapChar = '"' 61 | val sb = StringBuilder(value.length + 2) 62 | sb.append(wrapChar) 63 | value.forEach { c -> 64 | val escapeChar = when (c) { 65 | wrapChar -> wrapChar 66 | '\n' -> 'n' 67 | '\r' -> 'r' 68 | '\t' -> 't' 69 | '\b' -> 'b' 70 | '\\' -> '\\' 71 | else -> null 72 | } 73 | if (escapeChar != null) { 74 | sb.append("\\" + escapeChar) 75 | } else { 76 | when (c.code) { 77 | in 0..0xf -> { 78 | sb.append("\\x0" + c.code.toString(16)) 79 | } 80 | 81 | in 0x10..0x1f -> { 82 | sb.append("\\x" + c.code.toString(16)) 83 | } 84 | 85 | else -> { 86 | sb.append(c) 87 | } 88 | } 89 | } 90 | } 91 | sb.append(wrapChar) 92 | return sb.toString() 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/PropertySegment.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | 6 | data class PropertySegment( 7 | /** 8 | * 此属性选择器是否被 @ 标记 9 | */ 10 | val tracked: Boolean, 11 | val name: String, 12 | val expressions: List, 13 | ) { 14 | private val matchAnyName = name.isBlank() || name == "*" 15 | 16 | val binaryExpressions 17 | get() = expressions.flatMap { it.binaryExpressions.toList() }.toTypedArray() 18 | 19 | override fun toString(): String { 20 | val matchTag = if (tracked) "@" else "" 21 | return matchTag + name + expressions.joinToString("") { "[$it]" } 22 | } 23 | 24 | private fun matchName(node: T, transform: Transform): Boolean { 25 | if (matchAnyName) return true 26 | val str = transform.getName(node) ?: return false 27 | if (str.length == name.length) { 28 | return str.contentEquals(name) 29 | } else if (str.length > name.length) { 30 | return str[str.length - name.length - 1] == '.' && str.endsWith(name) 31 | } 32 | return false 33 | } 34 | 35 | fun match(node: T, transform: Transform): Boolean { 36 | return matchName(node, transform) && expressions.all { ex -> ex.match(node, transform) } 37 | } 38 | 39 | } 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/PropertyWrapper.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | import li.songe.selector.Transform 4 | 5 | data class PropertyWrapper( 6 | val propertySegment: PropertySegment, 7 | val to: ConnectWrapper? = null, 8 | ) { 9 | override fun toString(): String { 10 | return (if (to != null) { 11 | to.toString() + "\u0020" 12 | } else { 13 | "" 14 | }) + propertySegment.toString() 15 | } 16 | 17 | fun matchTracks( 18 | node: T, 19 | transform: Transform, 20 | trackNodes: MutableList, 21 | ): List? { 22 | if (!propertySegment.match(node, transform)) { 23 | return null 24 | } 25 | trackNodes.add(node) 26 | if (to == null) { 27 | return trackNodes 28 | } 29 | val r = to.matchTracks(node, transform, trackNodes) 30 | if (r == null) { 31 | trackNodes.removeLast() 32 | } 33 | return r 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/data/TupleExpression.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.data 2 | 3 | data class TupleExpression( 4 | val numbers: List, 5 | ) : ConnectExpression() { 6 | override val minOffset = (numbers.firstOrNull() ?: 1) - 1 7 | override val maxOffset = numbers.lastOrNull() 8 | 9 | private val indexes = numbers.map { x -> x - 1 } 10 | override fun checkOffset(offset: Int): Boolean { 11 | return indexes.binarySearch(offset) >= 0 12 | } 13 | 14 | override fun getOffset(i: Int): Int { 15 | return numbers[i] 16 | } 17 | 18 | override fun toString(): String { 19 | if (numbers.size == 1) { 20 | return if (numbers.first() == 1) { 21 | "" 22 | } else { 23 | numbers.first().toString() 24 | } 25 | } 26 | return "(${numbers.joinToString(",")})" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/parser/Parser.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.parser 2 | 3 | internal data class Parser( 4 | val prefix: String = "", 5 | private val temp: (source: String, offset: Int, prefix: String) -> ParserResult 6 | ) { 7 | operator fun invoke(source: String, offset: Int) = temp(source, offset, prefix) 8 | } -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/parser/ParserResult.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector.parser 2 | 3 | internal data class ParserResult(val data: T, val length: Int = 0) 4 | -------------------------------------------------------------------------------- /selector/src/commonMain/kotlin/li/songe/selector/toMatches.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlin.js.JsExport 4 | 5 | expect fun String.toMatches(): (input: CharSequence) -> Boolean 6 | 7 | expect fun setWasmToMatches(wasmToMatches: (String) -> (String) -> Boolean) 8 | 9 | @JsExport 10 | fun updateWasmToMatches(toMatches: (String) -> (String) -> Boolean) { 11 | setWasmToMatches(toMatches) 12 | } -------------------------------------------------------------------------------- /selector/src/jsMain/kotlin/li/songe/selector/toMatches.js.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlin.js.RegExp 4 | 5 | actual fun String.toMatches(): (input: CharSequence) -> Boolean { 6 | if (wasmMatchesTemp !== null) { 7 | val matches = wasmMatchesTemp!!(this) 8 | return { input -> matches(input.toString()) } 9 | } 10 | if (length >= 4 && startsWith("(?")) { 11 | for (i in 2 until length) { 12 | when (get(i)) { 13 | in 'a'..'z' -> {} 14 | in 'A'..'Z' -> {} 15 | ')' -> { 16 | val flags = subSequence(2, i).toMutableList() 17 | .apply { add('g'); add('u') } 18 | .joinToString("") 19 | val nativePattern = RegExp(substring(i + 1), flags) 20 | return { input -> 21 | // // https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/js/src/kotlin/text/regex.kt 22 | nativePattern.reset() 23 | val match = nativePattern.exec(input.toString()) 24 | match != null && match.index == 0 && nativePattern.lastIndex == input.length 25 | } 26 | } 27 | 28 | else -> break 29 | } 30 | } 31 | } 32 | val regex = Regex(this) 33 | return { input -> regex.matches(input) } 34 | } 35 | 36 | private var wasmMatchesTemp: ((String) -> (String) -> Boolean)? = null 37 | actual fun setWasmToMatches(wasmToMatches: (String) -> (String) -> Boolean) { 38 | wasmMatchesTemp = wasmToMatches 39 | } -------------------------------------------------------------------------------- /selector/src/jvmMain/kotlin/li/songe/selector/toMatches.jvm.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | actual fun String.toMatches(): (input: CharSequence) -> Boolean { 4 | val regex = Regex(this) 5 | return { input -> regex.matches(input) } 6 | } 7 | 8 | actual fun setWasmToMatches(wasmToMatches: (String) -> (String) -> Boolean) {} -------------------------------------------------------------------------------- /selector/src/jvmTest/kotlin/li/songe/selector/TestNode.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.Transient 5 | import kotlinx.serialization.json.JsonPrimitive 6 | 7 | @Serializable 8 | data class TestNode( 9 | val id: Int, 10 | val pid: Int, 11 | val attr: Map, 12 | ) { 13 | @Transient 14 | var parent: TestNode? = null 15 | 16 | @Transient 17 | var children: MutableList = mutableListOf() 18 | 19 | override fun toString(): String { 20 | return id.toString() 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /selector/src/jvmTest/kotlin/li/songe/selector/TestSnapshot.kt: -------------------------------------------------------------------------------- 1 | package li.songe.selector 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class TestSnapshot( 7 | val nodes: List 8 | ) 9 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "gkd" 2 | include(":app") 3 | include(":selector") 4 | include(":hidden_api") 5 | include(":wasm_matches") 6 | 7 | pluginManagement { 8 | repositories { 9 | mavenLocal() 10 | mavenCentral() 11 | google() 12 | maven("https://jitpack.io") 13 | maven("https://plugins.gradle.org/m2/") 14 | } 15 | } 16 | 17 | dependencyResolutionManagement { 18 | repositories { 19 | mavenLocal() 20 | mavenCentral() 21 | google() 22 | maven("https://jitpack.io") 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /stability_config.conf: -------------------------------------------------------------------------------- 1 | li.songe.gkd.* 2 | -------------------------------------------------------------------------------- /wasm_matches/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /wasm_matches/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.kotlin.multiplatform) 5 | } 6 | 7 | kotlin { 8 | jvm { 9 | compilerOptions { 10 | jvmTarget.set(JvmTarget.JVM_17) 11 | } 12 | } 13 | wasmJs { 14 | binaries.executable() 15 | useEsModules() 16 | generateTypeScriptDefinitions() 17 | browser {} 18 | } 19 | sourceSets { 20 | all { 21 | languageSettings.optIn("kotlin.js.ExperimentalJsExport") 22 | } 23 | commonMain { 24 | dependencies { 25 | implementation(libs.kotlin.stdlib.common) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /wasm_matches/src/commonMain/kotlin/li/songe/matches/toMatches.kt: -------------------------------------------------------------------------------- 1 | package li.songe.matches 2 | 3 | import kotlin.js.JsExport 4 | 5 | // wasm gc 6 | @JsExport 7 | fun toMatches(source: String): (input: String) -> Boolean { 8 | val regex = Regex(source) 9 | return { input -> regex.matches(input) } 10 | } --------------------------------------------------------------------------------