diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 052cfd4f2..f21e8b0ad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -5,11 +5,11 @@ body: - type: markdown attributes: value: | - Thanks for reporting issues of LSPosed! + Thanks for reporting issues of Vector! To make it easier for us to help you, please read all pinned issues and provide the following details. - 感谢给 LSPosed 汇报问题! + 感谢给 Vector 汇报问题! 为了使我们更好地帮助你,请务必阅读所有已置顶的 Issues 并提供以下细节。 为了防止重复汇报,标题请务必使用英文。 - type: textarea @@ -54,7 +54,7 @@ body: required: true - type: input attributes: - label: LSPosed version/LSPosed 版本 + label: Vector version/Vector 版本 description: Don't use 'latest'. Specify actual version with 4 digits, otherwise your issue will be closed./不要填用“最新版”。给出四位版本号,不然 issue 会被关闭。 validations: required: true @@ -69,7 +69,7 @@ body: attributes: label: Version requirement/版本要求 options: - - label: I am using the latest debug build from [GitHub Actions](https://github.com/JingMatrix/LSPosed/actions?query=branch%3Amaster)./我正在使用 [GitHub Actions](https://github.com/JingMatrix/LSPosed/actions?query=branch%3Amaster) 中最新的调试版本。 + - label: I am using the latest debug build from [GitHub Actions](https://github.com/JingMatrix/Vector/actions?query=branch%3Amaster)./我正在使用 [GitHub Actions](https://github.com/JingMatrix/Vector/actions?query=branch%3Amaster) 中最新的调试版本。 required: true - type: textarea attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index cb30f3e26..896e8f875 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Ask a question/提问 - url: https://github.com/JingMatrix/LSPosed/discussions/new?category=Q-A + url: https://github.com/JingMatrix/Vector/discussions/new?category=Q-A about: Please ask and answer questions here./如果有任何疑问请在这里提问 diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index c0a3d55ce..89b65713d 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -10,8 +10,25 @@ on: jobs: build: + # A "Release Vector …" commit is cut straight to a `v*` tag, and the tag build below is what + # publishes it — as the stable, `--latest` release. Skip the master-push run for that one commit + # so the very same code does not also go out as a canary prerelease: a release must reach users + # once, as the stable release, never also as a canary. The tag build is a separate push event + # (ref refs/tags/v*, not refs/heads/master) and is unaffected, so the release still ships. + if: ${{ !(github.event_name == 'push' && github.ref == 'refs/heads/master' && startsWith(github.event.head_commit.message, 'Release Vector')) }} runs-on: ubuntu-latest + # Needed to publish the canary prerelease below. Everything else here only reads. + permissions: + contents: write env: + # Where this build's code came from, for the version string the manager and module.prop show. + # Both are the head of the pull request rather than GitHub's defaults, which on a pull request + # describe the run instead of the code: GITHUB_REPOSITORY is this repository even when the + # branch came from a fork, and the checked-out HEAD is an ephemeral merge commit that exists + # nowhere and cannot be looked up by anyone who reads it off a device. Both head.* values are + # null outside a pull request, where the defaults are already right. + VECTOR_BUILD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + VECTOR_BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} CCACHE_COMPILERCHECK: "%compiler% -dumpmachine; %compiler% -dumpversion" CCACHE_NOHASHDIR: "true" CCACHE_HARDLINK: "true" @@ -19,21 +36,53 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive fetch-depth: 0 + # Before anything is built, because it needs nothing but Python and a translator's mistake + # should not wait twenty minutes to surface. Crowdin owns the content of these files, so what + # this catches is a bad merge or a line pasted into the wrong language -- both of which have + # happened, and neither of which any other check in this repository looks for. + - name: Check translations + run: | + python3 manager/tools/check_translations.py manager/src/main/res + python3 manager/tools/check_translations.py daemon/src/main/res + + # A missing secret used to be skipped over in silence, and the build carried on to publish a + # canary or a tag signed with the debug key — which the daemon's InstallerVerifier rejects, + # and which nobody notices until someone tries to install the manager. On this repository the + # secret is always meant to be there, so its absence stops the run here. A fork has no access + # to it and cannot be given one, so a fork builds unsigned, as does any branch that is not + # master: neither publishes anything. - name: Write key if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} + env: + KEY_STORE: ${{ secrets.KEY_STORE }} + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + ALIAS: ${{ secrets.ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + IS_UPSTREAM: ${{ github.repository_owner == 'JingMatrix' }} run: | - if [ ! -z "${{ secrets.KEY_STORE }}" ]; then - echo androidStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> gradle.properties - echo androidKeyAlias='${{ secrets.ALIAS }}' >> gradle.properties - echo androidKeyPassword='${{ secrets.KEY_PASSWORD }}' >> gradle.properties - echo androidStoreFile='key.jks' >> gradle.properties - echo ${{ secrets.KEY_STORE }} | base64 --decode > key.jks + set -euo pipefail + if [ -z "$KEY_STORE" ]; then + if [ "$IS_UPSTREAM" = "true" ]; then + echo "::error::KEY_STORE is empty on ${{ github.ref }}. Refusing to publish an unsigned build." + exit 1 + fi + echo "No signing secret on this fork; building unsigned." + exit 0 fi + # Through the environment rather than interpolated into the script: a password holding a + # quote would otherwise be pasted into a shell word and mangled, or worse, executed. + { + echo "androidStorePassword=$KEY_STORE_PASSWORD" + echo "androidKeyAlias=$ALIAS" + echo "androidKeyPassword=$KEY_PASSWORD" + echo "androidStoreFile=key.jks" + } >> gradle.properties + printf '%s' "$KEY_STORE" | base64 --decode > key.jks - name: Setup Java uses: actions/setup-java@v5 @@ -42,22 +91,21 @@ jobs: java-version: 21 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@v6 - name: Configure Gradle properties run: | echo 'android.native.buildOutput=verbose' >> ~/.gradle/gradle.properties echo 'org.gradle.parallel=true' >> ~/.gradle/gradle.properties echo 'org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC' >> ~/.gradle/gradle.properties - echo 'android.native.buildOutput=verbose' >> ~/.gradle/gradle.properties - name: Setup ninja uses: seanmiddleditch/gha-setup-ninja@v6 with: - version: 1.12.1 + version: 1.13.2 - name: Setup ccache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.ccache @@ -67,7 +115,7 @@ jobs: ${{ runner.os }}-ccache- - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Remove Android's cmake shell: bash @@ -83,32 +131,150 @@ jobs: run: | zygiskReleaseName=`ls zygisk/release/Vector-v*-Release.zip | awk -F '(/|.zip)' '{print $3}'` && echo "zygiskReleaseName=$zygiskReleaseName" >> $GITHUB_OUTPUT zygiskDebugName=`ls zygisk/release/Vector-v*-Debug.zip | awk -F '(/|.zip)' '{print $3}'` && echo "zygiskDebugName=$zygiskDebugName" >> $GITHUB_OUTPUT + versionCode=`echo "$zygiskDebugName" | awk -F '-' '{print $3}'` && echo "versionCode=$versionCode" >> $GITHUB_OUTPUT + versionName=`echo "$zygiskDebugName" | awk -F '-' '{print $2}'` && echo "versionName=$versionName" >> $GITHUB_OUTPUT unzip zygisk/release/Vector-v*-Release.zip -d Vector-Release unzip zygisk/release/Vector-v*-Debug.zip -d Vector-Debug - name: Upload zygisk release - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ steps.prepareArtifact.outputs.zygiskReleaseName }} path: "./Vector-Release/*" - name: Upload zygisk debug - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ steps.prepareArtifact.outputs.zygiskDebugName }} path: "./Vector-Debug/*" + # One entry per module that minifies. `app` was the old manager and has not existed since + # #796, so the manager's mapping — the one an obfuscated stack trace from a user actually + # needs — has been missing from every run since. - name: Upload mappings - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: mappings path: | zygisk/build/outputs/mapping - app/build/outputs/mapping + manager/build/outputs/mapping + daemon/build/outputs/mapping + # DEBUG_SYMBOLS_PATH is set from `layout.buildDirectory` inside the root `subprojects` block, + # so each module writes its own build/symbols and nothing ever lands in the root one. - name: Upload symbols - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: symbols - path: build/symbols + path: | + zygisk/build/symbols + daemon/build/symbols + dex2oat/build/symbols + + # --- stable release -------------------------------------------------------------------- + # + # A pushed `v*` tag is a stable release, not a canary. It is published here from the same + # signed zips this build produced, so cutting a release is `git tag && git push` with nothing + # hand-uploaded afterwards — the hand-assembly is exactly how v2.0's assets came to disagree + # with zygisk/update.json (#811), and an automated attach cannot drift from what was built. + # + # Marked `--latest`, unlike the canaries below: this is what `releases/latest`, and therefore + # the manager's update check, resolves to. The body is the module's own zygisk/changelog.md + # under a GitHub H1 the release page shows. The changelog itself carries no title line — the + # manager renders it beneath its own heading, so a banner there would only read twice. + - name: Publish release + if: ${{ success() && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # `%s` arguments, never the format string, so a `%` anywhere in the changelog is literal. + notes=$(printf '# 🎉 Vector %s 🎉\n\n%s\n' "${TAG#v}" "$(cat zygisk/changelog.md)") + + # Recreated rather than edited so a re-run replaces the assets instead of appending a + # second copy. No `--cleanup-tag`: the release is rebuilt, the pushed release tag is kept. + gh release delete "$TAG" --yes 2>/dev/null || true + gh release create "$TAG" \ + --latest \ + --title "Vector ${TAG}" \ + --notes "$notes" \ + zygisk/release/Vector-v*-Release.zip \ + zygisk/release/Vector-v*-Debug.zip + + # --- canary distribution --------------------------------------------------------------- + # + # The zips above are also attached to a prerelease, because an Actions artifact cannot be + # downloaded without a GitHub account — `GET /actions/artifacts//zip` answers 401 to an + # anonymous caller, while a release asset answers 206. Testing a canary is the lowest-friction + # way for an ordinary user to help, so it must not require handing an OAuth grant to anyone, + # and it must work for the many users who cannot reach GitHub's login page at all. + # + # Marked prerelease so `releases/latest` — which is what update checks read — keeps pointing + # at the last stable tag. + - name: Publish canary prerelease + if: >- + success() && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/master')) + env: + GH_TOKEN: ${{ github.token }} + VERSION_CODE: ${{ steps.prepareArtifact.outputs.versionCode }} + VERSION_NAME: ${{ steps.prepareArtifact.outputs.versionName }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }} + run: | + set -euo pipefail + tag="canary-${VERSION_CODE}" + subject=$(git log -1 --pretty=%s) + short=$(git rev-parse --short HEAD) + + notes=$(cat </dev/null || true + gh release create "$tag" \ + --prerelease \ + --target "${{ github.sha }}" \ + --title "Vector ${VERSION_NAME} canary ${VERSION_CODE}" \ + --notes "$notes" \ + zygisk/release/Vector-v*-Release.zip \ + zygisk/release/Vector-v*-Debug.zip + + # Five is what a tester needs: enough to bisect a regression across a few days, few enough + # that the releases page is still mostly releases. Sorted by version code, which is the commit + # count and therefore monotonic, rather than by date, which reruns and reverts can disorder. + - name: Keep only the five most recent canaries + if: >- + success() && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/master')) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # `grep` exits 1 when nothing matches, which under `pipefail` would fail the step on a + # repository that has no canaries yet. Collected first, then acted on. + tags=$(gh release list --limit 100 --json tagName --jq '.[].tagName' | grep '^canary-' || true) + [ -n "$tags" ] || exit 0 + echo "$tags" | sort -t- -k2 -n -r | tail -n +6 | while read -r old; do + echo "Removing $old" + gh release delete "$old" --yes --cleanup-tag + done diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index ad1ef4c53..ccb4e06fe 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -5,7 +5,12 @@ on: push: branches: [ master ] paths: - - app/src/main/res/values/strings.xml + # Named rather than globbed, to match crowdin.yml. The trigger's matching rules and the + # CLI's are defined independently — notably on whether `*` may match nothing — and a + # trigger that quietly never fires is the worse failure. + - manager/src/main/res/values/strings.xml + - manager/src/main/res/values/strings_logs.xml + - manager/src/main/res/values/strings_store.xml - daemon/src/main/res/values/strings.xml jobs: @@ -13,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@main + uses: actions/checkout@v7 - name: crowdin action - uses: crowdin/github-action@master + uses: crowdin/github-action@v2 with: upload_translations: true download_translations: false diff --git a/.gitignore b/.gitignore index 0022083f0..eb6a6826c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .cache *.iml .gradle +# Where the Kotlin plugin keeps its per-build session marker; a build leaves one behind. +.kotlin /local.properties /.idea .DS_Store diff --git a/README.md b/README.md index fed0b6a1d..984efa9d0 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ This project is made possible by the following open-source contributions: * [XposedBridge](https://github.com/rovo89/XposedBridge): The standard Xposed APIs. * [Dobby](https://github.com/JingMatrix/Dobby): Inline hooking implementation. * [LSPosed](https://github.com/LSPosed/LSPosed): Upstream source. +* [EdXposed](https://github.com/ElderDrivers/EdXposed): Upstream source, before LSPosed. * [xz-embedded](https://github.com/tukaani-project/xz-embedded): Library decompression utilities.
diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 796b96d1c..000000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index b883c23b7..000000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,158 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -import java.time.Instant - -plugins { - alias(libs.plugins.agp.app) - alias(libs.plugins.nav.safeargs) - alias(libs.plugins.autoresconfig) - alias(libs.plugins.materialthemebuilder) - alias(libs.plugins.lsplugin.apksign) -} - -apksign { - storeFileProperty = "androidStoreFile" - storePasswordProperty = "androidStorePassword" - keyAliasProperty = "androidKeyAlias" - keyPasswordProperty = "androidKeyPassword" -} - -val defaultManagerPackageName: String by rootProject.extra - -android { - buildFeatures { - viewBinding = true - buildConfig = true - } - - defaultConfig { - applicationId = defaultManagerPackageName - buildConfigField("long", "BUILD_TIME", Instant.now().epochSecond.toString()) - } - - packaging { - resources { - excludes += "META-INF/**" - excludes += "okhttp3/**" - excludes += "kotlin/**" - excludes += "org/**" - excludes += "**.properties" - excludes += "**.bin" - } - } - - dependenciesInfo.includeInApk = false - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles("proguard-rules.pro") - } - } - - sourceSets { named("main") { res { srcDirs("src/common/res") } } } - namespace = defaultManagerPackageName -} - -autoResConfig { - generateClass = true - generateRes = false - generatedClassFullName = "org.lsposed.manager.util.LangList" - generatedArrayFirstItem = "SYSTEM" -} - -materialThemeBuilder { - themes { - for ((name, color) in - listOf( - "Red" to "F44336", - "Pink" to "E91E63", - "Purple" to "9C27B0", - "DeepPurple" to "673AB7", - "Indigo" to "3F51B5", - "Blue" to "2196F3", - "LightBlue" to "03A9F4", - "Cyan" to "00BCD4", - "Teal" to "009688", - "Green" to "4FAF50", - "LightGreen" to "8BC3A4", - "Lime" to "CDDC39", - "Yellow" to "FFEB3B", - "Amber" to "FFC107", - "Orange" to "FF9800", - "DeepOrange" to "FF5722", - "Brown" to "795548", - "BlueGrey" to "607D8F", - "Sakura" to "FF9CA8", - )) { - create("Material$name") { - lightThemeFormat = "ThemeOverlay.Light.%s" - darkThemeFormat = "ThemeOverlay.Dark.%s" - primaryColor = "#$color" - } - } - } - // Add Material Design 3 color tokens (such as palettePrimary100) in generated theme - // rikka.material:material >= 2.0.0 provides such attributes - // Enable this if your are using rikka.material:material - generatePalette = true -} - -dependencies { - annotationProcessor(libs.glide.compiler) - implementation(libs.androidx.activity) - implementation(libs.androidx.browser) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.core) - implementation(libs.androidx.fragment) - implementation(libs.androidx.navigation.fragment) - implementation(libs.androidx.navigation.ui) - implementation(libs.androidx.preference) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.swiperefreshlayout) - implementation(libs.glide) - implementation(libs.material) - implementation(libs.gson) - implementation(libs.okhttp) - implementation(libs.okhttp.dnsoverhttps) - implementation(libs.okhttp.logging.interceptor) - implementation(libs.rikkax.appcompat) - implementation(libs.rikkax.core) - implementation(libs.rikkax.insets) - implementation(libs.rikkax.material) - implementation(libs.rikkax.material.preference) - implementation(libs.rikkax.recyclerview) - implementation(libs.rikkax.widget.borderview) - implementation(libs.rikkax.widget.mainswitchbar) - implementation(libs.rikkax.layoutinflater) - implementation(libs.appiconloader) - implementation(libs.hiddenapibypass) - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.coroutines.core) - implementation(projects.services.managerService) -} - -configurations.all { - exclude("org.jetbrains", "annotations") - exclude("androidx.appcompat", "appcompat") - exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk7") - exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk8") -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index fc0df121f..000000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,38 +0,0 @@ --keep class org.lsposed.manager.Constants { - public static boolean setBinder(android.os.IBinder); -} --assumenosideeffects class kotlin.jvm.internal.Intrinsics { - public static void check*(...); - public static void throw*(...); -} --assumenosideeffects class android.util.Log { - public static *** v(...); - public static *** d(...); -} - --keepclasseswithmembers,allowobfuscation class * { - @com.google.gson.annotations.SerializedName ; -} - --repackageclasses --allowaccessmodification --overloadaggressively - -# Gson uses generic type information stored in a class file when working with fields. Proguard -# removes such information by default, so configure it to keep all of it. --keepattributes Signature,InnerClasses,EnclosingMethod - --dontwarn org.jetbrains.annotations.NotNull --dontwarn org.jetbrains.annotations.Nullable --dontwarn org.bouncycastle.jsse.BCSSLParameters --dontwarn org.bouncycastle.jsse.BCSSLSocket --dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider --dontwarn org.conscrypt.Conscrypt* --dontwarn org.conscrypt.ConscryptHostnameVerifier --dontwarn org.openjsse.javax.net.ssl.SSLParameters --dontwarn org.openjsse.javax.net.ssl.SSLSocket --dontwarn org.openjsse.net.ssl.OpenJSSE - --keepclassmembers class * implements android.os.Parcelable { - public static final ** CREATOR; -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml deleted file mode 100644 index c4de29277..000000000 --- a/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/assets/webview/colors_dark.css b/app/src/main/assets/webview/colors_dark.css deleted file mode 100644 index abd4e73a9..000000000 --- a/app/src/main/assets/webview/colors_dark.css +++ /dev/null @@ -1,156 +0,0 @@ -/* Primer Colors */ -/* Please also update colors_light.css with light mode appropriate colors when modifying this file. */ - -:root { - --blue-900: #082a52; - --blue-800: #063366; - --blue-700: #0b498f; - --blue-600: #0c65c9; - --blue-500: #0d6edb; - --blue-400: #2e8fff; - --blue-300: #85beff; - --blue-200: #d1e6ff; - --blue-100: #e5f2ff; - --blue-000: #f5faff; - --gray-1000: #050505; - --gray-950: #0b0b0d; - --gray-900: #17181a; - --gray-850: #242528; - --gray-800: #2e2f37; - --gray-750: #383a42; - --gray-700: #41434e; - --gray-650: #4b4d58; - --gray-600: #525560; - --gray-550: #5e616e; - --gray-500: #6c6f7e; - --gray-450: #787c8c; - --gray-400: #9194a1; - --gray-350: #a9abb6; - --gray-300: #bfc1c9; - --gray-250: #d6d7dc; - --gray-200: #e3e4e8; - --gray-150: #eff0f5; - --gray-100: #f7f7f9; - --gray-050: #fbfbfc; - --gray-000: #ffffff; - --green-900: #184d25; - --green-800: #1b612b; - --green-700: #1e7533; - --green-600: #2b8f43; - --green-500: #32b24f; - --green-400: #40d663; - --green-300: #95f0ab; - --green-200: #cbf7d5; - --green-100: #e5ffeb; - --green-000: #f5fff8; - --yellow-900: #755f13; - --yellow-800: #b89007; - --yellow-700: #e0b112; - --yellow-600: #ffcb1a; - --yellow-500: #ffd74d; - --yellow-400: #ffe166; - --yellow-300: #ffec8a; - --yellow-200: #fff6b8; - --yellow-100: #fffce5; - --yellow-000: #fffef5; - --orange-900: #a84603; - --orange-800: #c75204; - --orange-700: #d65c09; - --orange-600: #eb680e; - --orange-500: #fa6f0f; - --orange-400: #ff8a38; - --orange-300: #ffae75; - --orange-200: #ffd5b2; - --orange-100: #ffeee0; - --orange-000: #fffaf5; - --red-900: #8f1d22; - --red-800: #a82229; - --red-700: #bd222d; - --red-600: #d62b38; - --red-500: #e04352; - --red-400: #f55363; - --red-300: #ff808d; - --red-200: #ffb2bb; - --red-100: #ffe0e4; - --red-000: #fff0f2; - --pink-900: #702653; - --pink-800: #9e3674; - --pink-700: #c2428e; - --pink-600: #d63c99; - --pink-500: #f051b0; - --pink-400: #f576c2; - --pink-300: #fa9bd4; - --pink-200: #ffbde4; - --pink-100: #fee0f2; - --pink-000: #fff0f9; - --purple-900: #2e1757; - --purple-800: #3f2175; - --purple-700: #522e8f; - --purple-600: #6139a8; - --purple-500: #7548c7; - --purple-400: #916bd6; - --purple-300: #b899f0; - --purple-200: #dac7ff; - --purple-100: #eae0ff; - --purple-000: #f6f2ff; - --textPrimary: var(--gray-050); - --textSecondary: var(--gray-300); - --textTertiary: var(--gray-400); - --textPlaceholder: rgba(145, 148, 161, 0.5); - --link: var(--blue-400); - --appBackground: var(--gray-1000); - --backgroundSecondary: var(--gray-900); - --backgroundTertiary: var(--gray-850); - --border: rgba(191, 193, 201, 0.16); - --borderOpaque: var(--gray-700); - --iconPrimary: var(--gray-300); - --iconSecondary: var(--gray-500); - --inputBackground: rgba(191, 193, 201, 0.12); - --backgroundPrimary: var(--gray-1000); - --backgroundElevatedPrimary: var(--gray-900); - --backgroundElevatedSecondary: rgba(191, 193, 201, 0.04); - --backgroundElevatedTertiary: rgba(191, 193, 201, 0.08); - --backgroundInset: var(--gray-900); - --link-hover: var(--gray-800); - --color-icon-success: var(--green-600); - --color-text-danger: var(--red-600); - --diffLineNumberAdditionBackground: #08260f; - --diffLineNumberAdditionText: #95f0ab; - --diffLineAdditionBackground: #061c0b; - --diffLineNumberDeletionBackground: #3b0507; - --diffLineNumberDeletionText: #ff808d; - --diffLineDeletionBackground: #300406; - --suggestedChangeDeletionText: #ffffff; - --suggestedChangeAdditionText: #ffffff; - --suggestedChangeDeletionBackground: rgba(218,54,51,0.6); - --suggestedChangeAdditionBackground: rgba(46,160,67,0.6); - --videoBackground: #000000; -} - -/* Custom Base Styles */ - -:root { - --link-highlight: rgba(46, 143, 255, 0.08); - --pre-background: var(--backgroundElevatedTertiary); - --code-background: var(--pre-background); - --hr-background: var(--borderOpaque); - --thead-background: var(--pre-background); - --thead-border: var(--hr-background); - --tr-border: var(--gray-300); - --tr-alt-background: var(--pre-background); - --kbd-background: var(--pre-background); - --kbd-color: var(--gray-350); - --kbd-border: var(--gray-750); - --blockquote-color: var(--gray-400); - --blockquote-border: var(--hr-background); - --heading-color: var(--gray-100); - --h6-color: var(--gray-500); - --frame-border: var(--hr-background); - --frame-color: var(--gray-200); - --mention-color: var(--textPrimary); - --email-toggle-color: var(--kbd-color); - --email-toggle-background: var(--blockquote-border); - --email-quoted-color: var(--blockquote-color); - --keyword-color: var(--gray-600); - --code-font: ui-monospace, Menlo, monospace; -} diff --git a/app/src/main/assets/webview/colors_light.css b/app/src/main/assets/webview/colors_light.css deleted file mode 100644 index 25eb8e445..000000000 --- a/app/src/main/assets/webview/colors_light.css +++ /dev/null @@ -1,156 +0,0 @@ -/* Primer Colors */ -/* Please also update colors_dark.css with dark mode appropriate colors when modifying this file. */ - -:root { - --blue-900: #05264c; - --blue-800: #032f62; - --blue-700: #044289; - --blue-600: #005cc5; - --blue-500: #0366d6; - --blue-400: #2188ff; - --blue-300: #79b8ff; - --blue-200: #c8e1ff; - --blue-100: #dbedff; - --blue-000: #f1f8ff; - --gray-1000: #050505; - --gray-950: #0b0b0d; - --gray-900: #17181a; - --gray-850: #242528; - --gray-800: #2f3037; - --gray-750: #383a42; - --gray-700: #41434e; - --gray-650: #4b4d58; - --gray-600: #525560; - --gray-550: #5e616e; - --gray-500: #6a6d7c; - --gray-450: #787c8c; - --gray-400: #9194a1; - --gray-350: #a9abb6; - --gray-300: #bfc1c9; - --gray-250: #d6d7dc; - --gray-200: #e3e4e8; - --gray-150: #eff0f5; - --gray-100: #f7f7f9; - --gray-050: #fbfbfc; - --gray-000: #ffffff; - --green-900: #144620; - --green-800: #165c26; - --green-700: #176f2c; - --green-600: #22863a; - --green-500: #28a745; - --green-400: #34d058; - --green-300: #85e89d; - --green-200: #bef5cb; - --green-100: #dcffe4; - --green-000: #f0fff4; - --yellow-900: #735c0f; - --yellow-800: #b08800; - --yellow-700: #dbab09; - --yellow-600: #f9c513; - --yellow-500: #ffd33d; - --yellow-400: #ffdf5d; - --yellow-300: #ffea7f; - --yellow-200: #fff5b1; - --yellow-100: #fffbdd; - --yellow-000: #fffdef; - --orange-900: #a04100; - --orange-800: #c24e00; - --orange-700: #d15704; - --orange-600: #e36209; - --orange-500: #f66a0a; - --orange-400: #fb8532; - --orange-300: #ffab70; - --orange-200: #ffd1ac; - --orange-100: #ffebda; - --orange-000: #fff8f2; - --red-900: #86181d; - --red-800: #9e1c23; - --red-700: #b31d28; - --red-600: #cb2431; - --red-500: #d73a49; - --red-400: #ea4a5a; - --red-300: #f97583; - --red-200: #fdaeb7; - --red-100: #ffdce0; - --red-000: #ffeef0; - --pink-900: #6d224f; - --pink-800: #99306f; - --pink-700: #b93a86; - --pink-600: #d03592; - --pink-500: #ea4aaa; - --pink-400: #ec6cb9; - --pink-300: #f692ce; - --pink-200: #f9b3dd; - --pink-100: #fedbf0; - --pink-000: #ffeef8; - --purple-900: #29134e; - --purple-800: #3a1d6e; - --purple-700: #4c2888; - --purple-600: #5a32a3; - --purple-500: #6f42c1; - --purple-400: #8a63d2; - --purple-300: #b392f0; - --purple-200: #d1bcf9; - --purple-100: #e6dcfd; - --purple-000: #f5f0ff; - --textPrimary: var(--gray-1000); - --textSecondary: var(--gray-700); - --textTertiary: var(--gray-500); - --textPlaceholder: rgba(82, 85, 96, 0.5); - --link: var(--blue-500); - --appBackground: var(--gray-000); - --backgroundSecondary: var(--gray-000); - --backgroundTertiary: var(--gray-000); - --border: rgba(65, 67, 78, 0.25); - --borderOpaque: var(--gray-300); - --iconPrimary: var(--gray-600); - --iconSecondary: var(--gray-400); - --inputBackground: rgba(65, 67, 78, 0.12); - --backgroundPrimary: var(--gray-150); - --backgroundElevatedPrimary: var(--gray-150); - --backgroundElevatedSecondary: var(--gray-000); - --backgroundElevatedTertiary: var(--gray-000); - --backgroundInset: var(--gray-200); - --link-hover: var(--gray-200); - --color-icon-success: var(--green-600); - --color-text-danger: var(--red-600); - --diffLineNumberAdditionBackground: #dcffe4; - --diffLineNumberAdditionText: #22863a; - --diffLineAdditionBackground: #f0fff4; - --diffLineNumberDeletionBackground: #ffdce0; - --diffLineNumberDeletionText: #cb2431; - --diffLineDeletionBackground: #ffeef0; - --suggestedChangeDeletionText: #ffffff; - --suggestedChangeAdditionText: #ffffff; - --suggestedChangeDeletionBackground: rgba(218,54,51,0.6); - --suggestedChangeAdditionBackground: rgba(46,160,67,0.6); - --videoBackground: #000000; -} - -/* Custom Base Styles */ - -:root { - --link-highlight: rgba(3, 102, 214, 0.08); - --pre-background: var(--gray-100); - --code-background: var(--pre-background); - --hr-background: var(--gray-200); - --thead-background: var(--pre-background); - --thead-border: var(--hr-background); - --tr-border: var(--gray-300); - --tr-alt-background: var(--pre-background); - --kbd-background: var(--pre-background); - --kbd-color: var(--gray-650); - --kbd-border: var(--gray-250); - --blockquote-color: var(--gray-500); - --blockquote-border: var(--hr-background); - --heading-color: var(--gray-900); - --h6-color: var(--gray-500); - --frame-border: var(--hr-background); - --frame-color: var(--gray-850); - --mention-color: var(--gray-850); - --email-toggle-color: var(--kbd-color); - --email-toggle-background: var(--blockquote-border); - --email-quoted-color: var(--blockquote-color); - --keyword-color: var(--gray-400); - --code-font: ui-monospace, Menlo, monospace; -} diff --git a/app/src/main/assets/webview/markdown.css b/app/src/main/assets/webview/markdown.css deleted file mode 100644 index 684aa4816..000000000 --- a/app/src/main/assets/webview/markdown.css +++ /dev/null @@ -1,589 +0,0 @@ -/* Shared styles between light & dark mode so all colors should be variables */ - -* { - box-sizing: border-box; -} - -input:disabled { - touch-action: none; -} - -html { - -webkit-text-size-adjust: none; - text-size-adjust: none; - font: -apple-system-body; -} - -body { - color: var(--textPrimary); - background-color: var(--background); -} - -a { - color: var(--link); - text-decoration: none; - -webkit-tap-highlight-color: var(--link-highlight); - word-break: break-word; -} - -a:not([target]):hover { - border-radius: 5px; - background-color: var(--link-hover); - transition-duration: 0.2s; - transform: scale(1.015); -} - -/* -Web views hold on to their hover event if the app is backgrounded. We need to disable custom hover effects by setting a -class on body and overriding them in CSS when we apply this workaround. When the mouse enters the web view again, we -can disable our override. -*/ -body.hover-override a:not([target]) { - background-color: transparent; - transform: scale(1); -} - -details summary { - outline: 0; -} - -table { - border-spacing: 0; - border-collapse: collapse; -} - -blockquote { - margin: 0; -} - -table, table *, pre { - touch-action: pan-x; -} - -.markdown-body ul.contains-task-list { - list-style: none; - padding-left: 0; -} - -.task-list-item { - padding-left: 40px; - margin-left: -16px; -} - -.task-list-item-checkbox { - margin-left: -24px -} - -pre, code, kbd { - font-size: 1em; - font-family: var(--code-font); -} - -.issue-keyword { - border-bottom: 1px dotted var(--keyword-color); -} - -.team-mention, .user-mention { - font-weight: 600; - color: var(--mention-color); - white-space: nowrap; -} - -.email-hidden-toggle, .email-hidden-reply { - display: none; -} - -/* Fix checkboxes looking cut off when they render larger than the default size */ -input[type="checkbox"] { - transform: translate(0px); -} - -/* --- */ - -.markdown-body { - font-size: inherit; - line-height: 1.5; - word-wrap: break-word; -} - -.markdown-body kbd { - display: inline-block; - padding: 0.18em 0.31em; - font-size: 0.7em; - line-height: 1.2em; - color: var(--kbd-color); - vertical-align: middle; - background-color: var(--kbd-background); - border: 1px solid var(--kbd-border); - border-radius: 0.25em; - box-shadow: inset 0 -1px 0 var(--kbd-border); - margin-right: 2px; -} - -.markdown-body:after, .markdown-body:before { - display: table; - content: "" -} - -.markdown-body:after { - clear: both; -} - -.markdown-body > :first-child { - margin-top: 0 !important; -} - -.markdown-body > :last-child { - margin-bottom: 0 !important; -} - -.markdown-body a:not([href]) { - color: inherit; - text-decoration: none; -} - -.markdown-body .absent { - color: var(--red-600); -} - -/* GitHub now emits the heading permalink anchor as a sibling AFTER the - heading (not a child), so the old ".../h6 .octicon-link" hide rule no - longer matches it and the floated icon leaks into the left gutter of the - following block. These permalinks are useless in this viewer (no hover, - no address bar), so hide them outright. */ -.markdown-body .anchor { - display: none; -} - -.markdown-body .anchor:focus { - outline: none; -} - -.markdown-body blockquote, .markdown-body details, .markdown-body dl, .markdown-body ol, .markdown-body p, .markdown-body pre, .markdown-body table, .markdown-body ul { - margin-top: 0; - margin-bottom: 16px; -} - -.markdown-body hr { - height: .25em; - padding: 0; - margin: 24px 0; - background-color: var(--hr-background); - border: 0; -} - -.markdown-body blockquote { - padding-left: 1em; - color: var(--blockquote-color); - position: relative; -} - -.markdown-body blockquote::before { - content: ''; - width: 2px; - position: absolute; - top: 0; - bottom: 0; - left: 0; - background-color: var(--blockquote-border); - border-radius: 2px; -} - -.markdown-body blockquote > :first-child { - margin-top: 0; -} - -.markdown-body blockquote > :last-child { - margin-bottom: 0; -} - -.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { - margin-top: 24px; - margin-bottom: 16px; - font-weight: 600; - line-height: 1.25; -} - -.markdown-body h1 .octicon-link, .markdown-body h2 .octicon-link, .markdown-body h3 .octicon-link, .markdown-body h4 .octicon-link, .markdown-body h5 .octicon-link, .markdown-body h6 .octicon-link { - color: var(--heading-color); - vertical-align: middle; - visibility: hidden; -} - -.markdown-body h1:hover .anchor, .markdown-body h2:hover .anchor, .markdown-body h3:hover .anchor, .markdown-body h4:hover .anchor, .markdown-body h5:hover .anchor, .markdown-body h6:hover .anchor { - text-decoration: none; -} - -.markdown-body h1:hover .anchor .octicon-link, .markdown-body h2:hover .anchor .octicon-link, .markdown-body h3:hover .anchor .octicon-link, .markdown-body h4:hover .anchor .octicon-link, .markdown-body h5:hover .anchor .octicon-link, .markdown-body h6:hover .anchor .octicon-link { - visibility: visible; -} - -.markdown-body h1 code, .markdown-body h1 tt, .markdown-body h2 code, .markdown-body h2 tt, .markdown-body h3 code, .markdown-body h3 tt, .markdown-body h4 code, .markdown-body h4 tt, .markdown-body h5 code, .markdown-body h5 tt, .markdown-body h6 code, .markdown-body h6 tt { - font-size: inherit; -} - -.markdown-body h1 { - font-size: 2em; -} - -.markdown-body h1, .markdown-body h2 { - padding-bottom: .3em; - border-bottom: 1px solid var(--border); -} - -.markdown-body h2 { - font-size: 1.5em; -} - -.markdown-body h3 { - font-size: 1.25em; -} - -.markdown-body h4 { - font-size: 1em; -} - -.markdown-body h5 { - font-size: .875em; -} - -.markdown-body h6 { - font-size: .85em; - color: var(--h6-color); -} - -.markdown-body ul { - padding-left: 1.5em; -} - -.markdown-body ol.no-list, .markdown-body ul.no-list { - padding: 0; - list-style-type: none; -} - -.markdown-body ol ol, .markdown-body ol ul, .markdown-body ul ol, .markdown-body ul ul { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-body li { - word-wrap: break-all; -} - -.markdown-body li > p { - margin-top: 16px; -} - -.markdown-body li + li { - margin-top: .25em; -} - -.markdown-body dl { - padding: 0; -} - -.markdown-body dl dt { - padding: 0; - margin-top: 16px; - font-size: 1em; - font-style: italic; - font-weight: 600; -} - -.markdown-body dl dd { - padding: 0 16px; - margin-bottom: 16px; -} - -.markdown-body table { - display: block; - width: 100%; - overflow: auto; -} - -.markdown-body table th { - font-weight: 600; -} - -.markdown-body table td, .markdown-body table th { - padding: 6px 13px; - border: 1px solid var(--thead-border); -} - -.markdown-body table tr { - background-color: var(--background); - border-top: 1px solid var(--tr-border); -} - -.markdown-body table tr:nth-child(2n) { - background-color: var(--tr-alt-background); -} - -.markdown-body table img { - background-color: initial; -} - -.markdown-body img { - max-width: 100%; - box-sizing: initial; - background-color: var(--background); -} - -.markdown-body img[align=right] { - padding-left: 20px; -} - -.markdown-body img[align=left] { - padding-right: 20px; -} - -.markdown-body video { - max-width: 100%; - box-sizing: initial; - background-color: var(--videoBackground); -} - -.markdown-body .emoji { - max-width: none; - vertical-align: text-top; - background-color: initial; -} - -.markdown-body span.frame { - display: block; - overflow: hidden; -} - -.markdown-body span.frame > span { - display: block; - float: left; - width: auto; - padding: 7px; - margin: 13px 0 0; - overflow: hidden; - border: 1px solid var(--frame-border); -} - -.markdown-body span.frame span img { - display: block; - float: left; -} - -.markdown-body span.frame span span { - display: block; - padding: 5px 0 0; - clear: both; - color: var(--frame-color); -} - -.markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-center > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; -} - -.markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; -} - -.markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-right > span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; -} - -.markdown-body span.align-right span img { - margin: 0; - text-align: right; -} - -.markdown-body span.float-left { - display: block; - float: left; - margin-right: 13px; - overflow: hidden; -} - -.markdown-body span.float-left span { - margin: 13px 0 0; -} - -.markdown-body span.float-right { - display: block; - float: right; - margin-left: 13px; - overflow: hidden; -} - -.markdown-body span.float-right > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; -} - -.markdown-body code, .markdown-body tt { - padding: .2em .4em; - margin: 0; - font-size: 85%; - background-color: var(--code-background); - border-radius: 6px; -} - -.markdown-body code br, .markdown-body tt br { - display: none; -} - -.markdown-body del code { - text-decoration: inherit; -} - -.markdown-body pre { - word-wrap: normal; -} - -.markdown-body pre > code { - padding: 0; - margin: 0; - font-size: 100%; - word-break: normal; - white-space: pre; - background: transparent; - border: 0; -} - -.markdown-body .highlight { - margin-bottom: 16px; -} - -.markdown-body .highlight pre { - margin-bottom: 0; - word-break: normal; -} - -.markdown-body .highlight pre, .markdown-body pre { - padding: 16px; - overflow: auto; - font-size: 85%; - line-height: 1.45; - background-color: var(--pre-background); - border-radius: 6px; -} - -.markdown-body pre code, .markdown-body pre tt { - display: inline; - max-width: auto; - padding: 0; - margin: 0; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: initial; - border: 0; -} - -.markdown-body .csv-data td, .markdown-body .csv-data th { - padding: 5px; - overflow: hidden; - font-size: 12px; - line-height: 1; - text-align: left; - white-space: nowrap; -} - -.markdown-body .csv-data .blob-num { - padding: 10px 8px 9px; - text-align: right; - background: var(--background); - border: 0; -} - -.markdown-body .csv-data tr { - border-top: 0; -} - -.markdown-body .csv-data th { - font-weight: 600; - background: var(--thead-background); - border-top: 0; -} - -.open.octicon, .draft.octicon, .closed.octicon, .merged.octicon, .color-text-secondary.octicon { - display: inline-block; - margin-top: 0.15em; - vertical-align: text-top; - fill: currentColor; - width: 1em; - height: 1em; - font: -apple-system-body; -} - -.open.octicon { - color: var(--color-icon-success); -} - -.draft.octicon { - color: var(--textTertiary); -} - -.closed.octicon { - color: var(--color-text-danger); -} - -.merged.octicon { - color: var(--purple-500); -} - -.color-text-secondary.octicon { - color: var(--textSecondary); -} - -.reference { - white-space: nowrap; -} - -.issue-link { - font-weight: 600; - color: var(--mention-color); - white-space: normal; -} - -.issue-shorthand { - font-weight: 400; - color: var(--textTertiary); -} - -.mr-1 { - margin-right: 4px; -} - -.ml-1 { - margin-left: 4px; -} - -.d-inline-block { - display: inline-block; -} - -.v-align-middle { - vertical-align: middle; -} - -.Box { - border-radius: 6px; -} diff --git a/app/src/main/assets/webview/syntax.css b/app/src/main/assets/webview/syntax.css deleted file mode 100644 index 727766652..000000000 --- a/app/src/main/assets/webview/syntax.css +++ /dev/null @@ -1,124 +0,0 @@ -/* From https://github.com/primer/github-syntax-light/blob/master/lib/github-light.css */ -.pl-c /* comment, punctuation.definition.comment, string.comment */ { - color: #6a737d; -} - -.pl-c1 /* constant, entity.name.constant, variable.other.constant, variable.language, support, meta.property-name, support.constant, support.variable, meta.module-reference, markup.raw, meta.diff.header, meta.output */, -.pl-s .pl-v /* string variable */ { - color: #005cc5; -} - -.pl-e /* entity */, -.pl-en /* entity.name */ { - color: #6f42c1; -} - -.pl-smi /* variable.parameter.function, storage.modifier.package, storage.modifier.import, storage.type.java, variable.other */, -.pl-s .pl-s1 /* string source */ { - color: #24292e; -} - -.pl-ent /* entity.name.tag, markup.quote */ { - color: #22863a; -} - -.pl-k /* keyword, storage, storage.type */ { - color: #d73a49; -} - -.pl-s /* string */, -.pl-pds /* punctuation.definition.string, source.regexp, string.regexp.character-class */, -.pl-s .pl-pse .pl-s1 /* string punctuation.section.embedded source */, -.pl-sr /* string.regexp */, -.pl-sr .pl-cce /* string.regexp constant.character.escape */, -.pl-sr .pl-sre /* string.regexp source.ruby.embedded */, -.pl-sr .pl-sra /* string.regexp string.regexp.arbitrary-repitition */ { - color: #032f62; -} - -.pl-v /* variable */, -.pl-smw /* sublimelinter.mark.warning */ { - color: #e36209; -} - -.pl-bu /* invalid.broken, invalid.deprecated, invalid.unimplemented, message.error, brackethighlighter.unmatched, sublimelinter.mark.error */ { - color: #b31d28; -} - -.pl-ii /* invalid.illegal */ { - color: #fafbfc; - background-color: #b31d28; -} - -.pl-c2 /* carriage-return */ { - color: #fafbfc; - background-color: #d73a49; -} - -.pl-c2::before /* carriage-return */ { - content: "^M"; -} - -.pl-sr .pl-cce /* string.regexp constant.character.escape */ { - font-weight: bold; - color: #22863a; -} - -.pl-ml /* markup.list */ { - color: #735c0f; -} - -.pl-mh /* markup.heading */, -.pl-mh .pl-en /* markup.heading entity.name */, -.pl-ms /* meta.separator */ { - font-weight: bold; - color: #005cc5; -} - -.pl-mi /* markup.italic */ { - font-style: italic; - color: #24292e; -} - -.pl-mb /* markup.bold */ { - font-weight: bold; - color: #24292e; -} - -.pl-md /* markup.deleted, meta.diff.header.from-file, punctuation.definition.deleted */ { - color: #b31d28; - background-color: #ffeef0; -} - -.pl-mi1 /* markup.inserted, meta.diff.header.to-file, punctuation.definition.inserted */ { - color: #22863a; - background-color: #f0fff4; -} - -.pl-mc /* markup.changed, punctuation.definition.changed */ { - color: #e36209; - background-color: #ffebda; -} - -.pl-mi2 /* markup.ignored, markup.untracked */ { - color: #f6f8fa; - background-color: #005cc5; -} - -.pl-mdr /* meta.diff.range */ { - font-weight: bold; - color: #6f42c1; -} - -.pl-ba /* brackethighlighter.tag, brackethighlighter.curly, brackethighlighter.round, brackethighlighter.square, brackethighlighter.angle, brackethighlighter.quote */ { - color: #586069; -} - -.pl-sg /* sublimelinter.gutter-mark */ { - color: #959da5; -} - -.pl-corl /* constant.other.reference.link, string.other.link */ { - text-decoration: underline; - color: #032f62; -} diff --git a/app/src/main/assets/webview/syntax_dark.css b/app/src/main/assets/webview/syntax_dark.css deleted file mode 100644 index e7858b39c..000000000 --- a/app/src/main/assets/webview/syntax_dark.css +++ /dev/null @@ -1,124 +0,0 @@ -/* From https://github.com/primer/github-syntax-dark/blob/master/lib/github-dark.css */ -.pl-c /* comment, punctuation.definition.comment, string.comment */ { - color: #959da5; -} - -.pl-c1 /* constant, entity.name.constant, variable.other.constant, variable.language, support, meta.property-name, support.constant, support.variable, meta.module-reference, markup.quote, markup.raw, meta.diff.header */, -.pl-s .pl-v /* string variable */ { - color: #c8e1ff; -} - -.pl-e /* entity */, -.pl-en /* entity.name */ { - color: #b392f0; -} - -.pl-smi /* variable.parameter.function, storage.modifier.package, storage.modifier.import, storage.type.java, variable.other */, -.pl-s .pl-s1 /* string source */ { - color: #f6f8fa; -} - -.pl-ent /* entity.name.tag */ { - color: #7bcc72; -} - -.pl-k /* keyword, storage, storage.type */ { - color: #ea4a5a; -} - -.pl-s /* string */, -.pl-pds /* punctuation.definition.string, source.regexp, string.regexp.character-class */, -.pl-s .pl-pse .pl-s1 /* string punctuation.section.embedded source */, -.pl-sr /* string.regexp */, -.pl-sr .pl-cce /* string.regexp constant.character.escape */, -.pl-sr .pl-sre /* string.regexp source.ruby.embedded */, -.pl-sr .pl-sra /* string.regexp string.regexp.arbitrary-repitition */ { - color: #79b8ff; -} - -.pl-v /* variable */, -.pl-ml /* markup.list, sublimelinter.mark.warning */ { - color: #fb8532; -} - -.pl-bu /* invalid.broken, invalid.deprecated, invalid.unimplemented, message.error, brackethighlighter.unmatched, sublimelinter.mark.error */ { - color: #d73a49; -} - -.pl-ii /* invalid.illegal */ { - color: #fafbfc; - background-color: #d73a49; -} - -.pl-c2 /* carriage-return */ { - color: #fafbfc; - background-color: #d73a49; -} - -.pl-c2::before /* carriage-return */ { - content: "^M"; -} - -.pl-sr .pl-cce /* string.regexp constant.character.escape */ { - font-weight: bold; - color: #7bcc72; -} - -.pl-mh /* markup.heading */, -.pl-mh .pl-en /* markup.heading entity.name */, -.pl-ms /* meta.separator */ { - font-weight: bold; - color: #0366d6; -} - -.pl-mi /* markup.italic */ { - font-style: italic; - color: #f6f8fa; -} - -.pl-mb /* markup.bold */ { - font-weight: bold; - color: #f6f8fa; -} - -.pl-md /* markup.deleted, meta.diff.header.from-file, punctuation.definition.deleted */ { - color: #ffdcd7; - background-color: #67060c; -} - -.pl-mi1 /* markup.inserted, meta.diff.header.to-file, punctuation.definition.inserted */ { - color: #aff5b4; - background-color: #033a16; -} - -.pl-mc /* markup.changed, punctuation.definition.changed */ { - color: #b08800; - background-color: #fffdef; -} - -.pl-mi2 /* markup.ignored, markup.untracked */ { - color: #2f363d; - background-color: #959da5; -} - -.pl-mdr /* meta.diff.range */ { - font-weight: bold; - color: #b392f0; -} - -.pl-mo /* meta.output */ { - color: #0366d6; -} - -.pl-ba /* brackethighlighter.tag, brackethighlighter.curly, brackethighlighter.round, brackethighlighter.square, brackethighlighter.angle, brackethighlighter.quote */ { - color: #ffeef0; -} - -.pl-sg /* sublimelinter.gutter-mark */ { - color: #6a737d; -} - -.pl-corl /* constant.other.reference.link, string.other.link */ { - text-decoration: underline; - color: #79b8ff; -} diff --git a/app/src/main/assets/webview/template.html b/app/src/main/assets/webview/template.html deleted file mode 100644 index c6c9e00f5..000000000 --- a/app/src/main/assets/webview/template.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - -
@body@
- - diff --git a/app/src/main/assets/webview/template_dark.html b/app/src/main/assets/webview/template_dark.html deleted file mode 100644 index 964ba6abb..000000000 --- a/app/src/main/assets/webview/template_dark.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - -
@body@
- - diff --git a/app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java b/app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java deleted file mode 100644 index f2207f1ac..000000000 --- a/app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java +++ /dev/null @@ -1,1263 +0,0 @@ -package com.google.android.material.appbar; - -import android.animation.ValueAnimator; -import android.content.Context; -import android.content.res.ColorStateList; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.view.Gravity; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.widget.FrameLayout; - -import androidx.annotation.ColorInt; -import androidx.annotation.DrawableRes; -import androidx.annotation.IntRange; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.annotation.StyleRes; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.math.MathUtils; -import androidx.core.view.GravityCompat; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; - -import com.google.android.material.animation.AnimationUtils; -import com.google.android.material.internal.DescendantOffsetUtils; -import com.google.android.material.internal.SubtitleCollapsingTextHelper; -import com.google.android.material.internal.ThemeEnforcement; - -import org.lsposed.manager.R; - -/** - * @see CollapsingToolbarLayout - */ -public class SubtitleCollapsingToolbarLayout extends FrameLayout { - - private static final int DEFAULT_SCRIM_ANIMATION_DURATION = 600; - - private boolean refreshToolbar = true; - private int toolbarId; - @Nullable - private Toolbar toolbar; - @Nullable - private View toolbarDirectChild; - private View dummyView; - - private int expandedMarginStart; - private int expandedMarginTop; - private int expandedMarginEnd; - private int expandedMarginBottom; - - private final Rect tmpRect = new Rect(); - @NonNull - final SubtitleCollapsingTextHelper collapsingTextHelper; - private boolean collapsingTitleEnabled; - private boolean drawCollapsingTitle; - - @Nullable - private Drawable contentScrim; - @Nullable - Drawable statusBarScrim; - private int scrimAlpha; - private boolean scrimsAreShown; - private ValueAnimator scrimAnimator; - private long scrimAnimationDuration; - private int scrimVisibleHeightTrigger = -1; - - private AppBarLayout.OnOffsetChangedListener onOffsetChangedListener; - - int currentOffset; - - @Nullable - WindowInsetsCompat lastInsets; - - public SubtitleCollapsingToolbarLayout(@NonNull Context context) { - this(context, null); - } - - public SubtitleCollapsingToolbarLayout(@NonNull Context context, @Nullable AttributeSet attrs) { - this(context, attrs, 0); - } - - public SubtitleCollapsingToolbarLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - collapsingTextHelper = new SubtitleCollapsingTextHelper(this); - collapsingTextHelper.setTextSizeInterpolator(AnimationUtils.DECELERATE_INTERPOLATOR); - collapsingTextHelper.setRtlTextDirectionHeuristicsEnabled(false); - - TypedArray a = ThemeEnforcement.obtainStyledAttributes( - context, - attrs, - R.styleable.SubtitleCollapsingToolbarLayout, - defStyleAttr, - R.style.Widget_Design_SubtitleCollapsingToolbar); - - collapsingTextHelper.setExpandedTextGravity(a.getInt( - R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleGravity, - GravityCompat.START | Gravity.BOTTOM)); - collapsingTextHelper.setCollapsedTextGravity(a.getInt( - R.styleable.SubtitleCollapsingToolbarLayout_collapsedTitleGravity, - GravityCompat.START | Gravity.CENTER_VERTICAL)); - - expandedMarginStart = expandedMarginTop = expandedMarginEnd = expandedMarginBottom = - a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMargin, 0); - - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginStart)) { - expandedMarginStart = - a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginStart, 0); - } - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd)) { - expandedMarginEnd = - a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd, 0); - } - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginTop)) { - expandedMarginTop = - a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginTop, 0); - } - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom)) { - expandedMarginBottom = - a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom, 0); - } - - collapsingTitleEnabled = a.getBoolean(R.styleable.SubtitleCollapsingToolbarLayout_titleEnabled, true); - setTitle(a.getText(R.styleable.SubtitleCollapsingToolbarLayout_title)); - setSubtitle(a.getText(R.styleable.SubtitleCollapsingToolbarLayout_subtitle)); - - // First load the default text appearances - collapsingTextHelper.setExpandedTitleTextAppearance( - R.style.TextAppearance_Design_SubtitleCollapsingToolbar_ExpandedTitle); - collapsingTextHelper.setCollapsedTitleTextAppearance( - androidx.appcompat.R.style.TextAppearance_AppCompat_Widget_ActionBar_Title); - collapsingTextHelper.setExpandedSubtitleTextAppearance( - R.style.TextAppearance_Design_SubtitleCollapsingToolbar_ExpandedSubtitle); - collapsingTextHelper.setCollapsedSubtitleTextAppearance( - androidx.appcompat.R.style.TextAppearance_AppCompat_Widget_ActionBar_Subtitle); - - // Now overlay any custom text appearances - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleTextAppearance)) { - collapsingTextHelper.setExpandedTitleTextAppearance( - a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleTextAppearance, 0)); - } - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_collapsedTitleTextAppearance)) { - collapsingTextHelper.setCollapsedTitleTextAppearance( - a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_collapsedTitleTextAppearance, 0)); - } - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedSubtitleTextAppearance)) { - collapsingTextHelper.setExpandedSubtitleTextAppearance( - a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_expandedSubtitleTextAppearance, 0)); - } - if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_collapsedSubtitleTextAppearance)) { - collapsingTextHelper.setCollapsedSubtitleTextAppearance( - a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_collapsedSubtitleTextAppearance, 0)); - } - - scrimVisibleHeightTrigger = a - .getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_scrimVisibleHeightTrigger, -1); - - scrimAnimationDuration = a.getInt( - R.styleable.SubtitleCollapsingToolbarLayout_scrimAnimationDuration, - DEFAULT_SCRIM_ANIMATION_DURATION); - - setContentScrim(a.getDrawable(R.styleable.SubtitleCollapsingToolbarLayout_contentScrim)); - setStatusBarScrim(a.getDrawable(R.styleable.SubtitleCollapsingToolbarLayout_statusBarScrim)); - - toolbarId = a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_toolbarId, -1); - - a.recycle(); - - setWillNotDraw(false); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - - // Add an OnOffsetChangedListener if possible - final ViewParent parent = getParent(); - if (parent instanceof AppBarLayout) { - // Copy over from the ABL whether we should fit system windows - ViewCompat.setFitsSystemWindows(this, ViewCompat.getFitsSystemWindows((View) parent)); - - if (onOffsetChangedListener == null) { - onOffsetChangedListener = new OffsetUpdateListener(); - } - ((AppBarLayout) parent).addOnOffsetChangedListener(onOffsetChangedListener); - - // We're attached, so lets request an inset dispatch - ViewCompat.requestApplyInsets(this); - } - } - - @Override - protected void onDetachedFromWindow() { - // Remove our OnOffsetChangedListener if possible and it exists - final ViewParent parent = getParent(); - if (onOffsetChangedListener != null && parent instanceof AppBarLayout) { - ((AppBarLayout) parent).removeOnOffsetChangedListener(onOffsetChangedListener); - } - - super.onDetachedFromWindow(); - } - - @Override - public void draw(@NonNull Canvas canvas) { - super.draw(canvas); - - // If we don't have a toolbar, the scrim will be not be drawn in drawChild() below. - // Instead, we draw it here, before our collapsing text. - ensureToolbar(); - if (toolbar == null && contentScrim != null && scrimAlpha > 0) { - contentScrim.mutate().setAlpha(scrimAlpha); - contentScrim.draw(canvas); - } - - // Let the collapsing text helper draw its text - if (collapsingTitleEnabled && drawCollapsingTitle) { - collapsingTextHelper.draw(canvas); - } - - // Now draw the status bar scrim - if (statusBarScrim != null && scrimAlpha > 0) { - final int topInset = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0; - if (topInset > 0) { - statusBarScrim.setBounds(0, -currentOffset, getWidth(), topInset - currentOffset); - statusBarScrim.mutate().setAlpha(scrimAlpha); - statusBarScrim.draw(canvas); - } - } - } - - @Override - protected boolean drawChild(Canvas canvas, View child, long drawingTime) { - // This is a little weird. Our scrim needs to be behind the Toolbar (if it is present), - // but in front of any other children which are behind it. To do this we intercept the - // drawChild() call, and draw our scrim just before the Toolbar is drawn - boolean invalidated = false; - if (contentScrim != null && scrimAlpha > 0 && isToolbarChild(child)) { - contentScrim.mutate().setAlpha(scrimAlpha); - contentScrim.draw(canvas); - invalidated = true; - } - return super.drawChild(canvas, child, drawingTime) || invalidated; - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - if (contentScrim != null) { - contentScrim.setBounds(0, 0, w, h); - } - } - - private void ensureToolbar() { - if (!refreshToolbar) { - return; - } - - // First clear out the current Toolbar - this.toolbar = null; - toolbarDirectChild = null; - - if (toolbarId != -1) { - // If we have an ID set, try and find it and it's direct parent to us - this.toolbar = findViewById(toolbarId); - if (this.toolbar != null) { - toolbarDirectChild = findDirectChild(this.toolbar); - } - } - - if (this.toolbar == null) { - // If we don't have an ID, or couldn't find a Toolbar with the correct ID, try and find - // one from our direct children - Toolbar toolbar = null; - for (int i = 0, count = getChildCount(); i < count; i++) { - final View child = getChildAt(i); - if (child instanceof Toolbar) { - toolbar = (Toolbar) child; - break; - } - } - this.toolbar = toolbar; - } - - updateDummyView(); - refreshToolbar = false; - } - - private boolean isToolbarChild(View child) { - return (toolbarDirectChild == null || toolbarDirectChild == this) - ? child == toolbar - : child == toolbarDirectChild; - } - - /** - * Returns the direct child of this layout, which itself is the ancestor of the given view. - */ - @NonNull - private View findDirectChild(@NonNull final View descendant) { - View directChild = descendant; - for (ViewParent p = descendant.getParent(); p != this && p != null; p = p.getParent()) { - if (p instanceof View) { - directChild = (View) p; - } - } - return directChild; - } - - private void updateDummyView() { - if (!collapsingTitleEnabled && dummyView != null) { - // If we have a dummy view and we have our title disabled, remove it from its parent - final ViewParent parent = dummyView.getParent(); - if (parent instanceof ViewGroup) { - ((ViewGroup) parent).removeView(dummyView); - } - } - if (collapsingTitleEnabled && toolbar != null) { - if (dummyView == null) { - dummyView = new View(getContext()); - } - if (dummyView.getParent() == null) { - toolbar.addView(dummyView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); - } - } - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - ensureToolbar(); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - - final int mode = MeasureSpec.getMode(heightMeasureSpec); - final int topInset = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0; - if (mode == MeasureSpec.UNSPECIFIED && topInset > 0) { - // If we have a top inset and we're set to wrap_content height we need to make sure - // we add the top inset to our height, therefore we re-measure - heightMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredHeight() + topInset, MeasureSpec.EXACTLY); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - // Set our minimum height to enable proper AppBarLayout collapsing - if (toolbar != null) { - if (toolbarDirectChild == null || toolbarDirectChild == this) { - setMinimumHeight(getHeightWithMargins(toolbar)); - } else { - setMinimumHeight(getHeightWithMargins(toolbarDirectChild)); - } - } - } - - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - - if (lastInsets != null) { - // Shift down any views which are not set to fit system windows - final int insetTop = lastInsets.getSystemWindowInsetTop(); - for (int i = 0, z = getChildCount(); i < z; i++) { - final View child = getChildAt(i); - if (!ViewCompat.getFitsSystemWindows(child)) { - if (child.getTop() < insetTop) { - // If the child isn't set to fit system windows but is drawing within - // the inset offset it down - ViewCompat.offsetTopAndBottom(child, insetTop); - } - } - } - } - - // Update our child view offset helpers so that they track the correct layout coordinates - for (int i = 0, z = getChildCount(); i < z; i++) { - getViewOffsetHelper(getChildAt(i)).onViewLayout(); - } - - // Update the collapsed bounds by getting its transformed bounds - if (collapsingTitleEnabled && dummyView != null) { - // We only draw the title if the dummy view is being displayed (Toolbar removes - // views if there is no space) - drawCollapsingTitle = ViewCompat.isAttachedToWindow(dummyView) && dummyView.getVisibility() == VISIBLE; - - if (drawCollapsingTitle) { - final boolean isRtl = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL; - - // Update the collapsed bounds - final int maxOffset = - getMaxOffsetForPinChild(toolbarDirectChild != null ? toolbarDirectChild : toolbar); - DescendantOffsetUtils.getDescendantRect(this, dummyView, tmpRect); - collapsingTextHelper.setCollapsedBounds( - tmpRect.left + (isRtl ? toolbar.getTitleMarginEnd() : toolbar.getTitleMarginStart()), - tmpRect.top + maxOffset + toolbar.getTitleMarginTop(), - tmpRect.right - (isRtl ? toolbar.getTitleMarginStart() : toolbar.getTitleMarginEnd()), - tmpRect.bottom + maxOffset - toolbar.getTitleMarginBottom()); - - // Update the expanded bounds - collapsingTextHelper.setExpandedBounds( - isRtl ? expandedMarginEnd : expandedMarginStart, - tmpRect.top + expandedMarginTop, - right - left - (isRtl ? expandedMarginStart : expandedMarginEnd), - bottom - top - expandedMarginBottom); - // Now recalculate using the new bounds - collapsingTextHelper.recalculate(); - } - } - - if (toolbar != null) { - if (collapsingTitleEnabled && TextUtils.isEmpty(collapsingTextHelper.getTitle())) { - // If we do not currently have a title, try and grab it from the Toolbar - setTitle(toolbar.getTitle()); - setSubtitle(toolbar.getSubtitle()); - } - } - - updateScrimVisibility(); - - // Apply any view offsets, this should be done at the very end of layout - for (int i = 0, z = getChildCount(); i < z; i++) { - getViewOffsetHelper(getChildAt(i)).applyOffsets(); - } - } - - private static int getHeightWithMargins(@NonNull final View view) { - final ViewGroup.LayoutParams lp = view.getLayoutParams(); - if (lp instanceof MarginLayoutParams) { - final MarginLayoutParams mlp = (MarginLayoutParams) lp; - return view.getMeasuredHeight() + mlp.topMargin + mlp.bottomMargin; - } - return view.getMeasuredHeight(); - } - - static ViewOffsetHelper getViewOffsetHelper(View view) { - ViewOffsetHelper offsetHelper = (ViewOffsetHelper) view.getTag(com.google.android.material.R.id.view_offset_helper); - if (offsetHelper == null) { - offsetHelper = new ViewOffsetHelper(view); - view.setTag(com.google.android.material.R.id.view_offset_helper, offsetHelper); - } - return offsetHelper; - } - - /** - * Sets the title to be displayed by this view, if enabled. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_title - * @see #setTitleEnabled(boolean) - * @see #getTitle() - */ - public void setTitle(@Nullable CharSequence title) { - collapsingTextHelper.setTitle(title); - updateContentDescriptionFromTitle(); - } - - /** - * Returns the title currently being displayed by this view. If the title is not enabled, then - * this will return {@code null}. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_title - */ - @Nullable - public CharSequence getTitle() { - return collapsingTitleEnabled ? collapsingTextHelper.getTitle() : null; - } - - /** - * Sets the subtitle to be displayed by this view, if enabled. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_subtitle - * @see #setTitleEnabled(boolean) - * @see #getSubtitle() - */ - public void setSubtitle(@Nullable CharSequence subtitle) { - collapsingTextHelper.setSubtitle(subtitle); - updateContentDescriptionFromTitle(); - } - - /** - * Returns the subtitle currently being displayed by this view. If the title is not enabled, then - * this will return {@code null}. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_subtitle - */ - @Nullable - public CharSequence getSubtitle() { - return collapsingTitleEnabled ? collapsingTextHelper.getSubtitle() : null; - } - - /** - * Sets whether this view should display its own title and subtitle. - *

- *

The title and subtitle displayed by this view will shrink and grow based on the scroll offset. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_titleEnabled - * @see #setTitle(CharSequence) - * @see #setSubtitle(CharSequence) - * @see #isTitleEnabled() - */ - public void setTitleEnabled(boolean enabled) { - if (enabled != collapsingTitleEnabled) { - collapsingTitleEnabled = enabled; - updateContentDescriptionFromTitle(); - updateDummyView(); - requestLayout(); - } - } - - /** - * Returns whether this view is currently displaying its own title and subtitle. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_titleEnabled - * @see #setTitleEnabled(boolean) - */ - public boolean isTitleEnabled() { - return collapsingTitleEnabled; - } - - /** - * Set whether the content scrim and/or status bar scrim should be shown or not. Any change in the - * vertical scroll may overwrite this value. Any visibility change will be animated if this view - * has already been laid out. - * - * @param shown whether the scrims should be shown - * @see #getStatusBarScrim() - * @see #getContentScrim() - */ - public void setScrimsShown(boolean shown) { - setScrimsShown(shown, ViewCompat.isLaidOut(this) && !isInEditMode()); - } - - /** - * Set whether the content scrim and/or status bar scrim should be shown or not. Any change in the - * vertical scroll may overwrite this value. - * - * @param shown whether the scrims should be shown - * @param animate whether to animate the visibility change - * @see #getStatusBarScrim() - * @see #getContentScrim() - */ - public void setScrimsShown(boolean shown, boolean animate) { - if (scrimsAreShown != shown) { - if (animate) { - animateScrim(shown ? 0xFF : 0x0); - } else { - setScrimAlpha(shown ? 0xFF : 0x0); - } - scrimsAreShown = shown; - } - } - - private void animateScrim(int targetAlpha) { - ensureToolbar(); - if (scrimAnimator == null) { - scrimAnimator = new ValueAnimator(); - scrimAnimator.setDuration(scrimAnimationDuration); - scrimAnimator.setInterpolator(targetAlpha > scrimAlpha - ? AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR - : AnimationUtils.LINEAR_OUT_SLOW_IN_INTERPOLATOR); - scrimAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animator) { - setScrimAlpha((int) animator.getAnimatedValue()); - } - }); - } else if (scrimAnimator.isRunning()) { - scrimAnimator.cancel(); - } - - scrimAnimator.setIntValues(scrimAlpha, targetAlpha); - scrimAnimator.start(); - } - - void setScrimAlpha(int alpha) { - if (alpha != scrimAlpha) { - final Drawable contentScrim = this.contentScrim; - if (contentScrim != null && toolbar != null) { - ViewCompat.postInvalidateOnAnimation(toolbar); - } - scrimAlpha = alpha; - ViewCompat.postInvalidateOnAnimation(SubtitleCollapsingToolbarLayout.this); - } - } - - int getScrimAlpha() { - return scrimAlpha; - } - - /** - * Set the drawable to use for the content scrim from resources. Providing null will disable the - * scrim functionality. - * - * @param drawable the drawable to display - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim - * @see #getContentScrim() - */ - public void setContentScrim(@Nullable Drawable drawable) { - if (contentScrim != drawable) { - if (contentScrim != null) { - contentScrim.setCallback(null); - } - contentScrim = drawable != null ? drawable.mutate() : null; - if (contentScrim != null) { - contentScrim.setBounds(0, 0, getWidth(), getHeight()); - contentScrim.setCallback(this); - contentScrim.setAlpha(scrimAlpha); - } - ViewCompat.postInvalidateOnAnimation(this); - } - } - - /** - * Set the color to use for the content scrim. - * - * @param color the color to display - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim - * @see #getContentScrim() - */ - public void setContentScrimColor(@ColorInt int color) { - setContentScrim(new ColorDrawable(color)); - } - - /** - * Set the drawable to use for the content scrim from resources. - * - * @param resId drawable resource id - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim - * @see #getContentScrim() - */ - public void setContentScrimResource(@DrawableRes int resId) { - setContentScrim(ContextCompat.getDrawable(getContext(), resId)); - } - - /** - * Returns the drawable which is used for the foreground scrim. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim - * @see #setContentScrim(Drawable) - */ - @Nullable - public Drawable getContentScrim() { - return contentScrim; - } - - /** - * Set the drawable to use for the status bar scrim from resources. Providing null will disable - * the scrim functionality. - *

- *

This scrim is only shown when we have been given a top system inset. - * - * @param drawable the drawable to display - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim - * @see #getStatusBarScrim() - */ - public void setStatusBarScrim(@Nullable Drawable drawable) { - if (statusBarScrim != drawable) { - if (statusBarScrim != null) { - statusBarScrim.setCallback(null); - } - statusBarScrim = drawable != null ? drawable.mutate() : null; - if (statusBarScrim != null) { - if (statusBarScrim.isStateful()) { - statusBarScrim.setState(getDrawableState()); - } - DrawableCompat.setLayoutDirection(statusBarScrim, ViewCompat.getLayoutDirection(this)); - statusBarScrim.setVisible(getVisibility() == VISIBLE, false); - statusBarScrim.setCallback(this); - statusBarScrim.setAlpha(scrimAlpha); - } - ViewCompat.postInvalidateOnAnimation(this); - } - } - - @Override - protected void drawableStateChanged() { - super.drawableStateChanged(); - - final int[] state = getDrawableState(); - boolean changed = false; - - Drawable d = statusBarScrim; - if (d != null && d.isStateful()) { - changed |= d.setState(state); - } - d = contentScrim; - if (d != null && d.isStateful()) { - changed |= d.setState(state); - } - if (collapsingTextHelper != null) { - changed |= collapsingTextHelper.setState(state); - } - - if (changed) { - invalidate(); - } - } - - @Override - protected boolean verifyDrawable(Drawable who) { - return super.verifyDrawable(who) || who == contentScrim || who == statusBarScrim; - } - - @Override - public void setVisibility(int visibility) { - super.setVisibility(visibility); - - final boolean visible = visibility == VISIBLE; - if (statusBarScrim != null && statusBarScrim.isVisible() != visible) { - statusBarScrim.setVisible(visible, false); - } - if (contentScrim != null && contentScrim.isVisible() != visible) { - contentScrim.setVisible(visible, false); - } - } - - /** - * Set the color to use for the status bar scrim. - *

- *

This scrim is only shown when we have been given a top system inset. - * - * @param color the color to display - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim - * @see #getStatusBarScrim() - */ - public void setStatusBarScrimColor(@ColorInt int color) { - setStatusBarScrim(new ColorDrawable(color)); - } - - /** - * Set the drawable to use for the content scrim from resources. - * - * @param resId drawable resource id - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim - * @see #getStatusBarScrim() - */ - public void setStatusBarScrimResource(@DrawableRes int resId) { - setStatusBarScrim(ContextCompat.getDrawable(getContext(), resId)); - } - - /** - * Returns the drawable which is used for the status bar scrim. - * - * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim - * @see #setStatusBarScrim(Drawable) - */ - @Nullable - public Drawable getStatusBarScrim() { - return statusBarScrim; - } - - /** - * Sets the text color and size for the collapsed title from the specified TextAppearance - * resource. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedTitleTextAppearance - */ - public void setCollapsedTitleTextAppearance(@StyleRes int resId) { - collapsingTextHelper.setCollapsedTitleTextAppearance(resId); - } - - /** - * Sets the text color of the collapsed title. - * - * @param color The new text color in ARGB format - */ - public void setCollapsedTitleTextColor(@ColorInt int color) { - setCollapsedTitleTextColor(ColorStateList.valueOf(color)); - } - - /** - * Sets the text colors of the collapsed title. - * - * @param colors ColorStateList containing the new text colors - */ - public void setCollapsedTitleTextColor(@NonNull ColorStateList colors) { - collapsingTextHelper.setCollapsedTitleTextColor(colors); - } - - /** - * Sets the text color and size for the collapsed subtitle from the specified TextAppearance - * resource. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedSubtitleTextAppearance - */ - public void setCollapsedSubtitleTextAppearance(@StyleRes int resId) { - collapsingTextHelper.setCollapsedSubtitleTextAppearance(resId); - } - - /** - * Sets the text color of the collapsed subtitle. - * - * @param color The new text color in ARGB format - */ - public void setCollapsedSubtitleTextColor(@ColorInt int color) { - setCollapsedSubtitleTextColor(ColorStateList.valueOf(color)); - } - - /** - * Sets the text colors of the collapsed subtitle. - * - * @param colors ColorStateList containing the new text colors - */ - public void setCollapsedSubtitleTextColor(@NonNull ColorStateList colors) { - collapsingTextHelper.setCollapsedSubtitleTextColor(colors); - } - - /** - * Sets the horizontal alignment of the collapsed title and the vertical gravity that will be used - * when there is extra space in the collapsed bounds beyond what is required for the title itself. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedTitleGravity - */ - public void setCollapsedTitleGravity(int gravity) { - collapsingTextHelper.setCollapsedTextGravity(gravity); - } - - /** - * Returns the horizontal and vertical alignment for title when collapsed. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedTitleGravity - */ - public int getCollapsedTitleGravity() { - return collapsingTextHelper.getCollapsedTextGravity(); - } - - /** - * Sets the text color and size for the expanded title from the specified TextAppearance resource. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleTextAppearance - */ - public void setExpandedTitleTextAppearance(@StyleRes int resId) { - collapsingTextHelper.setExpandedTitleTextAppearance(resId); - } - - /** - * Sets the text color of the expanded title. - * - * @param color The new text color in ARGB format - */ - public void setExpandedTitleTextColor(@ColorInt int color) { - setExpandedTitleTextColor(ColorStateList.valueOf(color)); - } - - /** - * Sets the text colors of the expanded title. - * - * @param colors ColorStateList containing the new text colors - */ - public void setExpandedTitleTextColor(@NonNull ColorStateList colors) { - collapsingTextHelper.setExpandedTitleTextColor(colors); - } - - /** - * Sets the text color and size for the expanded subtitle from the specified TextAppearance resource. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedSubtitleTextAppearance - */ - public void setExpandedSubtitleTextAppearance(@StyleRes int resId) { - collapsingTextHelper.setExpandedSubtitleTextAppearance(resId); - } - - /** - * Sets the text color of the expanded subtitle. - * - * @param color The new text color in ARGB format - */ - public void setExpandedSubtitleTextColor(@ColorInt int color) { - setExpandedSubtitleTextColor(ColorStateList.valueOf(color)); - } - - /** - * Sets the text colors of the expanded subtitle. - * - * @param colors ColorStateList containing the new text colors - */ - public void setExpandedSubtitleTextColor(@NonNull ColorStateList colors) { - collapsingTextHelper.setExpandedSubtitleTextColor(colors); - } - - /** - * Sets the horizontal alignment of the expanded title and the vertical gravity that will be used - * when there is extra space in the expanded bounds beyond what is required for the title itself. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleGravity - */ - public void setExpandedTitleGravity(int gravity) { - collapsingTextHelper.setExpandedTextGravity(gravity); - } - - /** - * Returns the horizontal and vertical alignment for title when expanded. - * - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleGravity - */ - public int getExpandedTitleGravity() { - return collapsingTextHelper.getExpandedTextGravity(); - } - - /** - * Set the typeface to use for the collapsed title. - * - * @param typeface typeface to use, or {@code null} to use the default. - */ - public void setCollapsedTitleTypeface(@Nullable Typeface typeface) { - collapsingTextHelper.setCollapsedTitleTypeface(typeface); - } - - /** - * Returns the typeface used for the collapsed title. - */ - @NonNull - public Typeface getCollapsedTitleTypeface() { - return collapsingTextHelper.getCollapsedTitleTypeface(); - } - - /** - * Set the typeface to use for the expanded title. - * - * @param typeface typeface to use, or {@code null} to use the default. - */ - public void setExpandedTitleTypeface(@Nullable Typeface typeface) { - collapsingTextHelper.setExpandedTitleTypeface(typeface); - } - - /** - * Returns the typeface used for the expanded title. - */ - @NonNull - public Typeface getExpandedTitleTypeface() { - return collapsingTextHelper.getExpandedTitleTypeface(); - } - - /** - * Set the typeface to use for the collapsed title. - * - * @param typeface typeface to use, or {@code null} to use the default. - */ - public void setCollapsedSubtitleTypeface(@Nullable Typeface typeface) { - collapsingTextHelper.setCollapsedSubtitleTypeface(typeface); - } - - /** - * Returns the typeface used for the collapsed title. - */ - @NonNull - public Typeface getCollapsedSubtitleTypeface() { - return collapsingTextHelper.getCollapsedSubtitleTypeface(); - } - - /** - * Set the typeface to use for the expanded title. - * - * @param typeface typeface to use, or {@code null} to use the default. - */ - public void setExpandedSubtitleTypeface(@Nullable Typeface typeface) { - collapsingTextHelper.setExpandedSubtitleTypeface(typeface); - } - - /** - * Returns the typeface used for the expanded title. - */ - @NonNull - public Typeface getExpandedSubtitleTypeface() { - return collapsingTextHelper.getExpandedSubtitleTypeface(); - } - - /** - * Sets the expanded title margins. - * - * @param start the starting title margin in pixels - * @param top the top title margin in pixels - * @param end the ending title margin in pixels - * @param bottom the bottom title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMargin - * @see #getExpandedTitleMarginStart() - * @see #getExpandedTitleMarginTop() - * @see #getExpandedTitleMarginEnd() - * @see #getExpandedTitleMarginBottom() - */ - public void setExpandedTitleMargin(int start, int top, int end, int bottom) { - expandedMarginStart = start; - expandedMarginTop = top; - expandedMarginEnd = end; - expandedMarginBottom = bottom; - requestLayout(); - } - - /** - * @return the starting expanded title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginStart - * @see #setExpandedTitleMarginStart(int) - */ - public int getExpandedTitleMarginStart() { - return expandedMarginStart; - } - - /** - * Sets the starting expanded title margin in pixels. - * - * @param margin the starting title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginStart - * @see #getExpandedTitleMarginStart() - */ - public void setExpandedTitleMarginStart(int margin) { - expandedMarginStart = margin; - requestLayout(); - } - - /** - * @return the top expanded title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginTop - * @see #setExpandedTitleMarginTop(int) - */ - public int getExpandedTitleMarginTop() { - return expandedMarginTop; - } - - /** - * Sets the top expanded title margin in pixels. - * - * @param margin the top title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginTop - * @see #getExpandedTitleMarginTop() - */ - public void setExpandedTitleMarginTop(int margin) { - expandedMarginTop = margin; - requestLayout(); - } - - /** - * @return the ending expanded title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd - * @see #setExpandedTitleMarginEnd(int) - */ - public int getExpandedTitleMarginEnd() { - return expandedMarginEnd; - } - - /** - * Sets the ending expanded title margin in pixels. - * - * @param margin the ending title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd - * @see #getExpandedTitleMarginEnd() - */ - public void setExpandedTitleMarginEnd(int margin) { - expandedMarginEnd = margin; - requestLayout(); - } - - /** - * @return the bottom expanded title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom - * @see #setExpandedTitleMarginBottom(int) - */ - public int getExpandedTitleMarginBottom() { - return expandedMarginBottom; - } - - /** - * Sets the bottom expanded title margin in pixels. - * - * @param margin the bottom title margin in pixels - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom - * @see #getExpandedTitleMarginBottom() - */ - public void setExpandedTitleMarginBottom(int margin) { - expandedMarginBottom = margin; - requestLayout(); - } - - /** - * Sets whether {@code TextDirectionHeuristics} should be used to determine whether the title text - * is RTL. Experimental Feature. - */ - public void setRtlTextDirectionHeuristicsEnabled(boolean rtlTextDirectionHeuristicsEnabled) { - collapsingTextHelper.setRtlTextDirectionHeuristicsEnabled(rtlTextDirectionHeuristicsEnabled); - } - - /** - * Gets whether {@code TextDirectionHeuristics} should be used to determine whether the title text - * is RTL. Experimental Feature. - */ - public boolean isRtlTextDirectionHeuristicsEnabled() { - return collapsingTextHelper.isRtlTextDirectionHeuristicsEnabled(); - } - - /** - * Set the amount of visible height in pixels used to define when to trigger a scrim visibility - * change. - *

- *

If the visible height of this view is less than the given value, the scrims will be made - * visible, otherwise they are hidden. - * - * @param height value in pixels used to define when to trigger a scrim visibility change - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd - */ - public void setScrimVisibleHeightTrigger(@IntRange(from = 0) final int height) { - if (scrimVisibleHeightTrigger != height) { - scrimVisibleHeightTrigger = height; - // Update the scrim visibility - updateScrimVisibility(); - } - } - - /** - * Returns the amount of visible height in pixels used to define when to trigger a scrim - * visibility change. - * - * @see #setScrimVisibleHeightTrigger(int) - */ - public int getScrimVisibleHeightTrigger() { - if (scrimVisibleHeightTrigger >= 0) { - // If we have one explicitly set, return it - return scrimVisibleHeightTrigger; - } - - // Otherwise we'll use the default computed value - final int insetTop = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0; - - final int minHeight = ViewCompat.getMinimumHeight(this); - if (minHeight > 0) { - // If we have a minHeight set, lets use 2 * minHeight (capped at our height) - return Math.min((minHeight * 2) + insetTop, getHeight()); - } - - // If we reach here then we don't have a min height set. Instead we'll take a - // guess at 1/3 of our height being visible - return getHeight() / 3; - } - - /** - * Set the duration used for scrim visibility animations. - * - * @param duration the duration to use in milliseconds - * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_scrimAnimationDuration - */ - public void setScrimAnimationDuration(@IntRange(from = 0) final long duration) { - scrimAnimationDuration = duration; - } - - /** - * Returns the duration in milliseconds used for scrim visibility animations. - */ - public long getScrimAnimationDuration() { - return scrimAnimationDuration; - } - - @Override - protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { - return p instanceof LayoutParams; - } - - @Override - protected LayoutParams generateDefaultLayoutParams() { - return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); - } - - @Override - public FrameLayout.LayoutParams generateLayoutParams(AttributeSet attrs) { - return new LayoutParams(getContext(), attrs); - } - - @Override - protected FrameLayout.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { - return new LayoutParams(p); - } - - public static class LayoutParams extends CollapsingToolbarLayout.LayoutParams { - public LayoutParams(Context c, AttributeSet attrs) { - super(c, attrs); - } - - public LayoutParams(int width, int height) { - super(width, height); - } - - public LayoutParams(int width, int height, int gravity) { - super(width, height, gravity); - } - - public LayoutParams(ViewGroup.LayoutParams p) { - super(p); - } - - public LayoutParams(MarginLayoutParams source) { - super(source); - } - - @RequiresApi(19) - public LayoutParams(FrameLayout.LayoutParams source) { - super(source); - } - } - - /** - * Show or hide the scrims if needed - */ - final void updateScrimVisibility() { - if (contentScrim != null || statusBarScrim != null) { - setScrimsShown(getHeight() + currentOffset < getScrimVisibleHeightTrigger()); - } - } - - final int getMaxOffsetForPinChild(View child) { - final ViewOffsetHelper offsetHelper = getViewOffsetHelper(child); - final LayoutParams lp = (LayoutParams) child.getLayoutParams(); - return getHeight() - offsetHelper.getLayoutTop() - child.getHeight() - lp.bottomMargin; - } - - private void updateContentDescriptionFromTitle() { - // Set this layout's contentDescription to match the title if it's shown by CollapsingTextHelper - setContentDescription(getTitle()); - } - - private class OffsetUpdateListener implements AppBarLayout.OnOffsetChangedListener { - OffsetUpdateListener() { - } - - @Override - public void onOffsetChanged(AppBarLayout layout, int verticalOffset) { - currentOffset = verticalOffset; - - final int insetTop = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0; - - for (int i = 0, z = getChildCount(); i < z; i++) { - final View child = getChildAt(i); - final LayoutParams lp = (LayoutParams) child.getLayoutParams(); - final ViewOffsetHelper offsetHelper = getViewOffsetHelper(child); - - switch (lp.collapseMode) { - case LayoutParams.COLLAPSE_MODE_PIN: - offsetHelper.setTopAndBottomOffset( - MathUtils.clamp(-verticalOffset, 0, getMaxOffsetForPinChild(child))); - break; - case LayoutParams.COLLAPSE_MODE_PARALLAX: - offsetHelper.setTopAndBottomOffset(Math.round(-verticalOffset * lp.parallaxMult)); - break; - default: - break; - } - } - - // Show or hide the scrims if needed - updateScrimVisibility(); - - if (statusBarScrim != null && insetTop > 0) { - ViewCompat.postInvalidateOnAnimation(SubtitleCollapsingToolbarLayout.this); - } - - // Update the collapsing text's fraction - final int expandRange = getHeight() - - ViewCompat.getMinimumHeight(SubtitleCollapsingToolbarLayout.this) - - insetTop; - collapsingTextHelper.setExpansionFraction(Math.abs(verticalOffset) / (float) expandRange); - } - } -} diff --git a/app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java b/app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java deleted file mode 100644 index 95848058c..000000000 --- a/app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java +++ /dev/null @@ -1,1255 +0,0 @@ -package com.google.android.material.internal; - -import android.animation.TimeInterpolator; -import android.content.res.ColorStateList; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.RectF; -import android.graphics.Typeface; -import android.os.Build; -import android.text.TextPaint; -import android.text.TextUtils; -import android.view.Gravity; -import android.view.View; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.math.MathUtils; -import androidx.core.text.TextDirectionHeuristicsCompat; -import androidx.core.view.GravityCompat; -import androidx.core.view.ViewCompat; - -import com.google.android.material.animation.AnimationUtils; -import com.google.android.material.resources.CancelableFontCallback; -import com.google.android.material.resources.TextAppearance; - -/** - * Helper class for {@link com.google.android.material.appbar.SubtitleCollapsingToolbarLayout}. - * - * @see CollapsingTextHelper - */ -public final class SubtitleCollapsingTextHelper { - - // Pre-JB-MR2 doesn't support HW accelerated canvas scaled title so we will workaround it - // by using our own texture - private static final boolean USE_SCALING_TEXTURE = Build.VERSION.SDK_INT < 18; - - private static final boolean DEBUG_DRAW = false; - @NonNull - private static final Paint DEBUG_DRAW_PAINT; - - static { - DEBUG_DRAW_PAINT = DEBUG_DRAW ? new Paint() : null; - if (DEBUG_DRAW_PAINT != null) { - DEBUG_DRAW_PAINT.setAntiAlias(true); - DEBUG_DRAW_PAINT.setColor(Color.MAGENTA); - } - } - - private final View view; - - private boolean drawTitle; - private float expandedFraction; - - @NonNull - private final Rect expandedBounds; - @NonNull - private final Rect collapsedBounds; - @NonNull - private final RectF currentBounds; - private int expandedTextGravity = Gravity.CENTER_VERTICAL; - private int collapsedTextGravity = Gravity.CENTER_VERTICAL; - private float expandedTitleTextSize, expandedSubtitleTextSize = 15; - private float collapsedTitleTextSize, collapsedSubtitleTextSize = 15; - private ColorStateList expandedTitleTextColor, expandedSubtitleTextColor; - private ColorStateList collapsedTitleTextColor, collapsedSubtitleTextColor; - - private float expandedTitleDrawY, expandedSubtitleDrawY; - private float collapsedTitleDrawY, collapsedSubtitleDrawY; - private float expandedTitleDrawX, expandedSubtitleDrawX; - private float collapsedTitleDrawX, collapsedSubtitleDrawX; - private float currentTitleDrawX, currentSubtitleDrawX; - private float currentTitleDrawY, currentSubtitleDrawY; - private Typeface collapsedTitleTypeface, collapsedSubtitleTypeface; - private Typeface expandedTitleTypeface, expandedSubtitleTypeface; - private Typeface currentTitleTypeface, currentSubtitleTypeface; - private CancelableFontCallback expandedTitleFontCallback, expandedSubtitleFontCallback; - private CancelableFontCallback collapsedTitleFontCallback, collapsedSubtitleFontCallback; - - @Nullable - private CharSequence title, subtitle; - @Nullable - private CharSequence titleToDraw, subtitleToDraw; - private boolean isRtl; - private boolean isRtlTextDirectionHeuristicsEnabled = true; - - private boolean useTexture; - @Nullable - private Bitmap expandedTitleTexture, expandedSubtitleTexture; - private Paint titleTexturePaint, subtitleTexturePaint; - private float titleTextureAscent, subtitleTextureAscent; - private float titleTextureDescent, subtitleTextureDescent; - - private float titleScale, subtitleScale; - private float currentTitleTextSize, currentSubtitleTextSize; - - private int[] state; - - private boolean boundsChanged; - - @NonNull - private final TextPaint titleTextPaint, subtitleTextPaint; - @NonNull - private final TextPaint titleTmpPaint, subtitleTmpPaint; - - private TimeInterpolator positionInterpolator; - private TimeInterpolator textSizeInterpolator; - - private float collapsedTitleShadowRadius, collapsedSubtitleShadowRadius; - private float collapsedTitleShadowDx, collapsedSubtitleShadowDx; - private float collapsedTitleShadowDy, collapsedSubtitleShadowDy; - private ColorStateList collapsedTitleShadowColor, collapsedSubtitleShadowColor; - - private float expandedTitleShadowRadius, expandedSubtitleShadowRadius; - private float expandedTitleShadowDx, expandedSubtitleShadowDx; - private float expandedTitleShadowDy, expandedSubtitleShadowDy; - private ColorStateList expandedTitleShadowColor, expandedSubtitleShadowColor; - - public SubtitleCollapsingTextHelper(View view) { - this.view = view; - - titleTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.SUBPIXEL_TEXT_FLAG); - titleTmpPaint = new TextPaint(titleTextPaint); - subtitleTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.SUBPIXEL_TEXT_FLAG); - subtitleTmpPaint = new TextPaint(subtitleTextPaint); - - collapsedBounds = new Rect(); - expandedBounds = new Rect(); - currentBounds = new RectF(); - } - - - public void setTextSizeInterpolator(TimeInterpolator interpolator) { - textSizeInterpolator = interpolator; - recalculate(); - } - - public void setPositionInterpolator(TimeInterpolator interpolator) { - positionInterpolator = interpolator; - recalculate(); - } - - public void setExpandedTitleTextSize(float textSize) { - if (expandedTitleTextSize != textSize) { - expandedTitleTextSize = textSize; - recalculate(); - } - } - - public void setCollapsedTitleTextSize(float textSize) { - if (collapsedTitleTextSize != textSize) { - collapsedTitleTextSize = textSize; - recalculate(); - } - } - - public void setExpandedSubtitleTextSize(float textSize) { - if (expandedSubtitleTextSize != textSize) { - expandedSubtitleTextSize = textSize; - recalculate(); - } - } - - public void setCollapsedSubtitleTextSize(float textSize) { - if (collapsedSubtitleTextSize != textSize) { - collapsedSubtitleTextSize = textSize; - recalculate(); - } - } - - public void setCollapsedTitleTextColor(ColorStateList textColor) { - if (collapsedTitleTextColor != textColor) { - collapsedTitleTextColor = textColor; - recalculate(); - } - } - - public void setExpandedTitleTextColor(ColorStateList textColor) { - if (expandedTitleTextColor != textColor) { - expandedTitleTextColor = textColor; - recalculate(); - } - } - - public void setCollapsedSubtitleTextColor(ColorStateList textColor) { - if (collapsedSubtitleTextColor != textColor) { - collapsedSubtitleTextColor = textColor; - recalculate(); - } - } - - public void setExpandedSubtitleTextColor(ColorStateList textColor) { - if (expandedSubtitleTextColor != textColor) { - expandedSubtitleTextColor = textColor; - recalculate(); - } - } - - public void setExpandedBounds(int left, int top, int right, int bottom) { - if (!rectEquals(expandedBounds, left, top, right, bottom)) { - expandedBounds.set(left, top, right, bottom); - boundsChanged = true; - onBoundsChanged(); - } - } - - public void setExpandedBounds(@NonNull Rect bounds) { - setExpandedBounds(bounds.left, bounds.top, bounds.right, bounds.bottom); - } - - public void setCollapsedBounds(int left, int top, int right, int bottom) { - if (!rectEquals(collapsedBounds, left, top, right, bottom)) { - collapsedBounds.set(left, top, right, bottom); - boundsChanged = true; - onBoundsChanged(); - } - } - - public void setCollapsedBounds(@NonNull Rect bounds) { - setCollapsedBounds(bounds.left, bounds.top, bounds.right, bounds.bottom); - } - - public void getCollapsedTitleTextActualBounds(@NonNull RectF bounds) { - boolean isRtl = calculateIsRtl(title); - - bounds.left = !isRtl ? collapsedBounds.left : collapsedBounds.right - calculateCollapsedTitleTextWidth(); - bounds.top = collapsedBounds.top; - bounds.right = !isRtl ? bounds.left + calculateCollapsedTitleTextWidth() : collapsedBounds.right; - bounds.bottom = collapsedBounds.top + getCollapsedTitleTextHeight(); - } - - public float calculateCollapsedTitleTextWidth() { - if (title == null) { - return 0; - } - getTitleTextPaintCollapsed(titleTmpPaint); - return titleTmpPaint.measureText(title, 0, title.length()); - } - - public void getCollapsedSubtitleTextActualBounds(@NonNull RectF bounds) { - boolean isRtl = calculateIsRtl(subtitle); - - bounds.left = !isRtl ? collapsedBounds.left : collapsedBounds.right - calculateCollapsedSubtitleTextWidth(); - bounds.top = collapsedBounds.top; - bounds.right = !isRtl ? bounds.left + calculateCollapsedSubtitleTextWidth() : collapsedBounds.right; - bounds.bottom = collapsedBounds.top + getCollapsedSubtitleTextHeight(); - } - - public float calculateCollapsedSubtitleTextWidth() { - if (subtitle == null) { - return 0; - } - getSubtitleTextPaintCollapsed(subtitleTmpPaint); - return subtitleTmpPaint.measureText(subtitle, 0, subtitle.length()); - } - - public float getExpandedTitleTextHeight() { - getTitleTextPaintExpanded(titleTmpPaint); - // Return expanded height measured from the baseline. - return -titleTmpPaint.ascent(); - } - - public float getCollapsedTitleTextHeight() { - getTitleTextPaintCollapsed(titleTmpPaint); - // Return collapsed height measured from the baseline. - return -titleTmpPaint.ascent(); - } - - public float getExpandedSubtitleTextHeight() { - getSubtitleTextPaintExpanded(subtitleTmpPaint); - // Return expanded height measured from the baseline. - return -subtitleTmpPaint.ascent(); - } - - public float getCollapsedSubtitleTextHeight() { - getSubtitleTextPaintCollapsed(subtitleTmpPaint); - // Return collapsed height measured from the baseline. - return -subtitleTmpPaint.ascent(); - } - - private void getTitleTextPaintExpanded(@NonNull TextPaint textPaint) { - textPaint.setTextSize(expandedTitleTextSize); - textPaint.setTypeface(expandedTitleTypeface); - } - - private void getTitleTextPaintCollapsed(@NonNull TextPaint textPaint) { - textPaint.setTextSize(collapsedTitleTextSize); - textPaint.setTypeface(collapsedTitleTypeface); - } - - private void getSubtitleTextPaintExpanded(@NonNull TextPaint textPaint) { - textPaint.setTextSize(expandedSubtitleTextSize); - textPaint.setTypeface(expandedSubtitleTypeface); - } - - private void getSubtitleTextPaintCollapsed(@NonNull TextPaint textPaint) { - textPaint.setTextSize(collapsedSubtitleTextSize); - textPaint.setTypeface(collapsedSubtitleTypeface); - } - - void onBoundsChanged() { - drawTitle = collapsedBounds.width() > 0 - && collapsedBounds.height() > 0 - && expandedBounds.width() > 0 - && expandedBounds.height() > 0; - } - - public void setExpandedTextGravity(int gravity) { - if (expandedTextGravity != gravity) { - expandedTextGravity = gravity; - recalculate(); - } - } - - public int getExpandedTextGravity() { - return expandedTextGravity; - } - - public void setCollapsedTextGravity(int gravity) { - if (collapsedTextGravity != gravity) { - collapsedTextGravity = gravity; - recalculate(); - } - } - - public int getCollapsedTextGravity() { - return collapsedTextGravity; - } - - public void setCollapsedTitleTextAppearance(int resId) { - TextAppearance textAppearance = new TextAppearance(view.getContext(), resId); - - if (textAppearance.getTextColor() != null) { - collapsedTitleTextColor = textAppearance.getTextColor(); - } - if (textAppearance.getTextSize() != 0) { - collapsedTitleTextSize = textAppearance.getTextSize(); - } - if (textAppearance.shadowColor != null) { - collapsedTitleShadowColor = textAppearance.shadowColor; - } - collapsedTitleShadowDx = textAppearance.shadowDx; - collapsedTitleShadowDy = textAppearance.shadowDy; - collapsedTitleShadowRadius = textAppearance.shadowRadius; - - // Cancel pending async fetch, if any, and replace with a new one. - if (collapsedTitleFontCallback != null) { - collapsedTitleFontCallback.cancel(); - } - collapsedTitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() { - @Override - public void apply(Typeface font) { - setCollapsedTitleTypeface(font); - } - }, textAppearance.getFallbackFont()); - textAppearance.getFontAsync(view.getContext(), collapsedTitleFontCallback); - - recalculate(); - } - - public void setExpandedTitleTextAppearance(int resId) { - TextAppearance textAppearance = new TextAppearance(view.getContext(), resId); - if (textAppearance.getTextColor() != null) { - expandedTitleTextColor = textAppearance.getTextColor(); - } - if (textAppearance.getTextSize() != 0) { - expandedTitleTextSize = textAppearance.getTextSize(); - } - if (textAppearance.shadowColor != null) { - expandedTitleShadowColor = textAppearance.shadowColor; - } - expandedTitleShadowDx = textAppearance.shadowDx; - expandedTitleShadowDy = textAppearance.shadowDy; - expandedTitleShadowRadius = textAppearance.shadowRadius; - - // Cancel pending async fetch, if any, and replace with a new one. - if (expandedTitleFontCallback != null) { - expandedTitleFontCallback.cancel(); - } - expandedTitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() { - @Override - public void apply(Typeface font) { - setExpandedTitleTypeface(font); - } - }, textAppearance.getFallbackFont()); - textAppearance.getFontAsync(view.getContext(), expandedTitleFontCallback); - - recalculate(); - } - - public void setCollapsedSubtitleTextAppearance(int resId) { - TextAppearance textAppearance = new TextAppearance(view.getContext(), resId); - - if (textAppearance.getTextColor() != null) { - collapsedSubtitleTextColor = textAppearance.getTextColor(); - } - if (textAppearance.getTextSize() != 0) { - collapsedSubtitleTextSize = textAppearance.getTextSize(); - } - if (textAppearance.shadowColor != null) { - collapsedSubtitleShadowColor = textAppearance.shadowColor; - } - collapsedSubtitleShadowDx = textAppearance.shadowDx; - collapsedSubtitleShadowDy = textAppearance.shadowDy; - collapsedSubtitleShadowRadius = textAppearance.shadowRadius; - - // Cancel pending async fetch, if any, and replace with a new one. - if (collapsedSubtitleFontCallback != null) { - collapsedSubtitleFontCallback.cancel(); - } - collapsedSubtitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() { - @Override - public void apply(Typeface font) { - setCollapsedSubtitleTypeface(font); - } - }, textAppearance.getFallbackFont()); - textAppearance.getFontAsync(view.getContext(), collapsedSubtitleFontCallback); - - recalculate(); - } - - public void setExpandedSubtitleTextAppearance(int resId) { - TextAppearance textAppearance = new TextAppearance(view.getContext(), resId); - if (textAppearance.getTextColor() != null) { - expandedSubtitleTextColor = textAppearance.getTextColor(); - } - if (textAppearance.getTextSize() != 0) { - expandedSubtitleTextSize = textAppearance.getTextSize(); - } - if (textAppearance.shadowColor != null) { - expandedSubtitleShadowColor = textAppearance.shadowColor; - } - expandedSubtitleShadowDx = textAppearance.shadowDx; - expandedSubtitleShadowDy = textAppearance.shadowDy; - expandedSubtitleShadowRadius = textAppearance.shadowRadius; - - // Cancel pending async fetch, if any, and replace with a new one. - if (expandedSubtitleFontCallback != null) { - expandedSubtitleFontCallback.cancel(); - } - expandedSubtitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() { - @Override - public void apply(Typeface font) { - if (font != null) setExpandedSubtitleTypeface(font); - } - }, null); - textAppearance.getFontAsync(view.getContext(), expandedSubtitleFontCallback); - - recalculate(); - } - - public void setCollapsedTitleTypeface(Typeface typeface) { - if (setCollapsedTitleTypefaceInternal(typeface)) { - recalculate(); - } - } - - public void setExpandedTitleTypeface(Typeface typeface) { - if (setExpandedTitleTypefaceInternal(typeface)) { - recalculate(); - } - } - - public void setCollapsedSubtitleTypeface(Typeface typeface) { - if (setCollapsedSubtitleTypefaceInternal(typeface)) { - recalculate(); - } - } - - public void setExpandedSubtitleTypeface(Typeface typeface) { - if (setExpandedSubtitleTypefaceInternal(typeface)) { - recalculate(); - } - } - - public void setTitleTypefaces(Typeface typeface) { - boolean collapsedFontChanged = setCollapsedTitleTypefaceInternal(typeface); - boolean expandedFontChanged = setExpandedTitleTypefaceInternal(typeface); - if (collapsedFontChanged || expandedFontChanged) { - recalculate(); - } - } - - public void setSubtitleTypefaces(Typeface typeface) { - boolean collapsedFontChanged = setCollapsedSubtitleTypefaceInternal(typeface); - boolean expandedFontChanged = setExpandedSubtitleTypefaceInternal(typeface); - if (collapsedFontChanged || expandedFontChanged) { - recalculate(); - } - } - - @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView - private boolean setCollapsedTitleTypefaceInternal(Typeface typeface) { - // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding - // already updated one when async op comes back after a while. - if (collapsedTitleFontCallback != null) { - collapsedTitleFontCallback.cancel(); - } - if (collapsedTitleTypeface != typeface) { - collapsedTitleTypeface = typeface; - return true; - } - return false; - } - - @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView - private boolean setExpandedTitleTypefaceInternal(Typeface typeface) { - // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding - // already updated one when async op comes back after a while. - if (expandedTitleFontCallback != null) { - expandedTitleFontCallback.cancel(); - } - if (expandedTitleTypeface != typeface) { - expandedTitleTypeface = typeface; - return true; - } - return false; - } - - @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView - private boolean setCollapsedSubtitleTypefaceInternal(Typeface typeface) { - // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding - // already updated one when async op comes back after a while. - if (collapsedSubtitleFontCallback != null) { - collapsedSubtitleFontCallback.cancel(); - } - if (collapsedSubtitleTypeface != typeface) { - collapsedSubtitleTypeface = typeface; - return true; - } - return false; - } - - @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView - private boolean setExpandedSubtitleTypefaceInternal(Typeface typeface) { - // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding - // already updated one when async op comes back after a while. - if (expandedSubtitleFontCallback != null) { - expandedSubtitleFontCallback.cancel(); - } - if (expandedSubtitleTypeface != typeface) { - expandedSubtitleTypeface = typeface; - return true; - } - return false; - } - - public Typeface getCollapsedTitleTypeface() { - return collapsedTitleTypeface != null ? collapsedTitleTypeface : Typeface.DEFAULT; - } - - public Typeface getExpandedTitleTypeface() { - return expandedTitleTypeface != null ? expandedTitleTypeface : Typeface.DEFAULT; - } - - public Typeface getCollapsedSubtitleTypeface() { - return collapsedSubtitleTypeface != null ? collapsedSubtitleTypeface : Typeface.DEFAULT; - } - - public Typeface getExpandedSubtitleTypeface() { - return expandedSubtitleTypeface != null ? expandedSubtitleTypeface : Typeface.DEFAULT; - } - - /** - * Set the value indicating the current scroll value. This decides how much of the background will - * be displayed, as well as the title metrics/positioning. - * - *

A value of {@code 0.0} indicates that the layout is fully expanded. A value of {@code 1.0} - * indicates that the layout is fully collapsed. - */ - public void setExpansionFraction(float fraction) { - fraction = MathUtils.clamp(fraction, 0f, 1f); - - if (fraction != expandedFraction) { - expandedFraction = fraction; - calculateCurrentOffsets(); - } - } - - public final boolean setState(final int[] state) { - this.state = state; - - if (isStateful()) { - recalculate(); - return true; - } - - return false; - } - - public final boolean isStateful() { - return (collapsedTitleTextColor != null && collapsedTitleTextColor.isStateful()) - || (expandedTitleTextColor != null && expandedTitleTextColor.isStateful()); - } - - public float getExpansionFraction() { - return expandedFraction; - } - - public float getCollapsedTitleTextSize() { - return collapsedTitleTextSize; - } - - public float getExpandedTitleTextSize() { - return expandedTitleTextSize; - } - - public float getCollapsedSubtitleTextSize() { - return collapsedSubtitleTextSize; - } - - public float getExpandedSubtitleTextSize() { - return expandedSubtitleTextSize; - } - - public void setRtlTextDirectionHeuristicsEnabled(boolean rtlTextDirectionHeuristicsEnabled) { - isRtlTextDirectionHeuristicsEnabled = rtlTextDirectionHeuristicsEnabled; - } - - public boolean isRtlTextDirectionHeuristicsEnabled() { - return isRtlTextDirectionHeuristicsEnabled; - } - - private void calculateCurrentOffsets() { - calculateOffsets(expandedFraction); - } - - private void calculateOffsets(final float fraction) { - interpolateBounds(fraction); - currentTitleDrawX = lerp(expandedTitleDrawX, collapsedTitleDrawX, fraction, positionInterpolator); - currentTitleDrawY = lerp(expandedTitleDrawY, collapsedTitleDrawY, fraction, positionInterpolator); - currentSubtitleDrawX = lerp(expandedSubtitleDrawX, collapsedSubtitleDrawX, fraction, positionInterpolator); - currentSubtitleDrawY = lerp(expandedSubtitleDrawY, collapsedSubtitleDrawY, fraction, positionInterpolator); - - setInterpolatedTitleTextSize(lerp(expandedTitleTextSize, collapsedTitleTextSize, fraction, textSizeInterpolator)); - setInterpolatedSubtitleTextSize(lerp(expandedSubtitleTextSize, collapsedSubtitleTextSize, fraction, textSizeInterpolator)); - - if (collapsedTitleTextColor != expandedTitleTextColor) { - // If the collapsed and expanded title colors are different, blend them based on the - // fraction - titleTextPaint.setColor(blendColors(getCurrentExpandedTitleTextColor(), getCurrentCollapsedTitleTextColor(), fraction)); - } else { - titleTextPaint.setColor(getCurrentCollapsedTitleTextColor()); - } - - titleTextPaint.setShadowLayer( - lerp(expandedTitleShadowRadius, collapsedTitleShadowRadius, fraction, null), - lerp(expandedTitleShadowDx, collapsedTitleShadowDx, fraction, null), - lerp(expandedTitleShadowDy, collapsedTitleShadowDy, fraction, null), - blendColors(getCurrentColor(expandedTitleShadowColor), getCurrentColor(collapsedTitleShadowColor), fraction)); - - if (collapsedSubtitleTextColor != expandedSubtitleTextColor) { - // If the collapsed and expanded title colors are different, blend them based on the - // fraction - subtitleTextPaint.setColor(blendColors(getCurrentExpandedSubtitleTextColor(), getCurrentCollapsedSubtitleTextColor(), fraction)); - } else { - subtitleTextPaint.setColor(getCurrentCollapsedSubtitleTextColor()); - } - - subtitleTextPaint.setShadowLayer( - lerp(expandedSubtitleShadowRadius, collapsedSubtitleShadowRadius, fraction, null), - lerp(expandedSubtitleShadowDx, collapsedSubtitleShadowDx, fraction, null), - lerp(expandedSubtitleShadowDy, collapsedSubtitleShadowDy, fraction, null), - blendColors(getCurrentColor(expandedSubtitleShadowColor), getCurrentColor(collapsedSubtitleShadowColor), fraction)); - - ViewCompat.postInvalidateOnAnimation(view); - } - - @ColorInt - private int getCurrentExpandedTitleTextColor() { - return getCurrentColor(expandedTitleTextColor); - } - - @ColorInt - private int getCurrentExpandedSubtitleTextColor() { - return getCurrentColor(expandedSubtitleTextColor); - } - - @ColorInt - public int getCurrentCollapsedTitleTextColor() { - return getCurrentColor(collapsedTitleTextColor); - } - - @ColorInt - public int getCurrentCollapsedSubtitleTextColor() { - return getCurrentColor(collapsedSubtitleTextColor); - } - - @ColorInt - private int getCurrentColor(@Nullable ColorStateList colorStateList) { - if (colorStateList == null) { - return 0; - } - if (state != null) { - return colorStateList.getColorForState(state, 0); - } - return colorStateList.getDefaultColor(); - } - - private void calculateBaseOffsets() { - final float currentTitleSize = this.currentTitleTextSize; - final float currentSubtitleSize = this.currentSubtitleTextSize; - final boolean isTitleOnly = TextUtils.isEmpty(subtitle); - - // We then calculate the collapsed title size, using the same logic - calculateUsingTitleTextSize(collapsedTitleTextSize); - calculateUsingSubtitleTextSize(collapsedSubtitleTextSize); - float titleWidth = titleToDraw != null ? titleTextPaint.measureText(titleToDraw, 0, titleToDraw.length()) : 0; - float subtitleWidth = subtitleToDraw != null ? subtitleTextPaint.measureText(subtitleToDraw, 0, subtitleToDraw.length()) : 0; - final int collapsedAbsGravity = - GravityCompat.getAbsoluteGravity( - collapsedTextGravity, - isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); - - // reusable dimension - float titleHeight = titleTextPaint.descent() - titleTextPaint.ascent(); - float titleOffset = titleHeight / 2 - titleTextPaint.descent(); - float subtitleHeight = subtitleTextPaint.descent() - subtitleTextPaint.ascent(); - float subtitleOffset = subtitleHeight / 2 - subtitleTextPaint.descent(); - - if (isTitleOnly) { - switch (collapsedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { - case Gravity.BOTTOM: - collapsedTitleDrawY = collapsedBounds.bottom; - break; - case Gravity.TOP: - collapsedTitleDrawY = collapsedBounds.top - titleTextPaint.ascent(); - break; - case Gravity.CENTER_VERTICAL: - default: - float textHeight = titleTextPaint.descent() - titleTextPaint.ascent(); - float textOffset = (textHeight / 2) - titleTextPaint.descent(); - collapsedTitleDrawY = collapsedBounds.centerY() + textOffset; - break; - } - } else { - final float offset = (collapsedBounds.height() - (titleHeight + subtitleHeight)) / 3; - collapsedTitleDrawY = collapsedBounds.top + offset - titleTextPaint.ascent(); - collapsedSubtitleDrawY = collapsedBounds.top + offset * 2 + titleHeight - subtitleTextPaint.ascent(); - } - switch (collapsedAbsGravity & GravityCompat.RELATIVE_HORIZONTAL_GRAVITY_MASK) { - case Gravity.CENTER_HORIZONTAL: - collapsedTitleDrawX = collapsedBounds.centerX() - (titleWidth / 2); - collapsedSubtitleDrawX = collapsedBounds.centerX() - (subtitleWidth / 2); - break; - case Gravity.RIGHT: - collapsedTitleDrawX = collapsedBounds.right - titleWidth; - collapsedSubtitleDrawX = collapsedBounds.right - subtitleWidth; - break; - case Gravity.LEFT: - default: - collapsedTitleDrawX = collapsedBounds.left; - collapsedSubtitleDrawX = collapsedBounds.left; - break; - } - - calculateUsingTitleTextSize(expandedTitleTextSize); - calculateUsingSubtitleTextSize(expandedSubtitleTextSize); - titleWidth = titleToDraw != null ? titleTextPaint.measureText(titleToDraw, 0, titleToDraw.length()) : 0; - subtitleWidth = subtitleToDraw != null ? subtitleTextPaint.measureText(subtitleToDraw, 0, subtitleToDraw.length()) : 0; - - // dimension modification - titleHeight = titleTextPaint.descent() - titleTextPaint.ascent(); - titleOffset = titleHeight / 2 - titleTextPaint.descent(); - subtitleHeight = subtitleTextPaint.descent() - subtitleTextPaint.ascent(); - subtitleOffset = subtitleHeight / 2 - subtitleTextPaint.descent(); - - final int expandedAbsGravity = GravityCompat.getAbsoluteGravity( - expandedTextGravity, - isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR - ); - if (isTitleOnly) { - switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { - case Gravity.BOTTOM: - expandedTitleDrawY = expandedBounds.bottom; - break; - case Gravity.TOP: - expandedTitleDrawY = expandedBounds.top - titleTextPaint.ascent(); - break; - case Gravity.CENTER_VERTICAL: - default: - float textHeight = titleTextPaint.descent() - titleTextPaint.ascent(); - float textOffset = (textHeight / 2) - titleTextPaint.descent(); - expandedTitleDrawY = expandedBounds.centerY() + textOffset; - break; - } - } else { - switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { - case Gravity.BOTTOM: - expandedTitleDrawY = expandedBounds.bottom - subtitleHeight - titleOffset; - expandedSubtitleDrawY = expandedBounds.bottom; - break; - case Gravity.TOP: - expandedTitleDrawY = expandedBounds.top - titleTextPaint.ascent(); - expandedSubtitleDrawY = expandedTitleDrawY + subtitleHeight + titleOffset; - break; - case Gravity.CENTER_VERTICAL: - default: - expandedTitleDrawY = expandedBounds.centerY() + titleOffset; - expandedSubtitleDrawY = expandedTitleDrawY + subtitleHeight + titleOffset; - break; - } - } - switch (expandedAbsGravity & GravityCompat.RELATIVE_HORIZONTAL_GRAVITY_MASK) { - case Gravity.CENTER_HORIZONTAL: - expandedTitleDrawX = expandedBounds.centerX() - (titleWidth / 2); - expandedSubtitleDrawX = expandedBounds.centerX() - (subtitleWidth / 2); - break; - case Gravity.RIGHT: - expandedTitleDrawX = expandedBounds.right - titleWidth; - expandedSubtitleDrawX = expandedBounds.right - subtitleWidth; - break; - case Gravity.LEFT: - default: - expandedTitleDrawX = expandedBounds.left; - expandedSubtitleDrawX = expandedBounds.left; - break; - } - - // The bounds have changed so we need to clear the texture - clearTexture(); - // Now reset the title size back to the original - setInterpolatedTitleTextSize(currentTitleSize); - setInterpolatedSubtitleTextSize(currentSubtitleSize); - } - - private void interpolateBounds(float fraction) { - currentBounds.left = lerp(expandedBounds.left, collapsedBounds.left, fraction, positionInterpolator); - currentBounds.top = lerp(expandedTitleDrawY, collapsedTitleDrawY, fraction, positionInterpolator); - currentBounds.right = lerp(expandedBounds.right, collapsedBounds.right, fraction, positionInterpolator); - currentBounds.bottom = lerp(expandedBounds.bottom, collapsedBounds.bottom, fraction, positionInterpolator); - } - - public void draw(@NonNull Canvas canvas) { - final int saveCount = canvas.save(); - - if (drawTitle && titleToDraw != null) { - float titleX = currentTitleDrawX; - float titleY = currentTitleDrawY; - float subtitleX = currentSubtitleDrawX; - float subtitleY = currentSubtitleDrawY; - - final boolean drawTitleTexture = useTexture && expandedTitleTexture != null; - final boolean drawSubtitleTexture = useTexture && expandedSubtitleTexture != null; - - final float titleAscent; - final float titleDescent; - if (drawTitleTexture) { - titleAscent = titleTextureAscent * titleScale; - titleDescent = titleTextureDescent * titleScale; - } else { - titleAscent = titleTextPaint.ascent() * titleScale; - titleDescent = titleTextPaint.descent() * titleScale; - } - - if (DEBUG_DRAW) { - // Just a debug tool, which drawn a magenta rect in the text bounds - canvas.drawRect(currentBounds.left, titleY + titleAscent, currentBounds.right, titleY + titleDescent, DEBUG_DRAW_PAINT); - } - - if (drawTitleTexture) { - titleY += titleAscent; - } - - // additional canvas save for subtitle - if (subtitleToDraw != null) { - final int subtitleSaveCount = canvas.save(); - - if (subtitleScale != 1f) { - canvas.scale(subtitleScale, subtitleScale, subtitleX, subtitleY); - } - - if (drawSubtitleTexture) { - // If we should use a texture, draw it instead of title - canvas.drawBitmap(expandedSubtitleTexture, subtitleX, subtitleY, subtitleTexturePaint); - } else { - canvas.drawText(subtitleToDraw, 0, subtitleToDraw.length(), subtitleX, subtitleY, subtitleTextPaint); - } - canvas.restoreToCount(subtitleSaveCount); - } - - if (titleScale != 1f) { - canvas.scale(titleScale, titleScale, titleX, titleY); - } - - if (drawTitleTexture) { - // If we should use a texture, draw it instead of text - canvas.drawBitmap(expandedTitleTexture, titleX, titleY, titleTexturePaint); - } else { - canvas.drawText(titleToDraw, 0, titleToDraw.length(), titleX, titleY, titleTextPaint); - } - } - - canvas.restoreToCount(saveCount); - } - - private boolean calculateIsRtl(@NonNull CharSequence text) { - final boolean defaultIsRtl = isDefaultIsRtl(); - return isRtlTextDirectionHeuristicsEnabled - ? isTextDirectionHeuristicsIsRtl(text, defaultIsRtl) - : defaultIsRtl; - } - - private boolean isDefaultIsRtl() { - return ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL; - } - - private boolean isTextDirectionHeuristicsIsRtl(@NonNull CharSequence text, boolean defaultIsRtl) { - return (defaultIsRtl - ? TextDirectionHeuristicsCompat.FIRSTSTRONG_RTL - : TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR) - .isRtl(text, 0, text.length()); - } - - private void setInterpolatedTitleTextSize(float textSize) { - calculateUsingTitleTextSize(textSize); - - // Use our texture if the scale isn't 1.0 - useTexture = USE_SCALING_TEXTURE && titleScale != 1f; - - if (useTexture) { - // Make sure we have an expanded texture if needed - ensureExpandedTitleTexture(); - } - - ViewCompat.postInvalidateOnAnimation(view); - } - - private void setInterpolatedSubtitleTextSize(float textSize) { - calculateUsingSubtitleTextSize(textSize); - - // Use our texture if the scale isn't 1.0 - useTexture = USE_SCALING_TEXTURE && subtitleScale != 1f; - - if (useTexture) { - // Make sure we have an expanded texture if needed - ensureExpandedSubtitleTexture(); - } - - ViewCompat.postInvalidateOnAnimation(view); - } - - @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView - private void calculateUsingTitleTextSize(final float size) { - if (title == null) { - return; - } - - final float collapsedWidth = collapsedBounds.width(); - final float expandedWidth = expandedBounds.width(); - - final float availableWidth; - final float newTextSize; - boolean updateDrawText = false; - - if (isClose(size, collapsedTitleTextSize)) { - newTextSize = collapsedTitleTextSize; - titleScale = 1f; - if (currentTitleTypeface != collapsedTitleTypeface) { - currentTitleTypeface = collapsedTitleTypeface; - updateDrawText = true; - } - availableWidth = collapsedWidth; - } else { - newTextSize = expandedTitleTextSize; - if (currentTitleTypeface != expandedTitleTypeface) { - currentTitleTypeface = expandedTitleTypeface; - updateDrawText = true; - } - if (isClose(size, expandedTitleTextSize)) { - // If we're close to the expanded title size, snap to it and use a scale of 1 - titleScale = 1f; - } else { - // Else, we'll scale down from the expanded title size - titleScale = size / expandedTitleTextSize; - } - - final float textSizeRatio = collapsedTitleTextSize / expandedTitleTextSize; - // This is the size of the expanded bounds when it is scaled to match the - // collapsed title size - final float scaledDownWidth = expandedWidth * textSizeRatio; - - if (scaledDownWidth > collapsedWidth) { - // If the scaled down size is larger than the actual collapsed width, we need to - // cap the available width so that when the expanded title scales down, it matches - // the collapsed width - availableWidth = Math.min(collapsedWidth / textSizeRatio, expandedWidth); - } else { - // Otherwise we'll just use the expanded width - availableWidth = expandedWidth; - } - } - - if (availableWidth > 0) { - updateDrawText = (currentTitleTextSize != newTextSize) || boundsChanged || updateDrawText; - currentTitleTextSize = newTextSize; - boundsChanged = false; - } - - if (titleToDraw == null || updateDrawText) { - titleTextPaint.setTextSize(currentTitleTextSize); - titleTextPaint.setTypeface(currentTitleTypeface); - // Use linear title scaling if we're scaling the canvas - titleTextPaint.setLinearText(titleScale != 1f); - - // If we don't currently have title to draw, or the title size has changed, ellipsize... - final CharSequence text = - TextUtils - .ellipsize(this.title, titleTextPaint, availableWidth, TextUtils.TruncateAt.END); - if (!TextUtils.equals(text, titleToDraw)) { - titleToDraw = text; - isRtl = calculateIsRtl(titleToDraw); - } - } - } - - @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView - private void calculateUsingSubtitleTextSize(final float size) { - if (subtitle == null) { - return; - } - - final float collapsedWidth = collapsedBounds.width(); - final float expandedWidth = expandedBounds.width(); - - final float availableWidth; - final float newTextSize; - boolean updateDrawText = false; - - if (isClose(size, collapsedSubtitleTextSize)) { - newTextSize = collapsedSubtitleTextSize; - subtitleScale = 1f; - if (currentSubtitleTypeface != collapsedSubtitleTypeface) { - currentSubtitleTypeface = collapsedSubtitleTypeface; - updateDrawText = true; - } - availableWidth = collapsedWidth; - } else { - newTextSize = expandedSubtitleTextSize; - if (currentSubtitleTypeface != expandedSubtitleTypeface) { - currentSubtitleTypeface = expandedSubtitleTypeface; - updateDrawText = true; - } - if (isClose(size, expandedSubtitleTextSize)) { - // If we're close to the expanded title size, snap to it and use a scale of 1 - subtitleScale = 1f; - } else { - // Else, we'll scale down from the expanded title size - subtitleScale = size / expandedSubtitleTextSize; - } - - final float textSizeRatio = collapsedSubtitleTextSize / expandedSubtitleTextSize; - // This is the size of the expanded bounds when it is scaled to match the - // collapsed title size - final float scaledDownWidth = expandedWidth * textSizeRatio; - - if (scaledDownWidth > collapsedWidth) { - // If the scaled down size is larger than the actual collapsed width, we need to - // cap the available width so that when the expanded title scales down, it matches - // the collapsed width - availableWidth = Math.min(collapsedWidth / textSizeRatio, expandedWidth); - } else { - // Otherwise we'll just use the expanded width - availableWidth = expandedWidth; - } - } - - if (availableWidth > 0) { - updateDrawText = (currentSubtitleTextSize != newTextSize) || boundsChanged || updateDrawText; - currentSubtitleTextSize = newTextSize; - boundsChanged = false; - } - - if (subtitleToDraw == null || updateDrawText) { - subtitleTextPaint.setTextSize(currentSubtitleTextSize); - subtitleTextPaint.setTypeface(currentSubtitleTypeface); - // Use linear title scaling if we're scaling the canvas - subtitleTextPaint.setLinearText(subtitleScale != 1f); - - // If we don't currently have title to draw, or the title size has changed, ellipsize... - final CharSequence text = - TextUtils.ellipsize(this.subtitle, subtitleTextPaint, availableWidth, TextUtils.TruncateAt.END); - if (!TextUtils.equals(text, subtitleToDraw)) { - subtitleToDraw = text; - isRtl = calculateIsRtl(subtitleToDraw); - } - } - } - - private void ensureExpandedTitleTexture() { - if (expandedTitleTexture != null || expandedBounds.isEmpty() || TextUtils.isEmpty(titleToDraw)) { - return; - } - - calculateOffsets(0f); - titleTextureAscent = titleTextPaint.ascent(); - titleTextureDescent = titleTextPaint.descent(); - - final int w = Math.round(titleTextPaint.measureText(titleToDraw, 0, titleToDraw.length())); - final int h = Math.round(titleTextureDescent - titleTextureAscent); - - if (w <= 0 || h <= 0) { - return; // If the width or height are 0, return - } - - expandedTitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - - Canvas c = new Canvas(expandedTitleTexture); - c.drawText(titleToDraw, 0, titleToDraw.length(), 0, h - titleTextPaint.descent(), titleTextPaint); - - if (titleTexturePaint == null) { - // Make sure we have a paint - titleTexturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); - } - } - - private void ensureExpandedSubtitleTexture() { - if (expandedSubtitleTexture != null || expandedBounds.isEmpty() || TextUtils.isEmpty(subtitleToDraw)) { - return; - } - - calculateOffsets(0f); - subtitleTextureAscent = subtitleTextPaint.ascent(); - subtitleTextureDescent = subtitleTextPaint.descent(); - - final int w = Math.round(subtitleTextPaint.measureText(subtitleToDraw, 0, subtitleToDraw.length())); - final int h = Math.round(subtitleTextureDescent - subtitleTextureAscent); - - if (w <= 0 || h <= 0) { - return; // If the width or height are 0, return - } - - expandedSubtitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - - Canvas c = new Canvas(expandedSubtitleTexture); - c.drawText(subtitleToDraw, 0, subtitleToDraw.length(), 0, h - subtitleTextPaint.descent(), subtitleTextPaint); - - if (subtitleTexturePaint == null) { - // Make sure we have a paint - subtitleTexturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); - } - } - - public void recalculate() { - if (view.getHeight() > 0 && view.getWidth() > 0) { - // If we've already been laid out, calculate everything now otherwise we'll wait - // until a layout - calculateBaseOffsets(); - calculateCurrentOffsets(); - } - } - - /** - * Set the title to display - * - * @param title - */ - public void setTitle(@Nullable CharSequence title) { - if (title == null || !title.equals(this.title)) { - this.title = title; - titleToDraw = null; - clearTexture(); - recalculate(); - } - } - - @Nullable - public CharSequence getTitle() { - return title; - } - - /** - * Set the subtitle to display - * - * @param subtitle - */ - public void setSubtitle(@Nullable CharSequence subtitle) { - if (subtitle == null || !subtitle.equals(this.subtitle)) { - this.subtitle = subtitle; - subtitleToDraw = null; - clearTexture(); - recalculate(); - } - } - - @Nullable - public CharSequence getSubtitle() { - return subtitle; - } - - private void clearTexture() { - if (expandedTitleTexture != null) { - expandedTitleTexture.recycle(); - expandedTitleTexture = null; - } - if (expandedSubtitleTexture != null) { - expandedSubtitleTexture.recycle(); - expandedSubtitleTexture = null; - } - } - - /** - * Returns true if {@code value} is 'close' to it's closest decimal value. Close is currently - * defined as it's difference being < 0.001. - */ - private static boolean isClose(float value, float targetValue) { - return Math.abs(value - targetValue) < 0.001f; - } - - public ColorStateList getExpandedTitleTextColor() { - return expandedTitleTextColor; - } - - public ColorStateList getExpandedSubtitleTextColor() { - return expandedSubtitleTextColor; - } - - public ColorStateList getCollapsedTitleTextColor() { - return collapsedTitleTextColor; - } - - public ColorStateList getCollapsedSubtitleTextColor() { - return collapsedSubtitleTextColor; - } - - /** - * Blend {@code color1} and {@code color2} using the given ratio. - * - * @param ratio of which to blend. 0.0 will return {@code color1}, 0.5 will give an even blend, - * 1.0 will return {@code color2}. - */ - private static int blendColors(int color1, int color2, float ratio) { - final float inverseRatio = 1f - ratio; - float a = (Color.alpha(color1) * inverseRatio) + (Color.alpha(color2) * ratio); - float r = (Color.red(color1) * inverseRatio) + (Color.red(color2) * ratio); - float g = (Color.green(color1) * inverseRatio) + (Color.green(color2) * ratio); - float b = (Color.blue(color1) * inverseRatio) + (Color.blue(color2) * ratio); - return Color.argb((int) a, (int) r, (int) g, (int) b); - } - - private static float lerp( - float startValue, float endValue, float fraction, @Nullable TimeInterpolator interpolator) { - if (interpolator != null) { - fraction = interpolator.getInterpolation(fraction); - } - return AnimationUtils.lerp(startValue, endValue, fraction); - } - - private static boolean rectEquals(@NonNull Rect r, int left, int top, int right, int bottom) { - return !(r.left != left || r.top != top || r.right != right || r.bottom != bottom); - } -} diff --git a/app/src/main/java/org/lsposed/manager/App.java b/app/src/main/java/org/lsposed/manager/App.java deleted file mode 100644 index 1e71a99fc..000000000 --- a/app/src/main/java/org/lsposed/manager/App.java +++ /dev/null @@ -1,278 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager; - -import android.app.ActivityManager; -import android.app.Application; -import android.content.BroadcastReceiver; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.os.Build; -import android.os.Environment; -import android.os.Handler; -import android.os.Looper; -import android.os.Process; -import android.provider.MediaStore; -import android.provider.Settings; -import android.system.Os; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.preference.PreferenceManager; - -import org.lsposed.hiddenapibypass.HiddenApiBypass; -import org.lsposed.manager.adapters.AppHelper; -import org.lsposed.manager.receivers.LSPManagerServiceHolder; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.util.CloudflareDNS; -import org.lsposed.manager.util.ModuleUtil; -import org.lsposed.manager.util.ThemeUtil; -import org.lsposed.manager.util.UpdateUtil; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.nio.charset.StandardCharsets; -import java.time.OffsetDateTime; -import java.util.HashMap; -import java.util.Locale; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.FutureTask; - -import okhttp3.Cache; -import okhttp3.OkHttpClient; -import okhttp3.logging.HttpLoggingInterceptor; -import rikka.core.os.FileUtils; -import rikka.material.app.LocaleDelegate; - -public class App extends Application { - public static final int PER_USER_RANGE = 100000; - public static final FutureTask HTML_TEMPLATE = new FutureTask<>(() -> readWebviewHTML("template.html")); - public static final FutureTask HTML_TEMPLATE_DARK = new FutureTask<>(() -> readWebviewHTML("template_dark.html")); - - private static String readWebviewHTML(String name) { - try { - var input = App.getInstance().getAssets().open("webview/" + name); - var result = new ByteArrayOutputStream(1024); - FileUtils.copy(input, result); - return result.toString(StandardCharsets.UTF_8.name()); - } catch (IOException e) { - Log.e(App.TAG, "read webview HTML", e); - return "@body@"; - } - } - - static { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - HiddenApiBypass.addHiddenApiExemptions(""); - } - Looper.myQueue().addIdleHandler(() -> { - if (App.getInstance() == null || App.getExecutorService() == null) return true; - App.getExecutorService().submit(() -> { - var list = AppHelper.getAppList(false); - var pm = App.getInstance().getPackageManager(); - list.parallelStream().forEach(i -> AppHelper.getAppLabel(i, pm)); - ModuleUtil.getInstance(); - RepoLoader.getInstance(); - }); - App.getExecutorService().submit(HTML_TEMPLATE); - App.getExecutorService().submit(HTML_TEMPLATE_DARK); - return false; - }); - } - - public static final String TAG = "LSPosedManager"; - private static final String ACTION_USER_ADDED = "android.intent.action.USER_ADDED"; - private static final String ACTION_USER_REMOVED = "android.intent.action.USER_REMOVED"; - private static final String ACTION_USER_INFO_CHANGED = "android.intent.action.USER_INFO_CHANGED"; - private static final String EXTRA_REMOVED_FOR_ALL_USERS = "android.intent.extra.REMOVED_FOR_ALL_USERS"; - private static App instance = null; - private static OkHttpClient okHttpClient; - private static Cache okHttpCache; - private SharedPreferences pref; - private static final ExecutorService executorService = Executors.newCachedThreadPool(); - private static final Handler MainHandler = new Handler(Looper.getMainLooper()); - - public static App getInstance() { - return instance; - } - - public static SharedPreferences getPreferences() { - return instance.pref; - } - - public static ExecutorService getExecutorService() { - return executorService; - } - - public static final boolean isParasitic = !Process.isApplicationUid(Process.myUid()); - - public static Handler getMainHandler() { - return MainHandler; - } - - @Override - protected void attachBaseContext(Context base) { - super.attachBaseContext(base); - var map = new HashMap(1); - map.put("isParasitic", String.valueOf(isParasitic)); - var am = getSystemService(ActivityManager.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - map.clear(); - var reasons = am.getHistoricalProcessExitReasons(null, 0, 1); - if (reasons.size() == 1) { - map.put("description", reasons.get(0).getDescription()); - map.put("importance", String.valueOf(reasons.get(0).getImportance())); - map.put("process", reasons.get(0).getProcessName()); - map.put("reason", String.valueOf(reasons.get(0).getReason())); - map.put("status", String.valueOf(reasons.get(0).getStatus())); - } - } - } - - private void setCrashReport() { - var handler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { - var time = OffsetDateTime.now(); - var dir = new File(getCacheDir(), "crash"); - //noinspection ResultOfMethodCallIgnored - dir.mkdir(); - var file = new File(dir, time.toEpochSecond() + ".log"); - try (var pw = new PrintWriter(file)) { - pw.println(BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")"); - pw.println(time); - pw.println("pid: " + Os.getpid() + " uid: " + Os.getuid()); - throwable.printStackTrace(pw); - } catch (IOException ignored) { - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var table = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); - var values = new ContentValues(); - values.put(MediaStore.Downloads.DISPLAY_NAME, "LSPosed_crash_report" + time.toEpochSecond() + ".zip"); - values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOCUMENTS); - var cr = getContentResolver(); - var uri = cr.insert(table, values); - if (uri == null) return; - try (var zipFd = cr.openFileDescriptor(uri, "wt")) { - LSPManagerServiceHolder.getService().getLogs(zipFd); - } catch (Exception ignored) { - cr.delete(uri, null, null); - } - } - if (handler != null) { - handler.uncaughtException(thread, throwable); - } - }); - } - - @Override - public void onCreate() { - super.onCreate(); - instance = this; - - setCrashReport(); - pref = PreferenceManager.getDefaultSharedPreferences(this); - if (!pref.contains("doh")) { - var name = "private_dns_mode"; - if ("hostname".equals(Settings.Global.getString(getContentResolver(), name))) { - pref.edit().putBoolean("doh", false).apply(); - } else { - pref.edit().putBoolean("doh", true).apply(); - } - } - AppCompatDelegate.setDefaultNightMode(ThemeUtil.getDarkTheme()); - LocaleDelegate.setDefaultLocale(getLocale()); - var res = getResources(); - var config = res.getConfiguration(); - config.setLocale(LocaleDelegate.getDefaultLocale()); - //noinspection deprecation - res.updateConfiguration(config, res.getDisplayMetrics()); - - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction("org.lsposed.manager.NOTIFICATION"); - registerReceiver(new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent inIntent) { - var intent = (Intent) inIntent.getParcelableExtra(Intent.EXTRA_INTENT); - Log.d(TAG, "onReceive: " + intent); - switch (intent.getAction()) { - case Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_CHANGED, Intent.ACTION_PACKAGE_FULLY_REMOVED, Intent.ACTION_UID_REMOVED -> { - var userId = intent.getIntExtra(Intent.EXTRA_USER, 0); - var packageName = intent.getStringExtra("android.intent.extra.PACKAGES"); - var packageRemovedForAllUsers = intent.getBooleanExtra(EXTRA_REMOVED_FOR_ALL_USERS, false); - var isXposedModule = intent.getBooleanExtra("isXposedModule", false); - if (packageName != null) { - if (isXposedModule) - ModuleUtil.getInstance().reloadSingleModule(packageName, userId, packageRemovedForAllUsers); - else - App.getExecutorService().submit(() -> AppHelper.getAppList(true)); - } - } - case ACTION_USER_ADDED, ACTION_USER_REMOVED, ACTION_USER_INFO_CHANGED -> App.getExecutorService().submit(() -> ModuleUtil.getInstance().reloadInstalledModules()); - } - } - }, intentFilter, Context.RECEIVER_NOT_EXPORTED); - - UpdateUtil.loadRemoteVersion(); - } - - @NonNull - public static OkHttpClient getOkHttpClient() { - if (okHttpClient != null) return okHttpClient; - var builder = new OkHttpClient.Builder() - .cache(getOkHttpCache()) - .dns(new CloudflareDNS()); - if (BuildConfig.DEBUG) { - var log = new HttpLoggingInterceptor(); - log.setLevel(HttpLoggingInterceptor.Level.HEADERS); - builder.addInterceptor(log); - } - okHttpClient = builder.build(); - return okHttpClient; - } - - @NonNull - public static Cache getOkHttpCache() { - if (okHttpCache != null) return okHttpCache; - long size50MiB = 50 * 1024 * 1024; - okHttpCache = new Cache(new File(instance.getCacheDir(), "http_cache"), size50MiB); - return okHttpCache; - } - - public static Locale getLocale(String tag) { - if (TextUtils.isEmpty(tag) || "SYSTEM".equals(tag)) { - return LocaleDelegate.getSystemLocale(); - } - return Locale.forLanguageTag(tag); - } - - public static Locale getLocale() { - String tag = getPreferences().getString("language", null); - return getLocale(tag); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java deleted file mode 100644 index 3dbc9e8d2..000000000 --- a/app/src/main/java/org/lsposed/manager/ConfigManager.java +++ /dev/null @@ -1,347 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager; - -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.os.ParcelFileDescriptor; -import android.os.RemoteException; -import android.util.Log; - -import org.lsposed.lspd.ILSPManagerService; -import org.lsposed.lspd.models.Application; -import org.lsposed.lspd.models.UserInfo; -import org.lsposed.manager.adapters.ScopeAdapter; -import org.lsposed.manager.receivers.LSPManagerServiceHolder; - -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Set; - -public class ConfigManager { - - public static boolean isBinderAlive() { - return LSPManagerServiceHolder.getService() != null; - } - - public static int getXposedApiVersion() { - try { - return LSPManagerServiceHolder.getService().getXposedApiVersion(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return -1; - } - } - - public static String getXposedVersionName() { - try { - return LSPManagerServiceHolder.getService().getXposedVersionName(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return ""; - } - } - - public static long getXposedVersionCode() { - try { - return LSPManagerServiceHolder.getService().getXposedVersionCode(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return -1; - } - } - - public static List getInstalledPackagesFromAllUsers(int flags, boolean filterNoProcess) { - List list = new ArrayList<>(); - try { - list.addAll(LSPManagerServiceHolder.getService().getInstalledPackagesFromAllUsers(flags, filterNoProcess).getList()); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - } - return list; - } - - public static String[] getEnabledModules() { - try { - return LSPManagerServiceHolder.getService().enabledModules(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return new String[0]; - } - } - - public static boolean setModuleEnabled(String packageName, boolean enable) { - try { - return enable ? LSPManagerServiceHolder.getService().enableModule(packageName) : LSPManagerServiceHolder.getService().disableModule(packageName); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean setModuleScope(String packageName, boolean legacy, Set applications) { - try { - List list = new ArrayList<>(); - applications.forEach(application -> { - Application app = new Application(); - app.userId = application.userId; - app.packageName = application.packageName; - list.add(app); - }); - if (legacy) { - Application app = new Application(); - app.userId = 0; - app.packageName = packageName; - list.add(app); - } - return LSPManagerServiceHolder.getService().setModuleScope(packageName, list); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static List getModuleScope(String packageName) { - List list = new ArrayList<>(); - try { - var applications = LSPManagerServiceHolder.getService().getModuleScope(packageName); - if (applications == null) { - return list; - } - applications.forEach(application -> { - if (!application.packageName.equals(packageName)) { - list.add(new ScopeAdapter.ApplicationWithEquals(application)); - } - }); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - } - return list; - } - - public static boolean enableStatusNotification() { - try { - return LSPManagerServiceHolder.getService().enableStatusNotification(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean setEnableStatusNotification(boolean enabled) { - try { - LSPManagerServiceHolder.getService().setEnableStatusNotification(enabled); - return true; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean isVerboseLogEnabled() { - try { - return LSPManagerServiceHolder.getService().isVerboseLog(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean setVerboseLogEnabled(boolean enabled) { - try { - LSPManagerServiceHolder.getService().setVerboseLog(enabled); - return true; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static ParcelFileDescriptor getLog(boolean verbose) { - try { - return verbose ? LSPManagerServiceHolder.getService().getVerboseLog() : LSPManagerServiceHolder.getService().getModulesLog(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return null; - } - } - - public static boolean clearLogs(boolean verbose) { - try { - return LSPManagerServiceHolder.getService().clearLogs(verbose); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static PackageInfo getPackageInfo(String packageName, int flags, int userId) throws PackageManager.NameNotFoundException { - try { - var info = LSPManagerServiceHolder.getService().getPackageInfo(packageName, flags, userId); - if (info == null) throw new PackageManager.NameNotFoundException(); - return info; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - throw new PackageManager.NameNotFoundException(); - } - } - - public static boolean forceStopPackage(String packageName, int userId) { - try { - LSPManagerServiceHolder.getService().forceStopPackage(packageName, userId); - return true; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean reboot() { - try { - LSPManagerServiceHolder.getService().reboot(); - return true; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean uninstallPackage(String packageName, int userId) { - try { - return LSPManagerServiceHolder.getService().uninstallPackage(packageName, userId); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean isSepolicyLoaded() { - try { - return LSPManagerServiceHolder.getService().isSepolicyLoaded(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static List getUsers() { - try { - return LSPManagerServiceHolder.getService().getUsers(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return null; - } - } - - public static boolean installExistingPackageAsUser(String packageName, int userId) { - final int INSTALL_SUCCEEDED = 1; - try { - var ret = LSPManagerServiceHolder.getService().installExistingPackageAsUser(packageName, userId); - return ret == INSTALL_SUCCEEDED; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean isMagiskInstalled() { - var path = System.getenv("PATH"); - if (path == null) return false; - else return Arrays.stream(path.split(File.pathSeparator)) - .anyMatch(str -> new File(str, "magisk").exists()); - } - - public static boolean systemServerRequested() { - try { - return LSPManagerServiceHolder.getService().systemServerRequested(); - } catch (RemoteException e) { - return false; - } - } - - public static boolean dex2oatFlagsLoaded() { - try { - return LSPManagerServiceHolder.getService().dex2oatFlagsLoaded(); - } catch (RemoteException e) { - return false; - } - } - - public static int startActivityAsUserWithFeature(Intent intent, int userId) { - try { - return LSPManagerServiceHolder.getService().startActivityAsUserWithFeature(intent, userId); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return -1; - } - } - - public static List queryIntentActivitiesAsUser(Intent intent, int flags, int userId) { - List list = new ArrayList<>(); - try { - list.addAll(LSPManagerServiceHolder.getService().queryIntentActivitiesAsUser(intent, flags, userId).getList()); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - } - return list; - } - - public static boolean setHiddenIcon(boolean hide) { - try { - LSPManagerServiceHolder.getService().setHiddenIcon(hide); - return true; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static int getDex2OatWrapperCompatibility() { - try { - return LSPManagerServiceHolder.getService().getDex2OatWrapperCompatibility(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return ILSPManagerService.DEX2OAT_CRASHED; - } - } - - public static boolean getAutoInclude(String packageName) { - try { - return LSPManagerServiceHolder.getService().getAutoInclude(packageName); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } - - public static boolean setAutoInclude(String packageName, boolean enable) { - try { - LSPManagerServiceHolder.getService().setAutoInclude(packageName, enable); - return true; - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return false; - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/Constants.java b/app/src/main/java/org/lsposed/manager/Constants.java deleted file mode 100644 index 7fd5817a3..000000000 --- a/app/src/main/java/org/lsposed/manager/Constants.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager; - -import android.os.IBinder; - -import org.lsposed.manager.receivers.LSPManagerServiceHolder; - -public class Constants { - public static boolean setBinder(IBinder binder) { - LSPManagerServiceHolder.init(binder); - return LSPManagerServiceHolder.getService().asBinder().isBinderAlive(); - } -} diff --git a/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java b/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java deleted file mode 100644 index f57d8d8bd..000000000 --- a/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.adapters; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.os.Parcel; -import android.view.MenuItem; - -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; - -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; - -public class AppHelper { - - public static final String SETTINGS_CATEGORY = "de.robv.android.xposed.category.MODULE_SETTINGS"; - public static final int FLAG_SHOW_FOR_ALL_USERS = 0x0400; - private static List denyList; - private static List appList; - private static final ConcurrentHashMap appLabel = new ConcurrentHashMap<>(); - - @SuppressLint("WrongConstant") - public static Intent getSettingsIntent(String packageName, int userId) { - Intent intentToResolve = new Intent(Intent.ACTION_MAIN); - intentToResolve.addCategory(SETTINGS_CATEGORY); - intentToResolve.setPackage(packageName); - - List ris = ConfigManager.queryIntentActivitiesAsUser(intentToResolve, 0, userId); - - if (ris.size() == 0) { - return getLaunchIntentForPackage(packageName, userId); - } - - Intent intent = new Intent(intentToResolve); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.setClassName(ris.get(0).activityInfo.packageName, - ris.get(0).activityInfo.name); - intent.putExtra("lsp_no_switch_to_user", (ris.get(0).activityInfo.flags & FLAG_SHOW_FOR_ALL_USERS) != 0); - return intent; - } - - @SuppressLint("WrongConstant") - public static Intent getLaunchIntentForPackage(String packageName, int userId) { - Intent intentToResolve = new Intent(Intent.ACTION_MAIN); - intentToResolve.addCategory(Intent.CATEGORY_INFO); - intentToResolve.setPackage(packageName); - List ris = ConfigManager.queryIntentActivitiesAsUser(intentToResolve, 0, userId); - - if (ris.size() == 0) { - intentToResolve.removeCategory(Intent.CATEGORY_INFO); - intentToResolve.addCategory(Intent.CATEGORY_LAUNCHER); - intentToResolve.setPackage(packageName); - ris = ConfigManager.queryIntentActivitiesAsUser(intentToResolve, 0, userId); - } - - if (ris.size() == 0) { - return null; - } - - Intent intent = new Intent(intentToResolve); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.setClassName(ris.get(0).activityInfo.packageName, - ris.get(0).activityInfo.name); - intent.putExtra("lsp_no_switch_to_user", (ris.get(0).activityInfo.flags & FLAG_SHOW_FOR_ALL_USERS) != 0); - return intent; - } - - public static boolean onOptionsItemSelected(MenuItem item, SharedPreferences preferences) { - int itemId = item.getItemId(); - int i = preferences.getInt("list_sort", 0); - if (itemId == R.id.item_sort_by_name) { - i = (i % 2 == 0) ? 0 : 1; - } else if (itemId == R.id.item_sort_by_package_name) { - i = (i % 2 == 0) ? 2 : 3; - } else if (itemId == R.id.item_sort_by_install_time) { - i = (i % 2 == 0) ? 4 : 5; - } else if (itemId == R.id.item_sort_by_update_time) { - i = (i % 2 == 0) ? 6 : 7; - } else if (itemId == R.id.reverse) { - if (i % 2 == 0) i++; - else i--; - } else { - return false; - } - preferences.edit().putInt("list_sort", i).apply(); - if (item.isCheckable()) - item.setChecked(!item.isChecked()); - return true; - } - - public static Comparator getAppListComparator(int sort, PackageManager pm) { - ApplicationInfo.DisplayNameComparator displayNameComparator = new ApplicationInfo.DisplayNameComparator(pm); - return switch (sort) { - case 7 -> - Collections.reverseOrder(Comparator.comparingLong((PackageInfo a) -> a.lastUpdateTime)); - case 6 -> Comparator.comparingLong((PackageInfo a) -> a.lastUpdateTime); - case 5 -> - Collections.reverseOrder(Comparator.comparingLong((PackageInfo a) -> a.firstInstallTime)); - case 4 -> Comparator.comparingLong((PackageInfo a) -> a.firstInstallTime); - case 3 -> Collections.reverseOrder(Comparator.comparing(a -> a.packageName)); - case 2 -> Comparator.comparing(a -> a.packageName); - case 1 -> - Collections.reverseOrder((PackageInfo a, PackageInfo b) -> displayNameComparator.compare(a.applicationInfo, b.applicationInfo)); - default -> - (PackageInfo a, PackageInfo b) -> displayNameComparator.compare(a.applicationInfo, b.applicationInfo); - }; - } - - synchronized public static List getAppList(boolean force) { - if (appList == null || force) { - appList = ConfigManager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | PackageManager.MATCH_UNINSTALLED_PACKAGES, true); - PackageInfo system = null; - for (var app : appList) { - if ("android".equals(app.packageName)) { - var p = Parcel.obtain(); - app.writeToParcel(p, 0); - p.setDataPosition(0); - system = PackageInfo.CREATOR.createFromParcel(p); - system.packageName = "system"; - system.applicationInfo.packageName = system.packageName; - break; - } - } - if (system != null) { - appList.add(system); - } - } - return appList; - } - - public static CharSequence getAppLabel(PackageInfo info, PackageManager pm) { - if (info == null || info.applicationInfo == null) return null; - return appLabel.computeIfAbsent(info, i -> i.applicationInfo.loadLabel(pm)); - } -} diff --git a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java deleted file mode 100644 index 5e131cb2f..000000000 --- a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java +++ /dev/null @@ -1,705 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.adapters; - -import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Build; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.TypefaceSpan; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CompoundButton; -import android.widget.Filter; -import android.widget.Filterable; -import android.widget.ImageView; -import android.widget.Switch; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.request.target.CustomTarget; -import com.bumptech.glide.request.transition.Transition; -import com.google.android.material.checkbox.MaterialCheckBox; - -import org.lsposed.lspd.models.Application; -import org.lsposed.manager.App; -import org.lsposed.manager.BuildConfig; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.ItemMasterSwitchBinding; -import org.lsposed.manager.databinding.ItemModuleBinding; -import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; -import org.lsposed.manager.ui.fragment.AppListFragment; -import org.lsposed.manager.ui.fragment.CompileDialogFragment; -import org.lsposed.manager.ui.widget.EmptyStateRecyclerView; -import org.lsposed.manager.util.GlideApp; -import org.lsposed.manager.util.ModuleUtil; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; - -import rikka.core.util.ResourceUtils; -import rikka.material.app.LocaleDelegate; -import rikka.widget.mainswitchbar.MainSwitchBar; -import rikka.widget.mainswitchbar.OnMainSwitchChangeListener; - -public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter implements Filterable { - - private final Activity activity; - private final AppListFragment fragment; - private final PackageManager pm; - private final SharedPreferences preferences; - private final ModuleUtil moduleUtil; - - private final ModuleUtil.InstalledModule module; - - private Set recommendedList = new HashSet<>(); - private Set checkedList = new HashSet<>(); - private List searchList = new ArrayList<>(); - private List showList = new ArrayList<>(); - - public RecyclerView.Adapter switchAdaptor = new RecyclerView.Adapter<>() { - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new RecyclerView.ViewHolder(ItemMasterSwitchBinding.inflate(activity.getLayoutInflater(), parent, false).masterSwitch) { - }; - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { - var mainSwitchBar = (MainSwitchBar) holder.itemView; - mainSwitchBar.setChecked(enabled); - mainSwitchBar.addOnSwitchChangeListener(switchBarOnCheckedChangeListener); - } - - @Override - public int getItemCount() { - return 1; - } - }; - - private final OnMainSwitchChangeListener switchBarOnCheckedChangeListener = new OnMainSwitchChangeListener() { - @Override - public void onSwitchChanged(Switch view, boolean isChecked) { - enabled = isChecked; - if (!moduleUtil.setModuleEnabled(module.packageName, isChecked)) { - view.setChecked(!isChecked); - enabled = !isChecked; - } - var tmpChkList = new HashSet<>(checkedList); - if (isChecked && !tmpChkList.isEmpty() && !ConfigManager.setModuleScope(module.packageName, module.legacy, tmpChkList)) { - view.setChecked(false); - enabled = false; - } - fragment.runOnUiThread(ScopeAdapter.this::notifyDataSetChanged); - } - }; - - private ApplicationInfo selectedApplicationInfo; - private boolean isLoaded = false; - private boolean enabled = true; - - public ScopeAdapter(AppListFragment fragment, ModuleUtil.InstalledModule module) { - this.fragment = fragment; - this.activity = fragment.requireActivity(); - this.module = module; - moduleUtil = ModuleUtil.getInstance(); - preferences = App.getPreferences(); - pm = activity.getPackageManager(); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(ItemModuleBinding.inflate(activity.getLayoutInflater(), parent, false)); - } - - private boolean shouldHideApp(PackageInfo info, ApplicationWithEquals app, HashSet tmpChkList) { - if (info.packageName.equals("system")) { - return false; - } - if (tmpChkList.contains(app)) { - return false; - } - if (preferences.getBoolean("filter_modules", true)) { - if (ModuleUtil.getInstance().getModule(info.packageName, info.applicationInfo.uid / App.PER_USER_RANGE) != null) { - return true; - } - } - if (preferences.getBoolean("filter_games", true)) { - if (info.applicationInfo.category == ApplicationInfo.CATEGORY_GAME) { - return true; - } - //noinspection deprecation - if ((info.applicationInfo.flags & ApplicationInfo.FLAG_IS_GAME) != 0) { - return true; - } - } - return preferences.getBoolean("filter_system_apps", true) && (info.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; - } - - private int sortApps(AppInfo x, AppInfo y) { - Comparator comparator = AppHelper.getAppListComparator(preferences.getInt("list_sort", 0), pm); - Comparator frameworkComparator = (a, b) -> { - if (a.packageName.equals("system") == b.packageName.equals("system")) { - return comparator.compare(a.packageInfo, b.packageInfo); - } else if (a.packageName.equals("system")) { - return -1; - } else { - return 1; - } - }; - Comparator recommendedComparator = (a, b) -> { - boolean aRecommended = !recommendedList.isEmpty() && recommendedList.contains(a.application); - boolean bRecommended = !recommendedList.isEmpty() && recommendedList.contains(b.application); - if (aRecommended == bRecommended) { - return frameworkComparator.compare(a, b); - } else if (aRecommended) { - return -1; - } else { - return 1; - } - }; - boolean aChecked = checkedList.contains(x.application); - boolean bChecked = checkedList.contains(y.application); - if (aChecked == bChecked) { - return recommendedComparator.compare(x, y); - } else if (aChecked) { - return -1; - } else { - return 1; - } - } - - private void checkRecommended() { - if (!enabled) { - fragment.showHint(R.string.module_is_not_activated_yet, false); - return; - } - fragment.runAsync(() -> { - var tmpChkList = new HashSet<>(checkedList); - tmpChkList.removeIf(i -> i.userId == module.userId); - tmpChkList.addAll(recommendedList); - ConfigManager.setModuleScope(module.packageName, module.legacy, tmpChkList); - checkedList = tmpChkList; - fragment.runOnUiThread(this::notifyDataSetChanged); - }); - } - - @SuppressLint("NotifyDataSetChanged") - private void setLoaded(List list, boolean loaded) { - fragment.runOnUiThread(() -> { - if (list != null) showList = list; - isLoaded = loaded; - notifyDataSetChanged(); - }); - } - - public boolean onOptionsItemSelected(MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.use_recommended) { - if (!checkedList.isEmpty()) { - new BlurBehindDialogBuilder(activity, R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons) - .setMessage(R.string.use_recommended_message) - .setPositiveButton(android.R.string.ok, (dialog, which) -> checkRecommended()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } else { - checkRecommended(); - } - return true; - } else if (itemId == R.id.item_filter_system) { - item.setChecked(!item.isChecked()); - preferences.edit().putBoolean("filter_system_apps", item.isChecked()).apply(); - } else if (itemId == R.id.item_filter_games) { - item.setChecked(!item.isChecked()); - preferences.edit().putBoolean("filter_games", item.isChecked()).apply(); - } else if (itemId == R.id.item_filter_modules) { - item.setChecked(!item.isChecked()); - preferences.edit().putBoolean("filter_modules", item.isChecked()).apply(); - } else if (itemId == R.id.backup) { - LocalDateTime now = LocalDateTime.now(); - try { - fragment.backupLauncher.launch(String.format(LocaleDelegate.getDefaultLocale(), - "%s_%s.lsp", module.getAppName(), now.toString())); - return true; - } catch (ActivityNotFoundException e) { - fragment.showHint(R.string.enable_documentui, true); - return false; - } - } else if (itemId == R.id.restore) { - try { - fragment.restoreLauncher.launch(new String[]{"*/*"}); - return true; - } catch (ActivityNotFoundException e) { - fragment.showHint(R.string.enable_documentui, true); - return false; - } - } else if (itemId == R.id.select_all) { - var tmpChkList = new HashSet(ConfigManager.getModuleScope(module.packageName)); - for (AppInfo info : searchList) { - if (info.packageName.equals("android")) { - fragment.showHint(R.string.reboot_required, true, R.string.reboot, v -> ConfigManager.reboot()); - } - tmpChkList.add(info.application); - } - ConfigManager.setModuleScope(module.packageName, module.legacy, tmpChkList); - } else if (itemId == R.id.select_none) { - var tmpChkList = new HashSet(ConfigManager.getModuleScope(module.packageName)); - for (AppInfo info : searchList) { - if (tmpChkList.remove(info.application) && info.packageName.equals("android")) { - fragment.showHint(R.string.reboot_required, true, R.string.reboot, v -> ConfigManager.reboot()); - } - } - ConfigManager.setModuleScope(module.packageName, module.legacy, tmpChkList); - } else if (itemId == R.id.auto_include) { - item.setChecked(!item.isChecked()); - ConfigManager.setAutoInclude(module.packageName, item.isChecked()); - } else if (!AppHelper.onOptionsItemSelected(item, preferences)) { - return false; - } - refresh(); - return true; - } - - public boolean onContextItemSelected(@NonNull MenuItem item) { - var info = selectedApplicationInfo; - if (info == null) { - return false; - } - int itemId = item.getItemId(); - if (itemId == R.id.menu_launch) { - Intent launchIntent = AppHelper.getLaunchIntentForPackage(info.packageName, info.uid / App.PER_USER_RANGE); - if (launchIntent != null) { - ConfigManager.startActivityAsUserWithFeature(launchIntent, module.userId); - } - } else if (itemId == R.id.menu_compile_speed) { - CompileDialogFragment.speed(fragment.getChildFragmentManager(), info); - } else if (itemId == R.id.menu_other_app) { - var intent = new Intent(Intent.ACTION_SHOW_APP_INFO); - intent.putExtra(Intent.EXTRA_PACKAGE_NAME, module.packageName); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - ConfigManager.startActivityAsUserWithFeature(intent, module.userId); - } else if (itemId == R.id.menu_app_info) { - ConfigManager.startActivityAsUserWithFeature(new Intent(ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", info.packageName, null)), module.userId); - } else if (itemId == R.id.menu_force_stop) { - if (info.packageName.equals("system")) { - new BlurBehindDialogBuilder(activity, R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons) - .setTitle(R.string.reboot) - .setPositiveButton(android.R.string.ok, (dialog, which) -> ConfigManager.reboot()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } else { - new BlurBehindDialogBuilder(activity, R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons) - .setTitle(R.string.force_stop_dlg_title) - .setMessage(R.string.force_stop_dlg_text) - .setPositiveButton(android.R.string.ok, (dialog, which) -> ConfigManager.forceStopPackage(info.packageName, info.uid / 100000)) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - } else { - return false; - } - return true; - } - - public void onPrepareOptionsMenu(@NonNull Menu menu) { - List scopeList = module.getScopeList(); - if (scopeList == null || scopeList.isEmpty()) { - menu.removeItem(R.id.use_recommended); - } - menu.findItem(R.id.item_filter_system).setChecked(preferences.getBoolean("filter_system_apps", true)); - menu.findItem(R.id.item_filter_games).setChecked(preferences.getBoolean("filter_games", true)); - menu.findItem(R.id.item_filter_modules).setChecked(preferences.getBoolean("filter_modules", true)); - switch (preferences.getInt("list_sort", 0)) { - case 7 -> { - menu.findItem(R.id.item_sort_by_update_time).setChecked(true); - menu.findItem(R.id.reverse).setChecked(true); - } - case 6 -> menu.findItem(R.id.item_sort_by_update_time).setChecked(true); - case 5 -> { - menu.findItem(R.id.item_sort_by_install_time).setChecked(true); - menu.findItem(R.id.reverse).setChecked(true); - } - case 4 -> menu.findItem(R.id.item_sort_by_install_time).setChecked(true); - case 3 -> { - menu.findItem(R.id.item_sort_by_package_name).setChecked(true); - menu.findItem(R.id.reverse).setChecked(true); - } - case 2 -> menu.findItem(R.id.item_sort_by_package_name).setChecked(true); - case 1 -> { - menu.findItem(R.id.item_sort_by_name).setChecked(true); - menu.findItem(R.id.reverse).setChecked(true); - } - case 0 -> menu.findItem(R.id.item_sort_by_name).setChecked(true); - } - menu.findItem(R.id.auto_include).setChecked(ConfigManager.getAutoInclude(module.packageName)); - } - - @Override - public void onViewRecycled(@NonNull ViewHolder holder) { - if (holder.checkbox != null) { - holder.checkbox.setOnCheckedChangeListener(null); - } - super.onViewRecycled(holder); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - AppInfo appInfo = showList.get(position); - holder.root.setAlpha(enabled ? 1.0f : .5f); - boolean system = appInfo.packageName.equals("system"); - CharSequence appName; - int userId = appInfo.applicationInfo.uid / App.PER_USER_RANGE; - appName = system ? activity.getString(R.string.android_framework) : appInfo.label; - holder.appName.setText(appName); - GlideApp.with(holder.appIcon).load(appInfo.packageInfo).into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { - holder.appIcon.setImageDrawable(resource); - } - - @Override - public void onLoadCleared(@Nullable Drawable placeholder) { - - } - - @Override - public void onLoadFailed(@Nullable Drawable errorDrawable) { - holder.appIcon.setImageDrawable(pm.getDefaultActivityIcon()); - } - }); - if (system) { - //noinspection SetTextI18n - holder.appPackageName.setText("system"); - holder.appVersionName.setVisibility(View.GONE); - } else { - holder.appVersionName.setVisibility(View.VISIBLE); - holder.appPackageName.setText(appInfo.packageName); - } - holder.appPackageName.setVisibility(View.VISIBLE); - holder.appVersionName.setText(activity.getString(R.string.app_version, appInfo.packageInfo.versionName)); - var sb = new SpannableStringBuilder(); - if (!recommendedList.isEmpty() && recommendedList.contains(appInfo.application)) { - String recommended = activity.getString(R.string.requested_by_module); - sb.append(recommended); - final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(activity.getTheme(), com.google.android.material.R.attr.colorPrimary)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - final TypefaceSpan typefaceSpan = new TypefaceSpan(Typeface.create("sans-serif-medium", Typeface.NORMAL)); - sb.setSpan(typefaceSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD); - sb.setSpan(styleSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - sb.setSpan(foregroundColorSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - if (sb.length() == 0) { - holder.hint.setVisibility(View.GONE); - } else { - holder.hint.setText(sb); - holder.hint.setVisibility(View.VISIBLE); - } - - holder.itemView.setOnCreateContextMenuListener((menu, v, menuInfo) -> { - activity.getMenuInflater().inflate(R.menu.menu_app_item, menu); - menu.setHeaderTitle(appName); - Intent launchIntent = AppHelper.getLaunchIntentForPackage(appInfo.packageName, userId); - if (launchIntent == null) { - menu.removeItem(R.id.menu_launch); - } - if (system) { - menu.findItem(R.id.menu_force_stop).setTitle(R.string.reboot); - menu.removeItem(R.id.menu_compile_speed); - menu.removeItem(R.id.menu_other_app); - menu.removeItem(R.id.menu_app_info); - } - }); - - holder.checkbox.setChecked(checkedList.contains(appInfo.application)); - - holder.checkbox.setOnCheckedChangeListener((v, isChecked) -> onCheckedChange(v, isChecked, appInfo)); - - holder.itemView.setOnClickListener(v -> { - if (enabled) holder.checkbox.toggle(); - }); - holder.itemView.setOnLongClickListener(v -> { - fragment.searchView.clearFocus(); - selectedApplicationInfo = appInfo.applicationInfo; - return false; - }); - } - - @Override - public long getItemId(int position) { - PackageInfo info = showList.get(position).packageInfo; - return (info.packageName + "!" + info.applicationInfo.uid / App.PER_USER_RANGE).hashCode(); - } - - @Override - public Filter getFilter() { - return new ApplicationFilter(); - } - - @Override - public int getItemCount() { - return showList.size(); - } - - public void refresh() { - refresh(false); - } - - public void refresh(boolean force) { - setLoaded(null, false); - enabled = moduleUtil.isModuleEnabled(module.packageName); - fragment.runAsync(() -> { - List appList = AppHelper.getAppList(force); - var tmpRecList = new HashSet(); - var tmpChkList = new HashSet<>(ConfigManager.getModuleScope(module.packageName)); - final var tmpList = new ArrayList(); - final HashSet installedList = new HashSet<>(); - List scopeList = module.getScopeList(); - boolean emptyCheckedList = tmpChkList.isEmpty(); - appList.parallelStream().forEach(info -> { - int userId = info.applicationInfo.uid / App.PER_USER_RANGE; - String packageName = info.packageName; - if (packageName.equals("system") && userId != 0 || - packageName.equals(module.packageName) || - packageName.equals(BuildConfig.APPLICATION_ID)) { - return; - } - - ApplicationWithEquals application = new ApplicationWithEquals(packageName, userId); - - synchronized (installedList) { - installedList.add(application); - } - - if (userId != module.userId) { - return; - } - - if (scopeList != null && scopeList.contains(packageName)) { - synchronized (tmpRecList) { - tmpRecList.add(application); - } - } else if (shouldHideApp(info, application, tmpChkList)) { - return; - } - - AppInfo appInfo = new AppInfo(); - appInfo.packageInfo = info; - appInfo.label = AppHelper.getAppLabel(info, pm); - appInfo.application = application; - appInfo.packageName = info.packageName; - appInfo.applicationInfo = info.applicationInfo; - synchronized (tmpList) { - tmpList.add(appInfo); - } - }); - tmpChkList.retainAll(installedList); - checkedList = tmpChkList; - recommendedList = tmpRecList; - searchList = tmpList.parallelStream().sorted(this::sortApps).collect(Collectors.toList()); - - String queryStr = fragment.searchView != null ? fragment.searchView.getQuery().toString() : ""; - - fragment.runOnUiThread(() -> getFilter().filter(queryStr)); - }); - } - - protected void onCheckedChange(CompoundButton buttonView, boolean isChecked, AppInfo appInfo) { - var tmpChkList = new HashSet<>(checkedList); - if (isChecked) { - tmpChkList.add(appInfo.application); - } else { - tmpChkList.remove(appInfo.application); - } - if (!ConfigManager.setModuleScope(module.packageName, module.legacy, tmpChkList)) { - fragment.showHint(R.string.failed_to_save_scope_list, true); - if (!isChecked) { - tmpChkList.add(appInfo.application); - } else { - tmpChkList.remove(appInfo.application); - } - buttonView.setChecked(!isChecked); - } else if (appInfo.packageName.equals("system")) { - fragment.showHint(R.string.reboot_required, true, R.string.reboot, v -> ConfigManager.reboot()); - } - checkedList = tmpChkList; - } - - @Override - public boolean isLoaded() { - return isLoaded; - } - - public static class ViewHolder extends RecyclerView.ViewHolder { - ConstraintLayout root; - ImageView appIcon; - TextView appName; - TextView appPackageName; - TextView appVersionName; - TextView hint; - MaterialCheckBox checkbox; - - ViewHolder(ItemModuleBinding binding) { - super(binding.getRoot()); - root = binding.itemRoot; - appIcon = binding.appIcon; - appName = binding.appName; - appPackageName = binding.appPackageName; - appVersionName = binding.appVersionName; - checkbox = binding.checkbox; - hint = binding.hint; - checkbox.setVisibility(View.VISIBLE); - } - } - - private class ApplicationFilter extends Filter { - - private boolean lowercaseContains(String s, String filter) { - return !TextUtils.isEmpty(s) && s.toLowerCase().contains(filter); - } - - @Override - protected FilterResults performFiltering(CharSequence constraint) { - FilterResults filterResults = new FilterResults(); - List filtered = new ArrayList<>(); - String filter = constraint.toString().toLowerCase(); - for (AppInfo info : searchList) { - if (lowercaseContains(info.label.toString(), filter) - || lowercaseContains(info.packageName, filter)) { - filtered.add(info); - } - } - filterResults.values = filtered; - filterResults.count = filtered.size(); - return filterResults; - } - - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - //noinspection unchecked - setLoaded((List) results.values, true); - } - } - - public SearchView.OnQueryTextListener getSearchListener() { - return new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - getFilter().filter(query); - return true; - } - - @Override - public boolean onQueryTextChange(String query) { - getFilter().filter(query); - return true; - } - }; - } - - public void onBackPressed() { - fragment.searchView.clearFocus(); - if (isLoaded && enabled && checkedList.isEmpty()) { - var builder = new BlurBehindDialogBuilder(activity, R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons); - builder.setMessage(!recommendedList.isEmpty() ? R.string.no_scope_selected_has_recommended : R.string.no_scope_selected); - if (!recommendedList.isEmpty()) { - builder.setPositiveButton(android.R.string.ok, (dialog, which) -> checkRecommended()); - } else { - builder.setPositiveButton(android.R.string.cancel, null); - } - builder.setNegativeButton(!recommendedList.isEmpty() ? android.R.string.cancel : android.R.string.ok, (dialog, which) -> { - moduleUtil.setModuleEnabled(module.packageName, false); - Toast.makeText(activity, activity.getString(R.string.module_disabled_no_selection, module.getAppName()), Toast.LENGTH_LONG).show(); - fragment.navigateUp(); - }); - builder.show(); - } else { - fragment.navigateUp(); - } - } - - public static class AppInfo { - public PackageInfo packageInfo; - public ApplicationWithEquals application; - public ApplicationInfo applicationInfo; - public String packageName; - public CharSequence label = null; - } - - public static class ApplicationWithEquals extends Application { - public ApplicationWithEquals(String packageName, int userId) { - this.packageName = packageName; - this.userId = userId; - } - - public ApplicationWithEquals(Application application) { - packageName = application.packageName; - userId = application.userId; - } - - @Override - public boolean equals(@Nullable Object obj) { - if (!(obj instanceof Application)) { - return false; - } - return packageName.equals(((Application) obj).packageName) && userId == ((Application) obj).userId; - } - - @Override - public int hashCode() { - return Objects.hash(packageName, userId); - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/receivers/LSPManagerServiceHolder.java b/app/src/main/java/org/lsposed/manager/receivers/LSPManagerServiceHolder.java deleted file mode 100644 index 64d36a0d5..000000000 --- a/app/src/main/java/org/lsposed/manager/receivers/LSPManagerServiceHolder.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.receivers; - -import android.os.IBinder; -import android.os.Process; -import android.os.RemoteException; -import android.system.Os; - -import org.lsposed.lspd.ILSPManagerService; - -public class LSPManagerServiceHolder implements IBinder.DeathRecipient { - private static LSPManagerServiceHolder holder = null; - private static ILSPManagerService service = null; - - public static void init(IBinder binder) { - if (holder == null) { - holder = new LSPManagerServiceHolder(binder); - } - } - - public static ILSPManagerService getService() { - return service; - } - - private LSPManagerServiceHolder(IBinder binder) { - linkToDeath(binder); - service = ILSPManagerService.Stub.asInterface(binder); - } - - private void linkToDeath(IBinder binder) { - try { - binder.linkToDeath(this, 0); - } catch (RemoteException e) { - binderDied(); - } - } - - @Override - public void binderDied() { - System.exit(0); - Process.killProcess(Os.getpid()); - } -} diff --git a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java deleted file mode 100644 index cd4ccffe2..000000000 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ /dev/null @@ -1,461 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.repo; - -import android.content.res.Resources; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.Gson; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; -import org.lsposed.manager.repo.model.OnlineModule; -import org.lsposed.manager.repo.model.Release; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -public class RepoLoader { - private static RepoLoader instance = null; - private Map onlineModules = new HashMap<>(); - private Map latestVersion = new ConcurrentHashMap<>(); - - public static class ModuleVersion { - public String versionName; - public long versionCode; - - private ModuleVersion(long versionCode, String versionName) { - this.versionName = versionName; - this.versionCode = versionCode; - } - - public boolean upgradable(long versionCode, String versionName) { - return this.versionCode > versionCode || (this.versionCode == versionCode && !versionName.replace(' ', '_').equals(this.versionName)); - } - - } - - private final Path repoFile = Paths.get(App.getInstance().getFilesDir().getAbsolutePath(), "repo.json"); - private final Set listeners = ConcurrentHashMap.newKeySet(); - private boolean repoLoaded = false; - // The full module list is only served by the backup host; modules.lsposed.org - // returns 403 for modules.json, and the blogcdn/cloudflare mirrors are dead. - private static final String[] listRepoUrls = new String[]{ - "https://backup.modules.lsposed.org/" - }; - // Per-module detail JSON is served by both the backup host and the public - // site, so the latter acts as a real fallback for module/.json. - private static final String[] detailRepoUrls = new String[]{ - "https://backup.modules.lsposed.org/", - "https://modules.lsposed.org/" - }; - // Each module is mirrored as a GitHub repo under this org; used as a - // last-resort README source when the JSON API omits it or is unreachable. - private static final String moduleGithubReadmeUrl = "https://api.github.com/repos/Xposed-Modules-Repo/%s/readme"; - private final Resources resources = App.getInstance().getResources(); - private final String[] channels = resources.getStringArray(R.array.update_channel_values); - - public boolean isRepoLoaded() { - return repoLoaded; - } - - public static synchronized RepoLoader getInstance() { - if (instance == null) { - instance = new RepoLoader(); - App.getExecutorService().submit(() -> instance.loadLocalData(true)); - } - return instance; - } - - synchronized public void loadRemoteData() { - repoLoaded = false; - boolean loaded = false; - Throwable lastError = null; - try { - for (String candidateRepoUrl : listRepoUrls) { - try { - String bodyString = requestString(candidateRepoUrl + "modules.json"); - OnlineModule[] repoModules = parseRepoModules(bodyString); - Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); - Log.i(App.TAG, "repo: fetched module list from " + candidateRepoUrl + " (" + repoModules.length + " entries, " + bodyString.length() + " bytes)"); - replaceRepoModules(repoModules); - loaded = true; - break; - } catch (Throwable t) { - lastError = t; - Log.e(App.TAG, "load remote data from " + candidateRepoUrl, t); - } - } - if (!loaded && lastError != null) { - for (RepoListener listener : listeners) { - listener.onThrowable(lastError); - } - } - } finally { - if (!loaded) { - Log.w(App.TAG, "repo: module list load failed on all mirrors, keeping cached data (" + onlineModules.size() + " modules)"); - repoLoaded = true; - for (RepoListener listener : listeners) { - listener.onRepoLoaded(); - } - } - } - } - - private OnlineModule[] parseRepoModules(String bodyString) throws IOException { - Gson gson = new Gson(); - OnlineModule[] repoModules = gson.fromJson(bodyString, OnlineModule[].class); - if (repoModules == null) { - throw new IOException("Invalid repo response"); - } - return repoModules; - } - - private void replaceRepoModules(OnlineModule[] repoModules) { - Map modules = new HashMap<>(); - Arrays.stream(repoModules).forEach(onlineModule -> modules.put(onlineModule.getName(), onlineModule)); - var channel = App.getPreferences().getString("update_channel", channels[0]); - onlineModules = modules; - Log.i(App.TAG, "repo: onlineModules replaced, now " + modules.size() + " modules (channel=" + channel + ")"); - updateLatestVersion(repoModules, channel); - } - - private String requestString(String url) throws IOException { - try (var response = App.getOkHttpClient().newCall(new Request.Builder().url(url).build()).execute()) { - if (!response.isSuccessful()) { - throw new IOException("Unexpected response " + response.code() + " from " + response.request().url()); - } - ResponseBody body = response.body(); - if (body == null) { - throw new IOException("Empty response from " + response.request().url()); - } - String bodyString = body.string(); - if (bodyString.trim().isEmpty()) { - throw new IOException("Empty response from " + response.request().url()); - } - return bodyString; - } - } - - synchronized public void loadLocalData(boolean updateRemoteRepo) { - repoLoaded = false; - Log.i(App.TAG, "repo: loadLocalData(updateRemoteRepo=" + updateRemoteRepo + "), cacheExists=" + Files.exists(repoFile)); - try { - if (Files.notExists(repoFile)) { - loadRemoteData(); - updateRemoteRepo = false; - } - byte[] encoded = Files.readAllBytes(repoFile); - String bodyString = new String(encoded, StandardCharsets.UTF_8); - OnlineModule[] repoModules = parseRepoModules(bodyString); - Log.i(App.TAG, "repo: loadLocalData parsed " + repoModules.length + " modules from cache (" + encoded.length + " bytes)"); - replaceRepoModules(repoModules); - } catch (Throwable t) { - Log.e(App.TAG, "repo: loadLocalData failed", t); - for (RepoListener listener : listeners) { - listener.onThrowable(t); - } - } finally { - repoLoaded = true; - for (RepoListener listener : listeners) { - listener.onRepoLoaded(); - } - if (updateRemoteRepo) loadRemoteData(); - } - } - - synchronized private void updateLatestVersion(OnlineModule[] onlineModules, String channel) { - repoLoaded = false; - Map versions = new ConcurrentHashMap<>(); - for (var module : onlineModules) { - String release = module.getLatestRelease(); - if (channel.equals(channels[1]) && module.getLatestBetaRelease() != null && !module.getLatestBetaRelease().isEmpty()) { - release = module.getLatestBetaRelease(); - } else if (channel.equals(channels[2])) { - if (module.getLatestSnapshotRelease() != null && !module.getLatestSnapshotRelease().isEmpty()) - release = module.getLatestSnapshotRelease(); - else if (module.getLatestBetaRelease() != null && !module.getLatestBetaRelease().isEmpty()) - release = module.getLatestBetaRelease(); - } - if (release == null || release.isEmpty()) continue; - var splits = release.split("-", 2); - if (splits.length < 2) continue; - long verCode; - String verName; - try { - verCode = Long.parseLong(splits[0]); - verName = splits[1]; - } catch (NumberFormatException ignored) { - continue; - } - String pkgName = module.getName(); - versions.put(pkgName, new ModuleVersion(verCode, verName)); - } - latestVersion = versions; - repoLoaded = true; - for (RepoListener listener : listeners) { - listener.onRepoLoaded(); - } - } - - public void updateLatestVersion(String channel) { - if (repoLoaded) - updateLatestVersion(onlineModules.keySet().parallelStream().map(onlineModules::get).toArray(OnlineModule[]::new), channel); - } - - @Nullable - public ModuleVersion getModuleLatestVersion(String packageName) { - return repoLoaded ? latestVersion.getOrDefault(packageName, null) : null; - } - - @Nullable - public List getReleases(String packageName) { - var channel = App.getPreferences().getString("update_channel", channels[0]); - List releases = new ArrayList<>(); - if (repoLoaded) { - var module = onlineModules.get(packageName); - if (module != null) { - releases = module.getReleases(); - if (!module.releasesLoaded) { - if (channel.equals(channels[1]) && !(module.getBetaReleases() != null && module.getBetaReleases().isEmpty())) { - releases = module.getBetaReleases(); - } else if (channel.equals(channels[2])) - if (!(module.getSnapshotReleases() != null && module.getSnapshotReleases().isEmpty())) - releases = module.getSnapshotReleases(); - else if (!(module.getBetaReleases() != null && module.getBetaReleases().isEmpty())) - releases = module.getBetaReleases(); - } - } - } - return releases; - } - - @Nullable - public String getLatestReleaseTime(String packageName, String channel) { - String releaseTime = null; - if (repoLoaded) { - var module = onlineModules.get(packageName); - if (module != null) { - releaseTime = module.getLatestReleaseTime(); - if (channel.equals(channels[1]) && module.getLatestBetaReleaseTime() != null) { - releaseTime = module.getLatestBetaReleaseTime(); - } else if (channel.equals(channels[2])) - if (module.getLatestSnapshotReleaseTime() != null) - releaseTime = module.getLatestSnapshotReleaseTime(); - else if (module.getLatestBetaReleaseTime() != null) - releaseTime = module.getLatestBetaReleaseTime(); - } - } - return releaseTime; - } - - public void loadRemoteReleases(String packageName) { - loadRemoteReleases(packageName, 0); - } - - private void loadRemoteReleases(String packageName, int attempt) { - if (attempt >= detailRepoUrls.length) { - // Every detail mirror failed; fall back to the module's GitHub repo - // so we can at least recover the README instead of failing outright. - Log.w(App.TAG, "repo: detail mirrors exhausted for " + packageName + ", falling back to GitHub README"); - loadReadmeFromGithub(packageName, null, new IOException("All module detail mirrors failed for " + packageName)); - return; - } - String candidateRepoUrl = detailRepoUrls[attempt]; - Log.i(App.TAG, "repo: loadRemoteReleases " + packageName + " attempt " + attempt + " -> " + candidateRepoUrl); - App.getOkHttpClient().newCall(new Request.Builder().url(String.format(candidateRepoUrl + "module/%s.json", packageName)).build()).enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - Log.w(App.TAG, "repo: detail fetch failed for " + packageName + " from " + call.request().url() + ": " + e.getMessage()); - loadRemoteReleases(packageName, attempt + 1); - } - - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (!response.isSuccessful()) { - Log.w(App.TAG, "repo: detail unexpected response " + response.code() + " for " + packageName + " from " + call.request().url()); - response.close(); - loadRemoteReleases(packageName, attempt + 1); - return; - } - OnlineModule module; - try (response) { - ResponseBody body = response.body(); - if (body == null) { - throw new IOException("Empty response from " + call.request().url()); - } - String bodyString = body.string(); - if (bodyString.trim().isEmpty()) { - throw new IOException("Empty response from " + call.request().url()); - } - Gson gson = new Gson(); - module = gson.fromJson(bodyString, OnlineModule.class); - if (module == null) { - throw new IOException("Invalid response from " + call.request().url()); - } - module.releasesLoaded = true; - onlineModules.replace(packageName, module); - } catch (Throwable t) { - Log.e(App.TAG, "repo: detail parse failed for " + packageName, t); - loadRemoteReleases(packageName, attempt + 1); - return; - } - int releaseCount = module.getReleases() == null ? 0 : module.getReleases().size(); - Log.i(App.TAG, "repo: detail loaded for " + packageName + " from " + candidateRepoUrl + ", hasReadme=" + hasReadme(module) + ", releases=" + releaseCount); - if (hasReadme(module)) { - for (RepoListener listener : listeners) { - listener.onModuleReleasesLoaded(module); - } - } else { - // Detail loaded but carries no README; enrich it from GitHub - // before publishing so the README tab is not shown as empty. - Log.i(App.TAG, "repo: " + packageName + " detail has no README, fetching from GitHub"); - loadReadmeFromGithub(packageName, module, null); - } - } - }); - } - - private boolean hasReadme(@Nullable OnlineModule module) { - return module != null && ((module.getReadmeHTML() != null && !module.getReadmeHTML().isEmpty()) - || (module.getReadme() != null && !module.getReadme().isEmpty())); - } - - // Fetches the module's rendered README from its GitHub repo. When `loaded` - // is non-null the detail JSON already succeeded (valid releases, README is a - // bonus) and is always published; when null, the JSON mirrors were - // unreachable, so we enrich the cached summary and surface `error` only if - // even the GitHub fetch fails. - private void loadReadmeFromGithub(String packageName, @Nullable OnlineModule loaded, @Nullable Throwable error) { - OnlineModule target = loaded != null ? loaded : onlineModules.get(packageName); - if (target == null) { - Log.w(App.TAG, "repo: no cached module to enrich for " + packageName + ", giving up"); - if (error != null) { - for (RepoListener listener : listeners) { - listener.onThrowable(error); - } - } - return; - } - App.getOkHttpClient().newCall(new Request.Builder() - .url(String.format(moduleGithubReadmeUrl, packageName)) - .header("Accept", "application/vnd.github.html+json") - .build()).enqueue(new Callback() { - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - Log.w(App.TAG, "repo: GitHub README fetch failed for " + packageName + ": " + e.getMessage()); - publishReadmeFallback(packageName, target, null, loaded != null, error); - } - - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - String html = null; - try (response) { - if (response.isSuccessful()) { - ResponseBody body = response.body(); - if (body != null) { - String bodyString = body.string(); - if (!bodyString.trim().isEmpty()) { - html = bodyString; - } - } - } else { - Log.w(App.TAG, "repo: GitHub README unexpected response " + response.code() + " for " + packageName); - } - } catch (Throwable t) { - Log.e(App.TAG, "repo: GitHub README read failed for " + packageName, t); - } - Log.i(App.TAG, "repo: GitHub README for " + packageName + " -> " + (html != null ? "recovered (" + html.length() + " bytes)" : "unavailable")); - publishReadmeFallback(packageName, target, html, loaded != null, error); - } - }); - } - - private void publishReadmeFallback(String packageName, OnlineModule module, @Nullable String readmeHTML, boolean detailLoaded, @Nullable Throwable error) { - if (readmeHTML != null) { - module.setReadmeHTML(readmeHTML); - onlineModules.replace(packageName, module); - } - if (detailLoaded || readmeHTML != null) { - // The releases are already valid, or we recovered a README: publish - // the (possibly enriched) module to the UI. - Log.i(App.TAG, "repo: publishing " + packageName + " (detailLoaded=" + detailLoaded + ", readmeRecovered=" + (readmeHTML != null) + ")"); - for (RepoListener listener : listeners) { - listener.onModuleReleasesLoaded(module); - } - } else if (error != null) { - Log.w(App.TAG, "repo: nothing to publish for " + packageName + ", reporting failure"); - for (RepoListener listener : listeners) { - listener.onThrowable(error); - } - } - } - - public void addListener(RepoListener listener) { - listeners.add(listener); - } - - public void removeListener(RepoListener listener) { - listeners.remove(listener); - } - - @Nullable - public OnlineModule getOnlineModule(String packageName) { - return repoLoaded && packageName != null ? onlineModules.get(packageName) : null; - } - - @Nullable - public Collection getOnlineModules() { - return repoLoaded ? onlineModules.values() : null; - } - - public interface RepoListener { - default void onRepoLoaded() { - } - - default void onModuleReleasesLoaded(OnlineModule module) { - } - - default void onThrowable(Throwable t) { - Log.e(App.TAG, "load repo failed", t); - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/repo/model/Collaborator.java b/app/src/main/java/org/lsposed/manager/repo/model/Collaborator.java deleted file mode 100644 index bd749d7d3..000000000 --- a/app/src/main/java/org/lsposed/manager/repo/model/Collaborator.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.repo.model; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -public class Collaborator { - - @SerializedName("login") - @Expose - private String login; - @SerializedName("name") - @Expose - private String name; - - @Nullable - public String getLogin() { - return login; - } - - public void setLogin(String login) { - this.login = login; - } - - @Nullable - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} diff --git a/app/src/main/java/org/lsposed/manager/repo/model/OnlineModule.java b/app/src/main/java/org/lsposed/manager/repo/model/OnlineModule.java deleted file mode 100644 index 2c11f6959..000000000 --- a/app/src/main/java/org/lsposed/manager/repo/model/OnlineModule.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.repo.model; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -import java.util.ArrayList; -import java.util.List; - -public class OnlineModule { - - @SerializedName("name") - @Expose - private String name; - @SerializedName("description") - @Expose - private String description; - @SerializedName("url") - @Expose - private String url; - @SerializedName("homepageUrl") - @Expose - private String homepageUrl; - @SerializedName("collaborators") - @Expose - private List collaborators = new ArrayList<>(); - @SerializedName("latestRelease") - @Expose - private String latestRelease; - @SerializedName("latestReleaseTime") - @Expose - private String latestReleaseTime; - @SerializedName("latestBetaRelease") - @Expose - private String latestBetaRelease; - @SerializedName("latestBetaReleaseTime") - @Expose - private String latestBetaReleaseTime; - @SerializedName("latestSnapshotRelease") - @Expose - private String latestSnapshotRelease; - @SerializedName("latestSnapshotReleaseTime") - @Expose - private String latestSnapshotReleaseTime; - @SerializedName("releases") - @Expose - private List releases = new ArrayList<>(); - @SerializedName("betaReleases") - @Expose - private final List betaReleases = new ArrayList<>(); - @SerializedName("snapshotReleases") - @Expose - private final List snapshotReleases = new ArrayList<>(); - @SerializedName("readme") - @Expose - private String readme; - @SerializedName("readmeHTML") - @Expose - private String readmeHTML; - @SerializedName("summary") - @Expose - private String summary; - @SerializedName("scope") - @Expose - private List scope = new ArrayList<>(); - @SerializedName("sourceUrl") - @Expose - private String sourceUrl; - @SerializedName("hide") - @Expose - private Boolean hide; - @SerializedName("additionalAuthors") - @Expose - private List additionalAuthors = null; - @SerializedName("updatedAt") - @Expose - private String updatedAt; - @SerializedName("createdAt") - @Expose - private String createdAt; - @SerializedName("stargazerCount") - @Expose - private Integer stargazerCount; - public boolean releasesLoaded = false; - - @Nullable - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - @Nullable - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @Nullable - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - @Nullable - public String getHomepageUrl() { - return homepageUrl; - } - - public void setHomepageUrl(String homepageUrl) { - this.homepageUrl = homepageUrl; - } - - @Nullable - public List getCollaborators() { - return collaborators; - } - - public void setCollaborators(List collaborators) { - this.collaborators = collaborators; - } - - @Nullable - public List getReleases() { - return releases; - } - - @Nullable - public String getLatestReleaseTime() { - return latestReleaseTime; - } - - public void setReleases(List releases) { - this.releases = releases; - } - - @Nullable - public String getReadme() { - return readme; - } - - public void setReadme(String readme) { - this.readme = readme; - } - - @Nullable - public String getReadmeHTML() { - return readmeHTML; - } - - public void setReadmeHTML(String readmeHTML) { - this.readmeHTML = readmeHTML; - } - - @Nullable - public String getSummary() { - return summary; - } - - public void setSummary(String summary) { - this.summary = summary; - } - - @Nullable - public List getScope() { - return scope; - } - - public void setScope(List scope) { - this.scope = scope; - } - - @Nullable - public String getSourceUrl() { - return sourceUrl; - } - - public void setSourceUrl(String sourceUrl) { - this.sourceUrl = sourceUrl; - } - - public Boolean isHide() { - return hide; - } - - public void setHide(Boolean hide) { - this.hide = hide; - } - - @Nullable - public List getAdditionalAuthors() { - return additionalAuthors; - } - - public void setAdditionalAuthors(List additionalAuthors) { - this.additionalAuthors = additionalAuthors; - } - - @Nullable - public String getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(String updatedAt) { - this.updatedAt = updatedAt; - } - - @Nullable - public String getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(String createdAt) { - this.createdAt = createdAt; - } - - @Nullable - public Integer getStargazerCount() { - return stargazerCount; - } - - public void setStargazerCount(Integer stargazerCount) { - this.stargazerCount = stargazerCount; - } - - @Nullable - public String getLatestRelease() { - return latestRelease; - } - - public void setLatestRelease(String latestRelease) { - this.latestRelease = latestRelease; - } - - @Nullable - public String getLatestBetaRelease() { - return latestBetaRelease; - } - - @Nullable - public String getLatestBetaReleaseTime() { - return latestBetaReleaseTime; - } - - @Nullable - public String getLatestSnapshotRelease() { - return latestSnapshotRelease; - } - - @Nullable - public String getLatestSnapshotReleaseTime() { - return latestSnapshotReleaseTime; - } - - @Nullable - public List getBetaReleases() { - return betaReleases; - } - - @Nullable - public List getSnapshotReleases() { - return snapshotReleases; - } -} diff --git a/app/src/main/java/org/lsposed/manager/repo/model/Release.java b/app/src/main/java/org/lsposed/manager/repo/model/Release.java deleted file mode 100644 index 57d248060..000000000 --- a/app/src/main/java/org/lsposed/manager/repo/model/Release.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.repo.model; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -import java.util.ArrayList; -import java.util.List; - -public class Release { - - @SerializedName("name") - @Expose - private String name; - @SerializedName("url") - @Expose - private String url; - @SerializedName("description") - @Expose - private String description; - @SerializedName("descriptionHTML") - @Expose - private String descriptionHTML; - @SerializedName("createdAt") - @Expose - private String createdAt; - @SerializedName("publishedAt") - @Expose - private String publishedAt; - @SerializedName("updatedAt") - @Expose - private String updatedAt; - @SerializedName("tagName") - @Expose - private String tagName; - @SerializedName("isPrerelease") - @Expose - private Boolean isPrerelease; - @SerializedName("releaseAssets") - @Expose - private List releaseAssets = new ArrayList<>(); - - @Nullable - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - @Nullable - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - @Nullable - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @Nullable - public String getDescriptionHTML() { - return descriptionHTML; - } - - public void setDescriptionHTML(String descriptionHTML) { - this.descriptionHTML = descriptionHTML; - } - - @Nullable - public String getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(String createdAt) { - this.createdAt = createdAt; - } - - @Nullable - public String getPublishedAt() { - return publishedAt; - } - - public void setPublishedAt(String publishedAt) { - this.publishedAt = publishedAt; - } - - @Nullable - public String getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(String updatedAt) { - this.updatedAt = updatedAt; - } - - @Nullable - public String getTagName() { - return tagName; - } - - public void setTagName(String tagName) { - this.tagName = tagName; - } - - @Nullable - public Boolean getIsPrerelease() { - return isPrerelease; - } - - public void setIsPrerelease(Boolean isPrerelease) { - this.isPrerelease = isPrerelease; - } - - @Nullable - public List getReleaseAssets() { - return releaseAssets; - } - - public void setReleaseAssets(List releaseAssets) { - this.releaseAssets = releaseAssets; - } -} diff --git a/app/src/main/java/org/lsposed/manager/repo/model/ReleaseAsset.java b/app/src/main/java/org/lsposed/manager/repo/model/ReleaseAsset.java deleted file mode 100644 index 773fe1a30..000000000 --- a/app/src/main/java/org/lsposed/manager/repo/model/ReleaseAsset.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.repo.model; - -import androidx.annotation.Nullable; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -public class ReleaseAsset { - - @SerializedName("name") - @Expose - private String name; - @SerializedName("contentType") - @Expose - private String contentType; - @SerializedName("downloadUrl") - @Expose - private String downloadUrl; - @SerializedName("downloadCount") - @Expose - private int downloadCount = 0; - @SerializedName("size") - @Expose - private int size = 0; - - @Nullable - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - @Nullable - public String getContentType() { - return contentType; - } - - public void setContentType(String contentType) { - this.contentType = contentType; - } - - @Nullable - public String getDownloadUrl() { - return downloadUrl; - } - - public void setDownloadUrl(String downloadUrl) { - this.downloadUrl = downloadUrl; - } - - public int getDownloadCount() { - return downloadCount; - } - - public void setDownloadCount(int downloadCount) { - this.downloadCount = downloadCount; - } - - public int getSize() { - return size; - } - - public void setSize(int size) { - this.size = size; - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/activity/MainActivity.java b/app/src/main/java/org/lsposed/manager/ui/activity/MainActivity.java deleted file mode 100644 index 20f19fddb..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/activity/MainActivity.java +++ /dev/null @@ -1,292 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.activity; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.KeyEvent; -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.navigation.NavController; -import androidx.navigation.NavOptions; -import androidx.navigation.Navigation; -import androidx.navigation.fragment.NavHostFragment; -import androidx.navigation.ui.NavigationUI; - -import com.google.android.material.navigation.NavigationBarView; - -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.ActivityMainBinding; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.ui.activity.base.BaseActivity; -import org.lsposed.manager.util.ModuleUtil; -import org.lsposed.manager.util.ShortcutUtil; -import org.lsposed.manager.util.UpdateUtil; - -import java.util.HashSet; -import java.util.Objects; - -import rikka.core.util.ResourceUtils; - -public class MainActivity extends BaseActivity implements RepoLoader.RepoListener, ModuleUtil.ModuleListener { - private static final String KEY_PREFIX = MainActivity.class.getName() + '.'; - private static final String EXTRA_SAVED_INSTANCE_STATE = KEY_PREFIX + "SAVED_INSTANCE_STATE"; - - private static final RepoLoader repoLoader = RepoLoader.getInstance(); - private static final ModuleUtil moduleUtil = ModuleUtil.getInstance(); - - private boolean restarting; - private ActivityMainBinding binding; - - @NonNull - public static Intent newIntent(@NonNull Context context) { - return new Intent(context, MainActivity.class); - } - - @NonNull - private static Intent newIntent(@NonNull Bundle savedInstanceState, @NonNull Context context) { - return newIntent(context) - .putExtra(EXTRA_SAVED_INSTANCE_STATE, savedInstanceState); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - if (savedInstanceState == null) { - savedInstanceState = getIntent().getBundleExtra(EXTRA_SAVED_INSTANCE_STATE); - } - super.onCreate(savedInstanceState); - - binding = ActivityMainBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - repoLoader.addListener(this); - moduleUtil.addListener(this); - - onModulesReloaded(); - - NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); - if (navHostFragment == null) { - return; - } - - NavController navController = navHostFragment.getNavController(); - var nav = (NavigationBarView) binding.nav; - NavigationUI.setupWithNavController(nav, navController); - - handleIntent(getIntent()); - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - handleIntent(intent); - } - - private void handleIntent(Intent intent) { - if (intent == null) { - return; - } - NavHostFragment navHostFragment = (NavHostFragment) getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); - if (navHostFragment == null) { - return; - } - NavController navController = navHostFragment.getNavController(); - var nav = (NavigationBarView) binding.nav; - if (intent.getAction() != null && intent.getAction().equals("android.intent.action.APPLICATION_PREFERENCES")) { - nav.setSelectedItemId(R.id.settings_fragment); - } else if (ConfigManager.isBinderAlive()) { - if (!TextUtils.isEmpty(intent.getDataString())) { - switch (intent.getDataString()) { - case "modules" -> nav.setSelectedItemId(R.id.modules_nav); - case "logs" -> nav.setSelectedItemId(R.id.logs_fragment); - case "repo" -> { - if (ConfigManager.isMagiskInstalled()) { - nav.setSelectedItemId(R.id.repo_nav); - } - } - case "settings" -> nav.setSelectedItemId(R.id.settings_fragment); - default -> { - var data = intent.getData(); - if (data != null && Objects.equals(data.getScheme(), "module")) { - navController.navigate( - new Uri.Builder().scheme("lsposed").authority("module").appendQueryParameter("modulePackageName", data.getHost()).appendQueryParameter("moduleUserId", String.valueOf(data.getPort())).build(), - new NavOptions.Builder().setEnterAnim(R.anim.fragment_enter).setExitAnim(R.anim.fragment_exit).setPopEnterAnim(R.anim.fragment_enter_pop).setPopExitAnim(R.anim.fragment_exit_pop).setLaunchSingleTop(true).setPopUpTo(navController.getGraph().getStartDestinationId(), false, true).build()); - } - } - } - } - } - } - - @Override - public boolean onSupportNavigateUp() { - NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); - return navController.navigateUp() || super.onSupportNavigateUp(); - } - - public void restart() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || App.isParasitic) { - recreate(); - } else { - try { - Bundle savedInstanceState = new Bundle(); - onSaveInstanceState(savedInstanceState); - finish(); - startActivity(newIntent(savedInstanceState, this)); - overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); - restarting = true; - } catch (Throwable e) { - recreate(); - } - } - } - - @Override - public boolean dispatchKeyEvent(@NonNull KeyEvent event) { - return restarting || super.dispatchKeyEvent(event); - } - - @SuppressLint("RestrictedApi") - @Override - public boolean dispatchKeyShortcutEvent(@NonNull KeyEvent event) { - return restarting || super.dispatchKeyShortcutEvent(event); - } - - @Override - public boolean dispatchTouchEvent(@NonNull MotionEvent event) { - return restarting || super.dispatchTouchEvent(event); - } - - @Override - public boolean dispatchTrackballEvent(@NonNull MotionEvent event) { - return restarting || super.dispatchTrackballEvent(event); - } - - @Override - public boolean dispatchGenericMotionEvent(@NonNull MotionEvent event) { - return restarting || super.dispatchGenericMotionEvent(event); - } - - - @Override - public void onRepoLoaded() { - final int[] count = new int[]{0}; - HashSet processedModules = new HashSet<>(); - var modules = moduleUtil.getModules(); - if (modules == null) return; - modules.forEach((k, v) -> { - if (!processedModules.contains(k.first)) { - var ver = repoLoader.getModuleLatestVersion(k.first); - if (ver != null && ver.upgradable(v.versionCode, v.versionName)) { - ++count[0]; - } - processedModules.add(k.first); - } - } - ); - runOnUiThread(() -> { - if (count[0] > 0 && binding != null) { - var nav = (NavigationBarView) binding.nav; - var badge = nav.getOrCreateBadge(R.id.repo_nav); - badge.setVisible(true); - badge.setNumber(count[0]); - } else { - onThrowable(null); - } - }); - } - - @Override - public void onThrowable(Throwable t) { - runOnUiThread(() -> { - if (binding != null) { - var nav = (NavigationBarView) binding.nav; - var badge = nav.getOrCreateBadge(R.id.repo_nav); - badge.setVisible(false); - } - }); - } - - @Override - public void onModulesReloaded() { - onRepoLoaded(); - setModulesSummary(moduleUtil.getEnabledModulesCount()); - } - - @Override - public void onResume() { - super.onResume(); - if (ConfigManager.isBinderAlive()) { - setModulesSummary(moduleUtil.getEnabledModulesCount()); - } else setModulesSummary(0); - if (binding != null) { - var nav = (NavigationBarView) binding.nav; - if (UpdateUtil.needUpdate()) { - var badge = nav.getOrCreateBadge(R.id.main_fragment); - badge.setVisible(true); - } - - if (!ConfigManager.isBinderAlive()) { - nav.getMenu().removeItem(R.id.logs_fragment); - nav.getMenu().removeItem(R.id.modules_nav); - if (!ConfigManager.isMagiskInstalled()) { - nav.getMenu().removeItem(R.id.repo_nav); - } - } - } - if (App.isParasitic) { - var updateShortcut = ShortcutUtil.updateShortcut(); - Log.d(App.TAG, "update shortcut success = " + updateShortcut); - } - } - - private void setModulesSummary(int moduleCount) { - runOnUiThread(() -> { - if (binding != null) { - var nav = (NavigationBarView) binding.nav; - var badge = nav.getOrCreateBadge(R.id.modules_nav); - badge.setBackgroundColor(ResourceUtils.resolveColor(getTheme(), com.google.android.material.R.attr.colorPrimary)); - badge.setBadgeTextColor(ResourceUtils.resolveColor(getTheme(), com.google.android.material.R.attr.colorOnPrimary)); - if (moduleCount > 0) { - badge.setVisible(true); - badge.setNumber(moduleCount); - } else { - badge.setVisible(false); - } - } - }); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - repoLoader.removeListener(this); - moduleUtil.removeListener(this); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/activity/base/BaseActivity.java b/app/src/main/java/org/lsposed/manager/ui/activity/base/BaseActivity.java deleted file mode 100644 index e0453f0be..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/activity/base/BaseActivity.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.ui.activity.base; - -import android.app.ActivityManager; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.BitmapDrawable; -import android.os.Bundle; -import android.view.Window; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; -import org.lsposed.manager.util.ThemeUtil; - -import rikka.material.app.MaterialActivity; - -public class BaseActivity extends MaterialActivity { - private static Bitmap icon = null; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - setTheme(R.style.AppTheme); - super.onCreate(savedInstanceState); - } - - @Override - protected void onStart() { - super.onStart(); - if (!App.isParasitic) return; - for (var task : getSystemService(ActivityManager.class).getAppTasks()) { - task.setExcludeFromRecents(false); - } - if (icon == null) { - var drawable = getApplicationInfo().loadIcon(getPackageManager()); - if (drawable instanceof BitmapDrawable) { - icon = ((BitmapDrawable) drawable).getBitmap(); - } else if (drawable instanceof AdaptiveIconDrawable) { - icon = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - final Canvas canvas = new Canvas(icon); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - } - } - setTaskDescription(new ActivityManager.TaskDescription(getTitle().toString(), icon, getColor(R.color.ic_launcher_background))); - } - - @Override - public void onApplyUserThemeResource(@NonNull Resources.Theme theme, boolean isDecorView) { - if (!ThemeUtil.isSystemAccent()) { - theme.applyStyle(ThemeUtil.getColorThemeStyleRes(), true); - } - theme.applyStyle(ThemeUtil.getNightThemeStyleRes(this), true); - theme.applyStyle(rikka.material.preference.R.style.ThemeOverlay_Rikka_Material3_Preference, true); - } - - @Override - public String computeUserThemeKey() { - return ThemeUtil.getColorTheme() + ThemeUtil.getNightTheme(this); - } - - @Override - public void onApplyTranslucentSystemBars() { - super.onApplyTranslucentSystemBars(); - Window window = getWindow(); - window.setStatusBarColor(Color.TRANSPARENT); - window.setNavigationBarColor(Color.TRANSPARENT); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/BlurBehindDialogBuilder.java b/app/src/main/java/org/lsposed/manager/ui/dialog/BlurBehindDialogBuilder.java deleted file mode 100644 index a559b1f06..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/dialog/BlurBehindDialogBuilder.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.ui.dialog; - -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.content.Context; -import android.os.Build; -import android.util.Log; -import android.view.SurfaceControl; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.view.animation.DecelerateInterpolator; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.lsposed.manager.App; - -import java.lang.reflect.Method; -import java.util.function.Consumer; - -@SuppressWarnings({"JavaReflectionMemberAccess", "ConstantConditions"}) -public class BlurBehindDialogBuilder extends MaterialAlertDialogBuilder { - private static final boolean supportBlur = getSystemProperty("ro.surface_flinger.supports_background_blur", false) && !getSystemProperty("persist.sys.sf.disable_blurs", false); - - public BlurBehindDialogBuilder(@NonNull Context context) { - super(context); - } - - public BlurBehindDialogBuilder(@NonNull Context context, int overrideThemeResId) { - super(context, overrideThemeResId); - } - - @NonNull - @Override - public AlertDialog create() { - AlertDialog dialog = super.create(); - setupWindowBlurListener(dialog); - return dialog; - } - - private void setupWindowBlurListener(AlertDialog dialog) { - var window = dialog.getWindow(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND); - Consumer windowBlurEnabledListener = enabled -> updateWindowForBlurs(window, enabled); - window.getDecorView().addOnAttachStateChangeListener( - new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(@NonNull View v) { - window.getWindowManager().addCrossWindowBlurEnabledListener( - windowBlurEnabledListener); - } - - @Override - public void onViewDetachedFromWindow(@NonNull View v) { - window.getWindowManager().removeCrossWindowBlurEnabledListener( - windowBlurEnabledListener); - } - }); - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - dialog.setOnShowListener(d -> updateWindowForBlurs(window, supportBlur)); - } - } - - private void updateWindowForBlurs(Window window, boolean blursEnabled) { - float mDimAmountWithBlur = 0.1f; - float mDimAmountNoBlur = 0.32f; - window.setDimAmount(blursEnabled ? - mDimAmountWithBlur : mDimAmountNoBlur); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.getAttributes().setBlurBehindRadius(20); - window.setAttributes(window.getAttributes()); - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - if (blursEnabled) { - View view = window.getDecorView(); - ValueAnimator animator = ValueAnimator.ofInt(1, 53); - animator.setInterpolator(new DecelerateInterpolator()); - try { - Object viewRootImpl = view.getClass().getMethod("getViewRootImpl").invoke(view); - if (viewRootImpl == null) { - return; - } - SurfaceControl surfaceControl = (SurfaceControl) viewRootImpl.getClass().getMethod("getSurfaceControl").invoke(viewRootImpl); - - @SuppressLint("BlockedPrivateApi") Method setBackgroundBlurRadius = SurfaceControl.Transaction.class.getDeclaredMethod("setBackgroundBlurRadius", SurfaceControl.class, int.class); - animator.addUpdateListener(animation -> { - try { - SurfaceControl.Transaction transaction = new SurfaceControl.Transaction(); - var animatedValue = animation.getAnimatedValue(); - if (animatedValue != null) { - setBackgroundBlurRadius.invoke(transaction, surfaceControl, (int) animatedValue); - } - transaction.apply(); - } catch (Throwable t) { - Log.e(App.TAG, "Blur behind dialog builder", t); - } - }); - } catch (Throwable t) { - Log.e(App.TAG, "Blur behind dialog builder", t); - } - view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(@NonNull View v) { - } - - @Override - public void onViewDetachedFromWindow(@NonNull View v) { - animator.cancel(); - } - }); - animator.start(); - } - } - } - - public static boolean getSystemProperty(String key, boolean defaultValue) { - boolean value = defaultValue; - try { - Class c = Class.forName("android.os.SystemProperties"); - Method get = c.getMethod("getBoolean", String.class, boolean.class); - value = (boolean) get.invoke(c, key, defaultValue); - } catch (Exception e) { - Log.e(App.TAG, "Blur behind dialog builder get system property", e); - } - return value; - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/WelcomeDialog.java b/app/src/main/java/org/lsposed/manager/ui/dialog/WelcomeDialog.java deleted file mode 100644 index 09ed378e9..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/dialog/WelcomeDialog.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.ui.dialog; - -import android.app.Dialog; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentManager; - -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.ui.fragment.BaseFragment; -import org.lsposed.manager.util.ShortcutUtil; - -public class WelcomeDialog extends DialogFragment { - private static boolean shown = false; - - private Dialog parasiticDialog(BlurBehindDialogBuilder builder) { - var shortcutSupported = ShortcutUtil.isRequestPinShortcutSupported(requireContext()); - builder - .setTitle(R.string.parasitic_welcome) - .setMessage(shortcutSupported ? R.string.parasitic_welcome_summary : - R.string.parasitic_welcome_summary_no_shortcut_support) - .setNegativeButton(R.string.never_show, (dialog, which) -> - App.getPreferences().edit().putBoolean("never_show_welcome", true).apply()) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(R.string.create_shortcut, (dialog, which) -> { - var home = (BaseFragment) getParentFragment(); - if (!ShortcutUtil.requestPinLaunchShortcut(() -> { - App.getPreferences().edit().putBoolean("never_show_welcome", true).apply(); - if (home != null) { - home.showHint(R.string.settings_shortcut_pinned_hint, false); - } - })) { - if (home != null) { - home.showHint(R.string.settings_unsupported_pin_shortcut_summary, false); - } - } - }); - return builder.create(); - } - - private Dialog appDialog(BlurBehindDialogBuilder builder) { - - return builder - .setTitle(R.string.app_welcome) - .setMessage(R.string.app_welcome_summary) - .setNegativeButton(R.string.never_show, (d, w) -> - App.getPreferences().edit().putBoolean("never_show_welcome", true).apply()) - .setPositiveButton(android.R.string.ok, null) - .create(); - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - var builder = new BlurBehindDialogBuilder(requireContext(), - R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons); - if (App.isParasitic) { - return parasiticDialog(builder); - } else { - return appDialog(builder); - } - } - - public static void showIfNeed(FragmentManager fm) { - if (shown) return; - if (!ConfigManager.isBinderAlive() || - App.getPreferences().getBoolean("never_show_welcome", false) || - (App.isParasitic && ShortcutUtil.isLaunchShortcutPinned())) { - shown = true; - return; - } - new WelcomeDialog().show(fm, "welcome"); - shown = true; - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java deleted file mode 100644 index f06468f1d..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import android.content.Intent; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.activity.OnBackPressedCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.core.view.MenuProvider; -import androidx.recyclerview.widget.ConcatAdapter; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.adapters.AppHelper; -import org.lsposed.manager.adapters.ScopeAdapter; -import org.lsposed.manager.databinding.FragmentAppListBinding; -import org.lsposed.manager.util.BackupUtils; -import org.lsposed.manager.util.ModuleUtil; - -import rikka.material.app.LocaleDelegate; -import rikka.recyclerview.RecyclerViewKt; - -public class AppListFragment extends BaseFragment implements MenuProvider { - - public SearchView searchView; - private ScopeAdapter scopeAdapter; - private ModuleUtil.InstalledModule module; - - private SearchView.OnQueryTextListener searchListener; - public FragmentAppListBinding binding; - public ActivityResultLauncher backupLauncher; - public ActivityResultLauncher restoreLauncher; - - private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - if (binding != null && scopeAdapter != null) { - binding.swipeRefreshLayout.setRefreshing(!scopeAdapter.isLoaded()); - } - } - }; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragmentAppListBinding.inflate(getLayoutInflater(), container, false); - if (module == null) { - return binding.getRoot(); - } - binding.appBar.setLiftable(true); - String title; - if (module.userId != 0) { - title = String.format(LocaleDelegate.getDefaultLocale(), "%s (%d)", module.getAppName(), module.userId); - } else { - title = module.getAppName(); - } - binding.toolbar.setSubtitle(module.packageName); - - scopeAdapter = new ScopeAdapter(this, module); - scopeAdapter.setHasStableIds(true); - scopeAdapter.registerAdapterDataObserver(observer); - var concatAdapter = new ConcatAdapter(); - concatAdapter.addAdapter(scopeAdapter.switchAdaptor); - concatAdapter.addAdapter(scopeAdapter); - binding.recyclerView.setAdapter(concatAdapter); - binding.recyclerView.setHasFixedSize(true); - binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); - binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> binding.appBar.setLifted(!top)); - RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true); - binding.swipeRefreshLayout.setOnRefreshListener(() -> scopeAdapter.refresh(true)); - binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset()); - Intent intent = AppHelper.getSettingsIntent(module.packageName, module.userId); - if (intent == null) { - binding.fab.setVisibility(View.GONE); - } else { - binding.fab.setVisibility(View.VISIBLE); - binding.fab.setOnClickListener(v -> ConfigManager.startActivityAsUserWithFeature(intent, module.userId)); - } - searchListener = scopeAdapter.getSearchListener(); - - setupToolbar(binding.toolbar, binding.clickView, title, R.menu.menu_app_list, view -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); - View.OnClickListener l = v -> { - if (searchView.isIconified()) { - binding.recyclerView.smoothScrollToPosition(0); - binding.appBar.setExpanded(true, true); - } - }; - binding.toolbar.setOnClickListener(l); - binding.clickView.setOnClickListener(l); - - return binding.getRoot(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (module == null) { - if (!safeNavigate(R.id.action_app_list_fragment_to_modules_fragment)) { - safeNavigate(R.id.modules_nav); - } - } - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - AppListFragmentArgs args = AppListFragmentArgs.fromBundle(getArguments()); - String modulePackageName = args.getModulePackageName(); - int moduleUserId = args.getModuleUserId(); - - module = ModuleUtil.getInstance().getModule(modulePackageName, moduleUserId); - if (module == null) { - if (!safeNavigate(R.id.action_app_list_fragment_to_modules_fragment)) { - safeNavigate(R.id.modules_nav); - } - } - - backupLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument("application/gzip"), - uri -> { - if (uri == null) return; - runAsync(() -> { - try { - BackupUtils.backup(uri, modulePackageName); - } catch (Exception e) { - var text = App.getInstance().getString(R.string.settings_backup_failed2, e.getMessage()); - showHint(text, false); - } - }); - }); - restoreLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), - uri -> { - if (uri == null) return; - runAsync(() -> { - try { - BackupUtils.restore(uri, modulePackageName); - } catch (Exception e) { - var text = App.getInstance().getString(R.string.settings_restore_failed2, e.getMessage()); - showHint(text, false); - } - }); - }); - - requireActivity().getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - scopeAdapter.onBackPressed(); - } - }); - } - - @Override - public void onResume() { - super.onResume(); - if (scopeAdapter != null) scopeAdapter.refresh(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - if (scopeAdapter != null) scopeAdapter.unregisterAdapterDataObserver(observer); - binding = null; - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem item) { - return scopeAdapter.onOptionsItemSelected(item); - } - - @Override - public void onPrepareMenu(@NonNull Menu menu) { - searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); - searchView.setOnQueryTextListener(searchListener); - searchView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(View arg0) { - binding.appBar.setExpanded(false, true); - binding.recyclerView.setNestedScrollingEnabled(false); - } - - @Override - public void onViewDetachedFromWindow(View v) { - binding.recyclerView.setNestedScrollingEnabled(true); - } - }); - searchView.findViewById(androidx.appcompat.R.id.search_edit_frame).setLayoutDirection(View.LAYOUT_DIRECTION_INHERIT); - scopeAdapter.onPrepareOptionsMenu(menu); - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - - } - - @Override - public boolean onContextItemSelected(@NonNull MenuItem item) { - if (scopeAdapter.onContextItemSelected(item)) { - return true; - } - return super.onContextItemSelected(item); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java deleted file mode 100644 index c62ca2c6f..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import android.view.View; -import android.widget.Toast; - -import androidx.annotation.IdRes; -import androidx.annotation.StringRes; -import androidx.appcompat.widget.Toolbar; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.Fragment; -import androidx.navigation.NavController; -import androidx.navigation.NavDirections; -import androidx.navigation.NavOptions; -import androidx.navigation.fragment.NavHostFragment; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.snackbar.Snackbar; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; -import org.lsposed.manager.util.AccessibilityUtils; - -import java.util.concurrent.Callable; -import java.util.concurrent.Future; -import java.util.concurrent.FutureTask; - -public abstract class BaseFragment extends Fragment { - - public void navigateUp() { - getNavController().navigateUp(); - } - - public NavController getNavController() { - return NavHostFragment.findNavController(this); - } - - public boolean safeNavigate(@IdRes int resId) { - try { - if (!AccessibilityUtils.isAnimationEnabled(requireContext().getContentResolver())) { - var clearedNavOptions = new NavOptions.Builder().build(); - getNavController().navigate(resId, clearedNavOptions); - } else { - getNavController().navigate(resId); - } - return true; - } catch (IllegalArgumentException ignored) { - return false; - } - } - - public boolean safeNavigate(NavDirections direction) { - try { - if (!AccessibilityUtils.isAnimationEnabled(requireContext().getContentResolver())) { - var clearedNavOptions = new NavOptions.Builder().build(); - getNavController().navigate(direction, clearedNavOptions); - } else { - getNavController().navigate(direction); - } - return true; - } catch (IllegalArgumentException ignored) { - return false; - } - } - - public void setupToolbar(Toolbar toolbar, View tipsView, int title) { - setupToolbar(toolbar, tipsView, getString(title), -1); - } - - public void setupToolbar(Toolbar toolbar, View tipsView, int title, int menu) { - setupToolbar(toolbar, tipsView, getString(title), menu, null); - } - - public void setupToolbar(Toolbar toolbar, View tipsView, String title, int menu) { - setupToolbar(toolbar, tipsView, title, menu, null); - } - - public void setupToolbar(Toolbar toolbar, View tipsView, String title, int menu, View.OnClickListener navigationOnClickListener) { - toolbar.setNavigationOnClickListener(navigationOnClickListener == null ? (v -> navigateUp()) : navigationOnClickListener); - toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24); - toolbar.setTitle(title); - toolbar.setTooltipText(title); - if (tipsView != null) tipsView.setTooltipText(title); - if (menu != -1) { - toolbar.inflateMenu(menu); - if (this instanceof MenuProvider self) { - toolbar.setOnMenuItemClickListener(self::onMenuItemSelected); - self.onPrepareMenu(toolbar.getMenu()); - } - } - } - - public void runAsync(Runnable runnable) { - App.getExecutorService().submit(runnable); - } - - public Future runAsync(Callable callable) { - return App.getExecutorService().submit(callable); - } - - public void runOnUiThread(Runnable runnable) { - App.getMainHandler().post(runnable); - } - - public Future runOnUiThread(Callable callable) { - var task = new FutureTask<>(callable); - runOnUiThread(task); - return task; - } - - public void showHint(@StringRes int res, boolean lengthShort, @StringRes int actionRes, View.OnClickListener action) { - showHint(App.getInstance().getString(res), lengthShort, App.getInstance().getString(actionRes), action); - } - - public void showHint(@StringRes int res, boolean lengthShort) { - showHint(App.getInstance().getString(res), lengthShort, null, null); - } - - public void showHint(CharSequence str, boolean lengthShort) { - showHint(str, lengthShort, null, null); - } - - public void showHint(CharSequence str, boolean lengthShort, CharSequence actionStr, View.OnClickListener action) { - var container = getView(); - if (isResumed() && container != null) { - var snackbar = Snackbar.make(container, str, lengthShort ? Snackbar.LENGTH_SHORT : Snackbar.LENGTH_LONG); - var fab = container.findViewById(R.id.fab); - if (fab instanceof FloatingActionButton && ((FloatingActionButton) fab).isOrWillBeShown()) - snackbar.setAnchorView(fab); - if (actionStr != null && action != null) snackbar.setAction(actionStr, action); - snackbar.show(); - return; - } - runOnUiThread(() -> { - try { - Toast.makeText(App.getInstance(), str, lengthShort ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG).show(); - } catch (Throwable ignored) { - } - }); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java deleted file mode 100644 index 9f1b11959..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.ui.fragment; - -import android.app.Dialog; -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.LayoutInflater; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatDialogFragment; -import androidx.fragment.app.FragmentManager; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.FragmentCompileDialogBinding; -import org.lsposed.manager.receivers.LSPManagerServiceHolder; -import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; - -import java.lang.ref.WeakReference; - -@SuppressWarnings("deprecation") -public class CompileDialogFragment extends AppCompatDialogFragment { - public static void speed(FragmentManager fragmentManager, ApplicationInfo info) { - CompileDialogFragment fragment = new CompileDialogFragment(); - fragment.setCancelable(false); - var bundle = new Bundle(); - bundle.putParcelable("appInfo", info); - fragment.setArguments(bundle); - fragment.show(fragmentManager, "compile_dialog"); - } - - @Override - @NonNull - public Dialog onCreateDialog(Bundle savedInstanceState) { - var arguments = getArguments(); - ApplicationInfo appInfo = arguments != null ? arguments.getParcelable("appInfo") : null; - if (appInfo == null) { - throw new IllegalStateException("appInfo should not be null."); - } - - FragmentCompileDialogBinding binding = FragmentCompileDialogBinding.inflate(LayoutInflater.from(requireActivity()), null, false); - final PackageManager pm = requireContext().getPackageManager(); - var builder = new BlurBehindDialogBuilder(requireActivity()) - .setIcon(appInfo.loadIcon(pm)) - .setTitle(appInfo.loadLabel(pm)) - .setView(binding.getRoot()); - - var alertDialog = builder.create(); - new CompileTask(this).executeOnExecutor(App.getExecutorService(), appInfo.packageName); - return alertDialog; - } - - private static class CompileTask extends AsyncTask { - - WeakReference outerRef; - - CompileTask(CompileDialogFragment fragment) { - outerRef = new WeakReference<>(fragment); - } - - @Override - protected Throwable doInBackground(String... commands) { - try { - if (LSPManagerServiceHolder.getService().optimizePackage(commands[0])) { - return null; - } else { - return new UnknownError(); - } - } catch (Throwable e) { - return e; - } - } - - @Override - protected void onPostExecute(Throwable result) { - Context context = App.getInstance(); - String text; - if (result != null) { - if (result instanceof UnknownError) { - text = context.getString(R.string.compile_failed); - } else { - text = context.getString(R.string.compile_failed_with_info) + result; - } - } else { - text = context.getString(R.string.compile_done); - } - try { - CompileDialogFragment fragment = outerRef.get(); - if (fragment != null) { - fragment.dismissAllowingStateLoss(); - var parent = fragment.getParentFragment(); - if (parent instanceof BaseFragment) { - ((BaseFragment) parent).showHint(text, true); - } - } - } catch (IllegalStateException ignored) { - } - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java deleted file mode 100644 index 4036587d1..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import android.app.Activity; -import android.app.Dialog; -import android.os.Build; -import android.os.Bundle; -import android.system.ErrnoException; -import android.system.Os; -import android.system.OsConstants; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.DialogFragment; - -import org.lsposed.lspd.ILSPManagerService; -import org.lsposed.manager.BuildConfig; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.DialogAboutBinding; -import org.lsposed.manager.databinding.FragmentHomeBinding; -import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; -import org.lsposed.manager.ui.dialog.WelcomeDialog; -import org.lsposed.manager.util.NavUtil; -import org.lsposed.manager.util.UpdateUtil; -import org.lsposed.manager.util.chrome.LinkTransformationMethod; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.HashMap; -import java.util.concurrent.atomic.AtomicBoolean; - -import rikka.core.util.ClipboardUtils; -import rikka.material.app.LocaleDelegate; - -public class HomeFragment extends BaseFragment implements MenuProvider { - private FragmentHomeBinding binding; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - WelcomeDialog.showIfNeed(getChildFragmentManager()); - } - - @Override - public void onPrepareMenu(Menu menu) { - menu.findItem(R.id.menu_about).setOnMenuItemClickListener(v -> { - showAbout(); - return true; - }); - menu.findItem(R.id.menu_issue).setOnMenuItemClickListener(v -> { - NavUtil.startURL(requireActivity(), "https://github.com/JingMatrix/LSPosed/issues/new/choose"); - return true; - }); - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - return false; - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentHomeBinding.inflate(inflater, container, false); - setupToolbar(binding.toolbar, binding.clickView, R.string.app_name, R.menu.menu_home); - binding.toolbar.setNavigationIcon(null); - binding.toolbar.setOnClickListener(v -> showAbout()); - binding.clickView.setOnClickListener(v -> showAbout()); - binding.appBar.setLiftable(true); - binding.nestedScrollView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> binding.appBar.setLifted(!top)); - - updateStates(requireActivity(), ConfigManager.isBinderAlive(), UpdateUtil.needUpdate()); - - return binding.getRoot(); - } - - private void updateStates(Activity activity, boolean binderAlive, boolean needUpdate) { - if (binderAlive) { - if (needUpdate) { - binding.updateTitle.setText(R.string.need_update); - binding.updateSummary.setText(getString(R.string.please_update_summary)); - binding.statusIcon.setImageResource(R.drawable.ic_round_update_24); - binding.updateBtn.setOnClickListener(v -> { - NavUtil.startURL(activity, getString(R.string.latest_url)); - }); - binding.updateCard.setVisibility(View.VISIBLE); - } else { - binding.updateCard.setVisibility(View.GONE); - } - boolean dex2oatAbnormal = ConfigManager.getDex2OatWrapperCompatibility() != ILSPManagerService.DEX2OAT_OK && !ConfigManager.dex2oatFlagsLoaded(); - var sepolicyAbnormal = !ConfigManager.isSepolicyLoaded(); - var systemServerAbnormal = !ConfigManager.systemServerRequested(); - if (sepolicyAbnormal || systemServerAbnormal || dex2oatAbnormal) { - binding.statusTitle.setText(R.string.partial_activated); - binding.statusIcon.setImageResource(R.drawable.ic_round_warning_24); - binding.warningCard.setVisibility(View.VISIBLE); - if (sepolicyAbnormal) { - binding.warningTitle.setText(R.string.selinux_policy_not_loaded_summary); - binding.warningSummary.setText(HtmlCompat.fromHtml(getString(R.string.selinux_policy_not_loaded), HtmlCompat.FROM_HTML_MODE_LEGACY)); - } - if (systemServerAbnormal) { - binding.warningTitle.setText(R.string.system_inject_fail_summary); - binding.warningSummary.setText(HtmlCompat.fromHtml(getString(R.string.system_inject_fail), HtmlCompat.FROM_HTML_MODE_LEGACY)); - } - if (dex2oatAbnormal) { - binding.warningTitle.setText(R.string.system_prop_incorrect_summary); - binding.warningSummary.setText(HtmlCompat.fromHtml(getString(R.string.system_prop_incorrect), HtmlCompat.FROM_HTML_MODE_LEGACY)); - } - } else { - binding.warningCard.setVisibility(View.GONE); - binding.statusTitle.setText(R.string.activated); - binding.statusIcon.setImageResource(R.drawable.ic_round_check_circle_24); - } - binding.statusSummary.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%d)", - ConfigManager.getXposedVersionName(), ConfigManager.getXposedVersionCode())); - binding.developerWarningCard.setVisibility(isDeveloper() ? View.VISIBLE : View.GONE); - } else { - boolean isMagiskInstalled = ConfigManager.isMagiskInstalled(); - if (isMagiskInstalled) { - binding.updateTitle.setText(R.string.install); - binding.updateSummary.setText(R.string.install_summary); - binding.statusIcon.setImageResource(R.drawable.ic_round_error_outline_24); - binding.updateBtn.setOnClickListener(v -> { - NavUtil.startURL(activity, getString(R.string.install_url)); - }); - binding.updateCard.setVisibility(View.VISIBLE); - } else { - binding.updateCard.setVisibility(View.GONE); - } - binding.warningCard.setVisibility(View.GONE); - binding.statusTitle.setText(R.string.not_installed); - binding.statusSummary.setText(R.string.not_install_summary); - } - - if (ConfigManager.isBinderAlive()) { - binding.apiVersion.setText(String.valueOf(ConfigManager.getXposedApiVersion())); - binding.frameworkVersion.setText(String.format(LocaleDelegate.getDefaultLocale(), "%1$s (%2$d)", ConfigManager.getXposedVersionName(), ConfigManager.getXposedVersionCode())); - binding.managerPackageName.setText(activity.getPackageName()); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - binding.dex2oatWrapper.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%s)", getString(R.string.unsupported), getString(R.string.android_version_unsatisfied))); - } else switch (ConfigManager.getDex2OatWrapperCompatibility()) { - case ILSPManagerService.DEX2OAT_OK -> - binding.dex2oatWrapper.setText(R.string.supported); - case ILSPManagerService.DEX2OAT_CRASHED -> - binding.dex2oatWrapper.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%s)", getString(R.string.unsupported), getString(R.string.crashed))); - case ILSPManagerService.DEX2OAT_MOUNT_FAILED -> - binding.dex2oatWrapper.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%s)", getString(R.string.unsupported), getString(R.string.mount_failed))); - case ILSPManagerService.DEX2OAT_SELINUX_PERMISSIVE -> - binding.dex2oatWrapper.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%s)", getString(R.string.unsupported), getString(R.string.selinux_permissive))); - case ILSPManagerService.DEX2OAT_SEPOLICY_INCORRECT -> - binding.dex2oatWrapper.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%s)", getString(R.string.unsupported), getString(R.string.sepolicy_incorrect))); - } - } else { - binding.apiVersion.setText(R.string.not_installed); - binding.frameworkVersion.setText(R.string.not_installed); - binding.managerPackageName.setText(activity.getPackageName()); - } - - if (Build.VERSION.PREVIEW_SDK_INT != 0) { - binding.systemVersion.setText(String.format(LocaleDelegate.getDefaultLocale(), "%1$s Preview (API %2$d)", Build.VERSION.CODENAME, Build.VERSION.SDK_INT)); - } else { - binding.systemVersion.setText(String.format(LocaleDelegate.getDefaultLocale(), "%1$s (API %2$d)", Build.VERSION.RELEASE, Build.VERSION.SDK_INT)); - } - - binding.device.setText(getDevice()); - binding.systemAbi.setText(Build.SUPPORTED_ABIS[0]); - String info = activity.getString(R.string.info_api_version) + - "\n" + - binding.apiVersion.getText() + - "\n\n" + - activity.getString(R.string.info_dex2oat_wrapper) + - "\n" + - binding.dex2oatWrapper.getText() + - "\n\n" + - activity.getString(R.string.info_framework_version) + - "\n" + - binding.frameworkVersion.getText() + - "\n\n" + - activity.getString(R.string.info_manager_package_name) + - "\n" + - binding.managerPackageName.getText() + - "\n\n" + - activity.getString(R.string.info_system_version) + - "\n" + - binding.systemVersion.getText() + - "\n\n" + - activity.getString(R.string.info_device) + - "\n" + - binding.device.getText() + - "\n\n" + - activity.getString(R.string.info_system_abi) + - "\n" + - binding.systemAbi.getText(); - var map = new HashMap(); - map.put("apiVersion", binding.apiVersion.getText().toString()); - map.put("frameworkVersion", binding.frameworkVersion.getText().toString()); - map.put("systemAbi", Arrays.toString(Build.SUPPORTED_ABIS)); - binding.copyInfo.setOnClickListener(v -> { - ClipboardUtils.put(activity, info); - showHint(R.string.info_copied, false); - }); - } - - private String getDevice() { - String manufacturer = Character.toUpperCase(Build.MANUFACTURER.charAt(0)) + Build.MANUFACTURER.substring(1); - if (!Build.BRAND.equals(Build.MANUFACTURER)) { - manufacturer += " " + Character.toUpperCase(Build.BRAND.charAt(0)) + Build.BRAND.substring(1); - } - manufacturer += " " + Build.MODEL + " "; - return manufacturer; - } - - private boolean isDeveloper() { - var developer = new AtomicBoolean(false); - var pids = Paths.get("/data/local/tmp/.studio/ipids"); - try (var dir = Files.list(pids)) { - dir.findFirst().ifPresent(name -> { - var pid = Integer.parseInt(name.getFileName().toString()); - try { - Os.kill(pid, 0); - developer.set(true); - } catch (ErrnoException e) { - if (e.errno == OsConstants.ESRCH) { - try { - Files.delete(name); - } catch (IOException ignored) { - } - } else { - developer.set(true); - } - } - }); - } catch (IOException e) { - return false; - } - return developer.get(); - } - - public static class AboutDialog extends DialogFragment { - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - DialogAboutBinding binding = DialogAboutBinding.inflate(getLayoutInflater(), null, false); - binding.designAboutTitle.setText(R.string.app_name); - binding.designAboutInfo.setMovementMethod(LinkMovementMethod.getInstance()); - binding.designAboutInfo.setTransformationMethod(new LinkTransformationMethod(requireActivity())); - binding.designAboutInfo.setText(HtmlCompat.fromHtml(getString( - R.string.about_view_source_code, - "GitHub", - "Telegram"), HtmlCompat.FROM_HTML_MODE_LEGACY)); - binding.designAboutVersion.setText(String.format(LocaleDelegate.getDefaultLocale(), "%s (%d)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)); - return new BlurBehindDialogBuilder(requireContext()) - .setView(binding.getRoot()).create(); - } - } - - private void showAbout() { - new AboutDialog().show(getChildFragmentManager(), "about"); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java deleted file mode 100644 index 04a46d1d1..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java +++ /dev/null @@ -1,445 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.ui.fragment; - -import android.annotation.SuppressLint; -import android.content.ActivityNotFoundException; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.HorizontalScrollView; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; -import com.google.android.material.textview.MaterialTextView; - -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.FragmentPagerBinding; -import org.lsposed.manager.databinding.ItemLogTextviewBinding; -import org.lsposed.manager.databinding.SwiperefreshRecyclerviewBinding; -import org.lsposed.manager.receivers.LSPManagerServiceHolder; -import org.lsposed.manager.ui.widget.EmptyStateRecyclerView; -import org.lsposed.manager.util.AccessibilityUtils; - -import java.io.BufferedReader; -import java.io.FileInputStream; -import java.io.InputStreamReader; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import rikka.material.app.LocaleDelegate; -import rikka.recyclerview.RecyclerViewKt; - -public class LogsFragment extends BaseFragment implements MenuProvider { - private FragmentPagerBinding binding; - private LogPageAdapter adapter; - private MenuItem wordWrap; - - interface OptionsItemSelectListener { - boolean onOptionsItemSelected(@NonNull MenuItem item); - } - - private OptionsItemSelectListener optionsItemSelectListener; - - private final ActivityResultLauncher saveLogsLauncher = registerForActivityResult( - new ActivityResultContracts.CreateDocument("application/zip"), - uri -> { - if (uri == null) return; - runAsync(() -> { - var context = requireContext(); - var cr = context.getContentResolver(); - try (var zipFd = cr.openFileDescriptor(uri, "wt")) { - showHint(context.getString(R.string.logs_saving), false); - LSPManagerServiceHolder.getService().getLogs(zipFd); - showHint(context.getString(R.string.logs_saved), true); - } catch (Throwable e) { - var cause = e.getCause(); - var message = cause == null ? e.getMessage() : cause.getMessage(); - var text = context.getString(R.string.logs_save_failed2, message); - showHint(text, false); - Log.w(App.TAG, "save log", e); - } - }); - }); - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragmentPagerBinding.inflate(inflater, container, false); - binding.appBar.setLiftable(true); - setupToolbar(binding.toolbar, binding.clickView, R.string.Logs, R.menu.menu_logs); - binding.toolbar.setNavigationIcon(null); - binding.toolbar.setSubtitle(ConfigManager.isVerboseLogEnabled() ? R.string.enabled_verbose_log : R.string.disabled_verbose_log); - adapter = new LogPageAdapter(this); - binding.viewPager.setAdapter(adapter); - - var isAnimationEnabled = AccessibilityUtils.isAnimationEnabled(requireContext().getContentResolver()); - new TabLayoutMediator( - binding.tabLayout, - binding.viewPager, - // `autoRefresh = true` by default. Update the tabs automatically when the data set of the view pager's - // adapter changes. - true, - isAnimationEnabled, - (tab, position) -> tab.setText((int) adapter.getItemId(position)) - ).attach(); - - binding.tabLayout.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { - ViewGroup vg = (ViewGroup) binding.tabLayout.getChildAt(0); - int tabLayoutWidth = IntStream.range(0, binding.tabLayout.getTabCount()).map(i -> vg.getChildAt(i).getWidth()).sum(); - if (tabLayoutWidth <= binding.getRoot().getWidth()) { - binding.tabLayout.setTabMode(TabLayout.MODE_FIXED); - binding.tabLayout.setTabGravity(TabLayout.GRAVITY_FILL); - } - }); - - return binding.getRoot(); - } - - public void setOptionsItemSelectListener(OptionsItemSelectListener optionsItemSelectListener) { - this.optionsItemSelectListener = optionsItemSelectListener; - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem item) { - var itemId = item.getItemId(); - if (itemId == R.id.menu_save) { - save(); - return true; - } else if (itemId == R.id.menu_word_wrap) { - item.setChecked(!item.isChecked()); - App.getPreferences().edit().putBoolean("enable_word_wrap", item.isChecked()).apply(); - binding.viewPager.setUserInputEnabled(item.isChecked()); - adapter.refresh(); - return true; - } - if (optionsItemSelectListener != null) { - return optionsItemSelectListener.onOptionsItemSelected(item); - } - return false; - } - - @Override - public void onPrepareMenu(@NonNull Menu menu) { - wordWrap = menu.findItem(R.id.menu_word_wrap); - wordWrap.setChecked(App.getPreferences().getBoolean("enable_word_wrap", false)); - binding.viewPager.setUserInputEnabled(wordWrap.isChecked()); - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - binding = null; - } - - private void save() { - LocalDateTime now = LocalDateTime.now(); - String filename = String.format(LocaleDelegate.getDefaultLocale(), "LSPosed_%s.zip", now.toString()); - try { - saveLogsLauncher.launch(filename); - } catch (ActivityNotFoundException e) { - showHint(R.string.enable_documentui, true); - } - } - - public static class LogFragment extends BaseFragment { - public static final int SCROLL_THRESHOLD = 500; - protected boolean verbose; - protected SwiperefreshRecyclerviewBinding binding; - protected LogAdaptor adaptor; - protected LinearLayoutManager layoutManager; - - class LogAdaptor extends EmptyStateRecyclerView.EmptyStateAdapter { - private List log = Collections.emptyList(); - private boolean isLoaded = false; - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(ItemLogTextviewBinding.inflate(getLayoutInflater(), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - holder.item.setText(log.get(position)); - } - - @Override - public int getItemCount() { - return log.size(); - } - - @SuppressLint("NotifyDataSetChanged") - void refresh(List log) { - runOnUiThread(() -> { - isLoaded = true; - this.log = log; - notifyDataSetChanged(); - }); - } - - void fullRefresh() { - runAsync(() -> { - isLoaded = false; - List tmp; - try (var parcelFileDescriptor = ConfigManager.getLog(verbose); - var br = new BufferedReader(new InputStreamReader(new FileInputStream(parcelFileDescriptor != null ? parcelFileDescriptor.getFileDescriptor() : null)))) { - tmp = br.lines().parallel().collect(Collectors.toList()); - } catch (Throwable e) { - tmp = Arrays.asList(Log.getStackTraceString(e).split("\n")); - } - refresh(tmp); - }); - } - - @Override - public boolean isLoaded() { - return isLoaded; - } - - class ViewHolder extends RecyclerView.ViewHolder { - final MaterialTextView item; - - public ViewHolder(ItemLogTextviewBinding binding) { - super(binding.getRoot()); - item = binding.logItem; - } - } - } - - protected LogAdaptor createAdaptor() { - return new LogAdaptor(); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = SwiperefreshRecyclerviewBinding.inflate(getLayoutInflater(), container, false); - var arguments = getArguments(); - if (arguments == null) return null; - verbose = arguments.getBoolean("verbose"); - adaptor = createAdaptor(); - binding.recyclerView.setAdapter(adaptor); - layoutManager = new LinearLayoutManager(requireActivity()); - binding.recyclerView.setLayoutManager(layoutManager); - // ltr even for rtl languages because of log format - binding.recyclerView.setLayoutDirection(View.LAYOUT_DIRECTION_LTR); - binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset()); - RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true); - binding.swipeRefreshLayout.setOnRefreshListener(adaptor::fullRefresh); - adaptor.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - binding.swipeRefreshLayout.setRefreshing(!adaptor.isLoaded()); - } - }); - adaptor.fullRefresh(); - return binding.getRoot(); - } - - public void scrollToTop(LogsFragment logsFragment) { - logsFragment.binding.appBar.setExpanded(true, true); - if (layoutManager.findFirstVisibleItemPosition() > SCROLL_THRESHOLD) { - binding.recyclerView.scrollToPosition(0); - } else { - binding.recyclerView.smoothScrollToPosition(0); - } - } - - public void scrollToBottom(LogsFragment logsFragment) { - logsFragment.binding.appBar.setExpanded(false, true); - var end = Math.max(adaptor.getItemCount() - 1, 0); - if (adaptor.getItemCount() - layoutManager.findLastVisibleItemPosition() > SCROLL_THRESHOLD) { - binding.recyclerView.scrollToPosition(end); - } else { - binding.recyclerView.smoothScrollToPosition(end); - } - } - - void attachListeners() { - var parent = getParentFragment(); - if (parent instanceof LogsFragment logsFragment) { - logsFragment.binding.appBar.setLifted(!binding.recyclerView.getBorderViewDelegate().isShowingTopBorder()); - binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> logsFragment.binding.appBar.setLifted(!top)); - logsFragment.setOptionsItemSelectListener(item -> { - int itemId = item.getItemId(); - if (itemId == R.id.menu_scroll_top) { - scrollToTop(logsFragment); - } else if (itemId == R.id.menu_scroll_down) { - scrollToBottom(logsFragment); - } else if (itemId == R.id.menu_clear) { - if (ConfigManager.clearLogs(verbose)) { - logsFragment.showHint(R.string.logs_cleared, true); - adaptor.fullRefresh(); - } else { - logsFragment.showHint(R.string.logs_clear_failed_2, true); - } - return true; - } - return false; - }); - - View.OnClickListener l = v -> scrollToTop(logsFragment); - logsFragment.binding.clickView.setOnClickListener(l); - logsFragment.binding.toolbar.setOnClickListener(l); - } - } - - void detachListeners() { - binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener(null); - } - - @Override - public void onStart() { - super.onStart(); - attachListeners(); - } - - @Override - public void onResume() { - super.onResume(); - attachListeners(); - } - - - @Override - public void onPause() { - super.onPause(); - detachListeners(); - } - - @Override - public void onStop() { - super.onStop(); - detachListeners(); - } - } - - public static class UnwrapLogFragment extends LogFragment { - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - var root = super.onCreateView(inflater, container, savedInstanceState); - binding.swipeRefreshLayout.removeView(binding.recyclerView); - HorizontalScrollView horizontalScrollView = new HorizontalScrollView(getContext()); - horizontalScrollView.setFillViewport(true); - horizontalScrollView.setHorizontalScrollBarEnabled(false); - horizontalScrollView.setLayoutDirection(View.LAYOUT_DIRECTION_LTR); - if (!AccessibilityUtils.isAnimationEnabled(requireContext().getContentResolver())) { - horizontalScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); - } - binding.swipeRefreshLayout.addView(horizontalScrollView); - horizontalScrollView.addView(binding.recyclerView); - binding.recyclerView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; - return root; - } - - @Override - protected LogAdaptor createAdaptor() { - return new LogAdaptor() { - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - super.onBindViewHolder(holder, position); - var view = holder.item; - view.measure(0, 0); - int desiredWidth = view.getMeasuredWidth(); - ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); - layoutParams.width = desiredWidth; - if (binding.recyclerView.getWidth() < desiredWidth) { - binding.recyclerView.requestLayout(); - } - } - }; - } - } - - class LogPageAdapter extends FragmentStateAdapter { - - public LogPageAdapter(@NonNull Fragment fragment) { - super(fragment); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - var bundle = new Bundle(); - bundle.putBoolean("verbose", verbose(position)); - var f = getItemViewType(position) == 0 ? new LogFragment() : new UnwrapLogFragment(); - f.setArguments(bundle); - return f; - } - - @Override - public int getItemCount() { - return 2; - } - - @Override - public long getItemId(int position) { - return verbose(position) ? R.string.nav_item_logs_verbose : R.string.nav_item_logs_module; - } - - @Override - public boolean containsItem(long itemId) { - return itemId == R.string.nav_item_logs_verbose || itemId == R.string.nav_item_logs_module; - } - - public boolean verbose(int position) { - return position != 0; - } - - @Override - public int getItemViewType(int position) { - return wordWrap.isChecked() ? 0 : 1; - } - - public void refresh() { - runOnUiThread(this::notifyDataSetChanged); - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java deleted file mode 100644 index 3df1a1c3a..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java +++ /dev/null @@ -1,802 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import static android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.TypefaceSpan; -import android.util.SparseArray; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Filter; -import android.widget.Filterable; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.Fragment; -import androidx.navigation.NavOptions; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.adapter.FragmentStateAdapter; -import androidx.viewpager2.widget.ViewPager2; - -import com.bumptech.glide.request.target.CustomTarget; -import com.bumptech.glide.request.transition.Transition; -import com.google.android.material.behavior.HideBottomViewOnScrollBehavior; -import com.google.android.material.checkbox.MaterialCheckBox; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; - -import org.lsposed.lspd.models.UserInfo; -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.adapters.AppHelper; -import org.lsposed.manager.databinding.FragmentPagerBinding; -import org.lsposed.manager.databinding.ItemModuleBinding; -import org.lsposed.manager.databinding.SwiperefreshRecyclerviewBinding; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; -import org.lsposed.manager.ui.widget.EmptyStateRecyclerView; -import org.lsposed.manager.util.GlideApp; -import org.lsposed.manager.util.ModuleUtil; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.function.Consumer; -import java.util.stream.IntStream; - -import rikka.core.util.ResourceUtils; -import rikka.material.app.LocaleDelegate; -import rikka.recyclerview.RecyclerViewKt; - -public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleListener, RepoLoader.RepoListener, MenuProvider { - private static final PackageManager pm = App.getInstance().getPackageManager(); - private static final ModuleUtil moduleUtil = ModuleUtil.getInstance(); - private static final RepoLoader repoLoader = RepoLoader.getInstance(); - protected FragmentPagerBinding binding; - protected SearchView searchView; - private SearchView.OnQueryTextListener searchListener; - - SparseArray adapters = new SparseArray<>(); - PagerAdapter pagerAdapter = null; - - private ModuleUtil.InstalledModule selectedModule; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - searchListener = new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - forEachAdaptor(adapter -> adapter.getFilter().filter(query)); - return false; - } - - @Override - public boolean onQueryTextChange(String query) { - forEachAdaptor(adapter -> adapter.getFilter().filter(query)); - return false; - } - }; - } - - private void forEachAdaptor(Consumer action) { - var snapshot = adapters; - for (var i = 0; i < snapshot.size(); ++i) { - action.accept(snapshot.valueAt(i)); - } - } - - private void showFab() { - var layoutParams = binding.fab.getLayoutParams(); - if (layoutParams instanceof CoordinatorLayout.LayoutParams) { - var coordinatorLayoutBehavior = - ((CoordinatorLayout.LayoutParams) layoutParams).getBehavior(); - if (coordinatorLayoutBehavior instanceof HideBottomViewOnScrollBehavior) { - //noinspection unchecked - ((HideBottomViewOnScrollBehavior) coordinatorLayoutBehavior).slideUp(binding.fab); - } - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragmentPagerBinding.inflate(inflater, container, false); - binding.appBar.setLiftable(true); - setupToolbar(binding.toolbar, binding.clickView, R.string.Modules, R.menu.menu_modules); - binding.toolbar.setNavigationIcon(null); - pagerAdapter = new PagerAdapter(this); - binding.viewPager.setAdapter(pagerAdapter); - binding.viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { - @Override - public void onPageSelected(int position) { - showFab(); - } - }); - - new TabLayoutMediator(binding.tabLayout, binding.viewPager, (tab, position) -> { - if (position < adapters.size()) { - tab.setText(adapters.valueAt(position).getUser().name); - } - }).attach(); - - binding.tabLayout.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { - ViewGroup vg = (ViewGroup) binding.tabLayout.getChildAt(0); - int tabLayoutWidth = IntStream.range(0, binding.tabLayout.getTabCount()).map(i -> vg.getChildAt(i).getWidth()).sum(); - if (tabLayoutWidth <= binding.getRoot().getWidth()) { - binding.tabLayout.setTabMode(TabLayout.MODE_FIXED); - binding.tabLayout.setTabGravity(TabLayout.GRAVITY_FILL); - } - }); - - binding.fab.setOnClickListener(v -> { - var bundle = new Bundle(); - var user = adapters.valueAt(binding.viewPager.getCurrentItem()).getUser(); - bundle.putParcelable("userInfo", user); - var f = new RecyclerViewDialogFragment(); - f.setArguments(bundle); - f.show(getChildFragmentManager(), "install_to_user" + user.id); - }); - - moduleUtil.addListener(this); - repoLoader.addListener(this); - onModulesReloaded(); - - return binding.getRoot(); - } - - @Override - public void onPrepareMenu(Menu menu) { - searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); - if (searchView != null) { - searchView.setOnQueryTextListener(searchListener); - searchView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(@NonNull View arg0) { - binding.appBar.setExpanded(false, true); - } - - @Override - public void onViewDetachedFromWindow(@NonNull View v) { - } - }); - searchView.findViewById(androidx.appcompat.R.id.search_edit_frame).setLayoutDirection(View.LAYOUT_DIRECTION_INHERIT); - } - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - return false; - } - - @Override - public void onResume() { - super.onResume(); - forEachAdaptor(ModuleAdapter::refresh); - } - - @Override - public void onSingleModuleReloaded(ModuleUtil.InstalledModule module) { - forEachAdaptor(ModuleAdapter::refresh); - } - - @Override - public void onModulesReloaded() { - var users = moduleUtil.getUsers(); - if (users == null) return; - - if (users.size() != 1) { - binding.viewPager.setUserInputEnabled(true); - binding.tabLayout.setVisibility(View.VISIBLE); - binding.fab.show(); - } else { - binding.viewPager.setUserInputEnabled(false); - binding.tabLayout.setVisibility(View.GONE); - } - - var tmp = new SparseArray(users.size()); - var snapshot = adapters; - for (var user : users) { - if (snapshot.indexOfKey(user.id) >= 0) { - tmp.put(user.id, snapshot.get(user.id)); - } else { - var adapter = new ModuleAdapter(user); - adapter.setHasStableIds(true); - tmp.put(user.id, adapter); - } - } - adapters = tmp; - forEachAdaptor(ModuleAdapter::refresh); - runOnUiThread(pagerAdapter::notifyDataSetChanged); - updateModuleSummary(); - } - - @Override - public void onRepoLoaded() { - forEachAdaptor(ModuleAdapter::refresh); - } - - private void updateModuleSummary() { - var moduleCount = moduleUtil.getEnabledModulesCount(); - runOnUiThread(() -> { - if (binding != null) { - binding.toolbar.setSubtitle(moduleCount == -1 ? getString(R.string.loading) : getResources().getQuantityString(R.plurals.modules_enabled_count, moduleCount, moduleCount)); - binding.toolbarLayout.setSubtitle(binding.toolbar.getSubtitle()); - } - }); - } - - void installModuleToUser(ModuleUtil.InstalledModule module, UserInfo user) { - new BlurBehindDialogBuilder(requireActivity(), R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons) - .setTitle(getString(R.string.install_to_user, user.name)) - .setMessage(getString(R.string.install_to_user_message, module.getAppName(), user.name)) - .setPositiveButton(android.R.string.ok, (dialog, which) -> - runAsync(() -> { - var success = ConfigManager.installExistingPackageAsUser(module.packageName, user.id); - String text = success ? - getString(R.string.module_installed, module.getAppName(), user.name) : - getString(R.string.module_install_failed); - showHint(text, false); - if (success) - moduleUtil.reloadSingleModule(module.packageName, user.id); - })) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @SuppressLint("WrongConstant") - @Override - public boolean onContextItemSelected(@NonNull MenuItem item) { - if (selectedModule == null) { - return false; - } - int itemId = item.getItemId(); - if (itemId == R.id.menu_launch) { - String packageName = selectedModule.packageName; - if (packageName == null) { - return false; - } - Intent intent = AppHelper.getSettingsIntent(packageName, selectedModule.userId); - if (intent != null) { - ConfigManager.startActivityAsUserWithFeature(intent, selectedModule.userId); - } - return true; - } else if (itemId == R.id.menu_other_app) { - var intent = new Intent(Intent.ACTION_SHOW_APP_INFO); - intent.putExtra(Intent.EXTRA_PACKAGE_NAME, selectedModule.packageName); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - ConfigManager.startActivityAsUserWithFeature(intent, selectedModule.userId); - return true; - } else if (itemId == R.id.menu_app_info) { - ConfigManager.startActivityAsUserWithFeature(new Intent(ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", selectedModule.packageName, null)), selectedModule.userId); - return true; - } else if (itemId == R.id.menu_uninstall) { - new BlurBehindDialogBuilder(requireActivity(), R.style.ThemeOverlay_MaterialAlertDialog_FullWidthButtons) - .setIcon(selectedModule.app.loadIcon(pm)) - .setTitle(selectedModule.getAppName()) - .setMessage(R.string.module_uninstall_message) - .setPositiveButton(android.R.string.ok, (dialog, which) -> - runAsync(() -> { - boolean success = ConfigManager.uninstallPackage(selectedModule.packageName, selectedModule.userId); - String text = success ? getString(R.string.module_uninstalled, selectedModule.getAppName()) : getString(R.string.module_uninstall_failed); - showHint(text, false); - if (success) - moduleUtil.reloadSingleModule(selectedModule.packageName, selectedModule.userId); - })) - .setNegativeButton(android.R.string.cancel, null) - .show(); - return true; - } else if (itemId == R.id.menu_repo) { - var navController = getNavController(); - navController.navigate( - new Uri.Builder().scheme("lsposed").authority("repo").appendQueryParameter("modulePackageName", selectedModule.packageName).build(), - new NavOptions.Builder().setEnterAnim(R.anim.fragment_enter).setExitAnim(R.anim.fragment_exit).setPopEnterAnim(R.anim.fragment_enter_pop).setPopExitAnim(R.anim.fragment_exit_pop).setLaunchSingleTop(true).setPopUpTo(getNavController().getGraph().getStartDestinationId(), false, true).build()); - return true; - } else if (itemId == R.id.menu_compile_speed) { - CompileDialogFragment.speed(getChildFragmentManager(), selectedModule.pkg.applicationInfo); - } - return super.onContextItemSelected(item); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - moduleUtil.removeListener(this); - repoLoader.removeListener(this); - binding = null; - } - - public static class ModuleListFragment extends Fragment { - public SwiperefreshRecyclerviewBinding binding; - private ModuleAdapter adapter; - private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - binding.swipeRefreshLayout.setRefreshing(!adapter.isLoaded()); - } - }; - - private final View.OnAttachStateChangeListener searchViewLocker = new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(@NonNull View v) { - binding.recyclerView.setNestedScrollingEnabled(false); - } - - @Override - public void onViewDetachedFromWindow(@NonNull View v) { - binding.recyclerView.setNestedScrollingEnabled(true); - } - }; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - ModulesFragment fragment = (ModulesFragment) getParentFragment(); - Bundle arguments = getArguments(); - if (fragment == null || arguments == null) { - return null; - } - int userId = arguments.getInt("user_id"); - binding = SwiperefreshRecyclerviewBinding.inflate(getLayoutInflater(), container, false); - adapter = fragment.adapters.get(userId); - binding.recyclerView.setAdapter(adapter); - binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); - binding.swipeRefreshLayout.setOnRefreshListener(adapter::fullRefresh); - binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset()); - RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true); - adapter.registerAdapterDataObserver(observer); - return binding.getRoot(); - } - - void attachListeners() { - var parent = getParentFragment(); - if (parent instanceof ModulesFragment moduleFragment) { - binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> moduleFragment.binding.appBar.setLifted(!top)); - moduleFragment.binding.appBar.setLifted(!binding.recyclerView.getBorderViewDelegate().isShowingTopBorder()); - moduleFragment.searchView.addOnAttachStateChangeListener(searchViewLocker); - binding.recyclerView.setNestedScrollingEnabled(moduleFragment.searchView.isIconified()); - View.OnClickListener l = v -> { - if (moduleFragment.searchView.isIconified()) { - binding.recyclerView.smoothScrollToPosition(0); - moduleFragment.binding.appBar.setExpanded(true, true); - } - }; - moduleFragment.binding.clickView.setOnClickListener(l); - moduleFragment.binding.toolbar.setOnClickListener(l); - } - } - - void detachListeners() { - binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener(null); - var parent = getParentFragment(); - if (parent instanceof ModulesFragment moduleFragment) { - moduleFragment.searchView.removeOnAttachStateChangeListener(searchViewLocker); - binding.recyclerView.setNestedScrollingEnabled(true); - } - } - - @Override - public void onStart() { - super.onStart(); - attachListeners(); - } - - @Override - public void onResume() { - super.onResume(); - attachListeners(); - } - - @Override - public void onDestroyView() { - adapter.unregisterAdapterDataObserver(observer); - super.onDestroyView(); - } - - @Override - public void onPause() { - super.onPause(); - detachListeners(); - } - - @Override - public void onStop() { - super.onStop(); - detachListeners(); - } - } - - private class PagerAdapter extends FragmentStateAdapter { - - public PagerAdapter(@NonNull Fragment fragment) { - super(fragment); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - Bundle bundle = new Bundle(); - bundle.putInt("user_id", adapters.keyAt(position)); - Fragment fragment = new ModuleListFragment(); - fragment.setArguments(bundle); - return fragment; - } - - @Override - public int getItemCount() { - return adapters.size(); - } - - @Override - public long getItemId(int position) { - return adapters.keyAt(position); - } - - @Override - public boolean containsItem(long itemId) { - return adapters.indexOfKey((int) itemId) >= 0; - } - } - - ModuleAdapter createPickModuleAdapter(UserInfo userInfo) { - return new ModuleAdapter(userInfo, true); - } - - class ModuleAdapter extends EmptyStateRecyclerView.EmptyStateAdapter implements Filterable { - private List searchList = new ArrayList<>(); - private List showList = new ArrayList<>(); - private final UserInfo user; - private final boolean isPick; - private boolean isLoaded; - private View.OnClickListener onPickListener; - - ModuleAdapter(UserInfo user) { - this(user, false); - } - - ModuleAdapter(UserInfo user, boolean isPick) { - this.user = user; - this.isPick = isPick; - } - - public UserInfo getUser() { - return user; - } - - @NonNull - @Override - public ModuleAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(ItemModuleBinding.inflate(getLayoutInflater(), parent, false)); - } - - public boolean isPick() { - return isPick; - } - - @Override - public void onBindViewHolder(@NonNull ModuleAdapter.ViewHolder holder, int position) { - ModuleUtil.InstalledModule item = showList.get(position); - String appName; - if (item.userId != 0) { - appName = String.format(LocaleDelegate.getDefaultLocale(), "%s (%d)", item.getAppName(), item.userId); - } else { - appName = item.getAppName(); - } - holder.appName.setText(appName); - GlideApp.with(holder.appIcon) - .load(item.getPackageInfo()) - .into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { - holder.appIcon.setImageDrawable(resource); - } - - @Override - public void onLoadCleared(@Nullable Drawable placeholder) { - - } - }); - SpannableStringBuilder sb = new SpannableStringBuilder(); - if (!item.getDescription().isEmpty()) { - sb.append(item.getDescription()); - } else { - sb.append(getString(R.string.module_empty_description)); - } - holder.appDescription.setText(sb); - holder.appDescription.setVisibility(View.VISIBLE); - sb = new SpannableStringBuilder(); - - int installXposedVersion = ConfigManager.getXposedApiVersion(); - String warningText = null; - if (item.minVersion == 0) { - warningText = getString(R.string.no_min_version_specified); - } else if (installXposedVersion > 0 && item.minVersion > installXposedVersion) { - warningText = getString(R.string.warning_xposed_min_version, item.minVersion); - } else if (item.targetVersion > installXposedVersion) { - warningText = getString(R.string.warning_target_version_higher, item.targetVersion); - } else if (item.minVersion < ModuleUtil.MIN_MODULE_VERSION) { - warningText = getString(R.string.warning_min_version_too_low, item.minVersion, ModuleUtil.MIN_MODULE_VERSION); - } else if (item.isInstalledOnExternalStorage()) { - warningText = getString(R.string.warning_installed_on_external_storage); - } - if (warningText != null) { - sb.append(warningText); - final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(requireActivity().getTheme(), com.google.android.material.R.attr.colorError)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - final TypefaceSpan typefaceSpan = new TypefaceSpan(Typeface.create("sans-serif-medium", Typeface.NORMAL)); - sb.setSpan(typefaceSpan, sb.length() - warningText.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD); - sb.setSpan(styleSpan, sb.length() - warningText.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - sb.setSpan(foregroundColorSpan, sb.length() - warningText.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - var ver = repoLoader.getModuleLatestVersion(item.packageName); - if (ver != null && ver.upgradable(item.versionCode, item.versionName)) { - if (warningText != null) sb.append("\n"); - String recommended = getString(R.string.update_available, ver.versionName); - sb.append(recommended); - final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(requireActivity().getTheme(), androidx.appcompat.R.attr.colorPrimary)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - final TypefaceSpan typefaceSpan = new TypefaceSpan(Typeface.create("sans-serif-medium", Typeface.NORMAL)); - sb.setSpan(typefaceSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD); - sb.setSpan(styleSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - sb.setSpan(foregroundColorSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - if (sb.length() == 0) { - holder.hint.setVisibility(View.GONE); - } else { - holder.hint.setVisibility(View.VISIBLE); - holder.hint.setText(sb); - } - - if (!isPick) { - holder.root.setAlpha(moduleUtil.isModuleEnabled(item.packageName) ? 1.0f : .5f); - holder.itemView.setOnClickListener(v -> { - searchView.clearFocus(); - if (isLoaded()) { - safeNavigate(ModulesFragmentDirections.actionModulesFragmentToAppListFragment(item.packageName, item.userId)); - } - }); - holder.itemView.setOnLongClickListener(v -> { - searchView.clearFocus(); - selectedModule = item; - return false; - }); - holder.itemView.setOnCreateContextMenuListener((menu, v, menuInfo) -> { - requireActivity().getMenuInflater().inflate(R.menu.context_menu_modules, menu); - menu.setHeaderTitle(item.getAppName()); - Intent intent = AppHelper.getSettingsIntent(item.packageName, item.userId); - if (intent == null) { - menu.removeItem(R.id.menu_launch); - } - if (repoLoader.getOnlineModule(item.packageName) == null) { - menu.removeItem(R.id.menu_repo); - } - if (item.userId == 0) { - var users = ConfigManager.getUsers(); - if (users != null) { - for (var user : users) { - if (moduleUtil.getModule(item.packageName, user.id) == null) { - menu.add(1, user.id, 0, getString(R.string.install_to_user, user.name)).setOnMenuItemClickListener(i -> { - installModuleToUser(selectedModule, user); - return true; - }); - } - } - } - } - }); - holder.appVersion.setVisibility(View.VISIBLE); - holder.appVersion.setText(item.versionName); - holder.appVersion.setSelected(true); - } else { - holder.itemView.setTag(item); - holder.itemView.setOnClickListener(v -> { - if (onPickListener != null) onPickListener.onClick(v); - }); - } - } - - @Override - public void onViewRecycled(@NonNull ViewHolder holder) { - holder.itemView.setTag(null); - super.onViewRecycled(holder); - } - - @Override - public int getItemCount() { - return showList.size(); - } - - @Override - public long getItemId(int position) { - var module = showList.get(position); - return (module.packageName + "!" + module.userId).hashCode(); - } - - @Override - public Filter getFilter() { - return new ModuleAdapter.ApplicationFilter(); - } - - public void setOnPickListener(View.OnClickListener onPickListener) { - this.onPickListener = onPickListener; - } - - public void refresh() { - runAsync(reloadModules); - } - - public void fullRefresh() { - runAsync(() -> { - setLoaded(null, false); - moduleUtil.reloadInstalledModules(); - refresh(); - }); - } - - private final Runnable reloadModules = () -> { - var modules = moduleUtil.getModules(); - if (modules == null) return; - Comparator cmp = AppHelper.getAppListComparator(0, pm); - setLoaded(null, false); - var tmpList = new ArrayList(); - modules.values().parallelStream() - .sorted((a, b) -> { - boolean aChecked = moduleUtil.isModuleEnabled(a.packageName); - boolean bChecked = moduleUtil.isModuleEnabled(b.packageName); - if (aChecked == bChecked) { - var c = cmp.compare(a.pkg, b.pkg); - if (c == 0) { - if (a.userId == getUser().id) return -1; - if (b.userId == getUser().id) return 1; - else return Integer.compare(a.userId, b.userId); - } - return c; - } else if (aChecked) { - return -1; - } else { - return 1; - } - }).forEachOrdered(new Consumer<>() { - private final HashSet uniquer = new HashSet<>(); - - @Override - public void accept(ModuleUtil.InstalledModule module) { - if (isPick()) { - if (!uniquer.contains(module.packageName)) { - uniquer.add(module.packageName); - if (module.userId != getUser().id) - tmpList.add(module); - } - } else if (module.userId == getUser().id) { - tmpList.add(module); - } - } - }); - String queryStr = searchView != null ? searchView.getQuery().toString() : ""; - searchList = tmpList; - runOnUiThread(() -> getFilter().filter(queryStr)); - }; - - @SuppressLint("NotifyDataSetChanged") - private void setLoaded(List list, boolean loaded) { - runOnUiThread(() -> { - if (list != null) showList = list; - isLoaded = loaded; - notifyDataSetChanged(); - }); - } - - @Override - public boolean isLoaded() { - return isLoaded && moduleUtil.isModulesLoaded(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - ConstraintLayout root; - ImageView appIcon; - TextView appName; - TextView appDescription; - TextView appVersion; - TextView hint; - MaterialCheckBox checkBox; - - ViewHolder(ItemModuleBinding binding) { - super(binding.getRoot()); - root = binding.itemRoot; - appIcon = binding.appIcon; - appName = binding.appName; - appDescription = binding.description; - appVersion = binding.versionName; - hint = binding.hint; - checkBox = binding.checkbox; - } - } - - class ApplicationFilter extends Filter { - - private boolean lowercaseContains(String s, String filter) { - return !TextUtils.isEmpty(s) && s.toLowerCase().contains(filter); - } - - @Override - protected FilterResults performFiltering(CharSequence constraint) { - FilterResults filterResults = new FilterResults(); - List filtered = new ArrayList<>(); - String filter = constraint.toString().toLowerCase(); - for (ModuleUtil.InstalledModule info : searchList) { - if (lowercaseContains(info.getAppName(), filter) || - lowercaseContains(info.packageName, filter) || - lowercaseContains(info.getDescription(), filter)) { - filtered.add(info); - } - } - filterResults.values = filtered; - filterResults.count = filtered.size(); - return filterResults; - } - - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - //noinspection unchecked - setLoaded((List) results.values, true); - } - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java deleted file mode 100644 index 5a49c8d74..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.ui.fragment; - -import android.app.Dialog; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatDialogFragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.lsposed.lspd.models.UserInfo; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.DialogTitleBinding; -import org.lsposed.manager.databinding.SwiperefreshRecyclerviewBinding; -import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; -import org.lsposed.manager.util.ModuleUtil; - -public class RecyclerViewDialogFragment extends AppCompatDialogFragment { - @Override - @NonNull - public Dialog onCreateDialog(Bundle savedInstanceState) { - var parent = getParentFragment(); - var arguments = getArguments(); - if (!(parent instanceof ModulesFragment) || arguments == null) { - throw new IllegalStateException(); - } - var modulesFragment = (ModulesFragment) parent; - var user = (UserInfo) arguments.getParcelable("userInfo"); - - var pickAdaptor = modulesFragment.createPickModuleAdapter(user); - var binding = SwiperefreshRecyclerviewBinding.inflate(LayoutInflater.from(requireActivity()), null, false); - - binding.recyclerView.setAdapter(pickAdaptor); - binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); - pickAdaptor.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - binding.swipeRefreshLayout.setRefreshing(!pickAdaptor.isLoaded()); - } - }); - binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset()); - binding.swipeRefreshLayout.setOnRefreshListener(pickAdaptor::fullRefresh); - pickAdaptor.refresh(); - var title = DialogTitleBinding.inflate(getLayoutInflater()).getRoot(); - title.setText(getString(R.string.install_to_user, user.name)); - var dialog = new BlurBehindDialogBuilder(requireActivity(), R.style.ThemeOverlay_MaterialAlertDialog_FullWidthButtons) - .setCustomTitle(title) - .setView(binding.getRoot()) - .setNegativeButton(android.R.string.cancel, null) - .create(); - title.setOnClickListener(s -> binding.recyclerView.smoothScrollToPosition(0)); - pickAdaptor.setOnPickListener(picked -> { - var module = (ModuleUtil.InstalledModule) picked.getTag(); - modulesFragment.installModuleToUser(module, user); - dialog.dismiss(); - }); - onViewCreated(binding.getRoot(), savedInstanceState); - return dialog; - } - - // prevent from overriding - public final void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java deleted file mode 100644 index d1a65b32e..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java +++ /dev/null @@ -1,470 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import android.annotation.SuppressLint; -import android.content.res.Resources; -import android.graphics.Typeface; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.style.ForegroundColorSpan; -import android.text.style.StyleSpan; -import android.text.style.TypefaceSpan; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebView; -import android.widget.Filter; -import android.widget.Filterable; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.view.MenuProvider; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.FragmentRepoBinding; -import org.lsposed.manager.databinding.ItemOnlinemoduleBinding; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.repo.model.OnlineModule; -import org.lsposed.manager.ui.widget.EmptyStateRecyclerView; -import org.lsposed.manager.util.ModuleUtil; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -import rikka.core.util.LabelComparator; -import rikka.core.util.ResourceUtils; -import rikka.recyclerview.RecyclerViewKt; - -public class RepoFragment extends BaseFragment implements RepoLoader.RepoListener, ModuleUtil.ModuleListener, MenuProvider { - protected FragmentRepoBinding binding; - protected SearchView searchView; - private SearchView.OnQueryTextListener mSearchListener; - private final Handler mHandler = new Handler(Looper.getMainLooper()); - private boolean preLoadWebview = true; - - private final RepoLoader repoLoader = RepoLoader.getInstance(); - private final ModuleUtil moduleUtil = ModuleUtil.getInstance(); - private RepoAdapter adapter; - private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - binding.swipeRefreshLayout.setRefreshing(!adapter.isLoaded()); - } - }; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - mSearchListener = new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - adapter.getFilter().filter(query); - return false; - } - - @Override - public boolean onQueryTextChange(String newText) { - adapter.getFilter().filter(newText); - return false; - } - }; - super.onCreate(savedInstanceState); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragmentRepoBinding.inflate(getLayoutInflater(), container, false); - binding.appBar.setLiftable(true); - binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> binding.appBar.setLifted(!top)); - setupToolbar(binding.toolbar, binding.clickView, R.string.module_repo, R.menu.menu_repo); - binding.toolbar.setNavigationIcon(null); - adapter = new RepoAdapter(); - adapter.setHasStableIds(true); - adapter.registerAdapterDataObserver(observer); - binding.recyclerView.setAdapter(adapter); - binding.recyclerView.setHasFixedSize(true); - binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); - RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true); - binding.swipeRefreshLayout.setOnRefreshListener(adapter::fullRefresh); - binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset()); - View.OnClickListener l = v -> { - if (searchView.isIconified()) { - binding.recyclerView.smoothScrollToPosition(0); - binding.appBar.setExpanded(true, true); - } - }; - binding.toolbar.setOnClickListener(l); - binding.clickView.setOnClickListener(l); - repoLoader.addListener(this); - moduleUtil.addListener(this); - onRepoLoaded(); - return binding.getRoot(); - } - - private void updateRepoSummary() { - final int[] count = new int[]{0}; - HashSet processedModules = new HashSet<>(); - var modules = moduleUtil.getModules(); - if (modules != null && repoLoader.isRepoLoaded()) { - modules.forEach((k, v) -> { - if (!processedModules.contains(k.first)) { - var ver = repoLoader.getModuleLatestVersion(k.first); - if (ver != null && ver.upgradable(v.versionCode, v.versionName)) { - ++count[0]; - } - processedModules.add(k.first); - } - } - ); - } else { - count[0] = -1; - } - runOnUiThread(() -> { - if (binding != null) { - if (count[0] > 0) { - binding.toolbar.setSubtitle(getResources().getQuantityString(R.plurals.module_repo_upgradable, count[0], count[0])); - } else if (count[0] == 0) { - binding.toolbar.setSubtitle(getResources().getString(R.string.module_repo_up_to_date)); - } else { - binding.toolbar.setSubtitle(getResources().getString(R.string.loading)); - } - binding.toolbarLayout.setSubtitle(binding.toolbar.getSubtitle()); - } - }); - } - - @Override - public void onPrepareMenu(Menu menu) { - searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView(); - if (searchView != null) { - searchView.setOnQueryTextListener(mSearchListener); - searchView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(@NonNull View arg0) { - binding.appBar.setExpanded(false, true); - binding.recyclerView.setNestedScrollingEnabled(false); - } - - @Override - public void onViewDetachedFromWindow(@NonNull View v) { - binding.recyclerView.setNestedScrollingEnabled(true); - } - }); - searchView.findViewById(androidx.appcompat.R.id.search_edit_frame).setLayoutDirection(View.LAYOUT_DIRECTION_INHERIT); - } - int sort = App.getPreferences().getInt("repo_sort", 0); - if (sort == 0) { - menu.findItem(R.id.item_sort_by_name).setChecked(true); - } else if (sort == 1) { - menu.findItem(R.id.item_sort_by_update_time).setChecked(true); - } - menu.findItem(R.id.item_upgradable_first).setChecked(App.getPreferences().getBoolean("upgradable_first", true)); - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - mHandler.removeCallbacksAndMessages(null); - repoLoader.removeListener(this); - moduleUtil.removeListener(this); - adapter.unregisterAdapterDataObserver(observer); - binding = null; - } - - @Override - public void onResume() { - super.onResume(); - adapter.refresh(); - if (preLoadWebview) { - mHandler.postDelayed(() -> new WebView(requireContext()), 500); - preLoadWebview = false; - } - } - - @Override - public void onRepoLoaded() { - if (adapter != null) { - adapter.refresh(); - } - updateRepoSummary(); - } - - @Override - public void onThrowable(Throwable t) { - showHint(getString(R.string.repo_load_failed, t.getLocalizedMessage()), true); - updateRepoSummary(); - } - - @Override - public void onModulesReloaded() { - updateRepoSummary(); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.item_sort_by_name) { - item.setChecked(true); - App.getPreferences().edit().putInt("repo_sort", 0).apply(); - adapter.refresh(); - } else if (itemId == R.id.item_sort_by_update_time) { - item.setChecked(true); - App.getPreferences().edit().putInt("repo_sort", 1).apply(); - adapter.refresh(); - } else if (itemId == R.id.item_upgradable_first) { - item.setChecked(!item.isChecked()); - App.getPreferences().edit().putBoolean("upgradable_first", item.isChecked()).apply(); - adapter.refresh(); - } else { - return false; - } - return true; - } - - private class RepoAdapter extends EmptyStateRecyclerView.EmptyStateAdapter implements Filterable { - private List fullList, showList; - private final LabelComparator labelComparator = new LabelComparator(); - private boolean isLoaded = false; - private final Resources resources = App.getInstance().getResources(); - private final String[] channels = resources.getStringArray(R.array.update_channel_values); - private String channel; - private final RepoLoader repoLoader = RepoLoader.getInstance(); - - RepoAdapter() { - fullList = showList = Collections.emptyList(); - } - - @NonNull - @Override - public RepoAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(ItemOnlinemoduleBinding.inflate(getLayoutInflater(), parent, false)); - } - - RepoLoader.ModuleVersion getUpgradableVer(OnlineModule module) { - ModuleUtil.InstalledModule installedModule = moduleUtil.getModule(module.getName()); - if (installedModule != null) { - var ver = repoLoader.getModuleLatestVersion(installedModule.packageName); - if (ver != null && ver.upgradable(installedModule.versionCode, installedModule.versionName)) - return ver; - } - return null; - } - - @Override - public void onBindViewHolder(@NonNull RepoAdapter.ViewHolder holder, int position) { - OnlineModule module = showList.get(position); - holder.appName.setText(module.getDescription()); - holder.appPackageName.setText(module.getName()); - Instant instant; - channel = App.getPreferences().getString("update_channel", channels[0]); - var latestReleaseTime = repoLoader.getLatestReleaseTime(module.getName(), channel); - instant = Instant.parse(latestReleaseTime != null ? latestReleaseTime : module.getLatestReleaseTime()); - var formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) - .withLocale(App.getLocale()).withZone(ZoneId.systemDefault()); - holder.publishedTime.setText(String.format(getString(R.string.module_repo_updated_time), formatter.format(instant))); - SpannableStringBuilder sb = new SpannableStringBuilder(); - - String summary = module.getSummary(); - if (summary != null) { - sb.append(summary); - } - holder.appDescription.setVisibility(View.VISIBLE); - holder.appDescription.setText(sb); - sb = new SpannableStringBuilder(); - var upgradableVer = getUpgradableVer(module); - if (upgradableVer != null) { - String hint = getString(R.string.update_available, upgradableVer.versionName); - sb.append(hint); - final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(requireActivity().getTheme(), com.google.android.material.R.attr.colorPrimary)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - final TypefaceSpan typefaceSpan = new TypefaceSpan(Typeface.create("sans-serif-medium", Typeface.NORMAL)); - sb.setSpan(typefaceSpan, sb.length() - hint.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD); - sb.setSpan(styleSpan, sb.length() - hint.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - sb.setSpan(foregroundColorSpan, sb.length() - hint.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else if (moduleUtil.getModule(module.getName()) != null) { - String installed = getString(R.string.installed); - sb.append(installed); - final StyleSpan styleSpan = new StyleSpan(Typeface.ITALIC); - sb.setSpan(styleSpan, sb.length() - installed.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(requireActivity().getTheme(), com.google.android.material.R.attr.colorSecondary)); - sb.setSpan(foregroundColorSpan, sb.length() - installed.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - if (sb.length() > 0) { - holder.hint.setVisibility(View.VISIBLE); - holder.hint.setText(sb); - } else { - holder.hint.setVisibility(View.GONE); - } - - holder.itemView.setOnClickListener(v -> { - searchView.clearFocus(); - safeNavigate(RepoFragmentDirections.actionRepoFragmentToRepoItemFragment(module.getName())); - }); - holder.itemView.setTooltipText(module.getDescription()); - } - - @Override - public int getItemCount() { - return showList.size(); - } - - @SuppressLint("NotifyDataSetChanged") - private void setLoaded(List list, boolean isLoaded) { - runOnUiThread(() -> { - if (list != null) showList = list; - this.isLoaded = isLoaded; - notifyDataSetChanged(); - }); - } - - public void setData(Collection modules) { - if (modules == null) return; - setLoaded(null, false); - channel = App.getPreferences().getString("update_channel", channels[0]); - int sort = App.getPreferences().getInt("repo_sort", 0); - boolean upgradableFirst = App.getPreferences().getBoolean("upgradable_first", true); - ConcurrentHashMap upgradable = new ConcurrentHashMap<>(); - fullList = modules.parallelStream().filter((onlineModule -> !onlineModule.isHide() && !(repoLoader.getReleases(onlineModule.getName()) != null && repoLoader.getReleases(onlineModule.getName()).isEmpty()))) - .sorted((a, b) -> { - if (upgradableFirst) { - var aUpgrade = upgradable.computeIfAbsent(a.getName(), n -> getUpgradableVer(a) != null); - var bUpgrade = upgradable.computeIfAbsent(b.getName(), n -> getUpgradableVer(b) != null); - if (aUpgrade && !bUpgrade) return -1; - else if (!aUpgrade && bUpgrade) return 1; - } - if (sort == 0) { - return labelComparator.compare(a.getDescription(), b.getDescription()); - } else { - return Instant.parse(repoLoader.getLatestReleaseTime(b.getName(), channel)).compareTo(Instant.parse(repoLoader.getLatestReleaseTime(a.getName(), channel))); - } - }).collect(Collectors.toList()); - String queryStr = searchView != null ? searchView.getQuery().toString() : ""; - runOnUiThread(() -> getFilter().filter(queryStr)); - } - - public void fullRefresh() { - runAsync(() -> { - setLoaded(null, false); - repoLoader.loadRemoteData(); - refresh(); - }); - } - - public void refresh() { - runAsync(() -> adapter.setData(repoLoader.getOnlineModules())); - } - - @Override - public long getItemId(int position) { - return showList.get(position).getName().hashCode(); - } - - @Override - public Filter getFilter() { - return new RepoAdapter.ModuleFilter(); - } - - @Override - public boolean isLoaded() { - return isLoaded && repoLoader.isRepoLoaded(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - ConstraintLayout root; - TextView appName; - TextView appPackageName; - TextView appDescription; - TextView hint; - TextView publishedTime; - - ViewHolder(ItemOnlinemoduleBinding binding) { - super(binding.getRoot()); - root = binding.itemRoot; - appName = binding.appName; - appPackageName = binding.appPackageName; - appDescription = binding.description; - hint = binding.hint; - publishedTime = binding.publishedTime; - } - } - - class ModuleFilter extends Filter { - - private boolean lowercaseContains(String s, String filter) { - return !TextUtils.isEmpty(s) && s.toLowerCase().contains(filter); - } - - @Override - protected FilterResults performFiltering(CharSequence constraint) { - FilterResults filterResults = new FilterResults(); - ArrayList filtered = new ArrayList<>(); - String filter = constraint.toString().toLowerCase(); - for (OnlineModule info : fullList) { - if (lowercaseContains(info.getDescription(), filter) || - lowercaseContains(info.getName(), filter) || - lowercaseContains(info.getSummary(), filter)) { - filtered.add(info); - } - } - filterResults.values = filtered; - filterResults.count = filtered.size(); - return filterResults; - } - - @Override - protected void publishResults(CharSequence constraint, FilterResults results) { - //noinspection unchecked - setLoaded((List) results.values, true); - } - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java deleted file mode 100644 index 4f64c8f48..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java +++ /dev/null @@ -1,824 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.Dialog; -import android.content.res.Resources; -import android.graphics.Color; -import android.os.Bundle; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.format.Formatter; -import android.text.style.ClickableSpan; -import android.text.style.ForegroundColorSpan; -import android.text.style.RelativeSizeSpan; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.WebResourceRequest; -import android.webkit.WebResourceResponse; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.ArrayAdapter; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.MenuProvider; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import com.google.android.material.button.MaterialButton; -import com.google.android.material.progressindicator.CircularProgressIndicator; -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.FragmentPagerBinding; -import org.lsposed.manager.databinding.ItemRepoLoadmoreBinding; -import org.lsposed.manager.databinding.ItemRepoReadmeBinding; -import org.lsposed.manager.databinding.ItemRepoRecyclerviewBinding; -import org.lsposed.manager.databinding.ItemRepoReleaseBinding; -import org.lsposed.manager.databinding.ItemRepoTitleDescriptionBinding; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.repo.model.Collaborator; -import org.lsposed.manager.repo.model.OnlineModule; -import org.lsposed.manager.repo.model.Release; -import org.lsposed.manager.repo.model.ReleaseAsset; -import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder; -import org.lsposed.manager.ui.widget.EmptyStateRecyclerView; -import org.lsposed.manager.ui.widget.LinkifyTextView; -import org.lsposed.manager.util.AccessibilityUtils; -import org.lsposed.manager.util.NavUtil; -import org.lsposed.manager.util.SimpleStatefulAdaptor; -import org.lsposed.manager.util.chrome.CustomTabsURLSpan; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.ArrayList; -import java.util.List; -import java.util.ListIterator; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import okhttp3.Headers; -import okhttp3.Request; -import okhttp3.Response; -import rikka.core.util.ResourceUtils; -import rikka.material.app.LocaleDelegate; -import rikka.recyclerview.RecyclerViewKt; -import rikka.widget.borderview.BorderView; - -public class RepoItemFragment extends BaseFragment implements RepoLoader.RepoListener, MenuProvider { - FragmentPagerBinding binding; - OnlineModule module; - private ReleaseAdapter releaseAdapter; - private InformationAdapter informationAdapter; - private boolean remoteModuleLoadRequested = false; - private boolean releaseLoadRequestedByUser = false; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragmentPagerBinding.inflate(getLayoutInflater(), container, false); - if (module == null) return binding.getRoot(); - String modulePackageName = module.getName(); - String moduleName = module.getDescription(); - binding.appBar.setLiftable(true); - setupToolbar(binding.toolbar, binding.clickView, moduleName, R.menu.menu_repo_item); - binding.clickView.setTooltipText(moduleName); - binding.toolbar.setSubtitle(modulePackageName); - binding.viewPager.setAdapter(new PagerAdapter(this)); - int[] titles = new int[]{R.string.module_readme, R.string.module_releases, R.string.module_information}; - - var isAnimationEnabled = AccessibilityUtils.isAnimationEnabled(requireContext().getContentResolver()); - new TabLayoutMediator( - binding.tabLayout, - binding.viewPager, - // `autoRefresh = true` by default. Update the tabs automatically when the data set of the view pager's - // adapter changes. - true, - isAnimationEnabled, - (tab, position) -> tab.setText(titles[position]) - ).attach(); - - binding.tabLayout.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { - ViewGroup vg = (ViewGroup) binding.tabLayout.getChildAt(0); - int tabLayoutWidth = IntStream.range(0, binding.tabLayout.getTabCount()).map(i -> vg.getChildAt(i).getWidth()).sum(); - if (tabLayoutWidth <= binding.getRoot().getWidth()) { - binding.tabLayout.setTabMode(TabLayout.MODE_FIXED); - binding.tabLayout.setTabGravity(TabLayout.GRAVITY_FILL); - } - }); - binding.toolbar.setOnClickListener(v -> binding.appBar.setExpanded(true, true)); - releaseAdapter = new ReleaseAdapter(); - informationAdapter = new InformationAdapter(); - RepoLoader.getInstance().addListener(this); - loadRemoteModuleIfReadmeMissing(); - return binding.getRoot(); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - RepoLoader.getInstance().addListener(this); - super.onCreate(savedInstanceState); - - String modulePackageName = getArguments() == null ? null : getArguments().getString("modulePackageName"); - module = RepoLoader.getInstance().getOnlineModule(modulePackageName); - Log.i(App.TAG, "RepoItem: open " + modulePackageName + " -> module " + (module == null ? "NOT FOUND (repoLoaded=" + RepoLoader.getInstance().isRepoLoaded() + "), navigating back" : "found")); - if (module == null) { - if (!safeNavigate(R.id.action_repo_item_fragment_to_repo_fragment)) { - safeNavigate(R.id.repo_nav); - } - } - } - - private void renderGithubMarkdown(WebView view, @Nullable String text) { - try { - view.setBackgroundColor(Color.TRANSPARENT); - var setting = view.getSettings(); - setting.setOffscreenPreRaster(true); - setting.setDomStorageEnabled(true); - setting.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); - setting.setAllowContentAccess(false); - setting.setAllowFileAccessFromFileURLs(true); - setting.setAllowFileAccess(false); - setting.setGeolocationEnabled(false); - setting.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK); - setting.setTextZoom(80); - String body; - String direction; - if (getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { - direction = "rtl"; - } else { - direction = "ltr"; - } - if (TextUtils.isEmpty(text)) { - text = "
" + App.getInstance().getString(R.string.list_empty) + "
"; - } - if (ResourceUtils.isNightMode(getResources().getConfiguration())) { - body = App.HTML_TEMPLATE_DARK.get().replace("@dir@", direction).replace("@body@", text); - } else { - body = App.HTML_TEMPLATE.get().replace("@dir@", direction).replace("@body@", text); - } - view.setWebViewClient(new WebViewClient() { - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - NavUtil.startURL(requireActivity(), request.getUrl()); - return true; - } - - @Nullable - @Override - public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { - if (!request.getUrl().getScheme().startsWith("http")) return null; - var client = App.getOkHttpClient(); - var call = client.newCall( - new Request.Builder() - .url(request.getUrl().toString()) - .method(request.getMethod(), null) - .headers(Headers.of(request.getRequestHeaders())) - .build()); - try { - Response reply = call.execute(); - var header = reply.header("content-type", "image/*;charset=utf-8"); - String[] contentTypes = new String[0]; - if (header != null) { - contentTypes = header.split(";\\s*"); - } - var mimeType = contentTypes.length > 0 ? contentTypes[0] : "image/*"; - var charset = contentTypes.length > 1 ? contentTypes[1].split("=\\s*")[1] : "utf-8"; - var body = reply.body(); - if (body == null) return null; - return new WebResourceResponse( - mimeType, - charset, - body.byteStream() - ); - } catch (Throwable e) { - return new WebResourceResponse("text/html", "utf-8", new ByteArrayInputStream(Log.getStackTraceString(e).getBytes(StandardCharsets.UTF_8))); - } - } - }); - view.loadDataWithBaseURL("https://github.com", body, "text/html", - StandardCharsets.UTF_8.name(), null); - } catch (Throwable e) { - Log.e(App.TAG, "render readme", e); - } - } - - @Nullable - private OnlineModule refreshModuleFromRepo() { - if (module == null || module.getName() == null) return module; - var updatedModule = RepoLoader.getInstance().getOnlineModule(module.getName()); - if (updatedModule != null) { - // A repo refresh can replace RepoLoader's entry with the summary - // object from modules.json, which lacks README/release detail that - // was already fetched for this fragment. Keep the richer instance so - // the UI does not flicker back to empty/truncated content. - var currentHasDetail = module.releasesLoaded || hasReadme(module); - var updatedHasDetail = updatedModule.releasesLoaded || hasReadme(updatedModule); - if (!currentHasDetail || updatedHasDetail) { - module = updatedModule; - } - } - return module; - } - - private boolean hasReadme(@Nullable OnlineModule module) { - return module != null && (!TextUtils.isEmpty(module.getReadmeHTML()) || !TextUtils.isEmpty(module.getReadme())); - } - - private void loadRemoteModuleIfReadmeMissing() { - var currentModule = refreshModuleFromRepo(); - if (currentModule == null || currentModule.getName() == null) return; - if (remoteModuleLoadRequested || currentModule.releasesLoaded || hasReadme(currentModule)) return; - - remoteModuleLoadRequested = true; - RepoLoader.getInstance().loadRemoteReleases(currentModule.getName()); - } - - // True while the per-module detail (which carries the README) is still being - // fetched, so the README tab can show a loading state instead of the empty - // placeholder on a slow connection. - private boolean isModuleDetailLoading() { - return remoteModuleLoadRequested; - } - - @Nullable - private String getModuleReadme() { - var currentModule = refreshModuleFromRepo(); - if (currentModule == null) return null; - String readme = currentModule.getReadmeHTML(); - if (TextUtils.isEmpty(readme)) { - readme = currentModule.getReadme(); - } - if (TextUtils.isEmpty(readme)) { - loadRemoteModuleIfReadmeMissing(); - } - return readme; - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem item) { - int id = item.getItemId(); - if (id == R.id.menu_open_in_browser) { - NavUtil.startURL(requireActivity(), "https://modules.lsposed.org/module/" + module.getName()); - return true; - } - return false; - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - RepoLoader.getInstance().removeListener(this); - remoteModuleLoadRequested = false; - binding = null; - } - - @Override - public void onRepoLoaded() { - refreshModuleFromRepo(); - loadRemoteModuleIfReadmeMissing(); - if (releaseAdapter != null) { - runAsync(releaseAdapter::loadItems); - } - } - - @Override - public void onModuleReleasesLoaded(OnlineModule module) { - if (this.module == null || module == null || !TextUtils.equals(this.module.getName(), module.getName())) return; - this.module = module; - remoteModuleLoadRequested = false; - var repoLoader = RepoLoader.getInstance(); - if (releaseAdapter != null) { - runAsync(releaseAdapter::loadItems); - } - if (releaseLoadRequestedByUser && (repoLoader.getReleases(module.getName()) != null ? repoLoader.getReleases(module.getName()).size() : 1) == 1) { - showHint(R.string.module_release_no_more, true); - } - releaseLoadRequestedByUser = false; - } - - @Override - public void onThrowable(Throwable t) { - remoteModuleLoadRequested = false; - releaseLoadRequestedByUser = false; - if (releaseAdapter != null) { - runAsync(releaseAdapter::loadItems); - } - showHint(getString(R.string.repo_load_failed, t.getLocalizedMessage()), true); - } - - private class InformationAdapter extends SimpleStatefulAdaptor { - - private int rowCount = 0; - private int homepageRow = -1; - private int collaboratorsRow = -1; - private int sourceUrlRow = -1; - - public InformationAdapter() { - if (!TextUtils.isEmpty(module.getHomepageUrl())) { - homepageRow = rowCount++; - } - if (module.getCollaborators() != null && !module.getCollaborators().isEmpty()) { - collaboratorsRow = rowCount++; - } - if (!TextUtils.isEmpty(module.getSourceUrl())) { - sourceUrlRow = rowCount++; - } - } - - @NonNull - @Override - public InformationAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new ViewHolder(ItemRepoTitleDescriptionBinding.inflate(getLayoutInflater(), parent, false)); - } - - @Override - public void onBindViewHolder(@NonNull InformationAdapter.ViewHolder holder, int position) { - if (position == homepageRow) { - holder.title.setText(R.string.module_information_homepage); - holder.description.setText(module.getHomepageUrl()); - } else if (position == collaboratorsRow) { - List collaborators = module.getCollaborators(); - if (collaborators == null) return; - holder.title.setText(R.string.module_information_collaborators); - SpannableStringBuilder sb = new SpannableStringBuilder(); - ListIterator iterator = collaborators.listIterator(); - while (iterator.hasNext()) { - Collaborator collaborator = iterator.next(); - var collaboratorLogin = collaborator.getLogin(); - if (collaboratorLogin == null) continue; - String name = collaborator.getName() == null ? collaboratorLogin : collaborator.getName(); - sb.append(name); - CustomTabsURLSpan span = new CustomTabsURLSpan(requireActivity(), String.format("https://github.com/%s", collaborator.getLogin())); - sb.setSpan(span, sb.length() - name.length(), sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - if (iterator.hasNext()) { - sb.append(", "); - } - } - holder.description.setText(sb); - } else if (position == sourceUrlRow) { - holder.title.setText(R.string.module_information_source_url); - holder.description.setText(module.getSourceUrl()); - } - holder.itemView.setOnClickListener(v -> { - if (position == homepageRow) { - NavUtil.startURL(requireActivity(), module.getHomepageUrl()); - } else if (position == collaboratorsRow) { - ClickableSpan span = holder.description.getCurrentSpan(); - holder.description.clearCurrentSpan(); - - if (span instanceof CustomTabsURLSpan) { - span.onClick(v); - } - } else if (position == sourceUrlRow) { - NavUtil.startURL(requireActivity(), module.getSourceUrl()); - } - }); - } - - @Override - public int getItemCount() { - return rowCount; - } - - class ViewHolder extends RecyclerView.ViewHolder { - TextView title; - LinkifyTextView description; - - public ViewHolder(ItemRepoTitleDescriptionBinding binding) { - super(binding.getRoot()); - title = binding.title; - description = binding.description; - } - } - } - - public static class DownloadDialog extends DialogFragment { - @NonNull - @Override - public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - var args = getArguments(); - if (args == null) throw new IllegalArgumentException(); - return new BlurBehindDialogBuilder(requireActivity(), R.style.ThemeOverlay_MaterialAlertDialog_Centered_FullWidthButtons) - .setTitle(R.string.module_release_view_assets) - .setPositiveButton(android.R.string.cancel, null) - .setAdapter(new ArrayAdapter<>(requireActivity(), R.layout.dialog_item, args.getCharSequenceArray("names")), - (dialog, which) -> NavUtil.startURL(requireActivity(), args.getStringArrayList("urls").get(which))) - .create(); - } - - static void create(Activity activity, FragmentManager fm, List assets) { - var f = new DownloadDialog(); - var bundle = new Bundle(); - - var displayNames = new CharSequence[assets.size()]; - for (int i = 0; i < assets.size(); i++) { - var sb = new SpannableStringBuilder(assets.get(i).getName()); - var count = assets.get(i).getDownloadCount(); - var countStr = activity.getResources().getQuantityString(R.plurals.module_release_assets_download_count, count, count); - var sizeStr = Formatter.formatShortFileSize(activity, assets.get(i).getSize()); - sb.append('\n').append(sizeStr).append('/').append(countStr); - final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(activity.getTheme(), android.R.attr.textColorSecondary)); - final RelativeSizeSpan relativeSizeSpan = new RelativeSizeSpan(0.8f); - sb.setSpan(foregroundColorSpan, sb.length() - sizeStr.length() - countStr.length() - 1, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - sb.setSpan(relativeSizeSpan, sb.length() - sizeStr.length() - countStr.length() - 1, sb.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - displayNames[i] = sb; - } - bundle.putCharSequenceArray("names", displayNames); - bundle.putStringArrayList("urls", assets.stream().map(ReleaseAsset::getDownloadUrl).collect(Collectors.toCollection(ArrayList::new))); - f.setArguments(bundle); - f.show(fm, "download"); - } - } - - private class ReleaseAdapter extends EmptyStateRecyclerView.EmptyStateAdapter { - private List items = new ArrayList<>(); - private final Resources resources = App.getInstance().getResources(); - - public ReleaseAdapter() { - runAsync(this::loadItems); - } - - @SuppressLint("NotifyDataSetChanged") - public void loadItems() { - var channels = resources.getStringArray(R.array.update_channel_values); - var channel = App.getPreferences().getString("update_channel", channels[0]); - // Prefer this fragment's module when its releases were already loaded - // in full; a repo refresh may have replaced RepoLoader's entry with - // the modules.json summary, whose truncated release list would - // shadow the complete data we already fetched. - List releases = module.releasesLoaded ? module.getReleases() : null; - if (releases == null) releases = RepoLoader.getInstance().getReleases(module.getName()); - if (releases == null) releases = module.getReleases(); - List tmpList; - if (channel.equals(channels[0])) { - tmpList = releases != null ? releases.parallelStream().filter(t -> { - if (Boolean.TRUE.equals(t.getIsPrerelease())) return false; - var name = t.getName() != null ? t.getName().toLowerCase(LocaleDelegate.getDefaultLocale()) : null; - return !(name != null && name.startsWith("snapshot")) && !(name != null && name.startsWith("nightly")); - }).collect(Collectors.toList()) : null; - } else if (channel.equals(channels[1])) { - tmpList = releases != null ? releases.parallelStream().filter(t -> { - var name = t.getName() != null ? t.getName().toLowerCase(LocaleDelegate.getDefaultLocale()) : null; - return !(name != null && name.startsWith("snapshot")) && !(name != null && name.startsWith("nightly")); - }).collect(Collectors.toList()) : null; - } else tmpList = releases; - List newItems = tmpList != null ? tmpList : new ArrayList<>(); - runOnUiThread(() -> { - items = newItems; - notifyDataSetChanged(); - }); - } - - @NonNull - @Override - public ReleaseAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - if (viewType == 0) { - return new ReleaseViewHolder(ItemRepoReleaseBinding.inflate(getLayoutInflater(), parent, false)); - } else { - return new LoadmoreViewHolder(ItemRepoLoadmoreBinding.inflate(getLayoutInflater(), parent, false)); - } - } - - @Override - public void onBindViewHolder(@NonNull ReleaseAdapter.ViewHolder holder, int position) { - if (holder.getItemViewType() == 1) { - holder.progress.setVisibility(View.GONE); - holder.title.setVisibility(View.VISIBLE); - holder.itemView.setOnClickListener(v -> { - if (holder.progress.getVisibility() == View.GONE) { - holder.title.setVisibility(View.GONE); - holder.progress.show(); - releaseLoadRequestedByUser = true; - RepoLoader.getInstance().loadRemoteReleases(module.getName()); - } - }); - } else { - Release release = items.get(position); - holder.title.setText(release.getName()); - var instant = Instant.parse(release.getPublishedAt()); - var formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) - .withLocale(App.getLocale()).withZone(ZoneId.systemDefault()); - holder.publishedTime.setText(String.format(getString(R.string.module_repo_published_time), formatter.format(instant))); - renderGithubMarkdown(holder.description, release.getDescriptionHTML()); - holder.openInBrowser.setOnClickListener(v -> NavUtil.startURL(requireActivity(), release.getUrl())); - List assets = release.getReleaseAssets(); - if (assets != null && !assets.isEmpty()) { - holder.viewAssets.setOnClickListener(v -> DownloadDialog.create(requireActivity(), getParentFragmentManager(), assets)); - } else { - holder.viewAssets.setVisibility(View.GONE); - } - } - } - - @Override - public int getItemCount() { - return items.size() + (module.releasesLoaded ? 0 : 1); - } - - @Override - public int getItemViewType(int position) { - return !module.releasesLoaded && position == getItemCount() - 1 ? 1 : 0; - } - - @Override - public boolean isLoaded() { - return module.releasesLoaded; - } - - class ViewHolder extends RecyclerView.ViewHolder { - TextView title; - TextView publishedTime; - WebView description; - MaterialButton openInBrowser; - MaterialButton viewAssets; - CircularProgressIndicator progress; - - public ViewHolder(@NonNull View itemView) { - super(itemView); - } - } - - class ReleaseViewHolder extends ReleaseAdapter.ViewHolder { - public ReleaseViewHolder(ItemRepoReleaseBinding binding) { - super(binding.getRoot()); - title = binding.title; - publishedTime = binding.publishedTime; - description = binding.description; - openInBrowser = binding.openInBrowser; - viewAssets = binding.viewAssets; - } - } - - class LoadmoreViewHolder extends ReleaseAdapter.ViewHolder { - public LoadmoreViewHolder(ItemRepoLoadmoreBinding binding) { - super(binding.getRoot()); - title = binding.title; - progress = binding.progress; - } - } - } - - private static class PagerAdapter extends FragmentStateAdapter { - - public PagerAdapter(@NonNull Fragment fragment) { - super(fragment); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - Bundle bundle = new Bundle(); - bundle.putInt("position", position); - Fragment f; - if (position == 0) { - f = new ReadmeFragment(); - } else if (position == 1) { - f = new RecyclerviewFragment(); - } else { - f = new RecyclerviewFragment(); - } - f.setArguments(bundle); - return f; - } - - @Override - public int getItemCount() { - return 3; - } - - @Override - public int getItemViewType(int position) { - return position == 0 ? 0 : 1; - } - - @Override - public long getItemId(int position) { - return position; - } - } - - public static abstract class BorderFragment extends BaseFragment { - BorderView borderView; - - void attachListeners() { - var parent = getParentFragment(); - if (parent instanceof RepoItemFragment) { - var repoItemFragment = (RepoItemFragment) parent; - borderView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> repoItemFragment.binding.appBar.setLifted(!top)); - repoItemFragment.binding.appBar.setLifted(!borderView.getBorderViewDelegate().isShowingTopBorder()); - repoItemFragment.binding.toolbar.setOnClickListener(v -> { - repoItemFragment.binding.appBar.setExpanded(true, true); - scrollToTop(); - }); - } - } - - abstract void scrollToTop(); - - void detachListeners() { - borderView.getBorderViewDelegate().setBorderVisibilityChangedListener(null); - } - - @Override - public void onResume() { - super.onResume(); - attachListeners(); - } - - @Override - public void onStart() { - super.onStart(); - attachListeners(); - } - - @Override - public void onStop() { - super.onStop(); - detachListeners(); - } - - @Override - public void onPause() { - super.onPause(); - detachListeners(); - } - } - - public static class ReadmeFragment extends BorderFragment implements RepoLoader.RepoListener { - ItemRepoReadmeBinding binding; - private String renderedReadme; - private boolean readmeRendered = false; - - private void renderReadme() { - var parent = getParentFragment(); - if (!(parent instanceof RepoItemFragment) || binding == null) return; - - var repoItemFragment = (RepoItemFragment) parent; - // getModuleReadme() also kicks off the per-module fetch when the - // README is missing, so query the loading state afterwards. - var readme = repoItemFragment.getModuleReadme(); - String display; - if (!TextUtils.isEmpty(readme)) { - display = readme; - } else if (repoItemFragment.isModuleDetailLoading()) { - // Detail is still downloading (e.g. slow connection); show a - // loading placeholder rather than the empty state so users are - // not misled into thinking the module has no README. - display = "
" + getString(R.string.loading) + "
"; - } else { - // Detail has loaded and there is genuinely no README; let - // renderGithubMarkdown fall back to the empty placeholder. - display = null; - } - var pkg = repoItemFragment.module == null ? null : repoItemFragment.module.getName(); - Log.i(App.TAG, "RepoItem: render README for " + pkg + " -> " + (!TextUtils.isEmpty(readme) ? "content" : repoItemFragment.isModuleDetailLoading() ? "loading" : "empty")); - // onRepoLoaded fires on every repo load and channel change; skip the - // WebView reload when the rendered content has not actually changed - // to avoid flicker. - if (readmeRendered && TextUtils.equals(renderedReadme, display)) return; - renderedReadme = display; - readmeRendered = true; - repoItemFragment.renderGithubMarkdown(binding.readme, display); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - var parent = getParentFragment(); - if (!(parent instanceof RepoItemFragment)) { - if (!safeNavigate(R.id.action_repo_item_fragment_to_repo_fragment)) { - safeNavigate(R.id.repo_nav); - } - return null; - } - binding = ItemRepoReadmeBinding.inflate(getLayoutInflater(), container, false); - borderView = binding.scrollView; - RepoLoader.getInstance().addListener(this); - renderReadme(); - return binding.getRoot(); - } - - @Override - public void onRepoLoaded() { - if (binding != null) { - runOnUiThread(this::renderReadme); - } - } - - @Override - public void onModuleReleasesLoaded(OnlineModule module) { - if (binding != null) { - var parent = getParentFragment(); - if (parent instanceof RepoItemFragment) { - var repoItemFragment = (RepoItemFragment) parent; - if (repoItemFragment.module != null && TextUtils.equals(repoItemFragment.module.getName(), module.getName())) { - runOnUiThread(this::renderReadme); - } - } - } - } - - @Override - public void onThrowable(Throwable t) { - // The fetch failed; re-render so the tab leaves the loading state - // (the parent already reset the in-flight flag before this runnable - // executes) instead of spinning forever. - if (binding != null) { - runOnUiThread(this::renderReadme); - } - } - - @Override - public void onDestroyView() { - RepoLoader.getInstance().removeListener(this); - binding = null; - renderedReadme = null; - readmeRendered = false; - super.onDestroyView(); - } - - @Override - void scrollToTop() { - binding.scrollView.fullScroll(ScrollView.FOCUS_UP); - } - } - - public static class RecyclerviewFragment extends BorderFragment { - ItemRepoRecyclerviewBinding binding; - RecyclerView.Adapter adapter; - - @Override - void scrollToTop() { - binding.recyclerView.smoothScrollToPosition(0); - } - - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - var arguments = getArguments(); - var parent = getParentFragment(); - if (arguments == null || !(parent instanceof RepoItemFragment)) { - if (!safeNavigate(R.id.action_repo_item_fragment_to_repo_fragment)) { - safeNavigate(R.id.repo_nav); - } - return null; - } - var repoItemFragment = (RepoItemFragment) parent; - var position = arguments.getInt("position", 0); - if (position == 1) - adapter = repoItemFragment.releaseAdapter; - else if (position == 2) - adapter = repoItemFragment.informationAdapter; - else return null; - binding = ItemRepoRecyclerviewBinding.inflate(getLayoutInflater(), container, false); - binding.recyclerView.setAdapter(adapter); - binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity())); - RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true); - borderView = binding.recyclerView; - return binding.getRoot(); - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java deleted file mode 100644 index 501bcef0a..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java +++ /dev/null @@ -1,369 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.fragment; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.os.Build; -import android.os.Bundle; -import android.provider.Settings; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.core.text.HtmlCompat; -import androidx.preference.Preference; -import androidx.preference.PreferenceFragmentCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.color.DynamicColors; - -import org.lsposed.manager.App; -import org.lsposed.manager.BuildConfig; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.R; -import org.lsposed.manager.databinding.FragmentSettingsBinding; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.ui.activity.MainActivity; -import org.lsposed.manager.util.BackupUtils; -import org.lsposed.manager.util.CloudflareDNS; -import org.lsposed.manager.util.LangList; -import org.lsposed.manager.util.NavUtil; -import org.lsposed.manager.util.ShortcutUtil; -import org.lsposed.manager.util.ThemeUtil; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Locale; - -import rikka.core.util.ResourceUtils; -import rikka.material.app.LocaleDelegate; -import rikka.material.preference.MaterialSwitchPreference; -import rikka.preference.SimpleMenuPreference; -import rikka.recyclerview.RecyclerViewKt; -import rikka.widget.borderview.BorderRecyclerView; - -public class SettingsFragment extends BaseFragment { - FragmentSettingsBinding binding; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - binding = FragmentSettingsBinding.inflate(inflater, container, false); - binding.appBar.setLiftable(true); - setupToolbar(binding.toolbar, binding.clickView, R.string.Settings); - binding.toolbar.setNavigationIcon(null); - if (savedInstanceState == null) { - getChildFragmentManager().beginTransaction().add(R.id.setting_container, new PreferenceFragment()).commitNow(); - } - if (ConfigManager.isBinderAlive()) { - binding.toolbar.setSubtitle(String.format(LocaleDelegate.getDefaultLocale(), "%s (%d)", ConfigManager.getXposedVersionName(), ConfigManager.getXposedVersionCode())); - } else { - binding.toolbar.setSubtitle(String.format(LocaleDelegate.getDefaultLocale(), "%s (%d) - %s", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, getString(R.string.not_installed))); - } - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - - binding = null; - } - - public static class PreferenceFragment extends PreferenceFragmentCompat { - private SettingsFragment parentFragment; - - ActivityResultLauncher backupLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument("application/gzip"), uri -> { - if (uri == null || parentFragment == null) return; - parentFragment.runAsync(() -> { - try { - BackupUtils.backup(uri); - } catch (Exception e) { - var text = App.getInstance().getString(R.string.settings_backup_failed2, e.getMessage()); - parentFragment.showHint(text, false); - } - }); - }); - ActivityResultLauncher restoreLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { - if (uri == null || parentFragment == null) return; - parentFragment.runAsync(() -> { - try { - BackupUtils.restore(uri); - } catch (Exception e) { - var text = App.getInstance().getString(R.string.settings_restore_failed2, e.getMessage()); - parentFragment.showHint(text, false); - } - }); - }); - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - - parentFragment = (SettingsFragment) requireParentFragment(); - } - - @Override - public void onDetach() { - super.onDetach(); - - parentFragment = null; - } - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - final String SYSTEM = "SYSTEM"; - - addPreferencesFromResource(R.xml.prefs); - - boolean installed = ConfigManager.isBinderAlive(); - MaterialSwitchPreference prefVerboseLogs = findPreference("disable_verbose_log"); - if (prefVerboseLogs != null) { - prefVerboseLogs.setEnabled(!BuildConfig.DEBUG && installed); - if (BuildConfig.DEBUG) ConfigManager.setVerboseLogEnabled(false); - prefVerboseLogs.setChecked(!installed || !ConfigManager.isVerboseLogEnabled()); - prefVerboseLogs.setOnPreferenceChangeListener((preference, newValue) -> ConfigManager.setVerboseLogEnabled(!(boolean) newValue)); - } - - MaterialSwitchPreference notificationPreference = findPreference("enable_status_notification"); - if (notificationPreference != null) { - notificationPreference.setVisible(installed); - if (installed) { - notificationPreference.setChecked(ConfigManager.enableStatusNotification()); - notificationPreference.setSummaryOn(R.string.settings_enable_status_notification_summary); - notificationPreference.setEnabled(true); - } - notificationPreference.setOnPreferenceChangeListener((p, v) -> - ConfigManager.setEnableStatusNotification((boolean) v) - ); - } - - Preference shortcut = findPreference("add_shortcut"); - if (shortcut != null) { - shortcut.setVisible(App.isParasitic); - if (!ShortcutUtil.isRequestPinShortcutSupported(requireContext())) { - shortcut.setEnabled(false); - shortcut.setSummary(R.string.settings_unsupported_pin_shortcut_summary); - } - shortcut.setOnPreferenceClickListener(preference -> { - if (!ShortcutUtil.requestPinLaunchShortcut(() -> { - App.getPreferences().edit().putBoolean("never_show_welcome", true).apply(); - parentFragment.showHint(R.string.settings_shortcut_pinned_hint, false); - })) { - parentFragment.showHint(R.string.settings_unsupported_pin_shortcut_summary, true); - } - return true; - }); - } - - Preference backup = findPreference("backup"); - if (backup != null) { - backup.setEnabled(installed); - backup.setOnPreferenceClickListener(preference -> { - LocalDateTime now = LocalDateTime.now(); - try { - backupLauncher.launch(String.format(LocaleDelegate.getDefaultLocale(), "LSPosed_%s.lsp", now.toString())); - return true; - } catch (ActivityNotFoundException e) { - parentFragment.showHint(R.string.enable_documentui, true); - return false; - } - }); - } - - Preference restore = findPreference("restore"); - if (restore != null) { - restore.setEnabled(installed); - restore.setOnPreferenceClickListener(preference -> { - try { - restoreLauncher.launch(new String[]{"*/*"}); - return true; - } catch (ActivityNotFoundException e) { - parentFragment.showHint(R.string.enable_documentui, true); - return false; - } - }); - } - - Preference theme = findPreference("dark_theme"); - if (theme != null) { - theme.setOnPreferenceChangeListener((preference, newValue) -> { - if (!App.getPreferences().getString("dark_theme", ThemeUtil.MODE_NIGHT_FOLLOW_SYSTEM).equals(newValue)) { - AppCompatDelegate.setDefaultNightMode(ThemeUtil.getDarkTheme((String) newValue)); - } - return true; - }); - } - - Preference black_dark_theme = findPreference("black_dark_theme"); - if (black_dark_theme != null) { - black_dark_theme.setOnPreferenceChangeListener((preference, newValue) -> { - MainActivity activity = (MainActivity) getActivity(); - if (activity != null && ResourceUtils.isNightMode(getResources().getConfiguration())) { - activity.restart(); - } - return true; - }); - } - - Preference primary_color = findPreference("theme_color"); - if (primary_color != null) { - primary_color.setOnPreferenceChangeListener((preference, newValue) -> { - MainActivity activity = (MainActivity) getActivity(); - if (activity != null) { - activity.restart(); - } - return true; - }); - } - - MaterialSwitchPreference prefShowHiddenIcons = findPreference("show_hidden_icon_apps_enabled"); - if (prefShowHiddenIcons != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (ConfigManager.isBinderAlive()) { - prefShowHiddenIcons.setEnabled(true); - prefShowHiddenIcons.setOnPreferenceChangeListener((preference, newValue) -> ConfigManager.setHiddenIcon(!(boolean) newValue)); - } - prefShowHiddenIcons.setChecked(Settings.Global.getInt(requireActivity().getContentResolver(), "show_hidden_icon_apps_enabled", 1) != 0); - } - - MaterialSwitchPreference prefFollowSystemAccent = findPreference("follow_system_accent"); - if (prefFollowSystemAccent != null && DynamicColors.isDynamicColorAvailable()) { - if (primary_color != null) { - primary_color.setVisible(!prefFollowSystemAccent.isChecked()); - } - prefFollowSystemAccent.setVisible(true); - prefFollowSystemAccent.setOnPreferenceChangeListener((preference, newValue) -> { - MainActivity activity = (MainActivity) getActivity(); - if (activity != null) { - activity.restart(); - } - return true; - }); - } - - MaterialSwitchPreference prefDoH = findPreference("doh"); - if (prefDoH != null) { - var dns = (CloudflareDNS) App.getOkHttpClient().dns(); - if (!dns.noProxy) { - prefDoH.setEnabled(false); - prefDoH.setVisible(false); - var group = prefDoH.getParent(); - assert group != null; - group.setVisible(false); - } - prefDoH.setOnPreferenceChangeListener((p, v) -> { - dns.DoH = (boolean) v; - return true; - }); - } - - SimpleMenuPreference language = findPreference("language"); - if (language != null) { - var tag = language.getValue(); - var userLocale = App.getLocale(); - var entries = new ArrayList(); - var lstLang = LangList.LOCALES; - for (var lang : lstLang) { - if (lang.equals(SYSTEM)) { - entries.add(getString(rikka.core.R.string.follow_system)); - continue; - } - var locale = Locale.forLanguageTag(lang); - entries.add(HtmlCompat.fromHtml(locale.getDisplayName(locale), HtmlCompat.FROM_HTML_MODE_LEGACY)); - } - language.setEntries(entries.toArray(new CharSequence[0])); - language.setEntryValues(lstLang); - if (TextUtils.isEmpty(tag) || SYSTEM.equals(tag)) { - language.setSummary(getString(rikka.core.R.string.follow_system)); - } else { - var locale = Locale.forLanguageTag(tag); - language.setSummary(!TextUtils.isEmpty(locale.getScript()) ? locale.getDisplayScript(userLocale) : locale.getDisplayName(userLocale)); - } - language.setOnPreferenceChangeListener((preference, newValue) -> { - var app = App.getInstance(); - var locale = App.getLocale((String) newValue); - var res = app.getResources(); - var config = res.getConfiguration(); - config.setLocale(locale); - LocaleDelegate.setDefaultLocale(locale); - //noinspection deprecation - res.updateConfiguration(config, res.getDisplayMetrics()); - MainActivity activity = (MainActivity) getActivity(); - if (activity != null) { - activity.restart(); - } - return true; - }); - } - - Preference translation = findPreference("translation"); - if (translation != null) { - translation.setOnPreferenceClickListener(preference -> { - NavUtil.startURL(requireActivity(), "https://crowdin.com/project/lsposed_jingmatrix"); - return true; - }); - translation.setSummary(getString(R.string.settings_translation_summary, getString(R.string.app_name))); - } - - Preference translation_contributors = findPreference("translation_contributors"); - if (translation_contributors != null) { - var translators = HtmlCompat.fromHtml(getString(R.string.translators), HtmlCompat.FROM_HTML_MODE_LEGACY); - if (translators.toString().equals("null")) { - translation_contributors.setVisible(false); - } else { - translation_contributors.setSummary(translators); - } - } - SimpleMenuPreference channel = findPreference("update_channel"); - if (channel != null) { - channel.setOnPreferenceChangeListener((preference, newValue) -> { - var repoLoader = RepoLoader.getInstance(); - repoLoader.updateLatestVersion(String.valueOf(newValue)); - return true; - }); - } - } - - @NonNull - @Override - public RecyclerView onCreateRecyclerView(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent, Bundle savedInstanceState) { - BorderRecyclerView recyclerView = (BorderRecyclerView) super.onCreateRecyclerView(inflater, parent, savedInstanceState); - RecyclerViewKt.fixEdgeEffect(recyclerView, false, true); - recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> parentFragment.binding.appBar.setLifted(!top)); - var fragment = getParentFragment(); - if (fragment instanceof SettingsFragment settingsFragment) { - View.OnClickListener l = v -> { - settingsFragment.binding.appBar.setExpanded(true, true); - recyclerView.smoothScrollToPosition(0); - }; - settingsFragment.binding.toolbar.setOnClickListener(l); - settingsFragment.binding.clickView.setOnClickListener(l); - } - return recyclerView; - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/widget/EmptyStateRecyclerView.java b/app/src/main/java/org/lsposed/manager/ui/widget/EmptyStateRecyclerView.java deleted file mode 100644 index 02a6cd699..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/widget/EmptyStateRecyclerView.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.widget; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.text.Layout; -import android.text.StaticLayout; -import android.text.TextPaint; -import android.util.AttributeSet; -import android.util.DisplayMetrics; - -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.ConcatAdapter; - -import org.lsposed.manager.R; -import org.lsposed.manager.util.SimpleStatefulAdaptor; - -import rikka.core.util.ResourceUtils; - -public class EmptyStateRecyclerView extends StatefulRecyclerView { - private final TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); - private final String emptyText; - - public EmptyStateRecyclerView(Context context) { - this(context, null); - } - - public EmptyStateRecyclerView(Context context, @Nullable AttributeSet attrs) { - this(context, attrs, 0); - } - - public EmptyStateRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - DisplayMetrics dm = context.getResources().getDisplayMetrics(); - - paint.setColor(ResourceUtils.resolveColor(context.getTheme(), android.R.attr.textColorSecondary)); - paint.setTextSize(16f * dm.scaledDensity); - - emptyText = context.getString(R.string.list_empty); - } - - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - var adapter = getAdapter(); - if (adapter instanceof ConcatAdapter) { - for (var a : ((ConcatAdapter) adapter).getAdapters()) { - if (a instanceof EmptyStateAdapter) { - adapter = a; - break; - } - } - } - if (adapter instanceof EmptyStateAdapter && ((EmptyStateAdapter) adapter).isLoaded() && adapter.getItemCount() == 0) { - final int width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); - final int height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom(); - - var textLayout = new StaticLayout(emptyText, paint, width, Layout.Alignment.ALIGN_CENTER, 1.0f, 0.0f, false); - - canvas.save(); - canvas.translate(getPaddingLeft(), (height >> 1) + getPaddingTop() - (textLayout.getHeight() >> 1)); - - textLayout.draw(canvas); - - canvas.restore(); - } - } - - public abstract static class EmptyStateAdapter extends SimpleStatefulAdaptor { - abstract public boolean isLoaded(); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/widget/ExpandableTextView.java b/app/src/main/java/org/lsposed/manager/ui/widget/ExpandableTextView.java deleted file mode 100644 index 4b460950c..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/widget/ExpandableTextView.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * - */ - -package org.lsposed.manager.ui.widget; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Typeface; -import android.os.Bundle; -import android.os.Parcelable; -import android.text.Layout; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextPaint; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.transition.TransitionManager; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; - -import com.google.android.material.textview.MaterialTextView; - -import org.lsposed.manager.R; - -public class ExpandableTextView extends MaterialTextView { - private CharSequence text = null; - private int nextLines = 0; - private final int maxLines; - private final SpannableString collapse; - private final SpannableString expand; - private final SpannableStringBuilder sb = new SpannableStringBuilder(); - private int lineCount = 0; - - public ExpandableTextView(Context context) { - this(context, null); - } - - public ExpandableTextView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ExpandableTextView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - maxLines = getMaxLines(); - collapse = new SpannableString(context.getString(R.string.collapse)); - ClickableSpan span = new ClickableSpan() { - @Override - public void onClick(@NonNull View widget) { - TransitionManager.beginDelayedTransition((ViewGroup) getParent()); - setMaxLines(nextLines); - ExpandableTextView.super.setText(text); - } - - @Override - public void updateDrawState(@NonNull TextPaint ds) { - ds.setTypeface(Typeface.DEFAULT_BOLD); - } - }; - collapse.setSpan(span, 0, collapse.length(), 0); - expand = new SpannableString(context.getString(R.string.expand)); - expand.setSpan(span, 0, expand.length(), 0); - setMovementMethod(LinkMovementMethod.getInstance()); - } - - @Override - public void setText(CharSequence text, BufferType type) { - this.text = text; - super.setText(text, type); - } - - @Override - public boolean onPreDraw() { - this.getViewTreeObserver().removeOnPreDrawListener(this); - if (lineCount == 0) { - lineCount = getLayout().getLineCount(); - } - if (lineCount > maxLines) { - int hintTextOffsetEnd; - if (maxLines == getMaxLines()) { - nextLines = lineCount + 1; - hintTextOffsetEnd = getLayout().getLineStart(getMaxLines() - 1); - setTextWithSpan(text, hintTextOffsetEnd - 1, expand); - } else if (nextLines == getMaxLines()) { - nextLines = maxLines; - hintTextOffsetEnd = getLayout().getLineStart(getMaxLines() - 1); - setTextWithSpan(text, hintTextOffsetEnd, collapse); - } - } - return super.onPreDraw(); - } - - private void setTextWithSpan(CharSequence text, int textOffsetEnd, - SpannableString sbStr) { - sb.clearSpans(); - sb.clear(); - sb.append(text, 0, textOffsetEnd); - sb.append("\n"); - sb.append(sbStr); - super.setText(sb, BufferType.NORMAL); - } - - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - if (getLayout() != null) { - lineCount = getLayout().getLineCount(); - } - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouchEvent(@NonNull MotionEvent event) { - Layout layout = this.getLayout(); - if (layout != null) { - int line = layout.getLineForVertical((int) event.getY()); - int offset = layout.getOffsetForHorizontal(line, event.getX()); - - if (getText() instanceof Spanned) { - Spanned spanned = (Spanned) getText(); - - ClickableSpan[] links = spanned.getSpans(offset, offset, ClickableSpan.class); - - if (links.length == 0) { - return false; - } else { - return super.onTouchEvent(event); - } - } - } - - return false; - } - - @Override - public Parcelable onSaveInstanceState() { - Bundle bundle = new Bundle(); - bundle.putParcelable("superState", super.onSaveInstanceState()); - bundle.putInt("maxLines", getMaxLines()); - return bundle; - } - - @Override - public void onRestoreInstanceState(Parcelable state) { - if (state instanceof Bundle) { - Bundle bundle = (Bundle) state; - setMaxLines(bundle.getInt("maxLines")); - state = bundle.getParcelable("superState"); - } - super.onRestoreInstanceState(state); - } - -} diff --git a/app/src/main/java/org/lsposed/manager/ui/widget/LinkifyTextView.java b/app/src/main/java/org/lsposed/manager/ui/widget/LinkifyTextView.java deleted file mode 100644 index 445413ec7..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/widget/LinkifyTextView.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.ui.widget; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.Layout; -import android.text.Spanned; -import android.text.style.ClickableSpan; -import android.util.AttributeSet; -import android.view.MotionEvent; - -import androidx.annotation.NonNull; - -public class LinkifyTextView extends androidx.appcompat.widget.AppCompatTextView { - - private ClickableSpan mCurrentSpan; - - public LinkifyTextView(Context context) { - super(context); - } - - public LinkifyTextView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public LinkifyTextView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public ClickableSpan getCurrentSpan() { - return mCurrentSpan; - } - - public void clearCurrentSpan() { - mCurrentSpan = null; - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouchEvent(@NonNull MotionEvent event) { - // Let the parent or grandparent of TextView to handles click action. - // Otherwise click effect like ripple will not work, and if touch area - // do not contain a url, the TextView will still get MotionEvent. - // onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch - // action on it, so we analyze touched url here. - if (event.getAction() == MotionEvent.ACTION_DOWN) { - mCurrentSpan = null; - - if (getText() instanceof Spanned) { - // Get this code from android.text.method.LinkMovementMethod. - // Work fine ! - int x = (int) event.getX(); - int y = (int) event.getY(); - - x -= getTotalPaddingLeft(); - y -= getTotalPaddingTop(); - - x += getScrollX(); - y += getScrollY(); - - Layout layout = getLayout(); - if (null != layout) { - int line = layout.getLineForVertical(y); - int off = layout.getOffsetForHorizontal(line, x); - - ClickableSpan[] spans = ((Spanned) getText()).getSpans(off, off, ClickableSpan.class); - - if (spans.length > 0) { - mCurrentSpan = spans[0]; - } - } - } - } - - return super.onTouchEvent(event); - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/widget/ScrollWebView.java b/app/src/main/java/org/lsposed/manager/ui/widget/ScrollWebView.java deleted file mode 100644 index 58757210f..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/widget/ScrollWebView.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.ui.widget; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewParent; -import android.webkit.WebView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import rikka.widget.borderview.BorderRecyclerView; - -public class ScrollWebView extends WebView { - public ScrollWebView(@NonNull Context context) { - super(context); - } - - public ScrollWebView(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public ScrollWebView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public ScrollWebView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouchEvent(MotionEvent event) { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - var viewParent = findViewParentIfNeeds(this); - if (viewParent != null) viewParent.requestDisallowInterceptTouchEvent(true); - } - return super.onTouchEvent(event); - } - - @Override - protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { - if (clampedX) { - var viewParent = findViewParentIfNeeds(this); - if (viewParent != null) viewParent.requestDisallowInterceptTouchEvent(false); - } - super.onOverScrolled(scrollX, scrollY, clampedX, clampedY); - } - - private static ViewParent findViewParentIfNeeds(View v) { - var parent = v.getParent(); - if (parent == null) return null; - if (parent instanceof RecyclerView && !(parent instanceof BorderRecyclerView)) { - return parent; - } else if (parent instanceof View) { - return findViewParentIfNeeds((View) parent); - } else { - return parent; - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/ui/widget/StatefulRecyclerView.java b/app/src/main/java/org/lsposed/manager/ui/widget/StatefulRecyclerView.java deleted file mode 100644 index b017df3d6..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/widget/StatefulRecyclerView.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.ui.widget; - -import android.content.Context; -import android.os.Bundle; -import android.os.Parcelable; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager2.adapter.StatefulAdapter; - -import rikka.widget.borderview.BorderRecyclerView; - -public class StatefulRecyclerView extends BorderRecyclerView { - public StatefulRecyclerView(@NonNull Context context) { - super(context); - } - - public StatefulRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public StatefulRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - - @Override - public Parcelable onSaveInstanceState() { - Bundle bundle = new Bundle(); - bundle.putParcelable("superState", super.onSaveInstanceState()); - var adapter = getAdapter(); - if (adapter instanceof StatefulAdapter) { - bundle.putParcelable("adaptor", ((StatefulAdapter) adapter).saveState()); - } - return bundle; - } - - @Override - public void onRestoreInstanceState(Parcelable state) { - if (state instanceof Bundle) { - Bundle bundle = (Bundle) state; - super.onRestoreInstanceState(bundle.getParcelable("superState")); - var adapter = getAdapter(); - if (adapter instanceof StatefulAdapter) { - ((StatefulAdapter) adapter).restoreState(bundle.getParcelable("adaptor")); - } - } else { - super.onRestoreInstanceState(state); - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/AccessibilityUtils.java b/app/src/main/java/org/lsposed/manager/util/AccessibilityUtils.java deleted file mode 100644 index 65de7d6a7..000000000 --- a/app/src/main/java/org/lsposed/manager/util/AccessibilityUtils.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.lsposed.manager.util; - -import android.content.ContentResolver; -import android.provider.Settings; - -public class AccessibilityUtils { - public static boolean isAnimationEnabled(ContentResolver cr) { - return !(Settings.Global.getFloat(cr, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f) == 0.0f - && Settings.Global.getFloat(cr, Settings.Global.TRANSITION_ANIMATION_SCALE, 1.0f) == 0.0f - && Settings.Global.getFloat(cr, Settings.Global.WINDOW_ANIMATION_SCALE, 1.0f) == 0.0f); - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/AppIconModelLoader.java b/app/src/main/java/org/lsposed/manager/util/AppIconModelLoader.java deleted file mode 100644 index fb65b0fda..000000000 --- a/app/src/main/java/org/lsposed/manager/util/AppIconModelLoader.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.lsposed.manager.util; - -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.graphics.Bitmap; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Px; - -import com.bumptech.glide.Priority; -import com.bumptech.glide.load.DataSource; -import com.bumptech.glide.load.Options; -import com.bumptech.glide.load.data.DataFetcher; -import com.bumptech.glide.load.model.ModelLoader; -import com.bumptech.glide.load.model.ModelLoaderFactory; -import com.bumptech.glide.load.model.MultiModelLoaderFactory; -import com.bumptech.glide.signature.ObjectKey; - -import org.lsposed.manager.App; - -import me.zhanghai.android.appiconloader.AppIconLoader; - -public class AppIconModelLoader implements ModelLoader { - @NonNull - private final AppIconLoader mLoader; - @NonNull - private final Context mContext; - - private AppIconModelLoader(@Px int iconSize, boolean shrinkNonAdaptiveIcons, - @NonNull Context context) { - mLoader = new AppIconLoader(iconSize, shrinkNonAdaptiveIcons, context); - mContext = context; - } - - @Override - public boolean handles(@NonNull PackageInfo model) { - return true; - } - - @Nullable - @Override - public LoadData buildLoadData(@NonNull PackageInfo model, int width, int height, - @NonNull Options options) { - var warpApplicationInfo = new ApplicationInfo(model.applicationInfo); - warpApplicationInfo.uid = warpApplicationInfo.uid % App.PER_USER_RANGE; - var warpPackageInfo = new PackageInfo(); - warpPackageInfo.applicationInfo = warpApplicationInfo; - warpPackageInfo.versionCode = model.versionCode; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - warpPackageInfo.setLongVersionCode(model.getLongVersionCode()); - } - return new LoadData<>(new ObjectKey(AppIconLoader.getIconKey(warpPackageInfo, mContext)), - new Fetcher(mLoader, warpApplicationInfo)); - } - - private static class Fetcher implements DataFetcher { - @NonNull - private final AppIconLoader mLoader; - @NonNull - private final ApplicationInfo mApplicationInfo; - - public Fetcher(@NonNull AppIconLoader loader, @NonNull ApplicationInfo applicationInfo) { - mLoader = loader; - mApplicationInfo = applicationInfo; - } - - @Override - public void loadData(@NonNull Priority priority, - @NonNull DataCallback callback) { - try { - Bitmap icon = mLoader.loadIcon(mApplicationInfo); - callback.onDataReady(icon); - } catch (Exception e) { - callback.onLoadFailed(e); - } - } - - @Override - public void cleanup() { - } - - @Override - public void cancel() { - } - - @NonNull - @Override - public Class getDataClass() { - return Bitmap.class; - } - - @NonNull - @Override - public DataSource getDataSource() { - return DataSource.LOCAL; - } - } - - public static class Factory implements ModelLoaderFactory { - @Px - private final int mIconSize; - private final boolean mShrinkNonAdaptiveIcons; - @NonNull - private final Context mContext; - - public Factory(@Px int iconSize, boolean shrinkNonAdaptiveIcons, @NonNull Context context) { - mIconSize = iconSize; - mShrinkNonAdaptiveIcons = shrinkNonAdaptiveIcons; - mContext = context.getApplicationContext(); - } - - @NonNull - @Override - public ModelLoader build( - @NonNull MultiModelLoaderFactory multiFactory) { - return new AppIconModelLoader(mIconSize, mShrinkNonAdaptiveIcons, mContext); - } - - @Override - public void teardown() { - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/AppModule.java b/app/src/main/java/org/lsposed/manager/util/AppModule.java deleted file mode 100644 index c827ffd7a..000000000 --- a/app/src/main/java/org/lsposed/manager/util/AppModule.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.content.Context; -import android.content.pm.PackageInfo; -import android.graphics.Bitmap; - -import androidx.annotation.NonNull; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.Registry; -import com.bumptech.glide.annotation.GlideModule; -import com.bumptech.glide.module.AppGlideModule; - -import org.lsposed.manager.R; - -@GlideModule -public class AppModule extends AppGlideModule { - @Override - public boolean isManifestParsingEnabled() { - return false; - } - - @Override - public void registerComponents(Context context, @NonNull Glide glide, Registry registry) { - int iconSize = context.getResources().getDimensionPixelSize(R.dimen.app_icon_size); - var factory = new AppIconModelLoader.Factory(iconSize, false, context); - registry.prepend(PackageInfo.class, Bitmap.class, factory); - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/BackupUtils.java b/app/src/main/java/org/lsposed/manager/util/BackupUtils.java deleted file mode 100644 index f81801475..000000000 --- a/app/src/main/java/org/lsposed/manager/util/BackupUtils.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.net.Uri; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.adapters.ScopeAdapter; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.HashSet; -import java.util.List; -import java.util.zip.GZIPInputStream; -import java.util.zip.GZIPOutputStream; - -import rikka.core.os.FileUtils; - -public class BackupUtils { - private static final int VERSION = 2; - - public static void backup(Uri uri) throws JSONException, IOException { - backup(uri, null); - } - - public static void backup(Uri uri, String packageName) throws IOException, JSONException { - JSONObject rootObject = new JSONObject(); - rootObject.put("version", VERSION); - JSONArray modulesArray = new JSONArray(); - var modules = ModuleUtil.getInstance().getModules(); - if (modules == null) return; - for (ModuleUtil.InstalledModule module : modules.values()) { - if (packageName != null && !module.packageName.equals(packageName)) { - continue; - } - JSONObject moduleObject = new JSONObject(); - moduleObject.put("enable", ModuleUtil.getInstance().isModuleEnabled(module.packageName)); - moduleObject.put("package", module.packageName); - List scope = ConfigManager.getModuleScope(module.packageName); - JSONArray scopeArray = new JSONArray(); - for (ScopeAdapter.ApplicationWithEquals s : scope) { - JSONObject app = new JSONObject(); - app.put("package", s.packageName); - app.put("userId", s.userId); - scopeArray.put(app); - } - moduleObject.put("scope", scopeArray); - modulesArray.put(moduleObject); - } - rootObject.put("modules", modulesArray); - try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(App.getInstance().getContentResolver().openOutputStream(uri))) { - gzipOutputStream.write(rootObject.toString().getBytes()); - } - } - - public static void restore(Uri uri) throws JSONException, IOException { - restore(uri, null); - } - - public static void restore(Uri uri, String packageName) throws IOException, JSONException { - try (GZIPInputStream gzipInputStream = new GZIPInputStream(App.getInstance().getContentResolver().openInputStream(uri), 32)) { - StringBuilder string = new StringBuilder(); - try (var os = new ByteArrayOutputStream()) { - FileUtils.copy(gzipInputStream, os); - string.append(os); - } - gzipInputStream.close(); - JSONObject rootObject = new JSONObject(string.toString()); - int version = rootObject.getInt("version"); - if (version == VERSION || version == 1) { - JSONArray modules = rootObject.getJSONArray("modules"); - for (int i = 0; i < modules.length(); i++) { - JSONObject moduleObject = modules.getJSONObject(i); - String name = moduleObject.getString("package"); - if (packageName != null && !name.equals(packageName)) { - continue; - } - ModuleUtil.InstalledModule module = ModuleUtil.getInstance().getModule(name); - if (module != null) { - var enabled = moduleObject.getBoolean("enable"); - ModuleUtil.getInstance().setModuleEnabled(name, enabled); - if (!enabled) continue; - JSONArray scopeArray = moduleObject.getJSONArray("scope"); - HashSet scope = new HashSet<>(); - for (int j = 0; j < scopeArray.length(); j++) { - if (version == VERSION) { - JSONObject app = scopeArray.getJSONObject(j); - scope.add(new ScopeAdapter.ApplicationWithEquals(app.getString("package"), app.getInt("userId"))); - } else { - scope.add(new ScopeAdapter.ApplicationWithEquals(scopeArray.getString(j), 0)); - } - } - ConfigManager.setModuleScope(name, module.legacy, scope); - } - } - } else { - throw new IllegalArgumentException("Unknown backup file version"); - } - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java b/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java deleted file mode 100644 index 5ab0dd17e..000000000 --- a/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.lsposed.manager.util; - -import android.os.Build; -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.lsposed.manager.App; - -import java.net.InetAddress; -import java.net.Proxy; -import java.net.ProxySelector; -import java.net.UnknownHostException; -import java.time.Duration; -import java.util.List; - -import okhttp3.ConnectionSpec; -import okhttp3.Dns; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.dnsoverhttps.DnsOverHttps; -import okhttp3.internal.platform.Platform; - -public final class CloudflareDNS implements Dns { - - private static final HttpUrl url = HttpUrl.get("https://cloudflare-dns.com/dns-query"); - public boolean DoH = App.getPreferences().getBoolean("doh", false); - public boolean noProxy = ProxySelector.getDefault().select(url.uri()).get(0) == Proxy.NO_PROXY; - private final Dns cloudflare; - // Set once the DoH resolver proves unreachable (e.g. Cloudflare blocked on - // this network) so we stop paying its timeout on every subsequent lookup and - // use the system resolver for the rest of the session. - private volatile boolean dohUnavailable = false; - - public CloudflareDNS() { - var trustManager = Platform.get().platformTrustManager(); - var tls = ConnectionSpec.RESTRICTED_TLS; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - //noinspection deprecation - tls = new ConnectionSpec.Builder(tls) - .supportsTlsExtensions(false) - .build(); - } - var builder = new DnsOverHttps.Builder() - .resolvePrivateAddresses(true) - .url(HttpUrl.get("https://cloudflare-dns.com/dns-query")) - .client(new OkHttpClient.Builder() - .cache(App.getOkHttpCache()) - .sslSocketFactory(new NoSniFactory(), trustManager) - .connectionSpecs(List.of(tls)) - // Fail fast when the DoH endpoint is blocked so the - // system-DNS fallback kicks in quickly instead of - // stalling on the default 10s connect timeout. - .connectTimeout(Duration.ofSeconds(3)) - .callTimeout(Duration.ofSeconds(5)) - .build()); - try { - builder.bootstrapDnsHosts(List.of( - InetAddress.getByName("1.1.1.1"), - InetAddress.getByName("1.0.0.1"), - InetAddress.getByName("2606:4700:4700::1111"), - InetAddress.getByName("2606:4700:4700::1001"))); - } catch (UnknownHostException ignored) { - } - cloudflare = builder.build(); - } - - @NonNull - @Override - public List lookup(@NonNull String hostname) throws UnknownHostException { - if (DoH && noProxy && !dohUnavailable) { - try { - return cloudflare.lookup(hostname); - } catch (UnknownHostException e) { - // The DoH resolver is unreachable on this network (e.g. Cloudflare - // is blocked). Fall back to the system resolver so the app keeps - // working instead of failing every lookup, and skip DoH for the - // rest of the session. - dohUnavailable = true; - Log.w(App.TAG, "DoH resolver unreachable, falling back to system DNS for this session: " + e.getMessage()); - } - } - return SYSTEM.lookup(hostname); - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/EmptyAccessibilityDelegate.java b/app/src/main/java/org/lsposed/manager/util/EmptyAccessibilityDelegate.java deleted file mode 100644 index 6df1d9fdc..000000000 --- a/app/src/main/java/org/lsposed/manager/util/EmptyAccessibilityDelegate.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.os.Bundle; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityNodeProvider; - -public class EmptyAccessibilityDelegate extends View.AccessibilityDelegate { - - @Override - public void sendAccessibilityEvent(View host, int eventType) { - - } - - @Override - public boolean performAccessibilityAction(View host, int action, Bundle args) { - return true; - } - - @Override - public void sendAccessibilityEventUnchecked(View host, AccessibilityEvent event) { - - } - - @Override - public boolean dispatchPopulateAccessibilityEvent(View host, AccessibilityEvent event) { - return true; - } - - @Override - public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) { - - } - - @Override - public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { - - } - - @Override - public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { - - } - - @Override - public void addExtraDataToAccessibilityNodeInfo(View host, AccessibilityNodeInfo info, String extraDataKey, Bundle arguments) { - - } - - @Override - public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, AccessibilityEvent event) { - return true; - } - - @Override - public AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { - return null; - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java deleted file mode 100644 index 1205b37c5..000000000 --- a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java +++ /dev/null @@ -1,399 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; -import android.os.Build; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.util.Pair; - -import org.lsposed.lspd.models.UserInfo; -import org.lsposed.manager.App; -import org.lsposed.manager.ConfigManager; -import org.lsposed.manager.repo.RepoLoader; -import org.lsposed.manager.repo.model.OnlineModule; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Properties; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.zip.ZipFile; - -public final class ModuleUtil { - // xposedminversion below this - public static int MIN_MODULE_VERSION = 2; // reject modules with - private static ModuleUtil instance = null; - private final PackageManager pm; - private final Set listeners = ConcurrentHashMap.newKeySet(); - private HashSet enabledModules = new HashSet<>(); - private List users = new ArrayList<>(); - private Map, InstalledModule> installedModules = new HashMap<>(); - private boolean modulesLoaded = false; - - static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER - - static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; - - private ModuleUtil() { - pm = App.getInstance().getPackageManager(); - } - - public boolean isModulesLoaded() { - return modulesLoaded; - } - - public static synchronized ModuleUtil getInstance() { - if (instance == null) { - instance = new ModuleUtil(); - App.getExecutorService().submit(instance::reloadInstalledModules); - } - return instance; - } - - public static int extractIntPart(String str) { - int result = 0, length = str.length(); - for (int offset = 0; offset < length; offset++) { - char c = str.charAt(offset); - if ('0' <= c && c <= '9') - result = result * 10 + (c - '0'); - else - break; - } - return result; - } - - public static ZipFile getModernModuleApk(ApplicationInfo info) { - String[] apks; - if (info.splitSourceDirs != null) { - apks = Arrays.copyOf(info.splitSourceDirs, info.splitSourceDirs.length + 1); - apks[info.splitSourceDirs.length] = info.sourceDir; - } else apks = new String[]{info.sourceDir}; - ZipFile zip = null; - for (var apk : apks) { - try { - zip = new ZipFile(apk); - if (zip.getEntry("META-INF/xposed/java_init.list") != null) { - return zip; - } - zip.close(); - zip = null; - } catch (IOException ignored) { - } - } - return zip; - } - - public static boolean isLegacyModule(ApplicationInfo info) { - return info.metaData != null && info.metaData.containsKey("xposedminversion"); - } - - synchronized public void reloadInstalledModules() { - modulesLoaded = false; - if (!ConfigManager.isBinderAlive()) { - modulesLoaded = true; - return; - } - - Map, InstalledModule> modules = new HashMap<>(); - var users = ConfigManager.getUsers(); - for (PackageInfo pkg : ConfigManager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false)) { - ApplicationInfo app = pkg.applicationInfo; - - var modernApk = getModernModuleApk(app); - if (modernApk != null || isLegacyModule(app)) { - modules.computeIfAbsent(Pair.create(pkg.packageName, app.uid / App.PER_USER_RANGE), k -> new InstalledModule(pkg, modernApk)); - } - } - - installedModules = modules; - - this.users = users; - - enabledModules = new HashSet<>(Arrays.asList(ConfigManager.getEnabledModules())); - modulesLoaded = true; - listeners.forEach(ModuleListener::onModulesReloaded); - } - - @Nullable - public List getUsers() { - return modulesLoaded ? users : null; - } - - public InstalledModule reloadSingleModule(String packageName, int userId) { - return reloadSingleModule(packageName, userId, false); - } - - public InstalledModule reloadSingleModule(String packageName, int userId, boolean packageFullyRemoved) { - if (packageFullyRemoved && isModuleEnabled(packageName)) { - enabledModules.remove(packageName); - listeners.forEach(ModuleListener::onModulesReloaded); - } - PackageInfo pkg; - - try { - pkg = ConfigManager.getPackageInfo(packageName, PackageManager.GET_META_DATA, userId); - } catch (NameNotFoundException e) { - InstalledModule old = installedModules.remove(Pair.create(packageName, userId)); - if (old != null) listeners.forEach(i -> i.onSingleModuleReloaded(old)); - return null; - } - - ApplicationInfo app = pkg.applicationInfo; - var modernApk = getModernModuleApk(app); - if (modernApk != null || isLegacyModule(app)) { - InstalledModule module = new InstalledModule(pkg, modernApk); - installedModules.put(Pair.create(packageName, userId), module); - listeners.forEach(i -> i.onSingleModuleReloaded(module)); - return module; - } else { - InstalledModule old = installedModules.remove(Pair.create(packageName, userId)); - if (old != null) listeners.forEach(i -> i.onSingleModuleReloaded(old)); - return null; - } - } - - @Nullable - public InstalledModule getModule(String packageName, int userId) { - return modulesLoaded ? installedModules.get(Pair.create(packageName, userId)) : null; - } - - @Nullable - public InstalledModule getModule(String packageName) { - return getModule(packageName, 0); - } - - @Nullable - synchronized public Map, InstalledModule> getModules() { - return modulesLoaded ? installedModules : null; - } - - public boolean setModuleEnabled(String packageName, boolean enabled) { - if (!ConfigManager.setModuleEnabled(packageName, enabled)) { - return false; - } - if (enabled) { - enabledModules.add(packageName); - } else { - enabledModules.remove(packageName); - } - return true; - } - - public boolean isModuleEnabled(String packageName) { - return enabledModules.contains(packageName); - } - - public int getEnabledModulesCount() { - return modulesLoaded ? enabledModules.size() : -1; - } - - public void addListener(ModuleListener listener) { - listeners.add(listener); - } - - public void removeListener(ModuleListener listener) { - listeners.remove(listener); - } - - public interface ModuleListener { - /** - * Called whenever one (previously or now) installed module has been - * reloaded - */ - default void onSingleModuleReloaded(InstalledModule module) { - - } - - default void onModulesReloaded() { - - } - } - - public class InstalledModule { - //private static final int FLAG_FORWARD_LOCK = 1 << 29; - public final int userId; - public final String packageName; - public final String versionName; - public final long versionCode; - public final boolean legacy; - public final int minVersion; - public final int targetVersion; - public final boolean staticScope; - public final long installTime; - public final long updateTime; - public final ApplicationInfo app; - public final PackageInfo pkg; - private String appName; // loaded lazily - private String description; // loaded lazily - private List scopeList; // loaded lazily - - private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) { - app = pkg.applicationInfo; - this.pkg = pkg; - userId = pkg.applicationInfo.uid / App.PER_USER_RANGE; - packageName = pkg.packageName; - versionName = pkg.versionName; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - versionCode = pkg.versionCode; - } else { - versionCode = pkg.getLongVersionCode(); - } - installTime = pkg.firstInstallTime; - updateTime = pkg.lastUpdateTime; - legacy = modernModuleApk == null; - - if (legacy) { - Object minVersionRaw = app.metaData.get("xposedminversion"); - if (minVersionRaw instanceof Integer) { - minVersion = (Integer) minVersionRaw; - } else if (minVersionRaw instanceof String) { - minVersion = extractIntPart((String) minVersionRaw); - } else { - minVersion = 0; - } - targetVersion = minVersion; // legacy modules don't have a target version - staticScope = false; - } else { - int minVersion = 100; - int targetVersion = 100; - boolean staticScope = false; - try (modernModuleApk) { - var propEntry = modernModuleApk.getEntry("META-INF/xposed/module.prop"); - if (propEntry != null) { - var prop = new Properties(); - prop.load(modernModuleApk.getInputStream(propEntry)); - minVersion = extractIntPart(prop.getProperty("minApiVersion")); - targetVersion = extractIntPart(prop.getProperty("targetApiVersion")); - staticScope = TextUtils.equals(prop.getProperty("staticScope"), "true"); - } - var scopeEntry = modernModuleApk.getEntry("META-INF/xposed/scope.list"); - if (scopeEntry != null) { - try (var reader = new BufferedReader(new InputStreamReader(modernModuleApk.getInputStream(scopeEntry)))) { - scopeList = reader.lines().collect(Collectors.toList()); - } - } else { - scopeList = Collections.emptyList(); - } - } catch (IOException | OutOfMemoryError e) { - Log.e(App.TAG, "Error while closing modern module APK", e); - } - this.minVersion = minVersion; - this.targetVersion = targetVersion; - this.staticScope = staticScope; - } - } - - public boolean isInstalledOnExternalStorage() { - return (app.flags & ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0; - } - - public String getAppName() { - if (appName == null) - appName = app.loadLabel(pm).toString(); - return appName; - } - - public String getDescription() { - if (this.description != null) return this.description; - String descriptionTmp = ""; - if (legacy) { - Object descriptionRaw = app.metaData.get("xposeddescription"); - if (descriptionRaw instanceof String) { - descriptionTmp = ((String) descriptionRaw).trim(); - } else if (descriptionRaw instanceof Integer) { - try { - int resId = (Integer) descriptionRaw; - if (resId != 0) - descriptionTmp = pm.getResourcesForApplication(app).getString(resId).trim(); - } catch (Exception ignored) { - } - } - } else { - var des = app.loadDescription(pm); - if (des != null) descriptionTmp = des.toString(); - } - this.description = descriptionTmp; - return this.description; - } - - public List getScopeList() { - if (scopeList != null) return scopeList; - List list = null; - try { - int scopeListResourceId = app.metaData.getInt("xposedscope"); - if (scopeListResourceId != 0) { - list = Arrays.asList(pm.getResourcesForApplication(app).getStringArray(scopeListResourceId)); - } else { - String scopeListString = app.metaData.getString("xposedscope"); - if (scopeListString != null) - list = Arrays.asList(scopeListString.split(";")); - } - } catch (Exception ignored) { - } - if (list == null) { - OnlineModule module = RepoLoader.getInstance().getOnlineModule(packageName); - if (module != null && module.getScope() != null) { - list = module.getScope(); - } - } - if (list != null) { - //For historical reasons, legacy modules use the opposite name. - //https://github.com/rovo89/XposedBridge/commit/6b49688c929a7768f3113b4c65b429c7a7032afa - list.replaceAll(s -> - switch (s) { - case "android" -> "system"; - case "system" -> "android"; - default -> s; - } - ); - scopeList = list; - } - return scopeList; - } - - public PackageInfo getPackageInfo() { - return pkg; - } - - @NonNull - @Override - public String toString() { - return getAppName(); - } - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/NavUtil.java b/app/src/main/java/org/lsposed/manager/util/NavUtil.java deleted file mode 100644 index 1848173db..000000000 --- a/app/src/main/java/org/lsposed/manager/util/NavUtil.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.net.Uri; -import android.widget.Toast; - -import androidx.browser.customtabs.CustomTabColorSchemeParams; -import androidx.browser.customtabs.CustomTabsIntent; - -import rikka.core.util.ResourceUtils; - -public final class NavUtil { - - public static void startURL(Activity activity, Uri uri) { - CustomTabsIntent.Builder customTabsIntent = new CustomTabsIntent.Builder(); - customTabsIntent.setShowTitle(true); - CustomTabColorSchemeParams params = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(ResourceUtils.resolveColor(activity.getTheme(), android.R.attr.colorBackground)) - .setNavigationBarColor(ResourceUtils.resolveColor(activity.getTheme(), android.R.attr.navigationBarColor)) - .setNavigationBarDividerColor(0) - .build(); - customTabsIntent.setDefaultColorSchemeParams(params); - boolean night = ResourceUtils.isNightMode(activity.getResources().getConfiguration()); - customTabsIntent.setColorScheme(night ? CustomTabsIntent.COLOR_SCHEME_DARK : CustomTabsIntent.COLOR_SCHEME_LIGHT); - try { - customTabsIntent.build().launchUrl(activity, uri); - } catch (ActivityNotFoundException ignored) { - Toast.makeText(activity, uri.toString(), Toast.LENGTH_SHORT).show(); - } - } - - public static void startURL(Activity activity, String url) { - startURL(activity, Uri.parse(url)); - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/NoSniFactory.java b/app/src/main/java/org/lsposed/manager/util/NoSniFactory.java deleted file mode 100644 index 034c6096b..000000000 --- a/app/src/main/java/org/lsposed/manager/util/NoSniFactory.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.lsposed.manager.util; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.Socket; - -import javax.net.ssl.SSLSocketFactory; - -public final class NoSniFactory extends SSLSocketFactory { - private static final SSLSocketFactory defaultFactory = (SSLSocketFactory) getDefault(); - @SuppressWarnings("deprecation") - private static final android.net.SSLCertificateSocketFactory openSSLSocket = - (android.net.SSLCertificateSocketFactory) android.net.SSLCertificateSocketFactory - .getDefault(1000); - - @Override - public String[] getDefaultCipherSuites() { - return defaultFactory.getDefaultCipherSuites(); - } - - @Override - public String[] getSupportedCipherSuites() { - return defaultFactory.getSupportedCipherSuites(); - } - - @Override - public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { - return config(defaultFactory.createSocket(s, host, port, autoClose)); - } - - @Override - public Socket createSocket(String host, int port) throws IOException { - return config(defaultFactory.createSocket(host, port)); - } - - @Override - public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { - return config(defaultFactory.createSocket(host, port, localHost, localPort)); - } - - @Override - public Socket createSocket(InetAddress host, int port) throws IOException { - return config(defaultFactory.createSocket(host, port)); - } - - @Override - public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { - return config(defaultFactory.createSocket(address, port, localAddress, localPort)); - } - - private Socket config(Socket socket) { - try { - openSSLSocket.setHostname(socket, null); - openSSLSocket.setUseSessionTickets(socket, true); - } catch (IllegalArgumentException ignored) { - } - return socket; - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/ShortcutUtil.java b/app/src/main/java/org/lsposed/manager/util/ShortcutUtil.java deleted file mode 100644 index 2c49eb956..000000000 --- a/app/src/main/java/org/lsposed/manager/util/ShortcutUtil.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.IntentSender; -import android.content.pm.PackageManager; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.AdaptiveIconDrawable; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.Icon; -import android.graphics.drawable.LayerDrawable; -import android.os.Build; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -public class ShortcutUtil { - private static final String SHORTCUT_ID = "org.lsposed.manager.shortcut"; - - private static Bitmap getBitmap(Context context, int id) { - var r = context.getResources(); - var res = r.getDrawable(id, context.getTheme()); - if (res instanceof BitmapDrawable) { - return ((BitmapDrawable) res).getBitmap(); - } else { - if (res instanceof AdaptiveIconDrawable) { - var layers = new Drawable[]{((AdaptiveIconDrawable) res).getBackground(), - ((AdaptiveIconDrawable) res).getForeground()}; - res = new LayerDrawable(layers); - } - var bitmap = Bitmap.createBitmap(res.getIntrinsicWidth(), - res.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - var canvas = new Canvas(bitmap); - res.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - res.draw(canvas); - return bitmap; - } - } - - private static Intent getLaunchIntent(Context context) { - var pm = context.getPackageManager(); - var pkg = context.getPackageName(); - var intent = pm.getLaunchIntentForPackage(pkg); - if (intent == null) { - try { - var pkgInfo = pm.getPackageInfo(pkg, PackageManager.GET_ACTIVITIES); - if (pkgInfo.activities != null) { - for (var activityInfo : pkgInfo.activities) { - if (activityInfo.processName.equals(activityInfo.packageName)) { - intent = new Intent(Intent.ACTION_MAIN); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.setComponent(new ComponentName(pkg, activityInfo.name)); - break; - } - } - } - } catch (PackageManager.NameNotFoundException ignored) { - } - } - if (intent != null) { - var categories = intent.getCategories(); - if (categories != null) { - categories.clear(); - } - intent.addCategory("org.lsposed.manager.LAUNCH_MANAGER"); - intent.setPackage(pkg); - } - return intent; - } - - @SuppressLint("InlinedApi") - private static IntentSender registerReceiver(Context context, Runnable task) { - if (task == null) return null; - var uuid = UUID.randomUUID().toString(); - var filter = new IntentFilter(uuid); - var permission = "android.permission.CREATE_USERS"; - var receiver = new BroadcastReceiver() { - @Override - public void onReceive(Context c, Intent intent) { - if (!uuid.equals(intent.getAction())) return; - context.unregisterReceiver(this); - task.run(); - } - }; - context.registerReceiver(receiver, filter, permission, - null/* main thread */, Context.RECEIVER_EXPORTED); - - var intent = new Intent(uuid); - int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; - return PendingIntent.getBroadcast(context, 0, intent, flags).getIntentSender(); - } - - private static ShortcutInfo.Builder getShortcutBuilder(Context context) { - var builder = new ShortcutInfo.Builder(context, SHORTCUT_ID) - .setShortLabel(context.getString(R.string.app_name)) - .setIntent(getLaunchIntent(context)) - .setIcon(Icon.createWithAdaptiveBitmap(getBitmap(context, - R.drawable.ic_launcher))); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - var activity = new ComponentName(context.getPackageName(), - "android.app.AppDetailsActivity"); - builder.setActivity(activity); - } - return builder; - } - - public static boolean isRequestPinShortcutSupported(Context context) throws RuntimeException { - var sm = context.getSystemService(ShortcutManager.class); - return sm.isRequestPinShortcutSupported(); - } - - public static boolean requestPinLaunchShortcut(Runnable afterPinned) { - if (!App.isParasitic) throw new RuntimeException(); - var context = App.getInstance(); - var sm = context.getSystemService(ShortcutManager.class); - if (!sm.isRequestPinShortcutSupported()) return false; - return sm.requestPinShortcut(getShortcutBuilder(context).build(), - registerReceiver(context, afterPinned)); - } - - public static boolean updateShortcut() { - if (!isLaunchShortcutPinned()) return false; - var context = App.getInstance(); - var sm = context.getSystemService(ShortcutManager.class); - List shortcutInfoList = new ArrayList<>(); - shortcutInfoList.add(getShortcutBuilder(context).build()); - return sm.updateShortcuts(shortcutInfoList); - } - - public static boolean isLaunchShortcutPinned() { - var context = App.getInstance(); - var sm = context.getSystemService(ShortcutManager.class); - for (var info : sm.getPinnedShortcuts()) { - if (SHORTCUT_ID.equals(info.getId())) { - return true; - } - } - return false; - } - -} diff --git a/app/src/main/java/org/lsposed/manager/util/SimpleStatefulAdaptor.java b/app/src/main/java/org/lsposed/manager/util/SimpleStatefulAdaptor.java deleted file mode 100644 index a4db646d9..000000000 --- a/app/src/main/java/org/lsposed/manager/util/SimpleStatefulAdaptor.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.os.Bundle; -import android.os.Parcelable; -import android.util.SparseArray; - -import androidx.annotation.CallSuper; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.adapter.StatefulAdapter; - -import java.util.HashMap; -import java.util.List; - -public abstract class SimpleStatefulAdaptor extends RecyclerView.Adapter implements StatefulAdapter { - HashMap> states = new HashMap<>(); - protected RecyclerView rv = null; - - public SimpleStatefulAdaptor() { - setStateRestorationPolicy(StateRestorationPolicy.PREVENT_WHEN_EMPTY); - } - - @Override - @CallSuper - public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { - rv = recyclerView; - super.onAttachedToRecyclerView(recyclerView); - } - - @Override - public void onViewRecycled(@NonNull T holder) { - saveStateOf(holder); - super.onViewRecycled(holder); - } - - @CallSuper - @Override - public final void onBindViewHolder(@NonNull T holder, int position, @NonNull List payloads) { - var state = states.remove(holder.getItemId()); - if (state != null) { - holder.itemView.restoreHierarchyState(state); - } - onBindViewHolder(holder, position); - } - - private void saveStateOf(@NonNull RecyclerView.ViewHolder holder) { - var state = new SparseArray(); - holder.itemView.saveHierarchyState(state); - states.put(holder.getItemId(), state); - } - - @NonNull - public Parcelable saveState() { - for (int childCount = rv.getChildCount(), i = 0; i < childCount; ++i) { - saveStateOf(rv.getChildViewHolder(rv.getChildAt(i))); - } - - var out = new Bundle(); - for (var state : states.entrySet()) { - var item = new Bundle(); - for (int i = 0; i < state.getValue().size(); ++i) { - item.putParcelable(String.valueOf(state.getValue().keyAt(i)), state.getValue().valueAt(i)); - } - out.putParcelable(String.valueOf(state.getKey()), item); - } - return out; - } - - @Override - public void restoreState(@NonNull Parcelable savedState) { - if (savedState instanceof Bundle) { - for (var stateKey : ((Bundle) savedState).keySet()) { - var array = new SparseArray(); - var state = ((Bundle) savedState).getParcelable(stateKey); - if (state instanceof Bundle) { - for (var itemKey : ((Bundle) state).keySet()) { - var item = ((Bundle) state).getParcelable(itemKey); - array.put(Integer.parseInt(itemKey), item); - } - } - states.put(Long.parseLong(stateKey), array); - } - } - } - -} diff --git a/app/src/main/java/org/lsposed/manager/util/ThemeUtil.java b/app/src/main/java/org/lsposed/manager/util/ThemeUtil.java deleted file mode 100644 index d27acd48d..000000000 --- a/app/src/main/java/org/lsposed/manager/util/ThemeUtil.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.StyleRes; -import androidx.appcompat.app.AppCompatDelegate; - -import com.google.android.material.color.DynamicColors; - -import org.lsposed.manager.App; -import org.lsposed.manager.R; - -import java.util.HashMap; -import java.util.Map; - -import rikka.core.util.ResourceUtils; - -public class ThemeUtil { - private static final Map colorThemeMap = new HashMap<>(); - private static final SharedPreferences preferences; - - public static final String MODE_NIGHT_FOLLOW_SYSTEM = "MODE_NIGHT_FOLLOW_SYSTEM"; - public static final String MODE_NIGHT_NO = "MODE_NIGHT_NO"; - public static final String MODE_NIGHT_YES = "MODE_NIGHT_YES"; - - static { - preferences = App.getPreferences(); - colorThemeMap.put("SAKURA", R.style.ThemeOverlay_MaterialSakura); - colorThemeMap.put("MATERIAL_RED", R.style.ThemeOverlay_MaterialRed); - colorThemeMap.put("MATERIAL_PINK", R.style.ThemeOverlay_MaterialPink); - colorThemeMap.put("MATERIAL_PURPLE", R.style.ThemeOverlay_MaterialPurple); - colorThemeMap.put("MATERIAL_DEEP_PURPLE", R.style.ThemeOverlay_MaterialDeepPurple); - colorThemeMap.put("MATERIAL_INDIGO", R.style.ThemeOverlay_MaterialIndigo); - colorThemeMap.put("MATERIAL_BLUE", R.style.ThemeOverlay_MaterialBlue); - colorThemeMap.put("MATERIAL_LIGHT_BLUE", R.style.ThemeOverlay_MaterialLightBlue); - colorThemeMap.put("MATERIAL_CYAN", R.style.ThemeOverlay_MaterialCyan); - colorThemeMap.put("MATERIAL_TEAL", R.style.ThemeOverlay_MaterialTeal); - colorThemeMap.put("MATERIAL_GREEN", R.style.ThemeOverlay_MaterialGreen); - colorThemeMap.put("MATERIAL_LIGHT_GREEN", R.style.ThemeOverlay_MaterialLightGreen); - colorThemeMap.put("MATERIAL_LIME", R.style.ThemeOverlay_MaterialLime); - colorThemeMap.put("MATERIAL_YELLOW", R.style.ThemeOverlay_MaterialYellow); - colorThemeMap.put("MATERIAL_AMBER", R.style.ThemeOverlay_MaterialAmber); - colorThemeMap.put("MATERIAL_ORANGE", R.style.ThemeOverlay_MaterialOrange); - colorThemeMap.put("MATERIAL_DEEP_ORANGE", R.style.ThemeOverlay_MaterialDeepOrange); - colorThemeMap.put("MATERIAL_BROWN", R.style.ThemeOverlay_MaterialBrown); - colorThemeMap.put("MATERIAL_BLUE_GREY", R.style.ThemeOverlay_MaterialBlueGrey); - } - - private static final String THEME_DEFAULT = "DEFAULT"; - private static final String THEME_BLACK = "BLACK"; - - private static boolean isBlackNightTheme() { - return preferences.getBoolean("black_dark_theme", false); - } - - public static boolean isSystemAccent() { - return DynamicColors.isDynamicColorAvailable() && preferences.getBoolean("follow_system_accent", true); - } - - public static String getNightTheme(Context context) { - if (isBlackNightTheme() - && ResourceUtils.isNightMode(context.getResources().getConfiguration())) - return THEME_BLACK; - - return THEME_DEFAULT; - } - - @StyleRes - public static int getNightThemeStyleRes(Context context) { - switch (getNightTheme(context)) { - case THEME_BLACK: - return R.style.ThemeOverlay_Black; - case THEME_DEFAULT: - default: - return R.style.ThemeOverlay; - } - } - - public static String getColorTheme() { - if (isSystemAccent()) { - return "SYSTEM"; - } - return preferences.getString("theme_color", "COLOR_BLUE"); - } - - @StyleRes - public static int getColorThemeStyleRes() { - Integer theme = colorThemeMap.get(getColorTheme()); - if (theme == null) { - return R.style.ThemeOverlay_MaterialBlue; - } - return theme; - } - - public static int getDarkTheme(String mode) { - switch (mode) { - case MODE_NIGHT_FOLLOW_SYSTEM: - default: - return AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM; - case MODE_NIGHT_YES: - return AppCompatDelegate.MODE_NIGHT_YES; - case MODE_NIGHT_NO: - return AppCompatDelegate.MODE_NIGHT_NO; - } - } - - public static int getDarkTheme() { - return getDarkTheme(preferences.getString("dark_theme", MODE_NIGHT_FOLLOW_SYSTEM)); - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/UpdateUtil.java b/app/src/main/java/org/lsposed/manager/util/UpdateUtil.java deleted file mode 100644 index 10d2191b0..000000000 --- a/app/src/main/java/org/lsposed/manager/util/UpdateUtil.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - -package org.lsposed.manager.util; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - -import org.lsposed.manager.App; -import org.lsposed.manager.BuildConfig; -import org.lsposed.manager.ConfigManager; - -import java.io.File; -import java.io.IOException; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Locale; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.Request; -import okhttp3.Response; -import okio.Okio; - -public class UpdateUtil { - public static void loadRemoteVersion() { - var request = new Request.Builder() - .url("https://api.github.com/repos/JingMatrix/LSPosed/releases/latest") - .addHeader("Accept", "application/vnd.github.v3+json") - .build(); - var callback = new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (!response.isSuccessful()) return; - var body = response.body(); - if (body == null) return; - try { - var info = JsonParser.parseReader(body.charStream()).getAsJsonObject(); - var notes = info.get("body").getAsString(); - var assetsArray = info.getAsJsonArray("assets"); - for (var assets : assetsArray) { - checkAssets(assets.getAsJsonObject(), notes); - } - } catch (Throwable t) { - Log.e(App.TAG, t.getMessage(), t); - } - } - - @Override - public void onFailure(@NonNull Call call, @NonNull IOException e) { - Log.e(App.TAG, "loadRemoteVersion: " + e.getMessage()); - var pref = App.getPreferences(); - if (pref.getBoolean("checked", false)) return; - pref.edit().putBoolean("checked", true).apply(); - } - }; - App.getOkHttpClient().newCall(request).enqueue(callback); - } - - private static void checkAssets(JsonObject assets, String releaseNotes) { - var pref = App.getPreferences(); - var name = assets.get("name").getAsString(); - var splitName = name.split("-"); - pref.edit() - .putInt("latest_version", Integer.parseInt(splitName[2])) - .putLong("latest_check", Instant.now().getEpochSecond()) - .putString("release_notes", releaseNotes) - .putString("zip_file", null) - .putBoolean("checked", true) - .apply(); - var updatedAt = Instant.parse(assets.get("updated_at").getAsString()); - var downloadUrl = assets.get("browser_download_url").getAsString(); - var zipTime = pref.getLong("zip_time", 0); - if (!updatedAt.equals(Instant.ofEpochSecond(zipTime))) { - var zip = downloadNewZipSync(downloadUrl, name); - var size = assets.get("size").getAsLong(); - if (zip != null && zip.length() == size) { - pref.edit() - .putLong("zip_time", updatedAt.getEpochSecond()) - .putString("zip_file", zip.getAbsolutePath()) - .apply(); - } - } - } - - public static boolean needUpdate() { - var pref = App.getPreferences(); - if (!pref.getBoolean("checked", false)) return false; - var now = Instant.now(); - var buildTime = Instant.ofEpochSecond(BuildConfig.BUILD_TIME); - var check = pref.getLong("latest_check", 0); - if (check > 0) { - var checkTime = Instant.ofEpochSecond(check); - if (checkTime.atOffset(ZoneOffset.UTC).plusDays(30).toInstant().isBefore(now)) - return true; - var code = pref.getInt("latest_version", 0); - return code > BuildConfig.VERSION_CODE; - } - return buildTime.atOffset(ZoneOffset.UTC).plusDays(30).toInstant().isBefore(now); - } - - @Nullable - private static File downloadNewZipSync(String url, String name) { - var request = new Request.Builder().url(url).build(); - var zip = new File(App.getInstance().getCacheDir(), name); - try (Response response = App.getOkHttpClient().newCall(request).execute()) { - var body = response.body(); - if (!response.isSuccessful() || body == null) return null; - try (var source = body.source(); - var sink = Okio.buffer(Okio.sink(zip))) { - sink.writeAll(source); - } - } catch (IOException e) { - Log.e(App.TAG, "downloadNewZipSync: " + e.getMessage()); - return null; - } - return zip; - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/chrome/CustomTabsURLSpan.java b/app/src/main/java/org/lsposed/manager/util/chrome/CustomTabsURLSpan.java deleted file mode 100644 index 927ac914a..000000000 --- a/app/src/main/java/org/lsposed/manager/util/chrome/CustomTabsURLSpan.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util.chrome; - -import android.app.Activity; -import android.text.style.URLSpan; -import android.view.View; - -import org.lsposed.manager.util.NavUtil; - -public class CustomTabsURLSpan extends URLSpan { - - private final Activity activity; - - public CustomTabsURLSpan(Activity activity, String url) { - super(url); - this.activity = activity; - } - - @Override - public void onClick(View widget) { - String url = getURL(); - NavUtil.startURL(activity, url); - } -} diff --git a/app/src/main/java/org/lsposed/manager/util/chrome/LinkTransformationMethod.java b/app/src/main/java/org/lsposed/manager/util/chrome/LinkTransformationMethod.java deleted file mode 100644 index bf3a3b366..000000000 --- a/app/src/main/java/org/lsposed/manager/util/chrome/LinkTransformationMethod.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2020 EdXposed Contributors - * Copyright (C) 2021 LSPosed Contributors - */ - -package org.lsposed.manager.util.chrome; - -import android.app.Activity; -import android.graphics.Rect; -import android.text.Spannable; -import android.text.Spanned; -import android.text.method.TransformationMethod; -import android.text.style.URLSpan; -import android.view.View; -import android.widget.TextView; - -public class LinkTransformationMethod implements TransformationMethod { - - private final Activity activity; - - public LinkTransformationMethod(Activity activity) { - this.activity = activity; - } - - @Override - public CharSequence getTransformation(CharSequence source, View view) { - if (view instanceof TextView) { - TextView textView = (TextView) view; - if (textView.getText() == null || !(textView.getText() instanceof Spannable)) { - return source; - } - Spannable text = (Spannable) textView.getText(); - URLSpan[] spans = text.getSpans(0, textView.length(), URLSpan.class); - for (int i = spans.length - 1; i >= 0; i--) { - URLSpan oldSpan = spans[i]; - int start = text.getSpanStart(oldSpan); - int end = text.getSpanEnd(oldSpan); - String url = oldSpan.getURL(); - text.removeSpan(oldSpan); - text.setSpan(new CustomTabsURLSpan(activity, url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - return text; - } - return source; - } - - @Override - public void onFocusChanged(View view, CharSequence sourceText, boolean focused, int direction, Rect previouslyFocusedRect) { - } -} diff --git a/app/src/main/res/anim/fragment_enter.xml b/app/src/main/res/anim/fragment_enter.xml deleted file mode 100644 index 729cbef69..000000000 --- a/app/src/main/res/anim/fragment_enter.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/app/src/main/res/anim/fragment_enter_pop.xml b/app/src/main/res/anim/fragment_enter_pop.xml deleted file mode 100644 index 7e863ffbc..000000000 --- a/app/src/main/res/anim/fragment_enter_pop.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/app/src/main/res/anim/fragment_exit.xml b/app/src/main/res/anim/fragment_exit.xml deleted file mode 100644 index c4ccf0dc4..000000000 --- a/app/src/main/res/anim/fragment_exit.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/app/src/main/res/anim/fragment_exit_pop.xml b/app/src/main/res/anim/fragment_exit_pop.xml deleted file mode 100644 index efb2c235e..000000000 --- a/app/src/main/res/anim/fragment_exit_pop.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_assignment_checkable.xml b/app/src/main/res/drawable/ic_assignment_checkable.xml deleted file mode 100644 index 953092d06..000000000 --- a/app/src/main/res/drawable/ic_assignment_checkable.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_attach_file.xml b/app/src/main/res/drawable/ic_attach_file.xml deleted file mode 100644 index 919b7fcd6..000000000 --- a/app/src/main/res/drawable/ic_attach_file.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml deleted file mode 100644 index 16b01c6dd..000000000 --- a/app/src/main/res/drawable/ic_baseline_add_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml deleted file mode 100644 index 801a1dead..000000000 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_assignment_24.xml b/app/src/main/res/drawable/ic_baseline_assignment_24.xml deleted file mode 100644 index 64b5cff24..000000000 --- a/app/src/main/res/drawable/ic_baseline_assignment_24.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_chat_24.xml b/app/src/main/res/drawable/ic_baseline_chat_24.xml deleted file mode 100644 index 21beb622b..000000000 --- a/app/src/main/res/drawable/ic_baseline_chat_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_extension_24.xml b/app/src/main/res/drawable/ic_baseline_extension_24.xml deleted file mode 100644 index db1669bd8..000000000 --- a/app/src/main/res/drawable/ic_baseline_extension_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_get_app_24.xml b/app/src/main/res/drawable/ic_baseline_get_app_24.xml deleted file mode 100644 index 7fd5a906b..000000000 --- a/app/src/main/res/drawable/ic_baseline_get_app_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_home_24.xml b/app/src/main/res/drawable/ic_baseline_home_24.xml deleted file mode 100644 index f4ab22a5d..000000000 --- a/app/src/main/res/drawable/ic_baseline_home_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml deleted file mode 100644 index 8edf9d4b4..000000000 --- a/app/src/main/res/drawable/ic_baseline_info_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml deleted file mode 100644 index c1ea83ba3..000000000 --- a/app/src/main/res/drawable/ic_baseline_search_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml deleted file mode 100644 index 99f112297..000000000 --- a/app/src/main/res/drawable/ic_baseline_settings_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml b/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml deleted file mode 100644 index 123d5230f..000000000 --- a/app/src/main/res/drawable/ic_baseline_settings_backup_restore_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_extension_checkable.xml b/app/src/main/res/drawable/ic_extension_checkable.xml deleted file mode 100644 index 940258ae8..000000000 --- a/app/src/main/res/drawable/ic_extension_checkable.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/ic_get_app_checkable.xml b/app/src/main/res/drawable/ic_get_app_checkable.xml deleted file mode 100644 index e08a1f9b0..000000000 --- a/app/src/main/res/drawable/ic_get_app_checkable.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_home_checkable.xml b/app/src/main/res/drawable/ic_home_checkable.xml deleted file mode 100644 index 7a087431e..000000000 --- a/app/src/main/res/drawable/ic_home_checkable.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down.xml deleted file mode 100644 index 0bc9590ac..000000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_down.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index c16bb8f93..000000000 --- a/app/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 8e75c220e..000000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_round.xml b/app/src/main/res/drawable/ic_launcher_round.xml deleted file mode 100644 index 496302eb9..000000000 --- a/app/src/main/res/drawable/ic_launcher_round.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_open_in_browser.xml b/app/src/main/res/drawable/ic_open_in_browser.xml deleted file mode 100644 index 3c04742fd..000000000 --- a/app/src/main/res/drawable/ic_open_in_browser.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_android_24.xml b/app/src/main/res/drawable/ic_outline_android_24.xml deleted file mode 100644 index 47196d1ea..000000000 --- a/app/src/main/res/drawable/ic_outline_android_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_app_shortcut_24.xml b/app/src/main/res/drawable/ic_outline_app_shortcut_24.xml deleted file mode 100644 index 0373d4cb5..000000000 --- a/app/src/main/res/drawable/ic_outline_app_shortcut_24.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/ic_outline_assignment_24.xml b/app/src/main/res/drawable/ic_outline_assignment_24.xml deleted file mode 100644 index fb83e0d85..000000000 --- a/app/src/main/res/drawable/ic_outline_assignment_24.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_dark_mode_24.xml b/app/src/main/res/drawable/ic_outline_dark_mode_24.xml deleted file mode 100644 index 184c7e5dd..000000000 --- a/app/src/main/res/drawable/ic_outline_dark_mode_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_dns_24.xml b/app/src/main/res/drawable/ic_outline_dns_24.xml deleted file mode 100644 index d6ba21455..000000000 --- a/app/src/main/res/drawable/ic_outline_dns_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_extension_24.xml b/app/src/main/res/drawable/ic_outline_extension_24.xml deleted file mode 100644 index 5850b5655..000000000 --- a/app/src/main/res/drawable/ic_outline_extension_24.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_format_color_fill_24.xml b/app/src/main/res/drawable/ic_outline_format_color_fill_24.xml deleted file mode 100644 index 1d404e9a2..000000000 --- a/app/src/main/res/drawable/ic_outline_format_color_fill_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_get_app_24.xml b/app/src/main/res/drawable/ic_outline_get_app_24.xml deleted file mode 100644 index 2b66ca700..000000000 --- a/app/src/main/res/drawable/ic_outline_get_app_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_groups_24.xml b/app/src/main/res/drawable/ic_outline_groups_24.xml deleted file mode 100644 index 9b3bd0fb8..000000000 --- a/app/src/main/res/drawable/ic_outline_groups_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_home_24.xml b/app/src/main/res/drawable/ic_outline_home_24.xml deleted file mode 100644 index 2c9291c47..000000000 --- a/app/src/main/res/drawable/ic_outline_home_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_invert_colors_24.xml b/app/src/main/res/drawable/ic_outline_invert_colors_24.xml deleted file mode 100644 index 0b76592cb..000000000 --- a/app/src/main/res/drawable/ic_outline_invert_colors_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_language_24.xml b/app/src/main/res/drawable/ic_outline_language_24.xml deleted file mode 100644 index be985e9cc..000000000 --- a/app/src/main/res/drawable/ic_outline_language_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_merge_type_24.xml b/app/src/main/res/drawable/ic_outline_merge_type_24.xml deleted file mode 100644 index 33c6e830c..000000000 --- a/app/src/main/res/drawable/ic_outline_merge_type_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_palette_24.xml b/app/src/main/res/drawable/ic_outline_palette_24.xml deleted file mode 100644 index a70d1a2e8..000000000 --- a/app/src/main/res/drawable/ic_outline_palette_24.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_outline_restore_24.xml b/app/src/main/res/drawable/ic_outline_restore_24.xml deleted file mode 100644 index 02b8dce82..000000000 --- a/app/src/main/res/drawable/ic_outline_restore_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_settings_24.xml b/app/src/main/res/drawable/ic_outline_settings_24.xml deleted file mode 100644 index 82bddbe8e..000000000 --- a/app/src/main/res/drawable/ic_outline_settings_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_shield_24.xml b/app/src/main/res/drawable/ic_outline_shield_24.xml deleted file mode 100644 index 2b98bb986..000000000 --- a/app/src/main/res/drawable/ic_outline_shield_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_speaker_notes_24.xml b/app/src/main/res/drawable/ic_outline_speaker_notes_24.xml deleted file mode 100644 index 7ffe2e971..000000000 --- a/app/src/main/res/drawable/ic_outline_speaker_notes_24.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_outline_translate_24.xml b/app/src/main/res/drawable/ic_outline_translate_24.xml deleted file mode 100644 index 1b2fb9ca5..000000000 --- a/app/src/main/res/drawable/ic_outline_translate_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_round_bug_report_24.xml b/app/src/main/res/drawable/ic_round_bug_report_24.xml deleted file mode 100644 index d9faada4f..000000000 --- a/app/src/main/res/drawable/ic_round_bug_report_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_round_check_circle_24.xml b/app/src/main/res/drawable/ic_round_check_circle_24.xml deleted file mode 100644 index 6fe7cdd1f..000000000 --- a/app/src/main/res/drawable/ic_round_check_circle_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_round_error_outline_24.xml b/app/src/main/res/drawable/ic_round_error_outline_24.xml deleted file mode 100644 index e51d888e8..000000000 --- a/app/src/main/res/drawable/ic_round_error_outline_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_round_settings_24.xml b/app/src/main/res/drawable/ic_round_settings_24.xml deleted file mode 100644 index 5cda2d061..000000000 --- a/app/src/main/res/drawable/ic_round_settings_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_round_update_24.xml b/app/src/main/res/drawable/ic_round_update_24.xml deleted file mode 100644 index 2b469b675..000000000 --- a/app/src/main/res/drawable/ic_round_update_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_round_warning_24.xml b/app/src/main/res/drawable/ic_round_warning_24.xml deleted file mode 100644 index 685f45378..000000000 --- a/app/src/main/res/drawable/ic_round_warning_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml deleted file mode 100644 index 325732501..000000000 --- a/app/src/main/res/drawable/ic_save.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_settings_checkable.xml b/app/src/main/res/drawable/ic_settings_checkable.xml deleted file mode 100644 index ec9892de9..000000000 --- a/app/src/main/res/drawable/ic_settings_checkable.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/shortcut_ic_logs.xml b/app/src/main/res/drawable/shortcut_ic_logs.xml deleted file mode 100644 index 889f7d8b3..000000000 --- a/app/src/main/res/drawable/shortcut_ic_logs.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/shortcut_ic_modules.xml b/app/src/main/res/drawable/shortcut_ic_modules.xml deleted file mode 100644 index e27f6c6d7..000000000 --- a/app/src/main/res/drawable/shortcut_ic_modules.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/shortcut_ic_repo.xml b/app/src/main/res/drawable/shortcut_ic_repo.xml deleted file mode 100644 index 439d29521..000000000 --- a/app/src/main/res/drawable/shortcut_ic_repo.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/shortcut_ic_settings.xml b/app/src/main/res/drawable/shortcut_ic_settings.xml deleted file mode 100644 index 68fbf4792..000000000 --- a/app/src/main/res/drawable/shortcut_ic_settings.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/simple_menu_background.xml b/app/src/main/res/drawable/simple_menu_background.xml deleted file mode 100644 index 45b614f7d..000000000 --- a/app/src/main/res/drawable/simple_menu_background.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw600dp/activity_main.xml b/app/src/main/res/layout-sw600dp/activity_main.xml deleted file mode 100644 index 4a622f174..000000000 --- a/app/src/main/res/layout-sw600dp/activity_main.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 7e956da97..000000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml deleted file mode 100644 index 2139cb2d6..000000000 --- a/app/src/main/res/layout/dialog_about.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_item.xml b/app/src/main/res/layout/dialog_item.xml deleted file mode 100644 index 0df9f1145..000000000 --- a/app/src/main/res/layout/dialog_item.xml +++ /dev/null @@ -1,28 +0,0 @@ - - diff --git a/app/src/main/res/layout/dialog_title.xml b/app/src/main/res/layout/dialog_title.xml deleted file mode 100644 index ffdbb2382..000000000 --- a/app/src/main/res/layout/dialog_title.xml +++ /dev/null @@ -1,25 +0,0 @@ - - diff --git a/app/src/main/res/layout/fragment_app_list.xml b/app/src/main/res/layout/fragment_app_list.xml deleted file mode 100644 index 0600e2354..000000000 --- a/app/src/main/res/layout/fragment_app_list.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_compile_dialog.xml b/app/src/main/res/layout/fragment_compile_dialog.xml deleted file mode 100644 index 98fe0e235..000000000 --- a/app/src/main/res/layout/fragment_compile_dialog.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml deleted file mode 100644 index f1f2f5b98..000000000 --- a/app/src/main/res/layout/fragment_home.xml +++ /dev/null @@ -1,372 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_pager.xml b/app/src/main/res/layout/fragment_pager.xml deleted file mode 100644 index a5fa82aca..000000000 --- a/app/src/main/res/layout/fragment_pager.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_repo.xml b/app/src/main/res/layout/fragment_repo.xml deleted file mode 100644 index 54a023813..000000000 --- a/app/src/main/res/layout/fragment_repo.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml deleted file mode 100644 index cab64c869..000000000 --- a/app/src/main/res/layout/fragment_settings.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_log_textview.xml b/app/src/main/res/layout/item_log_textview.xml deleted file mode 100644 index 7363d2761..000000000 --- a/app/src/main/res/layout/item_log_textview.xml +++ /dev/null @@ -1,29 +0,0 @@ - - diff --git a/app/src/main/res/layout/item_master_switch.xml b/app/src/main/res/layout/item_master_switch.xml deleted file mode 100644 index 4b3029ca1..000000000 --- a/app/src/main/res/layout/item_master_switch.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/item_module.xml b/app/src/main/res/layout/item_module.xml deleted file mode 100644 index 33980f66a..000000000 --- a/app/src/main/res/layout/item_module.xml +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_onlinemodule.xml b/app/src/main/res/layout/item_onlinemodule.xml deleted file mode 100644 index 482f5b119..000000000 --- a/app/src/main/res/layout/item_onlinemodule.xml +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_repo_loadmore.xml b/app/src/main/res/layout/item_repo_loadmore.xml deleted file mode 100644 index a77ec59df..000000000 --- a/app/src/main/res/layout/item_repo_loadmore.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/item_repo_readme.xml b/app/src/main/res/layout/item_repo_readme.xml deleted file mode 100644 index fee5011e8..000000000 --- a/app/src/main/res/layout/item_repo_readme.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/item_repo_recyclerview.xml b/app/src/main/res/layout/item_repo_recyclerview.xml deleted file mode 100644 index b4bda2229..000000000 --- a/app/src/main/res/layout/item_repo_recyclerview.xml +++ /dev/null @@ -1,32 +0,0 @@ - - diff --git a/app/src/main/res/layout/item_repo_release.xml b/app/src/main/res/layout/item_repo_release.xml deleted file mode 100644 index 3a307148b..000000000 --- a/app/src/main/res/layout/item_repo_release.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_repo_title_description.xml b/app/src/main/res/layout/item_repo_title_description.xml deleted file mode 100644 index a2df47dc6..000000000 --- a/app/src/main/res/layout/item_repo_title_description.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/preference_recyclerview.xml b/app/src/main/res/layout/preference_recyclerview.xml deleted file mode 100644 index d0f3985ff..000000000 --- a/app/src/main/res/layout/preference_recyclerview.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/app/src/main/res/layout/scrollable_dialog.xml b/app/src/main/res/layout/scrollable_dialog.xml deleted file mode 100644 index c0cab916a..000000000 --- a/app/src/main/res/layout/scrollable_dialog.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/swiperefresh_recyclerview.xml b/app/src/main/res/layout/swiperefresh_recyclerview.xml deleted file mode 100644 index badeaad53..000000000 --- a/app/src/main/res/layout/swiperefresh_recyclerview.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - diff --git a/app/src/main/res/menu-sw600dp/navigation_menu.xml b/app/src/main/res/menu-sw600dp/navigation_menu.xml deleted file mode 100644 index 6362d1264..000000000 --- a/app/src/main/res/menu-sw600dp/navigation_menu.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/menu/context_menu_modules.xml b/app/src/main/res/menu/context_menu_modules.xml deleted file mode 100644 index e126ec147..000000000 --- a/app/src/main/res/menu/context_menu_modules.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_app_item.xml b/app/src/main/res/menu/menu_app_item.xml deleted file mode 100644 index 1cb39221b..000000000 --- a/app/src/main/res/menu/menu_app_item.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/menu/menu_app_list.xml b/app/src/main/res/menu/menu_app_list.xml deleted file mode 100644 index 280fa02b5..000000000 --- a/app/src/main/res/menu/menu_app_list.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/menu_home.xml deleted file mode 100644 index fcaeb5b23..000000000 --- a/app/src/main/res/menu/menu_home.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/menu/menu_logs.xml b/app/src/main/res/menu/menu_logs.xml deleted file mode 100644 index 98fe53d9d..000000000 --- a/app/src/main/res/menu/menu_logs.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_modules.xml b/app/src/main/res/menu/menu_modules.xml deleted file mode 100644 index 4b8cbd3fc..000000000 --- a/app/src/main/res/menu/menu_modules.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/menu/menu_repo.xml b/app/src/main/res/menu/menu_repo.xml deleted file mode 100644 index 387c34446..000000000 --- a/app/src/main/res/menu/menu_repo.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_repo_item.xml b/app/src/main/res/menu/menu_repo_item.xml deleted file mode 100644 index 32e81b8cb..000000000 --- a/app/src/main/res/menu/menu_repo_item.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/app/src/main/res/menu/navigation_menu.xml b/app/src/main/res/menu/navigation_menu.xml deleted file mode 100644 index bf38a5589..000000000 --- a/app/src/main/res/menu/navigation_menu.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/navigation/main_nav.xml b/app/src/main/res/navigation/main_nav.xml deleted file mode 100644 index f855a2f65..000000000 --- a/app/src/main/res/navigation/main_nav.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/navigation/modules_nav.xml b/app/src/main/res/navigation/modules_nav.xml deleted file mode 100644 index 6d07808b5..000000000 --- a/app/src/main/res/navigation/modules_nav.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/navigation/repo_nav.xml b/app/src/main/res/navigation/repo_nav.xml deleted file mode 100644 index 2202d968c..000000000 --- a/app/src/main/res/navigation/repo_nav.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values-af/strings.xml b/app/src/main/res/values-af/strings.xml deleted file mode 100644 index 76548ffbf..000000000 --- a/app/src/main/res/values-af/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Oorsig - Modules - - %d module enabled - %d module enabled - - Logs - Settings - Terugvoer of voorstel - Oor - Rapporteer probleem - Bewaarplek - Alle modules op datum - Published at %s - Opgedateer op %s - - %d module opgradeerbaar - %d modules opgradeerbaar - - Sluit aan by ons %2$s -kanaal]]> - null - Installeer2 - Tik om LSPosed te installeer - Not installed1 - LSPosed is nie geïnstalleer nie - Activated - Partially activated - SEPolicy is nie behoorlik gelaai nie - Rapporteer dit asseblief aan Magisk ontwikkelaar.]]> - Stelselraamwerk-inspuiting het misluk - Magisk of sommige Magisk-modules van lae gehalte.
Probeer asseblief om Magisk-modules anders as Riru en LSPosed te deaktiveer of dien volledige log aan ontwikkelaars in.]]>
- Stelselstut is verkeerd - Modules kan soms ongeldig word.]]> - Need to update - Please install the latest version of LSPosed - API version - Framework version - Bestuurder pakket naam - Stelsel weergawe - Toestel - Stelsel ABI - Dex Optimizer Wrapper - Geaktiveer - Nie geaktiveer nie - Ondersteun - Ongesteun - Android-weergawe nie tevrede nie - Het neergestort - Montering het misluk - SELinux is permissief - SELinux-beleid is verkeerd - Dateer LSPosed op - Bevestig om LSPosed op te dateer? Hierdie toestel sal herlaai nadat die opdatering voltooi is - Gekopieer na knipbord - - Welkom by LSPosed - Jy gebruik die parasitiese bestuurder, wat kortpad kan skep of steeds oopmaak vanaf kennisgewing. - Jy gebruik die parasitiese bestuurder, wat kan oopmaak vanaf kennisgewing. - Skep kortpad - Moet nooit wys nie - Parasitiese Bestuurder Aanbeveel - LSPosed ondersteun nou stelselparasitering om opsporing te vermy, jy kan parasitiese bestuurder oopmaak vanaf kennisgewing. Dit word aanbeveel om die huidige toepassing te verwyder. - - Stoor - Uitgebreide logs - Modules - Stoor tans logboek, wag asseblief - Logs gestoor - Kon nie stoor nie:\n%s - Vee logboek nou uit - Log is suksesvol uitgevee. - Blaai na bo - Laai… - Blaai na onder - Herlaai - Kon nie die logboek skoonmaak nie - Woordomhulsel - Uitgebreide logboek geaktiveer - Uitgebreide logboek gedeaktiveer - - (geen beskrywing verskaf nie) - Hierdie module vereis \'n nuwer Xposed weergawe (%d) en kan dus nie geaktiveer word nie - This module is designed for a newer Xposed version (%d) and thus some functionalities may not work - Hierdie module spesifiseer nie die Xposed-weergawe wat dit benodig nie. - Hierdie module is geskep vir Xposed weergawe %1$d, maar as gevolg van onversoenbare veranderinge in weergawe %2$d, is dit gedeaktiveer - Hierdie module kan nie gelaai word nie omdat dit op die SD-kaart geïnstalleer is, skuif dit asseblief na interne berging - Deïnstalleer - Module enabled - Kyk in Repo - Wil jy hierdie module deïnstalleer? - Deïnstalleer %1$s - Deïnstalleer onsuksesvol - %d module enabled - Het %1$s by gebruiker %2$sgevoeg - %d module enabled - Installeer op gebruiker %s - Wil jy %1$s op gebruiker %2$sinstalleer? Dit word aanbeveel om met die hand te installeer, om installasie via LSPosed te dwing, kan probleme veroorsaak. - uitbrei - inval - - Heroptimaliseer - Optimaliseer… - Optimering voltooi - Begin dit - Optimalisering het misluk: terugkeerwaarde is leeg - Optimalisering het misluk: - Aansoeknaam - Pakketnaam - Installeer tyd - Dateer tyd op - Omgekeerde - Stelseltoepassings - Sorteer - Aktiveer module - Jy het geen toepassing gekies nie. Aanhou? - Speletjies - Modules - Kon nie omvanglys stoor nie - weergawe: %1$s - Aanbeveel - Jy het geen toepassing gekies nie. Kies aanbevole programme? - Kies aanbevole programme? - Xposed-module is nog nie geaktiveer nie - Aanbeveel - Opdatering beskikbaar: %1$s - Module %s is gedeaktiveer aangesien geen toepassing gekies is nie. - Stelselraamwerk - Ondersteuning - Ondersteuning - Herstel - Forseer om te stop - Forseer om te stop? - As jy \'n program dwing om te stop, kan dit dalk wangedra. - Herselflaai word vereis vir hierdie verandering om van toepassing te wees - Herlaai - Versteek - - Bekyk in \'n ander toepassing - App inligting - ¯\\\\_(ツ)_\/¯\nNiks hier nie - - Raamwerk - Deaktiveer verbose logs - Rapporteer kwessies versoek om verbose logs in te sluit - Swart donker tema - Gebruik die suiwer swart tema as donker tema geaktiveer is - Tema - Friends en herstel - Rugsteunmodulelys en omvanglyste. - Herstel modulelys en omvanglyste. - Ondersteuning - Kon nie rugsteun nie:\n%s - Aktiveer asseblief DocumentUI - Herstel - Kon nie herstel nie:\n%s - Netwerk - DNS oor HTTPS - Oplossing DNS-vergiftiging in sommige lande - Tema kleur - Stelsel tema kleur - Dwing programme om lanseerder-ikone te wys - Ná Android 10 word programme nie toegelaat om hul lanseerder-ikone te versteek nie. Skakel die skakelaar af om hierdie stelselkenmerk te deaktiveer. - Stelsel - Taal - Vertaling bydraers - Neem deel aan vertaling - Help ons om %s in jou taal te vertaal - Skep \'n kortpad wat parasitiese bestuurder kan oopmaak - Shortcut pinned - Die huidige versteklanseerder ondersteun nie penkortpaaie nie - Statuskennisgewing - Wys \'n kennisgewing wat parasitiese bestuurder kan oopmaak - Dateer kanaal op - Stabiel - Beta - Nag bou - - Lees my - Vrystellings - Info - Tuisblad - Bronkode - Medewerkers - Bates - Maak oop in blaaier - Wys ouer weergawes - Geen vrystelling meer nie - Kon nie module repo laai nie: %s - Eers opgradeerbaar - Geïnstalleer - - %d aflaai - %d aflaaie - - - Sakura - Rooi - Pienk - Pers - Diep pers - Indigo - Blou - Ligblou - Siaan - Blauwgroen - Groen - Ligte groen - Lemmetjie - Geel - Amber - Oranje - Diep oranje - Bruin - Blou grys -
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml deleted file mode 100644 index 21d4339ec..000000000 --- a/app/src/main/res/values-ar/strings.xml +++ /dev/null @@ -1,250 +0,0 @@ - - - - - ملخص - الوحدات - - %d وحدة مفعلة - %d وحدة مفعلة - %d وحدة مفعلة - %d وحدات مفعلة - %d وحدة مفعلة - %d وحدة مفعلة - - السجلات - الإعدادات - ملاحظات أو اقتراحات - عنّ - الإبلاغ عن مشكلة - المُستودع - جميع الوحدات محدثة - تم نشرها في %s - تَم تحديثها في %s - - %d وحدة قابلة للترقية - %d وحدة قابلة للترقية - %d وحدة قابلة للترقية - %d وحدات قابلة للترقية - %d وحدة قابلة للترقية - %d وحدة قابلة للترقية - - انضم إلى قناتنا %2$s]]> - Mahmoud Abd El-Hamed (7TM) - تثبيت - انقر لتثبيت LSPosed - غير مثبت - LSPosed غير مثبت - مُفعّل - مفعل جزئياً - لم يتم تحميل SEPolicy بشكل صحيح - الرجاء الإبلاغ عن هذا إلى ماجيسك مطور النظام.]]> - فشل حقن إطار عمل النظام - Magisk أو بعض وحدات Magisk منخفضة الجودة.
الرجاء محاولة تعطيل وحدات Magisk خلاف Riru وLSPosed أو إرسال سجل كامل للمطورين.]]>
- نظام prop غير صحيح - قد تبطل الوحدات أحيانا.]]> - تحتاج إلى التحديث - يرجى تثبيت أحدث إصدار من LSPosed - نصائح لمطور الوحدة - يرجى تعطيل تحسينات النشر على Android Studio، أو استخدام الأمر `gradlew installDebug` للتثبيت. وإلا فلن يتم تحديث ملف Apk للوحدة. - إصدار API - إصدار إطار العمل - Lاسم حزمة المديرo - إصدار النظام - الجهاز - نظام ABI - غلاف Dex المحسّن - مفعل - غير مفعل - مدعوم - غير متوافق - نسخة أندرويد غير راضية - تعطّل - فشل التحميل - SELinux متساهل - سياسة SELinux غير صحيحة - تحديث LSPosed - تأكيد تحديث LSPosed؟ سيتم إعـادة تشغيل هذا الجهاز بعد اكتمال التحديث - تم النسخ إلى الحافظة - - مرحباً بك في LSPosed - أنت تستخدم مدير الطفيليات، الذي يمكنه إنشاء اختصار أو لا يزال مفتوحا من الإشعارات. - أنت تستخدم المدير الطفيلي، الذي يمكن فتحه من الإشعار. - إنشاء إختصار - لا تظهر أبداً - مدير طفيلي موصي به - يدعم LSPosed الآن تطهير النظام لتجنب الكشف، يمكنك فتح مدير الطفيليات من الإشعار. من المستحسن إلغاء تثبيت التطبيق الحالي. - - احفظ - سجلات مفصّلة - سجلات الوحدات - حفظ السجل ، يرجى الانتظار - تم حفظ السجلات - فشل الحفظ:\n%s - مسح السجل الآن - تم مسح السجل بنجاح. - التمرير لأعلى - جارٍ التحميل… - التمرير لأسفل - إعادة التحميل - فشل مسح السجل - التفاف الكلمات - تم تفعيل السجل المفصّل - تم تعطيل السجل المفصّل - - (لم يتم تقديم وصف) - هذه الوحدة تتطلب إصدار Xposed الأحدث (%d) لذا لا يمكن تفعيلها - تم تصميم هذه الوحدة لإصدار Xposed أحدث (%d) وبالتالي قد لا تعمل بعض الوظائف - لا تحدد هذه الوحدة إصدار Xposed الذي تحتاجه. - تم إنشاء هذه الوحدة لإصدار Xposed %1$d ، ولكن بسبب التغييرات غير المتوافقة في الإصدار %2$d، فقد تم تعطيلها - لا يمكن تحميل هذه الوحدة لأنها مثبتة على بطاقة الذاكرة، يرجى نقلها إلى مساحة التخزين الداخلية - إلغاء التثبيت - إعدادات الوحدة - عرض في المستودع - هل تريد إلغاء تثبيت هذه الوحدة؟ - تم إلغاء تثبيت %1$s - فشل إلغاء التثبيت - إضافة وحدة للمستخدم - تم إضافة %1$s للمستخدم %2$s - فشل إضافة الوحدة - تثبيت للمستخدم %s - هل ترغب في تثبيت %1$s للمستخدم %2$s؟ ينصح بالتثبيت يدوياً، قد يسبب إجبار التثبيت عبر LSPosed مشكلات. - توسّع - انهيار - - إعادة تحسين - تحسين… - اكتمل التحسين - تشغيله - فشل التحسين: قيمة الإرجاع فارغة - فشل التحسين: - أسم التطبيق - أسم الحُزْمَة - وقت التثبيت - وقت التحديث - عكسي - تطبيقات النظام - ترتيب - تفعيل الوحدة - أنت لم تحدد أي تطبيق. المتابعة؟ - ألعاب - وحدات - فشل في حفظ قائمة النطاق - الإصدار: %1$s - مُوصى به - أنت لم تحدد أي تطبيق. تحديد التطبيقات الموصى بها؟ - تحديد التطبيقات الموصى بها؟ - وحدة Xposed لم يتم تفعيلها بعد - مُوصى به - تحديث متاح: %1$s - الوحدة %s تم تعطيلها لعدم تحديد أي تطبيق. - إطار النظام - نسخ احتياطي - نسخ احتياطي - استعادة - إيقاف إجباري - إيقاف إجباري؟ - إذا أغلقت التطبيق إجبارياً، قد يتصرف بشكل خاطئ. - إعادة التشغيل مطلوبة لتطبيق هذا التغيير - إعادة تشغيل - إخفاء - - عرض في تطبيق آخر - معلومات التطبيق - ¯\\\\_(ツ)_\/¯\n لا شيء هنا - - إطار العمل - تعطيل السجلات المفصّلة - الإبلاغ عن مشاكل طلب لتضمين السجلات المفصولة - السمة السوداء المظلمة - استخدام السمة السوداء الخالصة إذا تم تمكين السمة المظلمة - السمة - النسخ الاحتياطي والاستعادة - نسخ احتياطي لقائمة الوحدات وقوائم النطاق. - استعادة قائمة الوحدات وقوائم النطاق. - نسخ احتياطي - فشل في النسخ الاحتياطي:\n%s - الرجاء تمكين DocumentUI - استعادة - فشل في الاستعادة:\n%s - شبكة - DNS عبر HTTPS - حل بديل لتسمم DNS في بعض الدول - لون السمة - لون سمة النظام - إجبار التطبيقات على إظهار أيقونات المشغل - بعد أندرويد 10، لا يسمح للتطبيقات بإخفاء أيقونات المشغل. قم بإيقاف تشغيل التبديل لتعطيل مِيزة النظام هذه. - نظام - اللغة - المساهمون بالترجمة - المشاركة في الترجمة - ساعدنا في ترجمة %s إلى لغتك - إنشاء اختصار يمكنه فتح مدير الطفيليات - تم تثبيت الاختصار - المشغل الافتراضي الحالي لا يدعم اختصارات الدبوس - إشعارات الحالة - إظهار إشعار يمكنه فتح مدير الطفيليات - قناة التحديث - مستقر - تجريبي - البناء الليلي - - اقرأني - إصدارات - معلومات - الصفحة الرئيسية - كود المصدر - المتعاونين - الأصول - فتح في المتصفح - إظهار الإصدارات الأقدم - لا مزيد من الإصدار - فشل تحميل مستودع الوحدة: %s - قابل للترقية أولاً - المثبتة - - %d التنزيلات - %d تنزيل - %d التنزيلات - %d التنزيلات - %d التنزيلات - %d التنزيلات - - - لون ساكورا - أحمر - وردي - بنفسجي - بنفسجي عميق - نيلي - أزرق - أزرق فاتح - سماوي - أزرق مخضرّ - أخضر - أخضر فاتح - ليموني - أصفر - كهرماني - برتقالي - برتقالي عميق - بني - أزرق رمادي -
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml deleted file mode 100644 index 935aba7bf..000000000 --- a/app/src/main/res/values-bg/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - نظرة عامة - الوحدات - - %d модулът е активиран - %d включени модули - - Дневници - Настройки - Обратна връзка или предложение - За нас - Докладване на проблем - Хранилище - Всички модули са актуализирани - Публикувано в %s - Акттуализиран на %s - - %d модула има актуализация - %d модули с актуализации - - Присъединете се към нашия %2$s канал]]> - невалидно - Инсталиране на - Натиснете, за да инсталирате LSPosed - Не е инсталиран - LSPosed не е инсталиран - Активиран - Частично активиран - SEPolicy не е заредена правилно - Моля, докладвайте за това на Magisk разработчик.]]> - Неуспешно инжектиране на системната рамка - Magisk или някои нискокачествени модули на Magisk.
Моля, опитайте се да деактивирате модулите на Magisk, различни от Riru и LSPosed, или изпратете пълен журнал на разработчиците.]]>
- Неправилна стойност на системата - Понякога модулите могат да се обезсилват.]]> - Необходимо е да се актуализира - Моля, инсталирайте най-новата версия на LSPosed - Версия на API - Версия на рамката - Име на пакета на мениджъра - Версия на системата - Устройство - ABI на системата - Обвивка на Dex Optimizer - Разрешено - Не е разрешено - Поддържан - Неподдържан - Версията за Android е неудовлетворена - Счупен - Монтирането е неуспешно - SELinux е разрешаващ - Политиката на SELinux е неправилна - Актуализиране на LSPosed - Потвърждаване на актуализацията на LSPosed? Това устройство ще се рестартира след завършване на актуализацията - Копиране в клипборда - - Добре дошли в LSPosed - Използвате паразитния мениджър, който може да създаде пряк път или все още да се отваря от известието. - Използвате паразитния мениджър, който може да се отвори от известие. - Създаване на пряк път - Никога не показвайте - Препоръчва се паразитен мениджър - LSPosed вече поддържа паразитиране на системата, за да се избегне откриването, можете да отворите мениджъра на паразити от известието. Препоръчва се да деинсталирате текущото приложение. - - Запазете - Условни дневници - Дневници на модулите - Запазване на дневника, моля изчакайте - Запазени дневници - Не успяхте да запазите:\n%s - Изчистване на дневника сега - Дневникът е изчистен успешно. - Превъртете към началото - Зареждане… - Превъртете към дъното - Презареждане - Неуспешно изчистване на дневника - Обвиване на думата - Разрешен е вербален дневник - Деактивиран е вербалният дневник - - (не е предоставено описание) - Този модул изисква по-нова версия на Xposed (%d) и поради това не може да бъде активиран - Този модул е предназначен за по-нова версия на Xposed (%d) и поради това някои функционалности може да не работят - Този модул не посочва версията на Xposed, която му е необходима. - Този модул е създаден за Xposed версия %1$d, но поради несъвместими промени във версия %2$d, той е деактивиран - Този модул не може да бъде зареден, защото е инсталиран на SD картата, моля, преместете го във вътрешната памет - Деинсталиране на - Настройки на модула - Преглед в Repo - Искате ли да деинсталирате този модул? - Деинсталиран %1$s - Деинсталирането е неуспешно - Добавяне на модул към потребителя - Добавяне на %1$s към потребител %2$s - Добавянето на модул е неуспешно - Инсталиране на потребител %s - Искате да инсталирате %1$s на потребител %2$s? Препоръчително е да се инсталира ръчно, принудителното инсталиране чрез LSPosed може да доведе до проблеми. - разширяване на - срив - - Оптимизиране на - Оптимизиране на… - Оптимизацията е завършена - Стартирайте го - Оптимизацията е неуспешна: върнатата стойност е празна - Оптимизацията е неуспешна: - Име на приложението - Име на пакета - Време за инсталиране - Време за актуализация - Обратен - Системни приложения - Сортиране - Включване на модула - Не сте избрали нито едно приложение. Продължете? - Игри - Модули - Неуспешно запазване на списъка с обхвата - Версия: %1$s - Препоръчителен - Не сте избрали нито едно приложение. Избрахте препоръчани приложения? - Изберете препоръчани приложения? - Модулът Xposed все още не е активиран - Препоръчителен - Налична е актуализация: %1$s - Модулът %s е деактивиран, тъй като не е избрано приложение. - Рамка на системата - Резервно копие - Резервно копие - Възстановяване на - Спиране на силата - Насилствено спиране? - Ако спрете приложение принудително, то може да се държи неправилно. - Необходимо е рестартиране, за да се приложи тази промяна. - Рестартиране на - Скрий - - Преглед в друго приложение - Информация за приложението - ¯\\\\_(ツ)_\/¯\nНищо тук - - Рамка - Деактивиране на вербалните дневници - Искане за включване на вербални дневници - Черна тъмна тема - Използвайте чисто черната тема, ако е активирана тъмна тема - Тема - Архивиране и възстановяване - Списък с резервни копия на модули и списъци на обхвата. - Възстановяване на списъците с модули и области. - Резервно копие - Неуспешно архивиране:\n%s - Моля, разрешете DocumentUI - Възстановяване на - Неуспешно възстановяване:\n%s - Мрежа - DNS през HTTPS - Заобикаляне на отравянето на DNS в някои държави - Цвят на темата - Цвят на темата на системата - Принуждаване на приложенията да показват икони на стартирането - След Android 10 на приложенията не е позволено да скриват иконите си за стартиране. Изключете превключвателя, за да деактивирате тази системна функция. - Система - Език - Преводачи, които допринасят за превода - Участие в превод - Помогнете ни да преведем %s на вашия език - Създаване на пряк път, който може да отваря паразитен мениджър - Кратък път, закачен - Текущият стартер по подразбиране не поддържа преки пътища - Известие за състоянието - Показване на известие, което може да отвори паразитен мениджър - Актуализиране на канала - Стабилен - Бета - Нощно изграждане - - Readme - Освобождава - Информация - Начална страница - Изходен код - Сътрудници - Активи - Отваряне в браузъра - Показване на по-стари версии - Няма повече освобождаване - Неуспешно зареждане на модул repo: %s - Може да се надгражда първо - Инсталиран - - %d изтегляне - %d Изтегляния - - - Сакура - Червено - Розов - Лилаво - Наситено лилаво - Indigo - Синьо - Светлосиньо - Cyan - Teal - Зелен - Светлозелено - Lime - Жълт - Амбър - Orange - Наситено оранжево - Кафяв - Синьо сиво -
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml deleted file mode 100644 index 5a14afb1b..000000000 --- a/app/src/main/res/values-bn/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - সার সংক্ষেপ - মডিউল - - %d মডিউল সক্ষম - %d মডিউল সক্রিয় - - তথ্য সার-সংক্ষেপ - বিন্যাস - প্রতিক্রিয়া বা পরামর্শ - সম্পর্কিত - সমস্যা প্রতিবেদন - ভান্ডার - সব মডিউল আপ টু ডেট - %sএ প্রকাশিত - %sএ আপডেট করা হয়েছে - - %d মডিউল আপগ্রেডযোগ্য - %d মডিউল আপগ্রেডযোগ্য - - এ সোর্স কোড দেখুন আমাদের %2$s চ্যানেলে যোগ দিন]]> - bdtipsntricks - ইনস্টল করুন - LSPosed ইনস্টল করতে আলতো চাপুন - ইনস্টল করা না - LSPosed ইনস্টল করা হয় না - সক্রিয় - আংশিক সক্রিয় - এসইপলিসি সঠিকভাবে লোড করা হয় না - অনুগ্রহ করে এটি Magisk বিকাশকারীকে রিপোর্ট করুন।]]> - সিস্টেম ফ্রেমওয়ার্ক ইনজেকশন ব্যর্থ হয়েছে - Magisk বা কিছু নিম্নমানের Magisk মডিউলের কারণে হতে পারে।
অনুগ্রহ করে Riru এবং LSPosed ব্যতীত Magisk মডিউলগুলি নিষ্ক্রিয় করার চেষ্টা করুন বা বিকাশকারীদের কাছে সম্পূর্ণ লগ জমা দিন।]]>
- সিস্টেম প্রপ ভুল - মডিউল মাঝে মাঝে অবৈধ হতে পারে।]]> - আপডেট করতে হবে - অনুগ্রহ করে LSPosed এর সর্বশেষ সংস্করণটি ইনস্টল করুন - API সংস্করণ - ফ্রেমওয়ার্ক সংস্করণ - ম্যানেজার প্যাকেজের নাম - সিস্টেম সংস্করণ - যন্ত্র - সিস্টেম ABI - ডেক্স অপ্টিমাইজার মোড়ক - সক্রিয় - সক্রিয় না - সমর্থিত - অসমর্থিত - অ্যান্ড্রয়েড সংস্করণ অসন্তুষ্ট - বিধ্বস্ত - মাউন্ট ব্যর্থ হয়েছে - SELinux অনুমোদিত - SELinux নীতি ভুল - আপডেট LSPosed - LSPosed আপডেট করার জন্য নিশ্চিত? আপডেট সম্পূর্ণ হওয়ার পরে এই ডিভাইসটি রিবুট হবে - ক্লিপবোর্ডে কপি করা হয়েছে - - LSPosed স্বাগতম - আপনি পরজীবী ম্যানেজার ব্যবহার করছেন, যা শর্টকাট তৈরি করতে পারে বা এখনও বিজ্ঞপ্তি থেকে খুলতে পারে। - আপনি পরজীবী ম্যানেজার ব্যবহার করছেন, যা বিজ্ঞপ্তি থেকে খুলতে পারে। - শর্টকাট তৈরি করুন - কখনও দেখাবে না - পরজীবী ব্যবস্থাপক প্রস্তাবিত - LSPosed এখন সনাক্তকরণ এড়াতে সিস্টেম প্যারাসাইটাইজেশন সমর্থন করে, আপনি বিজ্ঞপ্তি থেকে পরজীবী ম্যানেজার খুলতে পারেন। বর্তমান অ্যাপ্লিকেশনটি আনইনস্টল করার পরামর্শ দেওয়া হচ্ছে। - - সংরক্ষণ - ভার্বোস লগ - মডিউল লগ - লগ সংরক্ষণ করা হচ্ছে, অনুগ্রহ করে অপেক্ষা করুন - লগ সংরক্ষিত - সংরক্ষণ করতে ব্যর্থ হয়েছে:\n%s - এখন লগ সাফ করুন - লগ সফলভাবে সাফ করা হয়েছে৷ - উপরে যান - লোড হচ্ছে… - নীচে স্ক্রোল করুন - পুনরায় লোড করুন - লগ সাফ করতে ব্যর্থ হয়েছে - শব্দ মোড়ানো - ভার্বোস লগ সক্ষম - ভার্বোস লগ নিষ্ক্রিয় - - (কোন বর্ণনা দেওয়া হয়নি) - এই মডিউলটির একটি নতুন Xposed সংস্করণ প্রয়োজন (%d) এবং এইভাবে সক্রিয় করা যাবে না - এই মডিউলটি একটি নতুন Xposed সংস্করণ (%d) এর জন্য ডিজাইন করা হয়েছে এবং এইভাবে কিছু কার্যকারিতা কাজ নাও করতে পারে - এই মডিউলটি তার প্রয়োজনীয় Xposed সংস্করণটি নির্দিষ্ট করে না। - এই মডিউলটি Xposed সংস্করণ %1$d-এর জন্য তৈরি করা হয়েছিল, কিন্তু সংস্করণ %2$d-এ অসামঞ্জস্যপূর্ণ পরিবর্তনের কারণে, এটি নিষ্ক্রিয় করা হয়েছে। - এই মডিউলটি লোড করা যাবে না কারণ এটি SD কার্ডে ইনস্টল করা আছে, দয়া করে এটিকে অভ্যন্তরীণ সঞ্চয়স্থানে নিয়ে যান৷ - আনইনস্টল করুন - মডিউল সেটিংস - রেপোতে দেখুন - আপনি এই মডিউল আনইনস্টল করতে চান? - আনইনস্টল %1$s - আনইনস্টল করা যায়নি - ব্যবহারকারীর জন্য মডিউল যোগ করুন - ব্যবহারকারী %2$sএ %1$s যোগ করা হয়েছে - মডিউল যোগ করা ব্যর্থ হয়েছে৷ - ব্যবহারকারী %sএ ইনস্টল করুন - ব্যবহারকারী %2$sথেকে %1$s ইনস্টল করতে চান? এটি ম্যানুয়ালি ইনস্টল করার সুপারিশ করা হয়, LSPosed এর মাধ্যমে জোর করে ইনস্টল করার ফলে সমস্যা হতে পারে। - বিস্তৃত করা - পতন - - পুনরায় অপ্টিমাইজ করুন - অপ্টিমাইজ করা… - অপ্টিমাইজেশান সম্পূর্ণ - এটি চালু করুন - অপ্টিমাইজেশান ব্যর্থ হয়েছে: রিটার্ন মান খালি - অপ্টিমাইজেশান ব্যর্থ হয়েছে: - আবেদনের নাম - প্যাকেজের নাম - ইন্সটল করার সময় - আপডেটের সময় - বিপরীত - সিস্টেম অ্যাপস - শ্রেণীবিভাজন - মডিউল সক্ষম করুন - আপনি কোনো অ্যাপ নির্বাচন করেননি। চালিয়ে যান? - গেমস - মডিউল - সুযোগ তালিকা সংরক্ষণ করতে ব্যর্থ হয়েছে - সংস্করণ: %1$s - প্রস্তাবিত - আপনি কোনো অ্যাপ নির্বাচন করেননি। প্রস্তাবিত অ্যাপ নির্বাচন করবেন? - প্রস্তাবিত অ্যাপ নির্বাচন করবেন? - Xposed মডিউল এখনও সক্রিয় করা হয় নি - প্রস্তাবিত - আপডেট উপলব্ধ: %1$s - মডিউল %s অক্ষম করা হয়েছে যেহেতু কোনো অ্যাপ নির্বাচন করা হয়নি৷ - সিস্টেম ফ্রেমওয়ার্ক - ব্যাকআপ - ব্যাকআপ - পুনরুদ্ধার করুন - জোরপুর্বক থামা - জোরপুর্বক থামা? - আপনি যদি একটি অ্যাপকে জোর করে বন্ধ করেন, তাহলে সেটি খারাপ আচরণ করতে পারে। - এই পরিবর্তনটি প্রয়োগ করার জন্য রিবুট প্রয়োজন - রিবুট করুন - লুকান - - অন্য অ্যাপে দেখুন - অ্যাপের তথ্য - ¯\\\\_(ツ)_\/¯\nএখানে কিছুই নেই - - ফ্রেমওয়ার্ক - ভার্বোস লগ অক্ষম করুন - ভার্বোস লগগুলি অন্তর্ভুক্ত করার জন্য সমস্যার প্রতিবেদন করুন - কালো অন্ধকার থিম - অন্ধকার থিম সক্ষম থাকলে খাঁটি কালো থিম ব্যবহার করুন - থিম - ব্যাকআপ এবং পুনঃস্থাপন - ব্যাকআপ মডিউল তালিকা এবং সুযোগ তালিকা. - মডিউল তালিকা এবং সুযোগ তালিকা পুনরুদ্ধার করুন। - ব্যাকআপ - ব্যাকআপ করতে ব্যর্থ হয়েছে:\n%s - অনুগ্রহ করে ডকুমেন্টইউআই সক্ষম করুন - পুনরুদ্ধার করুন - পুনরুদ্ধার করতে ব্যর্থ হয়েছে:\n%s - অন্তর্জাল - HTTPS এর উপর DNS - কিছু দেশে ডিএনএস বিষক্রিয়ার সমাধান - থিম রঙ - সিস্টেম থিম রঙ - অ্যাপ্লিকেশানগুলিকে লঞ্চার আইকনগুলি দেখাতে বাধ্য করুন৷ - অ্যান্ড্রয়েড 10 এর পরে, অ্যাপগুলিকে তাদের লঞ্চার আইকনগুলি লুকানোর অনুমতি দেওয়া হয় না। এই সিস্টেম বৈশিষ্ট্যটি নিষ্ক্রিয় করতে টগলটি বন্ধ করুন৷ - পদ্ধতি - ভাষা - অনুবাদ অবদানকারী - অনুবাদে অংশগ্রহণ করুন - আপনার ভাষায় %s অনুবাদ করতে আমাদের সাহায্য করুন - একটি শর্টকাট তৈরি করুন যা পরজীবী ম্যানেজার খুলতে পারে - শর্টকাট পিন করা হয়েছে - বর্তমান ডিফল্ট লঞ্চার পিন শর্টকাট সমর্থন করে না - স্থিতি বিজ্ঞপ্তি - পরজীবী ম্যানেজার খুলতে পারে এমন একটি বিজ্ঞপ্তি দেখান - চ্যানেল আপডেট করুন - স্থিতিশীল - বেটা - রাতারাতি নির্মাণ - - রিডমি - মুক্তি দেয় - তথ্য - হোমপেজ - সোর্স কোড - সহযোগীরা - সম্পদ - ব্রাউজারে খোলা - পুরোনো সংস্করণ দেখান - আর মুক্তি নেই - মডিউল রেপো লোড করতে ব্যর্থ হয়েছে: %s - প্রথমে আপগ্রেডযোগ্য - ইনস্টল করা হয়েছে - - %d ডাউনলোড - %d ডাউনলোড - - - সাকুরা - লাল - গোলাপী - বেগুনি - গভীর বেগুনি - নীল - নীল - হালকা নীল - সায়ান - টিল - সবুজ - হালকা সবুজ - চুন - হলুদ - অ্যাম্বার - কমলা - গভীর কমলা - বাদামী - নীল ধূসর -
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml deleted file mode 100644 index c49d65383..000000000 --- a/app/src/main/res/values-ca/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Visió general - Mòduls - - %d Mòdul activat - %d Mòduls activats - - Logs - Configuració - Comentari o suggeriment - Sobre - Reportar problema - Repositori - Tots els mòduls actualitzats - Published at %s - Actualitzat a les %s - - %d mòdul actualitzable - %d mòduls actualitzables - - Uneix-te al nostre %2$s canal]]> - Frederic Blay, Ghost Face, Yannick Kamin - Instalar - Toca per instal·lar LSPosed - No instalat - LSPosed no està instal·lat - Activat - Parcialment activat - SEPolicy no s\'ha carregat correctament - Informeu-ho al desenvolupador Magisk.]]> - La injecció del marc del sistema ha fallat - Magisk o algún Mòdul de baixa qualitat
Si us plau, prova a desactivar els demés mòduls de magisk excepte Riru i LSPosed o envia un informe complet als desarrolladors.]]>
- Prop del sistema incorrecte - Els mòduls poden invalidar-se ocasionalment.]]> - Cal actualitzar - Instal·leu la darrera versió de LSPosed - Versió de l\'API - Versió del marc - Nom del paquet del gestor - Versió del sistema - Dispositiu - Sistema ABI - Embolcall de Dex Optimizer - Habilitat - No habilitat - Admet - Sense suport - La versió d\'Android no està satisfeta - Estavellat - El muntatge ha fallat - SELinux és permissiu - La política de SELinux és incorrecta - Actualització LSPosed - Confirmeu per actualitzar LSPosed? Aquest dispositiu es reiniciarà un cop finalitzada l\'actualització - S\'ha copiat al porta-retalls - - Benvingut a LSPosed - Esteu utilitzant el gestor de paràsits, que pot crear dreceres o encara obrir-se des de la notificació. - Esteu utilitzant el gestor de paràsits, que es pot obrir des de la notificació. - Crear accès directe - No mostris mai - Administrador de paràsits recomanat - LSPosed ara admet la parasitització del sistema per evitar la detecció, podeu obrir el gestor de paràsits des de la notificació. Es recomana desinstal·lar l\'aplicació actual. - - Desa - Registres detallats - Registres de mòduls - S\'està desant el registre, espereu - Registres guardats - No s\'ha pogut desar:\n%s - Esborra el registre ara - El registre s\'ha esborrat correctament. - Desplaceu-vos cap a dalt - Carregant… - Desplaceu-vos cap avall - Recarregar - No s\'ha pogut esborrar el registre - L\'ajust de línia - Registre detallat activat - Registre detallat desactivat - - (no s\'ofereix cap descripció) - Aquest mòdul requereix una versió més nova de Xposed (%d) i per tant no es pot activar - Aquest mòdul està dissenyat per a una versió més nova de Xposed (%d) i per tant algunes funcionalitats poden no funcionar - Aquest mòdul no especifica la versió de Xposed que necessita. - Aquest mòdul es va crear per a Xposed versió %1$d, però a causa de canvis incompatibles a la versió %2$d, s\'ha desactivat - Aquest mòdul no es pot carregar perquè està instal·lat a la targeta SD, moveu-lo a l\'emmagatzematge intern - Desinstal·la - Configuració del mòdul - Veure a Repo - Voleu desinstal·lar aquest mòdul? - Desinstal·lat %1$s - La desinstal·lació no s\'ha realitzat correctament - Afegeix un mòdul a l\'usuari - S\'ha afegit %1$s a l\'usuari %2$s - S\'ha produït un error en afegir el mòdul - Instal·lar a l\'usuari %s - Voleu instal·lar %1$s a l\'usuari %2$s? Es recomana instal·lar manualment, forçar la instal·lació mitjançant LSPosed pot causar problemes. - expandir - col·lapse - - Torna a optimitzar - Optimització… - Optimització completa - Llança\'l - L\'optimització ha fallat: el valor de retorn és buit - L\'optimització ha fallat: - Nom de l\'aplicació - Nom del paquet - Temps d\'instal·lació - Hora d\'actualització - Revés - Aplicacions del sistema - Classificació - Activa el mòdul - No heu seleccionat cap aplicació. Continuar? - Jocs - Mòduls - No s\'ha pogut desar la llista d\'àmbits - Versió: %1$s - Recomanat - No heu seleccionat cap aplicació. Seleccioneu aplicacions recomanades? - Vols seleccionar aplicacions recomanades? - El mòdul Xposed encara no està activat - Recomanat - Actualització disponible: %1$s - El mòdul %s s\'ha desactivat perquè no s\'ha seleccionat cap aplicació. - Marc del sistema - Còpia de seguretat - Còpia de seguretat - Restaurar - Parada forçada - Parada forçada? - Si forços l\'aturada d\'una aplicació, és possible que es comporti malament. - Cal reiniciar perquè s\'apliqui aquest canvi - Reinicieu - Amaga - - Veure en una altra aplicació - Informació de l\'aplicació - ¯\\\\_(ツ)_\/¯\nAquí no hi ha res - - Marc - Desactiva els registres detallats - Sol·licitud d\'informes de problemes per incloure registres detallats - Tema negre fosc - Utilitzeu el tema negre pur si el tema fosc està habilitat - Tema - Còpia de seguretat i restaurar - Llista de mòduls de còpia de seguretat i llistes d\'abast. - Restaura la llista de mòduls i les llistes d\'abast. - Còpia de seguretat - No s\'ha pogut fer la còpia de seguretat:\n%s - Si us plau, activeu DocumentUI - Restaurar - No s\'ha pogut restaurar:\n%s - Xarxa - DNS sobre HTTPS - Solució alternativa a l\'enverinament per DNS en algunes nacions - Color del tema - Color del tema del sistema - Força les aplicacions a mostrar icones del llançador - Després d\'Android 10, les aplicacions no poden amagar les icones del llançador. Desactiveu el commutador per desactivar aquesta funció del sistema. - Sistema - Llenguatge - Col·laboradors de traducció - Participar en la traducció - Ajuda\'ns a traduir %s al teu idioma - Creeu una drecera que pugui obrir el gestor de paràsits - Drecera fixada - El llançador predeterminat actual no admet dreceres de pin - Notificació d\'estat - Mostra una notificació que pugui obrir el gestor de paràsits - Actualitza el canal - Estable - Beta - Construcció nocturna - - Llegiu-me - Alliberaments - Informació - Pàgina d\'inici - Codi font - Col·laboradors - Actius - Oberta al navegador - Mostra les versions anteriors - No més llançament - No s\'ha pogut carregar el dipòsit del mòdul: %s - Actualitzable primer - Instal·lat - - %d descàrrega - %d descàrregues - - - Sakura - Vermell - Rosa - Porpra - Lila fosc - Indigo - Blau - Blau clar - Cian - Teal - verd - Verd clar - Lima - groc - Ambre - taronja - Taronja profund - marró - Gris blau -
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml deleted file mode 100644 index 009db0cf1..000000000 --- a/app/src/main/res/values-cs/strings.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - Přehled - Moduly - - %d modul aktivován - %d modul povolen - %d Modul aktivován - %d Modul aktivován - - Protokoly - Nastavení - Zpětná vazba nebo návrh - O aplikaci - Nahlásit problém - Repozitář - Všechny moduly jsou aktuální - Publikováno v %s - Aktualizováno v %s - - %d modul je možné aktualizovat - %d moduly je možné aktualizovat - %d modolů je možné aktualizovat - %d modulů je možné aktualizovat - - Připojte se k našemu kanálu %2$s]]> - https://www.instagram.com/kasi33/ - Instalovat - Klepnutím nainstalujete LSPosed - Není nainstalováno - LSPosed není nainstalován - Aktivováno - Částečně aktivováno - SEPolicy není správně načten - Nahlaste to prosím vývojáři Magisk.]]> - Načtení System Framework se nezdařilo - Magiskem nebo některými málo kvalitními moduly Magisku.
Zkuste vypnout moduly Magisk jiné než Riru a LSPosed nebo odešlete kompletní log vývojářům.]]>
- Nesprávné systémové prop - Moduly mohou být někdy neplatné a tedy nefunkční.]]> - Je třeba aktualizovat - Nainstalujte si prosím nejnovější verzi LSPosed - Verze API - Verze frameworku - Název balíčku správce - Verze systému - Zařízení - Systémové ABI (architektura) - Dex Optimizer Wrapper - Povoleno - Není povoleno - Podporováno - Nepodporováno - Verze Androidu není spokojena - Havaroval - Připojení se nezdařilo - SELinux je permisivní - Zásady SELinuxu jsou nesprávné - Aktualizovat LSPosed - Potvrdit aktualizaci LSPosed? Toto zařízení se restartuje po dokončení aktualizace - Zkopírováno do schránky - - Vítejte v LSPosed - Používáte parazitního správce, který může vytvořit zástupce nebo se stále otevírat z oznámení. - Používáte parazitního správce, který se může otevřít z oznámení. - Vytvořit zástupce - Nikdy nezobrazovat - Doporučený parazitický manažer - LSPosed nyní podporuje parazitování systému. K zabránění detekce můžete správce parazitů otevřít z oznámení. Doporučuje se odinstalovat aktuální aplikaci. - - Uložit - Podrobné protokoly - Protokoly modulů - Ukládání protokolu, čekejte prosím - Protokoly uloženy - Nepodařilo se uložit:\n%s - Vymazat log - Log byl úspěšně vymazán. - Přejít na začátek - Načítání… - Přejít na začátek - Znovu načíst - Nepodařilo se vymazat protokol - Zalamování řádků - Podrobný záznam povolen - Podrobný záznam zakázán - - (žádný popis) - Tento modul vyžaduje novější Xposed verzi (%d) a proto nemůže být aktivován - Tento modul je určen pro novější verzi Xposed (%d), a proto některé funkce nemusí fungovat - Tento modul nespecifikuje potřebnou Xposed verzi. - Tento modul byl vytvořen pro Xposed verzi %1$d, a tak z důvodu nekompatibilních změn ve verzi %2$dbyl zakázán - Tento modul nelze načíst, protože je nainstalován na SD kartě, přesuňte jej na interní úložiště - Odinstalovat - Nastavení modulů - Zobrazit v repozitáři - Chcete odinstalovat tento modul? - %1$s odinstalován - Odinstalace nebyla úspěšná - Přidat modul k uživateli - Modul %1$s přidán k uživateli %2$s - Přidání modulu se nezdařilo - Instalovat uživateli %s - Chcete nainstalovat %1$s uživateli %2$s.? Je doporučeno instalovat ručně, vynucení instalace přes LSPosed může způsobit problémy. - rozbalit - sbalit - - Znovu optimalizovat - Optimalizace… - Optimalizace dokončena - Spustit - Optimalizace selhala: návratová hodnota je prázdná - Optimalizace selhala: - Název aplikace - Název balíčku - Doba instalace - Čas aktualizace - Obrátit pořadí řazení - Systémové aplikace - Řazení - Povolit modul - Nevybrali jste žádnou aplikaci. Pokračovat? - Hry - Moduly - Nepodařilo se uložit seznam - Verze: %1$s - Zvolit doporučené - Nevybrali jste žádnou aplikaci. Vybrat doporučené aplikace? - Vybrat doporučené aplikace? - Xposed modul ještě není aktivován - Doporučené - Je k dispozici aktualizace: %1$s - Modul %s byl zakázán, protože nebyla vybrána žádná aplikace. - Systémový Framework - Zálohování - Zálohovat - Obnovení - Vynutit zastavení - Vynutit zastavení? - Pokud vynutíte zastavení aplikace, může dojít k chybnému chování. - Pro aplikaci této změny je vyžadován restart - Restartovat - Skrýt - - Zobrazit v jiné aplikaci - Informace o aplikaci - <unk> \\\\_(<unk> )_\/ <unk>\nTady nic není - - Framework - Zakázat podrobné protokoly - Nahlásit požadavek na problémy a zahrnout detailní záznamy - Čistě černý motiv - Použít čistý černý motiv, pokud je tmavý motiv povolen - Vzhled - Zálohování a obnovení - Zálohovat seznam modulů a nastavení. - Obnovit seznam modulů a nastavení. - Zálohování - Zálohování se nezdařilo:\n%s - Prosím povolte DocumentUI - Obnovení - Nepodařilo se obnovit:\n%s - Síť - DNS over HTTPS - Řešení DNS oprav v některých zemích - Barva motivu - Barva systémového motivu - Vynutit aplikace k zobrazení ikon spouštěče - Od Androidu 10 není aplikacím povoleno skrývat ikony spouštěče. Vypněte přepínač pro vypnutí této systémové funkce. - Systém - Jazyk - Přispěvatelé překladu - Účast na překladu - Pomozte nám přeložit %s do vašeho jazyka - Vytvoření zástupce, který může otevřít parazitního správce - Připnutí zástupci - Současný výchozí spouštěč nepodporuje připnuté zkratky - Oznámení o stavu - Zobrazení oznámení, které může otevřít parazitního správce - Kanál aktualizace - Stabilní - Beta - Noční sestavení - - Přečti si mě - Vydání - Informace - Domovská stránka - Zdrojový kód - Spolupracovníci - Assets - Otevřít v prohlížeči - Zobrazit starší verze - Žádné další vydání - Nepodařilo se načíst repozitář modulu: %s - Nejprve aktualizovatelný - Instalováno - - %d stažení - %d ke stažení - %d ke stažení - %d ke stažení - - - Sakura - Červená - Růžová - Fialová - Tmavě fialová - Indigo - Modrá - Světle modrá - Azurová - Modrozelená - Zelená - Světle zelená - Limetková - Žlutá - Jantarová - Oranžová - Tmavě oranžová - Hnědá - Modro-šedivá -
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml deleted file mode 100644 index acda6c34d..000000000 --- a/app/src/main/res/values-da/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Oversigt - Moduler - - %d modul aktiveret - %d moduler aktiveret - - Logfiler - Indstillinger - Feedback eller forslag - Om - Anmeld problem - Lagre - Alle moduler opdateret - Udgivet på %s - Opdateret på %s - - %d modul opgraderbar - %d moduler opgraderbare - - Tilmeld dig vores %2$s kanal]]> - null - Installér - Tryk for at installere LSPosed - Ikke installeret - LSPosed er ikke installeret - Aktiveret - Delvist aktiveret - SEPolicy er ikke indlæst korrekt - Du bedes rapportere dette til Magisk udvikleren.]]> - System Framework injektion mislykkedes - Magisk eller nogle lavkvalitets Magisk moduler.
Prøv at deaktivere andre Magisk moduler end Riru og LSPosed eller indsende fuld log til udviklere.]]>
- System prop forkert - Moduler kan ugyldiggøre lejlighedsvis.]]> - Skal opdateres - Installér venligst den seneste version af LSPosed - API version - Rammer version - Navn på manager-pakke - System version - Enhed - System ABI - Dex Optimizer Wrapper - Aktiveret - Ikke aktiveret - Understøttet - Ikke understøttet - Android-version utilfreds - Nedstyrtet - Montering mislykkedes - SELinux er tilladende - SELinux-politik er forkert - Opdater LSPosed - Bekræft opdatering af LSPosed? Denne enhed vil genstarte efter opdateringsfuldførelse - Kopieret til udklipsholderen - - Velkommen til LSPosed - Du bruger den parasitære manager, som kan oprette genvej eller stadig åbne fra meddelelsen. - Du bruger den parasitære manager, som kan åbnes fra notifikationen. - Opret genvej - Vis aldrig - Parasitic Manager Anbefalet - LSPosed understøtter nu systemparasitering for at undgå registrering, du kan åbne parasitmanager fra meddelelsen. Det anbefales at afinstallere det aktuelle program. - - Gem - Verbose Logs - Moduler Logs - Gemmer log, vent venligst - Gemte logfiler - Mislykkedes at gemme:\n%s - Ryd log nu - Loggen blev ryddet. - Rul til toppen - Indlæser… - Rul til bunden - Reload - Kunne ikke rydde loggen - Tekstombrydning - Verbose log aktiveret - Verbose log deaktiveret - - (ingen beskrivelse angivet) - Dette modul kræver en nyere Xposed version (%d) og kan derfor ikke aktiveres - Dette modul er designet til en nyere Xposed-version (%d), og derfor fungerer nogle funktioner muligvis ikke - Dette modul angiver ikke den Xposed version det behøver. - Dette modul blev oprettet til Xposed version %1$d, men på grund af inkompatible ændringer i version %2$d, er det blevet deaktiveret - Dette modul kan ikke indlæses, da det er installeret på SD-kortet, flyt det til intern lagerplads - Afinstaller - Modul indstillinger - Se i Repo - Vil du afinstallere dette modul? - Afinstalleret %1$s - Afinstallation mislykkedes - Tilføj modul til bruger - Tilføjede %1$s til bruger %2$s - Tilføjelse af modul mislykkedes - Installér til bruger %s - Vil du installere %1$s til bruger %2$s? Det anbefales at installere manuelt, tvinger installation via LSPosed kan forårsage problemer. - udvid - kollaps - - Genoptimér - Optimerer… - Optimering fuldført - Start det - Optimering mislykkedes: returværdi er tom - Optimering mislykkedes: - Applikations navn - Pakke navn - Installér tid - Opdater tid - Omvendt - System apps - Sortering - Aktiver modul - Du valgte ikke nogen app. Fortsæt? - Spil - Moduler - Kunne ikke gemme scope-liste - Version: %1$s - Anbefalet - Du valgte ikke nogen app. Vælg anbefalede apps? - Vælg anbefalede apps? - Xposed modul er endnu ikke aktiveret - Anbefalet - Opdatering tilgængelig: %1$s - Modul %s er blevet deaktiveret siden ingen app er valgt. - System Framework - Sikkerhedskopi - Sikkerhedskopi - Gendan - Gennemtving stop - Gennemtving stop? - Hvis du tvinger til at stoppe en app, kan den virke forkert. - Genstart er påkrævet for at denne ændring kan anvendes - Reboot - Skjul - - Se i anden app - Oplysninger om appen - Spredning \\\\_(Ι)_\/ Ι\nIntet her - - Framework - Deaktivere udførlige logfiler - Anmodning om at medtage verbose logs i rapporten om problemer - Sort mørkt tema - Brug det rene sorte tema, hvis mørkt tema er aktiveret - Tema - Sikkerhedskopiér og gendan - Backup modul liste og scope-lister. - Gendan modulliste og scope-lister. - Sikkerhedskopi - Sikkerhedskopiering mislykkedes:\n%s - Aktiver venligst DocumentUI - Gendan - Kunne ikke gendanne:\n%s - Netværk - DNS over HTTPS - Workaround DNS forgiftning i nogle nationer - Tema farve - Farve på systemtema - Tving apps til at vise launcher-ikoner - Efter Android 10 må apps ikke skjule deres launcher-ikoner. Slå toggle fra for at deaktivere denne systemfunktion. - System - Sprog - Oversættelsesbidragsydere - Deltag i oversættelse - Hjælp os med at oversætte %s til dit sprog - Opret en genvej, der kan åbne parasitic manager - Genvej fastgjort - Den nuværende standardstarter understøtter ikke pin-genveje - Meddelelse om status - Vis en meddelelse, der kan åbne parasitic manager - Opdater kanal - Stabil - Beta - Nightly build - - Læs - Udgivelser - Info - Hjemmeside - Kilde kode - Samarbejdspartnere - Aktiver - Åbn i browser - Vis ældre versioner - Ikke mere udgivelse - Kunne ikke indlæse modul repo: %s - Opgradérbar først - Installeret - - %d download - %d downloads - - - Sakura - Rød - Lyserød - Lilla - Dyb lilla - Indigo - Blå - Lyseblå - Cyan - Grønblåt - Grøn - Lysegrøn - Limegrøn - Gul - Ravgul - Orange - Dyb orange - Brun - Blå grå -
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml deleted file mode 100644 index 4214f8caa..000000000 --- a/app/src/main/res/values-de/strings.xml +++ /dev/null @@ -1,243 +0,0 @@ - - - - - Übersicht - Module - - %d Modul aktiviert - %d Module aktiviert - - Protokolle - Einstellungen - Feedback oder Vorschlag - Über - Fehler melden - Modularchiv - Alle Module sind auf dem neusten Stand - Veröffentlicht am %s - Aktualisiert am %s - - %d Modul Update verfügbar - %d Modul Updates verfügbar - - Folge unserem %2$s-Kanal]]> - mshinni80, -JJ108 - Installieren - Tippen um LSPosed zu installieren - Nicht installiert - LSPosed ist nicht installiert - Aktiviert - Teilweise aktiviert - SEPolicy wird nicht korrekt geladen - Bitte melde es dem Magisk Entwickler.]]> - System-Framework-Injektion fehlgeschlagen - Magisk oder einige minderwertige Magisk Module verursacht werden.
Bitte deaktiviere alle Magisk-Module bis auf Riru und LSPosed, oder sende eine vollständige Fehlermeldung an die Entwickler.]]>
- System-Prop falsch - Module können gelegentlich außer Kraft gesetzt werden.]]> - Aktualisierung erforderlich - Bitte installieren Sie die neueste Version von LSPosed - Tipps für Modulentwickler - Bitte deaktiviere Deploy-Optimierungen in Android Studio oder benutze den `gradlew installDebug` Befehl zum Installieren. Andernfalls wird die Modul-Apk nicht aktualisiert. - API Version - Framework Version - Name des Managerpakets - System Version - Gerät - System ABI - Dex Optimizer Wrapper - Aktiviert - Deaktiviert - Unterstützt - Nicht unterstützt - Android-Version passt nicht - Abgestürzt - Mounten fehlgeschlagen - SELinux ist permissiv - SELinux-Richtlinie ist falsch - Bitte LSPosed aktualisieren - Soll LSPosed aktualisiert werden? Dieses Gerät wird nach dem Update neu gestartet - In die Zwischenablage kopiert - - Willkommen bei LSPosed - Du verwendest den parasitären Manager, der eine Verknüpfung erstellen oder über eine Benachrichtigung noch geöffnet werden kann. - Du verwendest den parasitären Manager, der von der Benachrichtigung geöffnet werden kann. - Verknüpfung erstellen - Niemals anzeigen - Parasitärer Manager empfohlen - LSPosed unterstützt nun Systemparasitisierung, um eine Erkennung zu vermeiden. Du kannst den parasitären Manager über die Benachrichtigung öffnen. Es wird empfohlen, die aktuelle App zu deinstallieren. - - Speichern - Ausführliche Protokolle - Modul-Protokolle - Protokoll wird gespeichert, bitte warten - Gespeicherte Protokolle - Speichern fehlgeschlagen:\n%s - Protokoll jetzt löschen - Protokoll erfolgreich gelöscht. - Hochscrollen - Laden… - Runterscrollen - Erneut laden - Protokoll löschen fehlgeschlagen - Wortumbruch - Ausführliches Protokoll aktiviert - Ausführliches Protokoll deaktiviert - - (keine Beschreibung angegeben) - Dieses Modul erfordert eine neuere Version von LSPosed (%d) und kann daher nicht aktiviert werden - Dieses Modul wurde für eine neuere Xposed-Version (%d) entwickelt und daher funktionieren einige Funktionen möglicherweise nicht - Dieses Modul gibt nicht die benötigte LSPosed-Version an. - Dieses Modul wurde für die LSPosed-Version %1$d erstellt, wurde jedoch aufgrund inkompatibler Änderungen in der Version %2$d deaktiviert - Dieses Modul kann nicht geladen werden, da es auf der SD-Karte installiert ist, bitte in den internen Speicher verschieben - Deinstallieren - Moduleinstellungen - In Repo anzeigen - Möchtest du dieses Modul deinstallieren? - %1$s deinstalliert - Deinstallation fehlgeschlagen - Modul zum Benutzer hinzufügen - %1$s zu Benutzer %2$s hinzugefügt - Modul hinzufügen fehlgeschlagen - Auf Benutzer %s installieren - Möchtest du %1$s auf Benutzer %2$s installieren? Es wird empfohlen manuell zu installieren, das Erzwingen der Installation über LSPosed kann Probleme verursachen. - ausklappen - einklappen - - Erneut optimieren - Optimieren … - Optimierung abgeschlossen. - Starten - Optimierung fehlgeschlagen: Rückgabewert ist leer - Optimierung fehlgeschlagen: - App-Name - Paketname - Installationszeit - Aktualisierungszeit - Umkehren - System-Apps - Sortieren - Modul aktivieren - Du hast keine App ausgewählt. Weiter? - Spiele - Module - Scope-Liste speichern fehlgeschlagen - Version: %1$s - Auswählen - Empfohlen - Du hast keine App ausgewählt. Empfohlene Apps auswählen? - Empfohlene Apps auswählen? - Alle Auswählen - Keine Auswahl - Automatisch einbinden - Das LSPosed-Modul wurde noch nicht aktiviert - Empfohlen - Aktualisierung verfügbar: %1$s - Modul %s wurde deaktiviert, da keine App ausgewählt wurde. - System-Framework - Sichern - Sichern - Wiederherstellen - Stopp erzwingen - Stopp erzwingen? - Wenn du einen App-Stopp erzwingst, können Probleme entstehen. - Neustart erforderlich, um diese Änderung zu übernehmen - Neustart - Ausblenden - - In anderer App anzeigen - App-Information - ¯\\\\_(ツ)_\/¯\nNichts hier - - Framework - Ausführliche Protokolle deaktivieren - Ausführliche Protokolle in Problemberichtsmeldungen einschließen - Dunkelschwarzes Thema - Schwarzes Thema verwenden, wenn dunkles Thema aktiviert ist - Design - Sichern und Wiederherstellen - Modul- und Scope-Listen sichern. - Modul- und Scope-Listen wiederherstellen - Sichern - Sicherung fehlgeschlagen:\n%s - Bitte DocumentUI aktivieren - Wiederherstellen - Wiederherstellung fehlgeschlagen:\n%s - Netzwerk - DNS über HTTPS - Problemumgehung für DNS-Vergiftungen in einigen Ländern - Designfarbe - System Themenfarbe - Apps erzwingen Launcher-Symbole anzuzeigen - Ab Android 10 dürfen Apps ihre Launcher-Symbole nicht ausblenden. Schalte den Schalter aus, um diese System-Funktion zu deaktivieren. - System - Sprache - Übersetzer - Beim Übersetzen mitmachen - Helfe uns, %s in deine Sprache zu übersetzen - Eine Verknüpfung zum Öffnen des parasitären Managers erstellen - Verknüpfung angeheftet - Der aktuelle Standard-Launcher unterstützt keine Pin-Verknüpfungen - Status-Benachrichtigung - Eine Benachrichtigung anzeigen, die den parasitären Manager öffnen kann - Update-Kanal - Stabil - Beta - Nightly Build - - Liesmich - Veröffentlichungen - Info - Webseite - Quellcode - Mitarbeiter - Ressourcen - Im Browser öffnen - Ältere Versionen anzeigen - Keine Veröffentlichung mehr - Modularchiv laden fehlgeschlagen: %s - Aktualisierbare zuerst - Eingerichtet - - %d Download - %d Downloads - - - Sakura - Rot - Pink - Lila - Dunkellila - Indigoblau - Blau - Hellblau - Türkis - Blaugrün - Grün - Hellgrün - Limette - Gelb - Bernstein - Orange - Dunkelorange - Braun - Blaugrau -
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml deleted file mode 100644 index 3f04c57b2..000000000 --- a/app/src/main/res/values-el/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Επισκόπηση - Πρόσθετα - - %d πρόσθετο ενεργοποιημένο - %d ενθέματα ενεργοποιήθηκαν - - Αρχεία καταγραφής - Ρυθμίσεις - Σχόλια ή πρόταση - Πληροφορίες - Αναφορά προβλήματος - Αποθετήριο - Όλα τα πρόσθετα είναι ενημερωμένα - Δημοσιεύθηκε στο %s - Ενημερώθηκε στο %s - - %d ένθεμα αναβαθμίσιμο - %d πρόσθετα μπορούν να ενημερωθούν - - Εγγραφείτε στο %2$s κανάλι μας]]> - Aristeidis Alexopoulos - Εγκατάσταση - Πατήστε για εγκατάσταση του LSPosed - Μη εγκατεστημένο - Το LSPosed δεν είναι εγκατεστημένο - Ενεργοποιημένο - Μερικώς ενεργοποιημένο - Το SEPolicy δεν φορτώθηκε σωστά - Παρακαλούμε αναφέρετε το γεγονός αυτό στον προγραμματιστή Magisk .]]> - Η έγχυση στο πλαίσιο συστήματος απέτυχε - Magisk ή από κάποια χαμηλής ποιότητας πρόσθετα τουMagisk.
Παρακαλώ προσπαθήστε να απενεργοποιήσετε όλα τα πρόσθετα του Magisk εκτός από το Riru και το LSPosed ή να υποβάλετε το πλήρες αρχείο καταγραφής στους προγραμματιστές.]]>
- Λανθασμένο στήριγμα συστήματος - Τα πρόσθετα μπορεί να ακυρωθούν περιστασιακά.]]> - Απαιτείται ενημέρωση - Παρακαλώ εγκαταστήστε την τελευταία έκδοση του LSPosed - Έκδοση API - Έκδοση πλαισίου - Όνομα πακέτου διαχειριστή - Έκδοση συστήματος - Συσκευή - Σύστημα ABI - Dex Optimizer Wrapper - Ενεργό - Μη ενεργό - Υποστηριζόμενο - Μη υποστηριζόμενο - Ανικανοποίητη έκδοση Android - Συνετρίβη - Mount απέτυχε - Το SELinux είναι επιτρεπτικό - Η πολιτική SELinux είναι λανθασμένη - Ενημέρωση LSPosed - Επιβεβαίωση ενημέρωσης του LSPosed? Αυτή η συσκευή θα επανεκκινηθεί μετά την ολοκλήρωση της ενημέρωσης - Αντιγραφή στο πρόχειρο - - Καλωσορίσατε στο LSPosed - Χρησιμοποιείτε τον παρασιτικό διαχειριστή, ο οποίος μπορεί να δημιουργήσει συντόμευση ή να παραμένει ανοικτός από την ειδοποίηση. - Χρησιμοποιείτε τον παρασιτικό διαχειριστή, ο οποίος μπορεί να δημιουργήσει συντόμευση ή να παραμένει ανοικτός από την ειδοποίηση. - Δημιουργία συντόμευσης - Να μην εμφανίζεται ποτέ - Προτεινόμενος Παρασιτικός Διαχειριστής - Το LSPosed υποστηρίζει τώρα την παρασιτοποίηση του συστήματος για να αποφύγετε την ανίχνευση, μπορείτε να ανοίξετε τον παρασιτικό διαχειριστή από την ειδοποίηση. Συνιστάται να απεγκαταστήσετε την τρέχουσα εφαρμογή. - - Αποθήκευση - Λεπτομερείς Καταγραφές - Αρχείο Καταγραφής Πρόσθετων - Αποθήκευση αρχείου καταγραφής, παρακαλώ περιμένετε - Αποθηκευμένα αρχεία καταγραφής - Αποτυχία αποθήκευσης:\n%s - Καθαρισμός αρχείου καταγραφής τώρα - Το αρχείο καταγραφής εκκαθαρίστηκε επιτυχώς. - Κύλιση στην κορυφή - Φόρτωση… - Κύλιση προς τα κάτω - Φόρτωση ξανά - Αποτυχία εκκαθάρισης του αρχείου καταγραφής - Αναδίπλωση Λέξεων - Ενεργοποίηση λεπτομερούς καταγραφής - Λεπτομερής καταγραφή απενεργοποιημένη - - (δεν παρέχεται περιγραφή) - Αυτό το ένθεμα απαιτεί μια νεότερη έκδοση του Xposed (%d) και επομένως δεν μπορεί να ενεργοποιηθεί - Αυτή η ενότητα έχει σχεδιαστεί για μια νεότερη έκδοση Xposed (%d) και ως εκ τούτου ορισμένες λειτουργίες ενδέχεται να μην λειτουργούν. - Αυτό το ένθεμα δεν καθορίζει την έκδοση Xposed που χρειάζεται. - Αυτό το ένθεμα δημιουργήθηκε για την έκδοση Xposed %1$d, αλλά λόγω μη συμβατών αλλαγών στην έκδοση %2$d, έχει απενεργοποιηθεί - Αυτό το πρόσθετο δεν μπορεί να φορτωθεί επειδή είναι εγκατεστημένο στην κάρτα SD, παρακαλώ μετακινήστε το στον εσωτερικό αποθηκευτικό χώρο - Απεγκατάσταση - Ρυθμίσεις πρόσθετου - Προβολή στο Repo - Θέλετε να απεγκαταστήσετε αυτό το πρόσθετο? - Απεγκαταστάθηκε %1$s - Απεγκατάσταση ανεπιτυχής - Προσθήκη module στο χρήστη - Προστέθηκε %1$s στον χρήστη %2$s - Η προσθήκη module απέτυχε - Εγκατάσταση σε χρήστη %s - Θέλετε να εγκαταστήσετε %1$s στο χρήστη %2$s? Συνιστάται η χειροκίνητη εγκατάσταση, αναγκάζοντας την εγκατάσταση μέσω LSPosed μπορεί να προκαλέσει προβλήματα. - επέκταση - σύμπτυξη - - Επαναβελτιστοποίηση - Βελτιστοποίηση… - Η βελτιστοποίηση ολοκληρώθηκε - Εκκίνηση - Η βελτιστοποίηση απέτυχε: η τιμή επιστροφής είναι κενή - Η βελτιστοποίηση απέτυχε: - Όνομα εφαρμογής - Όνομα πακέτου - Χρόνος εγκατάστασης - Χρόνος ενημέρωσης - Αντίστροφη - Εφαρμογές συστήματος - Ταξινόμηση - Ενεργοποίηση module - Δεν έχετε επιλέξει καμία εφαρμογή. Συνέχεια? - Παιχνίδια - Πρόσθετα - Αποτυχία αποθήκευσης της λίστας πεδίου - Έκδοση: %1$s - Προτεινόμενο - Δεν έχετε επιλέξει καμία εφαρμογή. Επιλέξτε τις προτεινόμενες εφαρμογές? - Επιλέξτε προτεινόμενες εφαρμογές? - Το Xposed πρόσθετο δεν έχει ενεργοποιηθεί ακόμα - Προτεινόμενο - Διαθέσιμη ενημέρωση: %1$s - Το ένθεμα %s έχει απενεργοποιηθεί δεδομένου ότι δεν έχει επιλεγεί εφαρμογή. - Πλαίσιο Συστήματος - Αντίγραφα Ασφαλείας - Αντίγραφα Ασφαλείας - Επαναφορά - Αναγκαστική διακοπή - Αναγκαστική διακοπή? - Αν επιβάλετε τη διακοπή μιας εφαρμογής, ενδέχεται να μην λειτουργήσει σωστά. - Απαιτείται επανεκκίνηση για να εφαρμοστεί αυτή η αλλαγή - Reboot - Απόκρυψη - - Προβολή σε άλλη εφαρμογή - Πληροφορίες εφαρμογής - ◆ \\\\_(\")_\/ \"\nΤίποτα εδώ - - Framework - Απενεργοποιήστε αναλυτικά στοιχεία καταγραφής - Αναφορά προβλημάτων ζητάει να συμπεριλαμβάνεται τα αναλυτικά στοιχεία καταγραφής - Μαύρο σκούρο θέμα - Χρησιμοποιήστε το καθαρό μαύρο θέμα αν το σκούρο θέμα είναι ενεργοποιημένο - Θέμα - Αντίγραφα ασφαλείας και επαναφορά - Λίστα module αντιγράφων ασφαλείας και λίστες εμβέλειας. - Επαναφορά λίστας ενθεμάτων και πεδίου εφαρμογής. - Αντίγραφα Ασφαλείας - Αποτυχία δημιουργίας αντιγράφων ασφαλείας:\n%s - Ενεργοποιήστε το DocumentUI - Επαναφορά - Αποτυχία επαναφοράς:\n%s - Δίκτυο - DNS μέσω HTTPS - Εργαστείτε γύρω από τη δηλητηρίαση DNS σε ορισμένες χώρες - Χρώμα θέματος - Χρώμα θέματος συστήματος - Εξαναγκασμός των εφαρμογών να εμφανίζουν εικονίδια εκκίνησης - Μετά το Android 10, οι εφαρμογές δεν επιτρέπεται να αποκρύψουν τα εικονίδια εκτοξευτή τους. Απενεργοποιήστε την εναλλαγή για να απενεργοποιήσετε αυτήν τη λειτουργία συστήματος. - Σύστημα - Γλώσσα - Συντελεστές μετάφρασης - Συμμετοχή στη μετάφραση - Βοηθήστε μας να μεταφράσουμε το %s στη γλώσσα σας - Δημιουργήστε μια συντόμευση που μπορεί να ανοίξει τον παρασιτικό διαχειριστή - Συντόμευση καρφιτσωμένη - Ο τρέχων προεπιλεγμένος εκτοξευτής δεν υποστηρίζει συντομεύσεις καρφίτσας - Ειδοποίηση καταστάσεως - Εμφάνιση μιας ειδοποίησης που μπορεί να ανοίξει τον διαχειριστή παρασιτικής - Ενημέρωση καναλιού - Σταθερό - Βήτα - Νυχτερινή κατασκευή - - Έτοιμο - Εκδόσεις - Πληροφορίες - Αρχική - Πηγαίος κώδικας - Συνεργάτες - Ενεργητικό - Άνοιγμα σε πρόγραμμα περιήγησης - Εμφάνιση παλαιότερων εκδόσεων - Δεν υπάρχει πλέον έκδοση - Αποτυχία φόρτωσης πρόσθετου repo: %s - Αναβαθμίσιμο πρώτα - Εγκατεστημένο - - %d λήψη - %d downloads - - - Sakura - Κόκκινο - Ροζ - Μωβ - Βαθύ μωβ - Indigo - Μπλε - Ανοιχτό μπλε - Κυανό - Τιρκουάζ - Πράσινο - Ανοιχτό πράσινο - Άσβεστος - Κίτρινο - Κεχριμπάρι - Πορτοκαλί - Βαθύ πορτοκαλί - Καφέ - Μπλε γκρι -
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml deleted file mode 100644 index 3e0991f7f..000000000 --- a/app/src/main/res/values-es/strings.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - Resumen - Módulos - - %d módulo activado - %d Módulos activados - - Trozas - Configuración - Comentarios o sugerencia - Acerca de - Reportar problema - Repositorio - Todos los modulos actualizados - Publicado el %s - Actualizado el %s - - %d módulo actualizable - %d módulos repositorio actualizables - - Únete a nuestro %2$s canal]]> - squaredDot - Instalar - Pulsa para instalar LSPosed - No instalado - LSPosed no está instalado - Activado - Parcialmente activado - Selinux policy no está cargado correctamente - Informa de ello al desarrollador de Magisk .]]> - System Framework injection failed - Magisk or some low-quality Magisk modules.
Please try to disable Magisk modules other than Riru and LSPosed or submit full log to developers.]]>
- System prop incorrect - Modules may invalidate occasionally.]]> - Need to update - Por favor instale la última versión de LSPosed - Sugerencias para desarrolladores de módulos - Por favor, desactiva las optimizaciones de implementación en Android Studio, o ejecuta el comando `gradlew installDebug` para instalar el módulo. De lo contrario, el apk del módulo no se actualizará. - Versión de la API - Versión del framework - Nombre del paquete de gestión - Versión del sistema - Dispositivo - ABI del Sistema - Envoltura del optimizador Dex - Activado - No habilitado - Apoyado - No soportado - Versión Android insatisfecha - Se estrelló - El montaje falló - SELinux es permisivo - La política de SELinux es incorrecta - Actualizar LSPosed - ¿Confirmar para actualizar LSPose? Este dispositivo se reiniciará después de completar la actualización - Información copiada al portapapeles - - Bienvenido a LSPosed - Usted está utilizando el gestor de parásitos, que puede crear acceso directo o todavía abierta de notificación. - Estás usando el gestor de parásitos, que se puede abrir desde la notificación. - Crear acceso directo - Nunca mostrar - Se recomienda Parasitic Manager - LSPosed ahora soporta la parasitación del sistema para evitar la detección, puede abrir el gestor de parásitos desde la notificación. Se recomienda desinstalar la aplicación actual. - - Guardar - Registros detallados - Logs de los módulos - Guardando registro, por favor espere - Registros guardados - No se pudo guardar:\n%s - Limpiar los registros - Registros limpiados satisfactoriamente. - Desplazar hasta el inicio - Cargando… - Desplazar hasta el final - Recargar - Fallo al limpiar los registros - Ajuste de palabras - Registro detallado habilitado - Registro detallado desactivado - - (sin descripción) - Este módulo requiere una versión más nueva de Xposed (%d), por lo que no puede ser activado - Este módulo está diseñado para una versión más reciente Xposed (%d) y por lo tanto algunas funcionalidades pueden no funcionar - Este módulo no especifica la versión de Xposed que necesita. - Este módulo fue creado para la versión de Xposed %1$d, pero, debido a cambios incompatibles en la versión %2$d, ha sido desactivado - Este módulo no puede ser cargado porque está instalado en la tarjeta SD. Por favor, muévelo al almacenamiento interno - Desinstalar - Configuración del módulo - Ver en el repositorio - ¿Quieres desinstalar este módulo? - Desinstalado %1$s - Fallo en la desinstalación - Añadir módulo al usuario - %1$s instalado %2$s - Fallo en la instalación - Instalar al usuario %s - ¿Quieres instalar %1$s al usuario %2$s? Se recomienda que lo instales manualmente; forzar la instalación a través de LSPosed puede causar problemas. - expandir - contraer - - Optimizar de nuevo - Optimizando… - Optimización completada. - Abrir - La optimización falló o devolvió un valor vacío. - Fallo en la optimización: - Filtrar por nombre de aplicación - Filtrar por nombre de paquete - Filtrar por fecha de instalación - Filtrar por fecha de actualización - Invertir - Aplicaciones del sistema - Filtrando - Activar módulo - No seleccionaste ninguna aplicación. ¿Quieres continuar? - Juegos - Módulos - Fallo al guardar la lista de scopes - Versión: %1$s - Seleccionar - Recomendado - No seleccionaste ninguna aplicación. ¿Quieres seleccionar las aplicaciones recomendadas? - ¿Quieres seleccionar las aplicaciones recomendadas? - Todo - Ninguno - Auto-Incluir - El módulo Xposed no está activado aún - Recomendado - Actualización disponible: %1$s - El módulo %s ha sido desactivado ya que no se ha seleccionado ninguna aplicación. - Framework del sistema - Respaldo - Hacer un respaldo - Restaurar - Forzar la detención - ¿Quieres forzar la detención? - Si fuerzas la detención de una aplicación puede que esta se comporte de manera indefinida. - Necesitas reiniciar la aplicación para aplicar este cambio - Reiniciar - Ocultar - - Ver en otra aplicación - Información de la aplicación - ¯\\\\_(ツ)_\/¯\nNo hay nada por aquí - - Framework - Desactivar registros detallados - Solicitud de inclusión de registros detallados en los informes de incidencias - Tema negro oscuro - Usar el tema negro puro si el tema oscuro está activado - Tema - Respaldo y restauración - Hacer un respaldo de la lista de módulos y scopes. - Hacer una restauración de la lista de módulos y scopes. - Hacer un respaldo - Error al realizar la copia de seguridad:\n%s - Por favor, activa DocumentUI - Restaurar - Error al restaurar:\n%s - Red - DNS sobre HTTPS - Solución alternativa al ataque de DNS en algunos países - Color del tema - Color de acentuación del sistema - Forzar a las aplicaciones a mostrar los íconos del ejecutable - En versiones posteriores a Android 10 no se permite a las aplicaciones (especialmente los módulos de Xposed) a ocultar el logo de su ejecutable. Desactiva la opción para desactivar esta característica. - Sistema - Idioma - Colaboradores de traducción - Participar en la traducción - Ayúdanos a traducir %s a tu idioma - Crear un acceso directo que pueda abrir el gestor de parásitos - Acceso directo anclado - El actual lanzador por defecto no admite accesos directos a pines - Notificación de estado - Mostrar una notificación que puede abrir el gestor de parásitos - Actualizar canal - Estable - Beta - Construcción nocturna - - Léeme - Versiones - Información - Página principal - Código fuente - Colaboradores - Archivos - Abrir en el navegador - Mostrar versiones anteriores - No hay más versiones - Fallo al cargar el módulo de repositorio: %s - Actualizables - Instalado - - %d descargar - %d descargas - - - Sakura - Rojo - Rosa - Morado - Morado profundo - Indigo - Azul - Azul claro - Cian - Teal - Verde - Verde claro - Lima - Amarillo - Ámbar - Naranja - Naranja oscuro - Marrón - Gris azul -
diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml deleted file mode 100644 index b07528766..000000000 --- a/app/src/main/res/values-et/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Kodu - Moodulid - - %d moodul on lubatud - %d moodulit on lubatud - - Logid - Seaded - Tagasiside või ettepanek - Kohta - Teata probleemist - Repo - Kõik moodulid on ajakohased - Avaldatud %s - Uuendatud %s - - %d moodul on täiendatav - %d moodulit täiendatavad - - Liitu meie %2$s \'i kanaliga]]> - Subaru Pan - Installi - Puudutage LSPosedi installimiseks - Puudub - LSPosed ei ole installitud - Aktiveeritud - Osaliselt aktiveeritud - SEPolicy ei ole korralikult laetud - Palun teatage sellest Magisk arendajale.]]> - System Frameworki süstimine ebaõnnestus - Magisk või mõned madala kvaliteediga Magisk-moodulid.
Palun proovige lülitada välja Magisk\'i moodulid peale Riru ja LSPosed või esitage täielik logi arendajatele.]]>
- Süsteemi tugi vale - Moodulid võivad aeg-ajalt kehtetuks muutuda.]]> - Vaja uuendada - Palun installige LSPosedi uusim versioon - API\'i versioon - Raamistiku versioon - Manager paketi nimi - Süsteemi versioon - Seade - Süsteemi ABI - Dex Optimizer Wrapper - Lubatud - Pole lubatud - Toetatud - Toetamata - Androidi versioon ei ole toetatud - Kokku jooksnud - Kinnitus ebaõnnestus - SELinux on lubav - SELinux policy on vale - LSPosedi uuendamine - Kas kinnitada LSPosed uuendamine? See seade taaskäivitub pärast uuendamise lõpetamist - Kopeeritud lõikelauale - - Tere tulemast LSPosedisse - Sa kasutad parasiitide haldurit, mis võib luua otsetee või ikka avada teateid. - Kasutate parasiitide haldurit, mida saab avada teatisest. - Loo otsetee - Mitte kunagi ei näidata - Parasiitide haldur Soovitatav - LSPosed toetab nüüd süsteemi parasiitide tuvastamise vältimiseks, saate avada parasiitide haldaja teatest. Praegune rakendus on soovitatav eemaldada. - - Salvesta - Põhjalikud logid - Moodulite logid - Logi salvestamine, palun oodake - Logi salvestatud - Salvestamine ebaõnnestus:\n%s - Kustuta logi kohe - Logi edukalt kustutatud. - Kerige üles - Laadimine… - Kerige alla - Laadi uuesti - Logi tühjendamine ebaõnnestus - Word Wrap - Paljusõnaline logi on lubatud - Paljusõnaline logi on välja lülitatud - - (kirjeldus puudub) - See moodul nõuab uuemat Xposed versiooni (%d) ja seega ei saa seda aktiveerida. - See moodul on mõeldud uuemale Xposedi versioonile (%d) ja seetõttu ei pruugi mõned funktsioonid töötada - See moodul ei täpsusta Xposedi versiooni, mida ta vajab. - See moodul loodi Xposedi versiooni %1$d jaoks, kuid versioonis %2$d tehtud ühildumatute muudatuste tõttu on see välja lülitatud - Seda moodulit ei saa laadida, sest see on paigaldatud SD-kaardile, palun viige see sisemällu. - Eemalda - Mooduli seaded - Vaata Repos - Kas soovite selle mooduli eemaldada? - Eemaldatud %1$s - Eemaldamine ebaõnnestus - Lisa kasutajale moodul - Lisatud %1$s kasutajale %2$s - Mooduli lisamine ebaõnnestus - Installi kasutajale %s - Tahad paigaldada %1$s kasutajale %2$s? Soovitatav on paigaldada käsitsi, LSPosed\'i kaudu sunniviisiline paigaldamine võib põhjustada probleeme. - laienda - kollaps - - Optimeeri uuesti - Optimeerimine… - Optimeeritud - Ava - Optimeerimine ebaõnnestus: tagastusväärtus on tühi - Optimeerimine ebaõnnestus: - Rakenduse nimi - Paketi nimi - Installimise aeg - Uuendamise aeg - Tagasipööra - Süsteemirakendused - Sortimisalus - Luba moodul - Te ei valinud ühtegi rakendust. Jätka? - Mängud - Moodulid - Ei õnnestunud salvestada reguleerimisala nimekirja - Versioon: %1$s - Soovitatav - Te ei valinud ühtegi rakendust. Valige soovitatud rakendused? - Valige soovitatavad rakendused? - Xposed moodul ei ole aktiveeritud - Soovitatav - Uuendus on saadaval: %1$s - Moodul %s on välja lülitatud, kuna ühtegi rakendust ei ole valitud. - Süsteemi raamistik - Varukoopia - Varukoopia - Taasta - Sundpeata - Sundpeata? - Kui te peatate rakenduse sunniviisiliselt, võib see halvasti käituda. - Selle muudatuse kohaldamiseks on vajalik taaskäivitamine - Taaskäivitus - Peida - - Vaadake teises rakenduses - Rakenduse teave - ¯\\\\_(ツ)_\/¯\nSiin pole midagi. - - Raamistik - Lülita sõnalised logid välja - Aruande probleemid taotluse lisada sõnalogid - Must tume teema - Kasutage puhast musta teemat, kui tume teema on lubatud. - Teema - Varundamine ja taastamine - Moodulite varukoopiate nimekiri ja ulatusloendid. - Taastab moodulite loendi ja ulatusloendite loendi. - Varukoopia - Varundamine ebaõnnestus:\n%s - Palun lubage DocumentUI - Taasta - Ei õnnestunud taastada:\n%s - Võrk - DNS üle HTTPS - Workaround DNS mürgistus mõnedes riikides - Teema värv - Süsteemi teema värv - Rakenduste sundimine käivitaja ikoonide kuvamiseks - Pärast Android 10 ei ole rakendustel lubatud oma käivitaja ikoonid ära peita. Selle süsteemifunktsiooni väljalülitamiseks lülitage lüliti välja. - Süsteem - Keel - Tõlkimise toetajad - Osalege tõlkimises - Aita meil tõlkida %s sinu keelde - Loo otsetee, mis võib avada parasiitide halduri - Otsetee kinnitatud - Praegune vaikekäivitusprogramm ei toeta nööpnõelte otseteid - Staatuse teatamine - Kuva teatis, mis võib avada parasiitide halduri - Uuenduskanal - Stabiilne - Beeta - Nightly build - - Readme - Väljaanded - Teave - Koduleht - Lähtekood - Koostööpartnerid - Varad - Ava brauseris - Kuva vanemad versioonid - Enam ei ole väljaannet - Ebaõnnestus mooduli repo laadimine: %s - Esimesena uuendatav - Paigaldatud - - allalaaditud on %d kord - allalaaditud on %d korda - - - Sakura - Punane - Roosa - Lilla - Sügavlilla - Indigo - Sinine - Helesinine - Tsüaansinine - Sinakasroheline - Roheline - Heleroheline - Laimiroheline - Kollane - Amber - Oranž - Sügavoranž - Pruun - Sinine hall -
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml deleted file mode 100644 index dcec2c809..000000000 --- a/app/src/main/res/values-fa/strings.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - نمای کلی - ماژول‌ها - - %d ماژول فعال - %d ماژول فعال - - لاگ ها - تنظیمات - بازخورد یا پیشنهاد - درباره - گزارش مشکل - مخزن - همه ماژول ها بروز هستند - منتشر شده در %s - بروزرسانی شده در %s - - %d ماژول قابل بروزرسانی - %d ماژول قابل بروزرسانی - - عضو کانال %2$s شوید]]> - null - نصب - برای نصب LSPosed لمس کنید - نصب نشده - LSPosed نصب نشده - فعال شده - نیمه فعال - سیاست SELinux به درستی بارگذاری نشده - لطفاً این موضوع را به توسعه دهنده Magisk گزارش دهید.]]> - تزریق به چارچوب سیستم ناموفق بود - Magisk یا برخی ماژول های بی کیفیت Magisk باشد.
لطفاً ماژول های Magisk به جز Riru و LSPosed را غیرفعال کنید یا لاگ کامل را برای توسعه دهندگان بفرستید.]]>
- ویژگی های سیستم نادرست است - ماژول ها ممکن است گاهی کار نکنند.]]> - نیاز به بروزرسانی - LSPosedLSPosed - نکات برای توسعه دهنده ماژول - لطفاً بهینه‌سازی‌های استقرار را در اندروید استودیو خاموش کنید یا از دستور `gradlew installDebug` استفاده کنید. در غیر این صورت APK ماژول آپدیت نمی‌شود. - نسخه API - نسخه چارچوب - نام پکیج مدیر - نسخه سیستم - دستگاه - ABI سیستم - قالب بهینه‌سازی Dex - فعال - غیرفعال - پشتیبانی شده - پشتیبانی نمی شود - نسخه اندروید پشتیبانی نمی شود - کرش کرد - مونت ناموفق بود - SELinux در حالت Permissive است - سیاست SELinux نادرست است - بروزرسانی LSPosed - آیا بروزرسانی LSPosed را تأیید می کنید؟ بعد از پایان، دستگاه ری استارت می شود - کپی شد - - به LSPosed خوش آمدید - شما از مدیر Parasitic استفاده می کنید که می تواند شورتکات بسازد یا از نوتیفیکیشن باز شود. - شما از مدیر Parasitic استفاده می کنید که فقط از نوتیفیکیشن باز می شود. - ساخت شورتکات - هرگز نمایش نده - مدیر Parasitic توصیه شده - LSPosed حالا از سیستم Parasitic پشتیبانی می کند تا شناسایی نشود، می توانید از نوتیفیکیشن مدیر Parasitic را باز کنید. بهتر است برنامه فعلی را حذف کنید. - - ذخیره - لاگ های مفصل - لاگ های ماژول - در حال ذخیره لاگ، لطفاً صبر کنید - لاگ ها ذخیره شدند - ذخیره موفق نبود:\n%s - همین الان لاگ ها را پاک کن - لاگ ها با موفقیت پاک شدند. - برگشت به بالا - در حال بارگذاری… - رفتن به پایین - بارگذاری مجدد - پاک کردن لاگ ناموفق بود - شکستن خودکار خطوط - لاگ مفصل فعال شد - لاگ مفصل غیرفعال شد - - (توضیحی داده نشده) - این ماژول نیاز به نسخه جدیدتر Xposed (%d) دارد و نمی تواند فعال شود - این ماژول برای نسخه جدیدتر Xposed (%d) ساخته شده، پس ممکن است برخی امکانات کار نکنند - این ماژول نسخه Xposed مورد نیازش را مشخص نکرده. - این ماژول برای نسخه %1$d ساخته شده، اما به دلیل تغییرات ناسازگار در نسخه %2$d غیرفعال شده - این ماژول نمی تواند بارگذاری شود چون روی کارت حافظه نصب شده، لطفاً به حافظه داخلی منتقل کنید - حذف نصب - تنظیمات ماژول - مشاهده در مخزن - می خواهید این ماژول را حذف کنید؟ - حذف شد %1$s - حذف موفق نبود - اضافه کردن ماژول به کاربر - اضافه شد %1$s به کاربر %2$s - اضافه کردن ماژول موفق نبود - نصب برای کاربر %s - می خواهید %1$s را برای کاربر %2$s نصب کنید؟ توصیه می شود دستی نصب کنید، نصب با LSPosed ممکن است مشکل ایجاد کند. - باز کن - ببند - - بهینه‌سازی مجدد - در حال بهینه‌سازی… - بهینه‌سازی تمام شد - باز کن - بهینه‌سازی شکست خورد: خروجی خالی است - بهینه‌سازی شکست خورد: - نام برنامه - نام پکیج - زمان نصب - زمان بروزرسانی - معکوس - برنامه های سیستمی - مرتب‌سازی - فعال کردن ماژول - برنامه ای انتخاب نکردی، ادامه میدی؟ - بازی ها - ماژول ها - ذخیره لیست ناموفق بود - نسخه: %1$s - انتخاب - توصیه شده - برنامه ای انتخاب نکردی. برنامه های توصیه شده را انتخاب کنم؟ - می خوای برنامه های توصیه شده رو انتخاب کنی؟ - همه - هیچی - شامل خودکار - ماژول Xposed هنوز فعال نشده - توصیه شده - بروزرسانی موجود: %1$s - ماژول %s به خاطر انتخاب نکردن برنامه غیرفعال شده. - چارچوب سیستم - پشتیبان گیری - پشتیبان گیری - بازیابی - توقف اجباری - توقف اجباری؟ - اگر برنامه را به زور متوقف کنی، ممکن است درست کار نکند. - برای اعمال تغییر باید ری استارت کنی - ری استارت - مخفی کن - - مشاهده در برنامه دیگر - اطلاعات برنامه - ¯\_(ツ)_/¯\nاینجا چیزی نیست - - چارچوب - غیرفعال کردن لاگ مفصل - لاگ مفصل برای گزارش مشکل لازم است - تم سیاه کامل - اگر تم تاریک فعال است از تم کاملا سیاه استفاده کن - تم - پشتیبان گیری و بازیابی - پشتیبان گیری از لیست ماژول ها و برنامه ها. - بازیابی لیست ماژول ها و برنامه ها. - پشتیبان گیری - پشتیبان گیری ناموفق بود:\n%s - لطفاً DocumentUI را فعال کنید - بازیابی - بازیابی ناموفق بود:\n%s - شبکه - DNS روی HTTPS - حل مشکل مسمومیت DNS در بعضی کشورها - رنگ تم - رنگ تم سیستم - نمایش آیکون های لانچر برنامه ها - از اندروید ۱۰ به بعد، برنامه ها نمی توانند آیکون لانچر را مخفی کنند. این گزینه را خاموش کن تا این ویژگی غیرفعال شود. - سیستم - زبان - مشارکت کنندگان ترجمه - مشارکت در ترجمه - کمک کن %s را به زبان خودت ترجمه کنیم - شورتکاتی بساز که مدیر Parasitic را باز کند - شورتکات پین شد - لانچر پیش فرض فعلی شورتکات های پین شده را پشتیبانی نمی کند - نمایش اعلان وضعیت - نمایش اعلانی که مدیر Parasitic را باز کند - کانال بروزرسانی - پایدار - بتا - نسخه شبانه - - راهنما - نسخه ها - اطلاعات - صفحه اصلی - سورس کد - همکاران - دارایی ها - باز کردن در مرورگر - نمایش نسخه های قدیمی تر - نسخه ای بیشتر نیست - بارگذاری مخزن ماژول شکست خورد: %s - اول ماژول های قابل بروزرسانی - نصب شده - - %d دانلود - %d دانلود - - - ساکورا - قرمز - صورتی - بنفش - بنفش تیره - نیلی - آبی - آبی روشن - آبی فیروزه ای - آبی خاکستری - سبز - سبز روشن - لیمویی - زرد - کهربایی - نارنجی - نارنجی تیره - قهوه ای - آبی خاکستری -
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml deleted file mode 100644 index a39c6a90d..000000000 --- a/app/src/main/res/values-fi/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Yleiskatsaus - Moduulit - - %d moduuli käytössä - %d moduulia käytössä - - Lokit - Asetukset - Palaute tai ehdotus - Tietoja - Ilmoita ongelmasta - Versiovarasto - Kaikki moduulit ajan tasalla - Julkaistu osoitteessa %s - Päivitetty osoitteessa %s - - %d moduuli päivitettävissä - %d moduulia päivitettävissä - - Liity kanavaamme %2$s]]> - null - Asenna - Napauta asentaaksesi LSPosed - Ei asennettu - LSPosed ei ole asennettu - Aktivoitu - Osittain aktivoitu - SEPolicy ei ole ladattu oikein - Ilmoita tästä Magisk kehittäjälle.]]> - Järjestelmän kehysinjektointi epäonnistui - Magisk tai joitakin heikkolaatuisia Magisk moduuleja.
Yritä poistaa käytöstä muut Magisk moduulit kuin Riru ja LSPosed tai lähettää täysi loki kehittäjille.]]>
- Järjestelmän prop virheellinen - Moduulit voivat mitätöidä satunnaisesti.]]> - Täytyy päivittää - Asenna LSPosedin uusin versio - API versio - Kehyksen versio - Manager-paketin nimi - Järjestelmän versio - Laite - Järjestelmä ABI - Dex Optimizer Wrapper - Käytössä - Ei käytössä - Tuettu - Ei tuettu - Android-versio tyytymätön - Crashed - Kiinnitys epäonnistui - SELinux on salliva - SELinux-käytäntö on virheellinen - Päivitys LSPostettu - Vahvista LSPost-päivitys? Tämä laite käynnistyy uudelleen päivityksen jälkeen - Kopioitu leikepöydälle - - Tervetuloa LSPosed - Käytät loishallintaohjelmaa, joka voi luoda pikakuvakkeen tai silti avata ilmoituksen. - Käytät loishallintaohjelmaa, joka voidaan avata ilmoituksesta. - Luo pikakuvake - Älä näytä koskaan - Parasitic Manager Suositellaan - LSPosed tukee nyt järjestelmän loisimista havaitsemisen välttämiseksi, voit avata loishallinnan ilmoituksesta. On suositeltavaa poistaa nykyinen sovellus. - - Tallenna - Verbose Lokit - Moduulien Lokit - Lokin tallentaminen, odota - Tallennetut lokit - Tallennus epäonnistui:\n%s - Tyhjennä loki nyt - Loki tyhjennetty. - Vieritä ylös - Ladataan… - Siirry alareunaan - Reload - Lokin tyhjentäminen epäonnistui - Sanan Rivitys - Verbose loki käytössä - Verbose loki pois käytöstä - - (ei kuvausta annettu) - Tämä moduuli vaatii uudemman Xposed version (%d) eikä sitä näin ollen voi aktivoida - Tämä moduuli on suunniteltu uudemmalle Xposed-versiolle (%d), joten jotkin toiminnot eivät välttämättä toimi. - Tämä moduuli ei määrittele tarvitsemaansa Xposed versiota. - Tämä moduuli on luotu Xposed versiolle %1$d, mutta koska versiossa %2$don tehty yhteensopimattomia muutoksia, se on poistettu käytöstä - Tätä moduulia ei voi ladata, koska se on asennettu SD-kortille, siirrä se sisäiseen tallennustilaan - Poista - Moduulin asetukset - Näytä repossa - Haluatko poistaa tämän moduulin? - Poista %1$s - Poisto epäonnistui - Lisää moduuli käyttäjälle - Lisätty %1$s käyttäjälle %2$s - Moduulin lisääminen epäonnistui - Asenna käyttäjälle %s - Haluatko asentaa %1$s käyttäjälle %2$s? On suositeltavaa asentaa manuaalisesti, pakottaa asennus LSPosedin kautta voi aiheuttaa ongelmia. - laajenna - pienennä - - Uudelleenoptimoi - Optimoidaan… - Optimointi valmis - Käynnistä se - Optimointi epäonnistui: palautusarvo on tyhjä - Optimointi epäonnistui: - Sovelluksen nimi - Paketin nimi - Asenna aika - Päivityksen aika - Käänteinen - Järjestelmäsovellukset - Lajittelu - Ota moduuli käyttöön - Et valinnut yhtään sovellusta. Jatketaanko? - Pelit - Moduulit - Valmistelulistan tallentaminen epäonnistui - Versio: %1$s - Suositeltu - Et valinnut yhtään sovellusta. Valitse suositellut sovellukset? - Valitse suositellut sovellukset? - Xposed moduuli ei ole vielä aktivoitu - Suositeltu - Päivitys saatavilla: %1$s - Moduuli %s on poistettu käytöstä koska sovellusta ei ole valittu. - Järjestelmän Puitteet - Varmuuskopio - Varmuuskopio - Palauta - Pakota lopetus - Pakotetaanko lopetus? - Jos pakotat sovelluksen pysähtymään, se saattaa käyttäytyä väärin. - Uudelleenkäynnistys vaaditaan tämän muutoksen käyttöönottamiseksi - Reboot - Piilota - - Näytä toisessa sovelluksessa - Sovelluksen tiedot - ¶ \\\\_(konferenssissa)_\/ ¶\nEi mitään tässä - - Framework - Sanallisten lokien poistaminen käytöstä - Raportti pyytää sisällyttämään sanalliset lokit - Musta tumma teema - Käytä puhdas musta teema, jos tumma teema on käytössä - Teema - Varmuuskopioi ja palauta - Varmuuskopioi moduulien listat ja sisällysluettelot. - Palauta moduulien luettelo ja sisällysluettelot. - Varmuuskopio - Varmuuskopiointi epäonnistui:\n%s - Ota DocumentUI käyttöön - Palauta - Palautus epäonnistui:\n%s - Verkko - DNS yli HTTPS - Workaround DNS myrkytys joissakin kansoissa - Teeman väri - Järjestelmän teeman väri - Pakota sovellukset näyttämään käynnistimen kuvakkeet - Android 10:n jälkeen sovellukset eivät saa piilottaa niiden käynnistyskuvakkeita. Poista valinta käytöstä poistaaksesi järjestelmän ominaisuuden. - Järjestelmä - Kieli - Käännöksen osallistujat - Osallistu käännökseen - Auta meitä kääntämään %s kielellesi - Luo pikakuvake, joka voi avata loishallintaohjelman. - Pikakuvake kiinnitetty - Nykyinen oletuskäynnistin ei tue pin-pikakuvakkeita. - Tilailmoitus - Näytä ilmoitus, joka voi avata loishallintaohjelman - Päivitä kanava - Vakaa - Beeta - Yöllinen rakentaminen - - Luennot - Julkaisut - Tiedot - Kotisivu - Lähdekoodi - Yhteistyökumppanit - Laitteet - Avaa selaimessa - Näytä vanhemmat versiot - Ei enää versiota - Ei voitu ladata moduulia repo: %s - Päivitettävissä ensin - Asennettu - - %d lataa - %d lataukset - - - Sakura - Punainen - Pinkki - Violetti - Syvä violetti - Indigo - Sininen - Vaalea sininen - Syaani - Sinappi - Vihreä - Vaalea vihreä - Limea - Keltainen - Meripihka - Oranssi - Syvä oranssi - Ruskea - Sininen harmaa -
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml deleted file mode 100644 index da73d619e..000000000 --- a/app/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,244 +0,0 @@ - - - - - Aperçu - Modules - - %d module actif - %d modules actifs - - Journaux - Réglages - Réaction ou suggestion - À propos - Signaler un problème - Dépôt - Tous les modules sont à jour - Publié le %s - Mise à jour le %s - - %d modules évolutifs - %d modules évolutifs - - Rejoindre notre canal %2$s]]> - https://github.com/xerta555 -https://github.com/tclement0922 -JingMatrix - Installer - Appuyer pour installer LSPosed - Non installé - LSPosed n\'est pas installé - Activé - Partiellement activé - SEPolicy n\’est pas chargé correctement - Merci de ne pas remonter celà vers le développeur Magisk.]]> - Échec de l\’injection du sous système - Magisk ou certains modules Magisk de basse qualité.
Essayez de désactiver les modules Magisk autres que Riru et LSPosed ou envoyez le journal complet aux développeurs.]]>
- Propriétés système incorrectes - Des modules peuvent s\'invalider occasionnellement.]]> - Mise à jour nécessaire - Merci d\’installer la dernière version de LSPosed - Conseils pour les développeurs de modules - Veuillez désactiver les optimisations de déploiement sur Android Studio, ou utilisez la commande `gradlew installDebug` pour installer. Sinon, l\'APK du module ne sera pas mis à jour. - Version de l\’API - Version du framework - Nom de paquet du gestionnaire - Version du système - Périphérique - Architecture du système - Enveloppeur Dex Optimizer - Activé - Non actif - Supporté - Non supporté - Version d\'Android non satisfaisante - Planté - Échec du montage - SELinux est permissif - La politique SELinux est incorrecte - Mettre à jour LSPosed - Vous confirmez la mise à jour LSPosed ? Ce périphérique redémarrera après la mise à jour effectuée - Copié dans le presse-papier - - Bienvenue dans LSPosed - Vous utilisez le gestionnaire parasité, qui ne peut pas créer de raccourcis ou même être ouvert à partir d\'une notification. - Vous utilisez le gestionnaire parasité, qui peut être ouvert depuis la notification. - Créer le raccourci - Ne jamais afficher - Gestionnaire parasité recommandé - LSPosed supporte maintenant la parasitage du système afin d\'éviter les détection, vous pouvez l\'ouvrir depuis la notification. Il est recommandé de désinstaller l\'application actuelle. - - Sauvegarder - Journaux détaillés - Journaux des modules - Enregistrement du journal, veuillez patienter - Journaux enregistrés - Échec de la sauvegarde :\n%s - Effacer le journal maintenant - Journal effacé avec succès. - Haut de page - Chargement… - Pied de page - Recharger - Échec de l\'effacement du journal - Retour à la ligne - Journaux détaillés activés - Journaux détaillés désactivés - - (aucune description fournie) - Ce module requière une nouvelle version d\'Xposed (%d) et n\'a donc pas pu être activé - Ce module a été conçu pour une nouvelle version d\’Xposed (%d) et certaines fonctionnalités pourraient ne pas fonctionner - Ce module ne spécifie pas la version d\'Xposed nécessaire. - Ce module à été créé pour la version Xposed %1$d, mais due à des changements incompatibles dans la version %2$d, il à été désactivé - Ce module ne peut pas être chargé car il est installé sur la carte SD, merci de le déplacer sur le stockage interne - Désinstaller - Réglages du module - Afficher dans le dépôt - Voulez-vous désinstaller ce module ? - Désinstallation de %1$s - Échec de la désinstallation - Ajouter le module à l\’utilisateur - %1$s ajouté à l’utilisateur %2$s - Échec de l\’ajout du module - Installer dans l\'utilisateur %s - Vous voulez installer %1$s dans l\'utilisateur %2$s ? Il est recommandé de l\'installer manuellement, forcer l\'installation via LSPosed pourrait causer des problèmes. - développer - réduire - - Ré-optimiser - Optimisation… - Optimisation terminée - Démarrer - Échec de l\’optimisation : la valeur renvoyée est vide - Échec de l\’optimisation : - Trier par nom d\’application - Trier par nom de paquet - Trier par date d\’installation - Trier par heure de mise à jour - Inversé - Applications système - Trier - Activer le module - Vous n\'avez sélectionné aucune application. Continuer ? - Jeux - Modules - Échec de l\'enregistrement de la liste des périmètres d\'applications - Version : %1$s - Choisir - Recommandé - Vous n\'avez sélectionné aucune application. Sélectionner les applications recommandées ? - Sélectionner les applications recommandées ? - Toutes - Aucune - Inclus auto - Le module Xposed n\’est pas encore activé - Recommandé - Mise à jour disponible : %1$s - Le module %s a été désactivé étant donné qu\’aucune application n\’ai été sélectionné. - Cadre du sous-système - Sauvegarde - Sauvegarder - Restaurer - Forcer l\’arrêt - Forcer l\'arrêt ? - Si vous forcez l\'arrêt d\'une application, celle-ci pourrait mal fonctionner. - Un redémarrage est requis pour appliquer les changements - Redémarrer - Masquage - - Afficher dans une autre application - Informations d\’application - ¯\\\\_(ツ)_\/¯\nIl n\’y a rien ici - - Sous-système - Désactiver les journaux détaillés - Les journaux détaillés sont requis pour signaler des problèmes - Thème noir et sombre - Utiliser le thème noir pur si le thème noir est activé - Thème - Sauvegarder et restaurer - Sauvegarder la liste des modules ainsi que leurs champs d\'applications. - Restaurer la liste des modules ainsi que leurs champs d\'applications. - Sauvegarder - Échec de la sauvegarde :\n%s - Merci d\'activer le gestionnaire de fichiers - Restaurer - Échec de la restauration :\n%s - Réseau - DNS sur HTTPS - Contourner la censure DNS dans certains pays - Couleur du thème - Couleur d\'accentuation du système - Forcer les applications à afficher leurs icônes dans le lanceur - Après Android 10, les applications ne sont pas autorisées à masquer leurs icônes dans le lanceur. Désactiver ce commutateur pour désactiver cette fonctionnalité du système. - Système - Langage - Contributeurs de traduction - Participer à la traduction - Aidez-nous à traduire %s dans votre langue - Créer un raccourci qui peut ouvrir le gestionnaire de parasites - Raccourci épinglé - Le lanceur par défaut actuel ne supporte pas les raccourcis épinglés - Notification d\'état - Afficher une notification qui peut ouvrir le gestionnaire de parasites - Canal de mise à jour - Stable - Bêta - Alpha - - Lisez-moi - Versions - Infos - Page d\’accueil - Code source - Collaborateurs - Actifs - Ouvrir dans le navigateur - Afficher les anciennes versions - Pas d\’autres versions - Échec de chargement du dépôt des modules : %s - Évolutifs en premier - installée - - %d téléchargé - %d téléchargés - - - Sakura - Rouge - Rose - Violet - Violet foncé - Indigo - Bleu - Bleu clair - Cyan - Turquoise - Vert - Vert clair - Vert citron - Jaune - Ambre - Orange - Orange foncé - Marron - Bleu grisâtre -
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml deleted file mode 100644 index a8c7c5b28..000000000 --- a/app/src/main/res/values-hi/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - ओवरव्यू - मॉड्यूल्स - - %d मॉड्यूल एनेबल किए गए - %d मॉड्यूल्स एनेबल किए गए - - लॉग्स - सेटिंग्स - फीडबैक या सजेशन - इसके बारे में - इशू को रिपोर्ट करें - रिपोज़िटरी - सभी मॉड्यूल अप टू डेट - %s. पर प्रकाशित - %s. पर अपडेट किया गया - - %d मॉड्यूल अपग्रेड करने योग्य - %d मॉड्यूल अपग्रेड करने योग्य - - पर सोर्स कोड देखें हमारे %2$s चैनल से जुड़ें]]> - Ahmad Shaikh - स्थापित करना - LSPosed स्थापित करने के लिए टैप करें - स्थापित नहीं हे - LSPosed स्थापित नहीं है - सक्रिय - आंशिक रूप से सक्रिय - SEPolicy ठीक से लोड नहीं है - कृपया इसकी सूचना मैजिक डेवलपर को दें।]]> - सिस्टम फ्रेमवर्क इंजेक्शन विफल - Magisk या कुछ निम्न-गुणवत्ता वाले Magisk मॉड्यूल के कारण हो सकता है।
कृपया Riru और LSPosed के अलावा अन्य Magisk मॉड्यूल को अक्षम करने का प्रयास करें या डेवलपर्स को पूर्ण लॉग सबमिट करें।]]>
- सिस्टम प्रोप गलत - मॉड्यूल कभी-कभी अमान्य हो सकते हैं।]]> - अद्यतन करने की आवश्यकता है - कृपया LSPosed का नवीनतम संस्करण स्थापित करें - एपीआई संस्करण - फ्रेमवर्क संस्करण - प्रबंधक पैकेज का नाम - सिस्टम संस्करण - उपकरण - सिस्टम एबीआई - डेक्स ऑप्टिमाइज़र रैपर - सक्रिय - निष्क्रिय - समर्थित - असमर्थित - Android संस्करण असंतुष्ट - दुर्घटनाग्रस्त - माउंट विफल - SELinux अनुमेय है - SELinux नीति गलत है - अद्यतन LSPosed - LSPosed को अपडेट करने की पुष्टि करें? अपडेट पूरा होने के बाद यह डिवाइस रीबूट हो जाएगा - क्लिपबोर्ड पर नकल - - LSPosed में आपका स्वागत है - आप परजीवी प्रबंधक का उपयोग कर रहे हैं, जो शॉर्टकट बना सकता है या सूचना से अभी भी खुला हो सकता है। - आप परजीवी प्रबंधक का उपयोग कर रहे हैं, जो सूचना से खुल सकता है। - शॉर्टकट बनाएं - कभी भी न दिखाओ - परजीवी प्रबंधक की सिफारिश की - LSPosed अब पता लगाने से बचने के लिए सिस्टम परजीवीकरण का समर्थन करता है, आप अधिसूचना से परजीवी प्रबंधक खोल सकते हैं। वर्तमान एप्लिकेशन को अनइंस्टॉल करने की अनुशंसा की जाती है। - - बचाना - वर्बोज़ लॉग्स - मॉड्यूल लॉग - लॉग सहेजा जा रहा है, कृपया प्रतीक्षा करें - लॉग सेव हो गए - सहेजने में विफल:\n%s - अभी लॉग साफ़ करें - लॉग सफलतापूर्वक साफ़ किया गया। - शीर्ष तक स्क्रॉल करें - लोड हो रहा है… - नीचे स्क्रॉल करें - पुनः लोड करें - लॉग साफ़ करने में विफल - वर्ड रैप - वर्बोज़ लॉग सक्षम - वर्बोज़ लॉग अक्षम - - (कोई विवरण नहीं दिया गया) - इस मॉड्यूल को एक नए Xposed संस्करण (%d) की आवश्यकता है और इस प्रकार इसे सक्रिय नहीं किया जा सकता है - यह मॉड्यूल एक नए Xposed संस्करण (%d) के लिए डिज़ाइन किया गया है और इस प्रकार कुछ कार्यात्मकताएँ काम नहीं कर सकती हैं - यह मॉड्यूल Xposed संस्करण को निर्दिष्ट नहीं करता है जिसकी उसे आवश्यकता है। - यह मॉड्यूल Xposed संस्करण %1$dके लिए बनाया गया था, लेकिन संस्करण %2$dमें असंगत परिवर्तनों के कारण, इसे अक्षम कर दिया गया है - यह मॉड्यूल लोड नहीं किया जा सकता क्योंकि यह एसडी कार्ड पर स्थापित है, कृपया इसे आंतरिक भंडारण में ले जाएं - स्थापना रद्द करें - मॉड्यूल सेटिंग्स - रेपो में देखें - क्या आप इस मॉड्यूल को अनइंस्टॉल करना चाहते हैं? - अनइंस्टॉल किया गया %1$s - अनइंस्टॉल असफल - उपयोगकर्ता में मॉड्यूल जोड़ें - उपयोगकर्ता %2$sमें %1$s जोड़ा गया - मॉड्यूल जोड़ना विफल - उपयोगकर्ता को स्थापित करें %s - उपयोगकर्ता %2$sपर %1$s स्थापित करना चाहते हैं? मैन्युअल रूप से स्थापित करने की अनुशंसा की जाती है, LSPosed के माध्यम से स्थापना को मजबूर करने से समस्या हो सकती है। - विस्तार - ढहना - - पुन: अनुकूलित - अनुकूलन… - अनुकूलन पूर्ण - इसे लॉन्च करें - अनुकूलन विफल: वापसी मूल्य खाली है - अनुकूलन विफल: - आवेदन का नाम - पैकेज का नाम - समय स्थापित करें - समय सुधारें - उल्टा - सिस्टम ऐप्स - छंटाई - मॉड्यूल सक्षम करें - आपने कोई ऐप नहीं चुना है। जारी रखें? - खेल - मॉड्यूल - कार्यक्षेत्र सूची सहेजने में विफल - संस्करण: %1$s - अनुशंसित - आपने कोई ऐप नहीं चुना है। अनुशंसित ऐप्स चुनें? - अनुशंसित ऐप्स चुनें? - एक्सपोज़ड मॉड्यूल अभी तक सक्रिय नहीं है - अनुशंसित - अपडेट उपलब्ध: %1$s - मॉड्यूल %s को अक्षम कर दिया गया है क्योंकि कोई ऐप नहीं चुना गया है। - सिस्टम फ्रेमवर्क - बैकअप - बैकअप - पुनर्स्थापित करना - जबर्दस्ती बंद करें - जबर्दस्ती बंद करें? - यदि आप किसी ऐप को जबरदस्ती बंद करते हैं, तो वह गलत व्यवहार कर सकता है। - इस परिवर्तन को लागू करने के लिए रीबूट की आवश्यकता है - रीबूट - छिपाना - - अन्य ऐप में देखें - अनुप्रयोग की जानकारी - ¯\\\\_(ツ)_\/¯\nयहाँ कुछ भी नहीं - - रूपरेखा - वर्बोज़ लॉग अक्षम करें - रिपोर्ट वर्बोज़ लॉग शामिल करने का अनुरोध जारी करती है - ब्लैक डार्क थीम - यदि डार्क थीम सक्षम है तो शुद्ध काली थीम का उपयोग करें - थीम - बैकअप और पुनर्स्थापना - बैकअप मॉड्यूल सूची और कार्यक्षेत्र सूचियाँ। - मॉड्यूल सूची और कार्यक्षेत्र सूचियों को पुनर्स्थापित करें। - बैकअप - बैकअप में विफल:\n%s - कृपया DocumentUI सक्षम करें - पुनर्स्थापित करना - पुनर्स्थापित करने में विफल:\n%s - नेटवर्क - एचटीटीपीएस पर डीएनएस - कुछ देशों में DNS विषाक्तता का समाधान - थीम रंग - सिस्टम थीम रंग - लॉन्चर आइकन दिखाने के लिए ऐप्स को बाध्य करें - Android 10 के बाद, ऐप्स को अपने लॉन्चर आइकन छिपाने की अनुमति नहीं है। इस सिस्टम सुविधा को अक्षम करने के लिए टॉगल बंद करें। - प्रणाली - भाषा - अनुवाद योगदानकर्ता - अनुवाद में भाग लें - %s को अपनी भाषा में अनुवाद करने में हमारी सहायता करें - एक शॉर्टकट बनाएं जो परजीवी प्रबंधक खोल सके - शॉर्टकट पिन किया गया - वर्तमान डिफ़ॉल्ट लांचर पिन शॉर्टकट का समर्थन नहीं करता - स्थिति अधिसूचना - एक अधिसूचना दिखाएं जो परजीवी प्रबंधक खोल सकती है - चैनल अपडेट करें - स्थिर - बीटा - सॉफ़्टवेयर की स्थिरता - - रीडमी - विज्ञप्ति - जानकारी - होमपेज - सोर्स कोड - सहयोगियों - संपत्तियां - ब्राउज़र में खोलें - पुराने संस्करण दिखाएं - कोई और रिलीज नहीं - मॉड्यूल रेपो लोड करने में विफल: %s - पहले अपग्रेड करने योग्य - स्थापित - - %d डाउनलोड - %d डाउनलोड - - - सकुरा - लाल - गुलाबी - बैंगनी - गहरा बैंगनी - नील - नीला - हल्का नीला रंग - सियान - टील - हरा - हल्का हरा - नींबू - पीला - अंबर - संतरा - गहरा नारंगी - भूरा - नीला ग्रे -
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml deleted file mode 100644 index b22c54967..000000000 --- a/app/src/main/res/values-hr/strings.xml +++ /dev/null @@ -1,239 +0,0 @@ - - - - - Pregled - Moduli - - %d modul omogućen - %d modula omogućeno - %d modula omogućeno - - Zapisi - Postavke - Povratna informacija ili prijedlog - Informacije - Prijavi problem - Spremište modula - Svi moduli ažurirani - Objavljeno u %s - Ažurirano u %s - - %d modul moguće nadograditi - %d modula moguće nadograditi - %d modula moguće nadograditi - - Pridružite se našem %2$s kanalu]]> - https://github.com/cube2412 - Instaliraj - Dodirnite za instaliranje LSPosed - Nije instalirano - LSPosed nije instaliran - Aktiviran - Djelomično aktiviran - SEPolicy nije pravilno učitan - Prijavite ovo Magisk programeru.]]> - Injektiranje u System Framework nije uspjelo - Magisk ili nekim Magisk modulima niske kvalitete.
Pokušajte onemogućiti Magisk module koji nisu Riru i LSPosed ili pošaljite cijeli zapis programerima.]]>
- Svojstva sustava nisu ispravna - Moduli povremeno mogu biti nedostupni.]]> - Treba ažurirati - Molimo instalirajte najnoviju verziju LSPosed - API verzija - Framework verzija - Naziv paketa upravitelja - System verzija - Uređaj - System ABI - Dex Optimizer Wrapper - Omogućeno - Nije omogućeno - Podržano - Nepodržano - Verzija Androida nije zadovoljavajuća - Srušio se - Postavljanje nije uspjelo - SELinux je permisivan - Pravila SELinuxa je netočna - Ažurirajte LSPosed - Potvrditi ažuriranje LSPosed? Ovaj će se uređaj ponovno pokrenuti nakon završetka ažuriranja - Kopirano u međuspremnik - - Dobrodošli u LSPosed - Koristite parazitski upravitelj, koji može stvoriti prečac ili još uvijek otvoriti iz obavijesti. - Koristite parazitski upravitelj koji se može otvoriti iz obavijesti. - Napravi prečac - Nikad ne pokazuj - Parazitski Manager Preporučen - LSPosed sada podržava parazitizaciju sustava kako bi se izbjeglo otkrivanje, možete otvoriti parazitski upravitelj iz obavijesti. Preporuča se deinstalirati trenutnu aplikaciju. - - Sačuvaj - Opširni zapisi - Zapisi Modula - Spremanje dnevnika, pričekajte - Zapisi spremljeni - Neuspješno spremanje:\n%s - Očisti zapis sada - Zapis je uspješno izbrisan. - Pomaknite se na vrh - Učitavanje… - Pomaknite se do dna - Ponovno učitaj - Brisanje zapisa nije uspjelo - Prijelom riječi - Opširni zapis omogućen - Opširni zapis onemogućen - - (nema opisa) - Ovaj modul zahtijeva noviju verziju Xposed (%d) i stoga se ne može aktivirati - Ovaj modul je dizajniran za noviju verziju Xposed (%d) i stoga neke funkcije možda neće raditi - Ovaj modul ne navodi verziju Xposed koja mu je potrebna. - Ovaj modul je stvoren za Xposed verziju %1$d, zbog nekompatibilnih promjena u verziji %2$d, modul je onemogućen - Ovaj modul nije moguće učitati jer je instaliran na SD kartici, molimo premjestite ga u internu memoriju - Deinstaliraj - Postavke modula - Pogledaj u Repou - Želite li deinstalirati ovaj modul? - Deinstalirano %1$s - Deinstalacija nije uspjela - Dodavanje modula korisniku - Dodano %1$s korisniku %2$s - Dodavanje modula nije uspjelo - Instaliraj na korisnika %s - Želite li instalirati %1$s korisniku %2$s? Preporuča se ručna instalacija, prisilna instalacija putem LSPoseda može uzrokovati probleme. - proširi - sklopi - - Ponovno optimiziraj - Optimizacija… - Optimizacija dovršena - Pokreni ga - Optimizacija nije uspjela: povratna vrijednost je prazna - Optimizacija nije uspjela: - Naziv aplikacije - Naziv paketa - Vrijeme instalacije - Vrijeme ažuriranja - Obrnuto - Aplikacije sustava - Sortiranje - Omogući modul - Niste odabrali nijednu aplikaciju. Nastaviti? - Igre - Moduli - Spremanje popisa opsega primjene nije uspjelo - Verzija: %1$s - Preporučeno - Niste odabrali nijednu aplikaciju. Odaberi preporučene aplikacije? - Odaberi preporučene aplikacije? - Xposed modul još nije aktiviran - Preporučeno - Dostupno ažuriranje: %1$s - Modul %s je onemogućen jer nije odabrana nijedna aplikacija. - System Framework - Sigurnosna kopija - Sigurnosna kopija - Vrati sigurnosnu kopiju - Prisilno zaustavi - Prisilno zaustaviti? - Ako prisilno zaustavite aplikaciju, može doći do nepredvidivog ponašanja. - Za primjenu ove promjene potrebno je ponovno pokretanje - Ponovno podizanje sustava - Sakrij - - Pogledaj u drugoj aplikaciji - Informacije o aplikaciji - ¯\\\\_(ツ)_\/¯\nOvdje nema ničega - - Framework - Onemogući opširne zapise - Izvješće o problemima zahtijeva uključivanje opširnih zapisa - Crna tamna tema - Koristi čistu crnu temu ako je tamna tema omogućena - Tema - Sigurnosno kopiranje i vraćanje - Lista sigurnosnih kopija modula i opširne liste. - Lista modula vraćenih iz sigurnosne kopije i opsežne liste. - Sigurnosna kopija - Sigurnosno kopiranje nije uspjelo:\n%s - Molimo omogućite DocumentUI - Vrati - Nije uspjelo vraćanje:\n%s - Mreža - DNS preko HTTPS-a - Zaobilazno rješenje DNS trovanja u nekim zemljama - Boja teme - Boja teme sustava - Prisilite aplikacije da prikazuju ikone pokretača - Nakon Androida 10 aplikacijama nije dopušteno skrivanje ikona pokretača. Isključite prekidač da biste onemogućili ovu značajku sustava. - Sustav - Jezik - Suradnici prijevoda - Sudjelujte u prevođenju - Pomozite nam prevesti %s na vaš jezik - Napravite prečac koji može otvoriti parazitski upravitelj - Prečac prikvačen - Trenutačni zadani pokretač ne podržava prečace pribadače - Obavijest o statusu - Prikaži obavijest koja može otvoriti parazitski upravitelj - Ažurirajte kanal - Stabilan - Beta - Noćna izgradnja - - Pročitaj me - Izdanja - Info - Početna stranica - Izvorni kod - Suradnici - Imovina - Otvori u pretraživaču - Prikaži starije verzije - Nema više puštanja - Neuspješno učitavanje spremišta modula: %s - Prvo nadogradivo - instalirano - - %d preuzimanje - %d preuzimanja - %d preuzimanja - - - Sakura - Crvena - Ružičasta - Ljubičasta - Tamno ljubičasta - Indigo - Plava - Svijetlo plava - cijan - Teal - zelena - Svijetlo zelena - Vapno - Žuta boja - jantar - naranča - Tamno narančasta - Smeđa - Plavo siva -
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml deleted file mode 100644 index c70ee1d2a..000000000 --- a/app/src/main/res/values-hu/strings.xml +++ /dev/null @@ -1,237 +0,0 @@ - - - - - Áttekintés - Modulok - - %d modul aktiválva - %d modulok aktiválva - - Napló fájlok - Beállítások - Visszajelzés vagy javaslat - Névjegy - Hibabejelentés - Modulok - Minden modul naprakész - Közzétéve: %s - Frissítve: %s - - %d modul frissíthető - %d modul frissíthető - - Iratkozz fel a %2$s csatornánkra]]> - عبدو المكحل, Balázs Juhász, Krisztián Molnár - Telepítés - Koppints az LSPosed telepítéséhez - Nincs telepítve - Az LSPosed nincs telepítve - Aktiválva - Részben aktiválva - Az SEPolicy nem töltött be megfelelően - Kérjük, jelezze ezt a Magisk fejlesztőnek.]]> - A System Framework injektálása sikertelen - Magisk vagy néhány Magisk modul okozott.
Kérlek próbálj meg deaktiválni néhány Magisk modult a Riru és LSPosed modulokon kívül vagy küldj el egy teljes napló fájlt a fejlesztőknek.]]>
- Helytelen rendszertulajdonságok - A modulok időnként érvénytelenné válhatnak.]]> - Frissítésre van szükség - Kérjük, telepítse az LSPosed legújabb verzióját - API verzió - Keretrendszer verzió - Menedzser csomag neve - Rendszer verzió - Eszköz - Rendszer ABI - Dex Optimizer Wrapper - Engedélyezve - Nincs engedélyezve - Támogatott - Nem támogatott - Az Android verzió nem megfelelő - Összeomlott - A csatolás sikertelen - Az SELinux engedélyezett - Az SELinux házirend helytelen - Az LSPosed frissítése - Jóváhagyja az LSPosed frissítését? A készülék a frissítés befejezése után újra fog indulni - A vágólapra másolva - - Üdvözöljük az LSPosed - Ön használja a parazita menedzser, amely képes létrehozni parancsikont vagy még mindig nyitva értesítésből. - Ön a parazita-kezelőt használja, amely az értesítésből megnyitható. - Parancsikon létrehozása - Soha ne mutassa - Parazita menedzser Ajánlott - Az LSPosed mostantól támogatja a rendszerparazitizációt a felismerés elkerülése érdekében, a parazita-kezelőt az értesítésből nyithatja meg. Javasoljuk, hogy távolítsa el az aktuális alkalmazást. - - Mentés - Szöveges naplók - Modulok Naplói - Napló mentése, kérem várjon - Mentett naplók - Nem sikerült menteni:\n%s - Törölje a naplót most - A napló sikeresen törlődött. - Görgessen a tetejére - Betöltés… - Görgessen az aljára - Újratöltés - Nem sikerült törölni a naplót - Word Wrap - Szöveges napló engedélyezve - Szöveges napló letiltva - - (nincs leírás megadva) - Ez a modul egy újabb Xposed verziót igényel (%d), ezért nem aktiválható - Ez a modul egy újabb Xposed verzióhoz készült (%d), ezért előfordulhat, hogy egyes funkciók nem működnek - Ez a modul nem határoz meg szükséges Xposed verziót. - Ez a modul az Xposed %1$dverziójához készült, de a %2$dverzióban bekövetkezett inkompatibilis változások miatt letiltásra került. - Ez a modul nem tölthető be, mert az SD-kártyára van telepítve, kérjük, helyezze át a belső tárhelyre. - Eltávolítás - Modul beállítások - Megtekintés a Repóban - Szeretné eltávolítani ezt a modult? - %1$s eltávolítva - Az eltávolítás sikertelen - Modul hozzáadása a felhasználóhoz - %1$s hozzáadva a(z) %2$s felhasználóhoz - A modul hozzáadása sikertelen - Telepítés a felhasználóhoz %s - Szeretné telepíteni a %1$s -t a %2$sfelhasználóhoz ? Javasoljuk a manuális telepítést, az LSPosed-en keresztül történő kényszerített telepítés problémákat okozhat. - kiterjesztés - összecsukás - - Újraoptimalizálás - Optimalizálás… - Az optimalizálás befejezve - Indítsd el - Optimalizálás sikertelen: a visszatérési érték üres - Az optimalizálás nem sikerült: - Alkalmazás neve - Csomag neve - Telepítés ideje - Frissítés ideje - Fordított - Rendszeralkalmazások - Rendezés - Modul engedélyezése - Nem választott ki egyetlen alkalmazást sem. Folytatja? - Játékok - Modulok - Nem sikerült elmenteni a hatókör listát - Verzió: %1$s - Ajánlott - Nem választott ki egyetlen alkalmazást sem. Kiválasztja az ajánlott alkalmazásokat? - Kiválasztja az ajánlott alkalmazásokat? - Az Xposed modul még nincs aktiválva - Ajánlott - Frissítés elérhető: %1$s - A %s modul le lett tiltva, mivel nincs kiválasztott alkalmazás. - Rendszer keretrendszer - Biztonsági mentés - Biztonsági mentés - Visszaállítás - Erőszakos megállás - Erőszakos megállás? - Ha egy alkalmazást erőltetett leállítással állít le, az rosszul viselkedhet. - A módosítás érvényesítéséhez újraindítás szükséges - Újraindítás - Rejtsd el - - Megtekintés más alkalmazásban - Alkalmazás információ - ¯\\\\_(ツ)_\/¯\nItt nincs semmi - - Keretrendszer - A szöveges naplózás kikapcsolása - Jelentési kérdések kérése a verbózus naplók felvételére - Fekete sötét téma - Teljesen fekete téma használata, ha a sötét téma engedélyezve van - Téma - Biztonsági mentés és visszaállítás - A modul lista és hatókör listák biztonsági mentése. - A modullista és a hatókörlisták visszaállítása. - Biztonsági mentés - A biztonsági mentés sikertelen:\n%s - Kérjük, engedélyezze a DocumentUI-t - Visszaállítás - Nem sikerült visszaállítani:\n%s - Hálózat - DNS HTTPS-en keresztül - Megoldás DNS-mérgezés esetén egyes országokban - Téma színe - Rendszertéma színe - Az alkalmazások kényszerítése az indító ikonok megjelenítésére - Az Android 10 után az alkalmazások nem rejthetik el az indítóikonjaikat. Kapcsolja ki a kapcsolót ennek a rendszerfunkciónak a letiltásához. - Rendszer - Nyelv - A fordításban közreműködők - Részvétel a fordításban - Segítsen nekünk lefordítani az %s -t az Ön nyelvére - Hozzon létre egy parancsikont, amely képes megnyitni a parazita menedzser - Parancsikon kitüzve - A jelenlegi alapértelmezett indítóprogram nem támogatja a pin parancsikonokat - Állapot értesítés - Értesítés megjelenítése, amely megnyithatja a parazita-kezelőt - Frissítési csatorna - Stabil - Béta - Nightly - - Olvass el - Kiadások - Információ - Honlap - Forráskód - Együttműködők - Eszközök - Megnyitás a böngészőben - Régebbi verziók megjelenítése - Nincs több kiadás - Nem sikerült betölteni a modul repo-t: %s - Frissíthetőek először - Telepítve - - %d letöltés - %d letöltések - - - Sakura - Piros - Rózsaszín - Lila - Mély lila - Indigo - Kék - Világoskék - Cián - Zöldeskék - Zöld - Világoszöld - Lime - Sárga - Borostyán - Narancs - Mély narancssárga - Barna - Kékes szürke -
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml deleted file mode 100644 index 204ce2c59..000000000 --- a/app/src/main/res/values-in/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Ringkasan - Modul - - %d modul diaktifkan - - Log - Pengaturan - Umpan balik atau saran - Tentang - Laporkan masalah - Gudang - Semua modul sudah terbaru - Diterbitkan pada %s - Diperbarui pada %s - - %d modul dapat ditingkatkan - - Gabung dengan kami di saluran %2$s]]> - pɹɐɥllıʇS - Pasang - Ketuk untuk memasang LSPosed - Tidak terpasang - LSPosed tidak terpasang - Diaktifkan - Diaktifkan sebagian - SEPolicy tidak dimuat dengan benar - Harap laporkan ini ke pengembang Magisk.]]> - Injeksi Kerangka Sistem gagal - Magisk atau beberapa modul Magisk berkualitas rendah.
Coba nonaktifkan modul Magisk selain Riru dan LSPosed atau kirimkan log lengkap ke pengembang.]]>
- Prop sistem salah - Modul terkadang tidak valid.]]> - Butuh pembaruan - Silakan pasang LSPosed versi terbaru - Tips untuk pengembang modul - Harap nonaktifkan pengoptimalan penerapan di Android Studio, atau gunakan perintah `gradlew installDebug` untuk menginstal. Jika tidak, apk modul tidak akan diperbarui. - Versi API - Versi framework - Nama paket manajer - Versi sistem - Perangkat - Sistem ABI - Pengoptimal Pengemas Dex - Diaktifkan - Tidak diaktifkan - Didukung - Tidak didukung - Versi Android tidak tersedia - Rusak - Mount gagal - SELinux permisif - SELinux policy salah - Perbarui LSPosed - Konfirmasi untuk pembaruan LSPosed? Perangkat ini akan mulai ulang setelah pembaruan selesai - Disalin ke papan klip - - Selamat datang di LSPosed - Anda menggunakan manajer parasit, yang dapat membuat pintasan atau dapat terbuka dari notifikasi. - Anda menggunakan manajer parasit, yang dapat dibuka dari notifikasi. - Buat pintasan - Jangan pernah tampilkan - Manajer Parasit Direkomendasikan - LSPosed sekarang mendukung parasitisasi sistem untuk menghindari deteksi, Anda dapat membuka manajer parasit dari pemberitahuan. Disarankan untuk menghapus aplikasi saat ini. - - Simpan - Log Verbose - Log Modul - Menyimpan log, harap tunggu - Log disimpan - Gagal menyimpan:\n%s - Hapus log sekarang - Log berhasil dihapus. - Gulir ke atas - Memuat… - Gulir ke bawah - Muat ulang - Gagal menghapus log - Bungkus Kata - Log verbose diaktifkan - Log verbose dinonaktifkan - - (tidak ada deskripsi yang diberikan) - Modul ini memerlukan versi Xposed yang lebih baru (%d) sehingga tidak dapat diaktifkan - Modul ini dirancang untuk versi Xposed yang lebih baru (%d) sehingga beberapa fungsi mungkin tidak berfungsi - Modul ini tidak menentukan versi Xposed yang diperlukan. - Modul ini dibuat untuk Xposed versi %1$d, tetapi karena perubahan yang tidak kompatibel di versi %2$d, modul ini telah dinonaktifkanModul ini dibuat untuk Xposed versi %1$d, tetapi karena perubahan yang tidak kompatibel di versi %2$d, modul ini telah dinonaktifkan - Modul ini tidak dapat dimuat karena terpasang di kartu SD, harap pindahkan ke penyimpanan internal - Copot - Pengaturan modul - Lihat di Repo - Apakah Anda ingin mencopot modul ini? - Tercopot %1$s - Copot pemasangan tidak berhasil - Tambahkan modul ke pengguna - Ditambahkan %1$s ke pengguna %2$s - Gagal menambahkan modul - Pasang ke pengguna %s - Ingin memasang %1$s ke pengguna %2$s? Disarankan untuk memasang secara manual, memaksa pemasangan melalui LSPosed dapat menyebabkan masalah. - perluas - ciutkan - - Mengoptimalkan ulang - Mengoptimalkan… - Optimalisasi selesai - Luncurkan - Optimalisasi gagal: nilai yang dihasilkan kosong - Optimalisasi gagal: - Nama aplikasi - Nama paket - Waktu pemasangan - Waktu pembaruan - Terbalik - Aplikasi sistem - Penyortiran - Aktifkan modul - Anda tidak memilih aplikasi apa pun. Lanjutkan? - Permainan - Modul - Gagal menyimpan ke daftar cakupan - Versi: %1$s - Direkomendasikan - Anda tidak memilih aplikasi apapun. Pilih aplikasi yang disarankan? - Pilih aplikasi yang disarankan? - Modul Xposed belum diaktifkan - Direkomendasikan - Pembaruan tersedia: %1$s - Modul %s telah dinonaktifkan karena tidak ada aplikasi yang dipilih. - Kerangka kerja sistem - Cadangan - Cadangkan - Pulihkan - Paksa berhenti - Paksa berhenti? - Jika Anda menghentikan paksa aplikasi, mungkin dapat bekerja tidak semestinya. - Mulai ulang diperlukan agar perubahan ini dapat diterapkan - Mulai ulang - Sembunyikan - - Lihat di aplikasi lain - Informasi aplikasi - ¯\\\\_(ツ)_\/¯\nTidak ada apa-apa di sini - - Kerangka kerja - Nonaktifkan log verbose - Permintaan laporan masalah dengan menyertakan log-log verbose - Tema hitam gelap - Gunakan tema hitam murni jika tema gelap diaktifkan - Tema - Cadangkan dan pulihkan - Cadangkan daftar modul dan daftar cakupan. - Pulihkan daftar modul dan daftar cakupan. - Cadangkan - Gagal mencadangkan:\n%s - Harap aktifkan DocumentUI - Pulihkan - Gagal memulihkan:\n%s - Jaringan - DNS melalui HTTPS - Solusi mengatasi masalah DNS di beberapa negara - Warna tema - Warna tema sistem - Paksa aplikasi untuk menampilkan ikon peluncur - Setelah Android 10, aplikasi tidak diizinkan menyembunyikan ikon peluncurnya. Matikan untuk menonaktifkan fitur sistem ini. - Sistem - Bahasa - Kontributor terjemahan - Berpartisipasi dalam terjemahan - Bantu kami menerjemahkan %s ke dalam bahasamu - Buat pintasan yang dapat membuka manajer parasit - Pintasan disematkan - Peluncur default saat ini tidak mendukung pintasan pin - Notifikasi Status - Tampilkan notifikasi yang dapat membuka manajer parasit - Perbarui saluran - Stabil - Beta - Rilis harian - - Baca aku - Rilis - Informasi - Beranda - Kode sumber - Kolaborator - Aset - Buka di browser - Tampilkan versi lama - Tidak ada rilis - Gagal memuat repo modul: %s - Dapat diupgrade terlebih dahulu - Terpasang - - %d unduh -%d unduhan - - - Sakura - Merah - Merah muda - Ungu - Ungu gelap - Biru gelap - Biru - Biru muda - Cyan - Hijau toska - Hijau - Hijau muda - Hijau limau - Kuning - Kuning madu - Jingga - Jingga gelap - Coklat - Abu-abu kebiruan -
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml deleted file mode 100644 index b1032c154..000000000 --- a/app/src/main/res/values-it/strings.xml +++ /dev/null @@ -1,238 +0,0 @@ - - - - - Panoramica - Moduli - - %d modulo abilitato - %d moduli abilitati - - Log - Impostazioni - Feedback o suggerimenti - Informazioni - Segnala il problema - Repository - Tutti i moduli sono aggiornati - Pubblicato alle %s - Aggiornato alle %s - - %d modulo aggiornabile - %d moduli aggiornabili - - Unisciti al nostro canale %2$s]]> - alex193a, Fs00, xDonatello - Installa - Tocca per installare LSPosed - Non installato - LSPosed non è installato - Attivo - Parzialmente attivo - SEPolicy non è caricato correttamente - Segnalalo allo sviluppatore di Magisk.]]> - Injection del framework di sistema fallita - Questo problema si verifica raramente e può essere causato da Magisk o da alcuni moduli Magisk di scarsa qualità.
Prova a disabilitare i moduli Magisk tranne Riru e LSPosed o invia il log completo agli sviluppatori.]]>
- Proprietà di sistema errate - In alcuni casi i moduli potrebbero non funzionare.]]> - Aggiornamento richiesto - Installa la versione più recente di LSPosed - Suggerimenti per lo sviluppatore del modulo - Disattivare le ottimizzazioni di distribuzione su Android Studio, o utilizzare il comando `gradlew installDebug` per eseguire l\'installazione. Altrimenti l\'apk del modulo non verrà aggiornato. - Versione API - Versione del framework - Nome pacchetto del manager - Versione del sistema - Dispositivo - ABI del sistema - Dex Optimizer Wrapper - Abilitato - Non abilitato - Supportato - Non supportato - Versione di Android non soddisfatta - Arrestato in modo anomalo - Mount fallito - SELinux è in modalità permissiva - La politica di SELinux non è corretta - Aggiorna LSPosed - Confermi di voler aggiornare LSPosed? Il dispositivo verrà riavviato dopo il completamento dell\'aggiornamento - Copiato negli appunti - - Benvenuto in LSPosed - Stai usando il manager parassitario, che può creare scorciatoie o essere aperto dalla notifica. - Stai usando il manager parassitario, che può essere aperto dalla notifica. - Crea scorciatoia - Non mostrare mai - Manager parassitario consigliato - LSPosed ora supporta la parassitazione del sistema per evitarne il rilevamento, è possibile aprire il manager parassitario dalla notifica. Si consiglia di disinstallare l\'applicazione attuale. - - Salva - Log verbosi - Log dei moduli - Salvataggio log, attendere - Log salvati - Salvataggio non riuscito:\n%s - Cancella il log ora - Log cancellato con successo. - Scorri in alto - Caricamento in corso… - Scorri in basso - Ricarica - Impossibile cancellare il log - A capo automatico - Log verboso abilitato - Log verboso disabilitato - - (nessuna descrizione fornita) - Questo modulo richiede una versione più recente di Xposed (%d) e quindi non può essere attivato - Questo modulo è progettato per una versione più recente di Xposed (%d) e quindi alcune funzionalità potrebbero non funzionare - Questo modulo non specifica la versione Xposed necessaria. - Questo modulo è stato creato per la versione %1$d di Xposed ma, a causa di modifiche incompatibili nella versione %2$d, è stato disabilitato - Questo modulo non può essere caricato perché è installato sulla scheda SD, spostalo nella memoria interna - Disinstalla - Impostazioni modulo - Visualizza nel repository - Vuoi disinstallare questo modulo? - %1$s disinstallato - Disinstallazione non riuscita - Aggiungi modulo all\'utente - %1$s aggiunto all\'utente %2$s - Aggiunta del modulo fallita - Installa per l\'utente %s - Vuoi installare %1$s per l\'utente %2$s? Si consiglia di farlo manualmente, forzare l\'installazione da LSPosed potrebbe causare problemi. - espandi - comprimi - - Ri-ottimizza - Ottimizzazione in corso… - Ottimizzazione completata - Avvia - Ottimizzazione non riuscita: il valore restituito è vuoto - Ottimizzazione fallita: - Nome dell\'applicazione - Nome del pacchetto - Data di installazione - Data di aggiornamento - Inverso - Applicazioni di sistema - Ordina - Abilita modulo - Non hai selezionato nessuna app. Continuare? - Giochi - Moduli - Impossibile salvare l\'elenco delle attivazioni - Versione: %1$s - Seleziona consigliate - Non hai selezionato nessuna app. Selezionare le app consigliate? - Selezionare le app consigliate? - Il modulo Xposed non è ancora attivo - Seleziona consigliate - Aggiornamento disponibile: %1$s - Il modulo %s è stato disabilitato poiché nessuna app è stata selezionata. - Framework di sistema - Backup - Backup - Ripristina - Forza l\'arresto - Forzare l\'arresto? - Se forzi l\'interruzione di un\'app, potrebbe non funzionare correttamente. - È necessario riavviare per applicare questa modifica - Riavvia - Nascondi - - Mostra in un\'altra app - Informazioni app - ¯\\\\_(ツ)_\\/¯\nNon c\'è nulla qui - - Framework - Disabilita il log verboso - La segnalazione di problemi richiede l\'inclusione di log dettagliati - Tema nero scuro - Usa il tema nero puro quando è abilitato il tema scuro - Tema - Backup e ripristino - Backup dell\'elenco dei moduli e delle attivazioni. - Ripristino dell\'elenco dei moduli e delle attivazioni. - Backup - Salvataggio non riuscito:\n%s - Abilitare DocumentUI - Ripristina - Ripristino fallito:\n%s - Rete - DNS over HTTPS - Aggira l\'avvelenamento della cache DNS in alcune nazioni - Colore del tema - Colore del tema del sistema - Forza le app a mostrare le icone nel launcher - A partire da Android 10, le app non possono più nascondere le loro icone nel launcher. Disabilita l\'opzione per disattivare questa funzionalità. - Sistema - Lingua - Contributori alla traduzione - Partecipa alla traduzione - Aiutaci a tradurre %s nella tua lingua - Crea una scorciatoia che può aprire il manager parassitario - Scorciatoia fissata - L\'attuale launcher predefinito non supporta le scorciatoie con i pin - Notifica di stato - Mostra una notifica che può aprire il manager parassitario - Canale di aggiornamento - Stabile - Beta - Nightly - - Leggimi - Versioni - Informazioni - Pagina web - Codice sorgente - Collaboratori - Risorse - Apri nel browser - Mostra le versioni precedenti - Non ci sono altre versioni - Impossibile caricare il repository dei moduli: %s - Aggiornabili prima - Installato - - %d download - %d downloads - - - Sakura - Rosso - Rosa - Viola - Viola scuro - Indaco - Blu - Azzurro - Ciano - Verde acqua - Verde - Verde chiaro - Lime - Giallo - Ambra - Arancione - Arancione scuro - Marrone - Blu grigio -
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml deleted file mode 100644 index af8ff4d51..000000000 --- a/app/src/main/res/values-iw/strings.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - סקירה כללית - מודולים - - %d מודול מופעל - %d מודולים מופעלים - %d מודולים מופעלים - %d מודולים מופעלים - - לוגים - הגדרות - משוב או הצעה - אודות - דווח על בעיה - מאגר מידע - כל המודולים מעודכנים - פורסם ב-%s - עודכן ב-%s - - %d מודולים ניתנים לשדרוג - %d מודולים ניתנים לשדרוג - %d מודולים ניתנים לשדרוג - %d מודולים ניתנים לשדרוג - - הצטרף לערוץ %2$s שלנו]]> - ריק - התקנה - הקש כדי להתקין את LSPosed - לא מותקן - LSPosed אינו מותקן - הופעל - הופעל חלקית - SEPolicy לא נטען כהלכה - נא לדווח על כך למפתחי Magisk .]]> - הזרקת תשתית המערכת נכשלה - Magisk או מודולי Magisk באיכות נמוכה.
אנא נסה להשבית מודולי Magisk שאינם Riru ו-LSPosed או שלח דיווח לוג מלא למפתחים.]]> - מאפיין המערכת שגוי - המודולים עשויים להתבטל מדי פעם.]]> - צריך לעדכן - נא להתקין את הגרסה העדכנית ביותר של LSPosed - גרסת API - גרסת תשתית - שם חבילת מנהל - גרסת מערכת - מכשיר - מערכת ABI - עטיפה של Dex Optimizer - מופעל - לא מופעל - נתמך - לא נתמך - גרסת אנדרואיד לא מספקת - התרסק - ה-mount נכשל - מדיניות SELinux הינה מתירנית - מדיניות SELinux הינה שגויה - עדכן LSPosed - אשר לעדכן את LSPosed? מכשיר זה יאתחל לאחר השלמת העדכון - הועתק ללוח - - ברוכים הבאים ל-LSPosed - הנך משתמש בגרסת אפליקציית ניהול בתצורת טפיל, שיכולה ליצור קיצור דרך או עדיין להיפתח מהתראה. - הנך משתמש בגרסת אפליקציית ניהול בתצורת טפיל, שיכולה להיפתח מהתראה. - צור קיצור דרך - לעולם אל תראה - מומלצת גרסת אפליקציית ניהול בתצורת טפיל - LSPosed תומך כעת בתצורת טפיל כדי למנוע זיהוי, ניתן לפתוח את גרסת אפליקציית הניהול בתצורת טפיל מהתראה. מומלץ להסיר את האפליקציה הנוכחית. - - שמור - לוגים מפורטים - לוגים של מודולים - שומר יומן, אנא המתן - לוגים נשמרו - השמירה נכשלה:\n%s - נקה לוגים עכשיו - לוגים נוקו בהצלחה. - גלול למעלה - טוען… - גלול למטה - רענן - נכשל בניקוי הלוגים - עטיפת מילה - לוגים מפורטים מופעלים - לוגים מפורטים מושבתים - - (לא מסופק תיאור) - מודול זה דורש גרסה חדשה יותר של LSPosed (%d) ולכן לא יכול להיות מופעל - מודול זה מיועד לגרסה חדשה יותר של Xposed (%d) ולכן ייתכן שחלק מהפונקציונליות לא תופעל כהלכה - מודול זה לא מציין את גסרת ה- LSPosed שהוא צריך. - מודול זה נוצר בשביל LSPosed גרסה %1$d, אך בשל חוסר תאימות לאחור בגרסה %2$d, הוא הופסק - מודול זה לא יכול להיטען מכיוון שהוא מותקן על כרטיס ה-SD, אנא העבר אותו לאחסון פנימי - הסר התקנה - הגדרות מודול - הצג ברפוסיטורי - האם אתה בטוח שאתה רוצב להסיר את התנקת המודול? - %1$s הוסר - הסרת ההתקנה הושלמה בהצלחה - הוסף מודל למשתמש - נוסף %1$s למשתמש %2$s - הוספת המודל נכשלה - התקן למשתמש %s - רוצה להתקין %1$s למשתמש %2$s? מומלץ להתקין באופן ידני, כפיית התקנה באמצעות LSPosed עלולה לגרום לבעיות. - להרחיב - התמוטטות - - מטב מחדש - ממטב… - אופטימיזציה הושלמה. - הרץ - אופטימיזציה נכשלה או שהערך המוחזר הוא ריק. - אופטימיזציה נכשלה: - מיין על פי שם האפליקציה - מיין על פי שם החבילה - מיין על פי זמן ההתקנה - מיין על פי זמן העדכון - להפוך - אפליקציות מערכת - ממיין - הפעל מודול - אתה לא בחרת שום אפליקציה. להמשיך? - משחקים - מודולים - נכשל לשמור רשימת תחומים - גרסה: %1$s - מומלץ - אתה לא בחרת שום אפליקציה. לבחור אפליקציות מומלצות? - בחר אפליקציות מומלצות? - מודול LSPosed עדיין לא הופעל - מומלץ - עדכון זמין: %1$s - מודול %s בוטל מכיוון שלא נבחרה שום אפליקציה. - מערכת Framework - מגבה - גיבוי - שחזור - אלץ עצירה - אצץ עצירה? - אם אתה תאלץ עצירה לאפליקציה, היא עלולה להתנהג בצורה לא רצויה. - הפעלה מחדש דרושה בכדי שהשינויים יכנסו לתוקף - הפעל מחדש - הסתר - - הצג באפליקציה אחרת - מידע על האפליקציה - ¯\\\\_(ツ)_\/¯\n אין פה כלום - - Framework - בטל verbose logs - בקשת דיווח על בעיות לכלול יומנים מילוליים - ערכת נושא שחור כהה - השתמש בערכת נושא שחור טהור אם ערכת נושא כהה מופעלת - ערכת נושא - גיבוי ושחזור - גיבוי רשימת מודולים ורשימות היקף. - שחזר את רשימת המודולים ורשימות ההיקף. - גיבוי - גיבוי נכשל:\n%s - אנא הפעל את DocumentUI - שחזור - השחזור נכשל::\n%s - רשת - DNS על פני HTTPS - מעקף הרעלת DNS במדינות מסוימות - צבע ערכת נושא - צבע נושא המערכת - כפה על אפליקציות להציג סמלי מפעיל - לאחר Android 10, אפליקציות אינן מורשות להסתיר את סמלי המשגר שלהן. בטל את הלחצן הדו-מצבי כדי להפוך תכונת מערכת זו ללא זמינה. - מערכת - שפה - תורמים לתרגום - השתתף בתרגום - עזור לנו לתרגם את %s לשפה שלך - צור קיצור דרך שיכול לפתוח מנהל טפילי - קיצור דרך הוצמד - מפעיל ברירת המחדל הנוכחי אינו תומך בקיצורי דרך - הודעת סטטוס - הצג הודעה שיכולה לפתוח מנהל טפילי - ערוץ עדכון - יציב - בטא - בניה לילית - - קרא אותי - גרסאות - מידע - דף בית - קוד מקור - מפתחים - קבצים - פתח בדפדפן - הראה גרסאות ישנות - אין עוד גרסאות - נשכל לטעון מודול: %s - ניתן לשדרוג תחילה - מוּתקָן - - %d הורדה - %d הורדות - %d הורדות - %d הורדות - - - סאקורה - אדום - ורוד - סגול - סגול עמוק - אינדיגו - כחול - כחול בהיר - טורקיז - ירוק כחלחל - ירוק - ירוק בהיר - לימון - צהוב - ענבר - כתום - כתום עמוק - חום - כחול אפור -
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml deleted file mode 100644 index 6a1a99fc7..000000000 --- a/app/src/main/res/values-ja/strings.xml +++ /dev/null @@ -1,239 +0,0 @@ - - - - - 概要 - モジュール - - %d 個のモジュールが有効です - - ログ - 設定 - フィードバックまたは提案 - このアプリについて - 問題を報告 - リポジトリ - 全てのモジュールが最新です - 公開日時: %s - 更新日時: %s - - %d 個のモジュールが更新可能です - - チャンネルに参加するにはこちら: %2$s]]> - yoshi818, a1678991, りお(koko0628) - インストール - タップして LSPosed をインストールします - インストールされていません - LSPosed はインストールされていません - 有効化済み - 部分的に有効化済み - SEPolicy が正しく読み込まれていません - これをMagiskの開発者に報告してください。]]> - システムフレームワークへのパッチに失敗しました - Magiskまたは低品質のMagiskモジュールが原因である可能性があります。
LSPosed以外のMagiskモジュールを一時的に無効化してみるか、完全なログを開発者に送信してください。]]>
- システム設定が正しくありません - モジュールが無効化される場合があります。]]> - 更新が必要です - 最新バージョンの LSPosed をインストールして下さい - モジュール開発者向けのヒント - Android Studio でデプロイの最適化を無効にするか、「gradlew installDebug」コマンドを使用してインストールしてください。この操作を行わないとモジュールの apk は更新できません。 - API バージョン - フレームワークバージョン - マネージャーパッケージ名 - システムバージョン - デバイス - システム ABI - Dex 最適化ラッパー - 有効 - 無効 - 対応 - 非対応 - Android のバージョンが適合しません - クラッシュしました - マウントに失敗しました - SELinux は Permissive です - SELinux のポリシーが正しくありません - LSPosed を更新 - LSPosed を更新してもよろしいですか?更新の完了後、このデバイスは再起動します - クリップボードにコピーされました - - LSPosed へようこそ - パラサイトマネージャーを使用しています。これにより、ショートカットを作成したり、通知から開いたりできます。 - 通知から開くことができるパラサイトマネージャーを使用しています。 - ショートカットを作成 - 再度表示しない - パラサイトマネージャーを使用することを推奨します - LSPosed は検出を回避するためのシステムパラサイトに対応し、通知からパラサイトマネージャーを開くことができるようになりました。現在のアプリケーションをアンインストールすることをおすすめします。 - - 保存 - 詳細ログ - モジュールログ - ログを保存しています。お待ちください - ログを保存しました - 保存に失敗しました:\n%s - ログを消去 - ログの消去に成功しました。 - ログの先頭行にスクロール - 読み込み中… - 一番下までスクロール - 再読み込み - ログを消去できませんでした - 単語を折り返す - 詳細ログは有効です - 詳細ログは無効です - - (説明はありません) - このモジュールは新しいバージョンの Xposed (%d) が必要なので有効化できません - このモジュールは新しい Xposed バージョン (%d) 用に設計されているため、一部の機能が動作しない可能性があります - このモジュールは必要な Xposed のバージョンを指定していません。 - このモジュールは Xposed バージョン %1$d 用に作成されましたが、バージョン %2$dでの互換性のない変更により無効化されました。 - このモジュールは SD カードにインストールされているため読み込むことができません。内部ストレージに移動してください。 - アンインストール - モジュール設定 - リポジトリで表示 - このモジュールをアンインストールしますか? - 「%1$s」をアンインインストールしました - アンインストールに成功 - ユーザへモジュールを追加 - %1$s をユーザー %2$s へ追加 - モジュールの追加に失敗しました - ユーザー %s へインストール - ユーザー %2$s へ %1$s をインストールしますか?LSPosed 経由での強制インストールは問題が発生する場合があるため、手動でインストールすることをおすすめします。 - 開く - 閉じる - - 再最適化 - 最適化中… - 最適化が完了しました - 起動 - 最適化に失敗しました: 戻り値が空です - 最適化に失敗しました: - アプリ名 - パッケージ名 - インストール日時 - 更新日時 - 逆順 - システムアプリ - 並び順 - モジュールを有効化 - アプリが選択されていません。よろしいですか? - ゲーム - モジュール - スコープリストの保存に失敗しました - バージョン: %1$s - 選択 - 推奨 - アプリが選択されていません。おすすめのアプリを選択しますか? - おすすめのアプリを選択しますか? - すべて - なし - 自動的に含む - Xposed モジュールが有効化されていません - 推奨 - アップデートが利用可能です: %1$s - アプリが選択されていないため、モジュール「%s」は無効になっています。 - システムフレームワーク - バックアップ - バックアップ - 復元 - 強制停止 - 強制停止しますか? - アプリを強制停止すると、アプリが動作しなくなる可能性があります。 - この設定を適用するには再起動が必要です - 再起動 - 非表示 - - 他のアプリで表示 - アプリの情報 - ¯\\_(ツ)_\/¯\nリストは空です - - フレームワーク - 詳細ログの無効化 - 問題を報告する際は、詳細なログを含めるようにしてください - 黒のダークテーマ - ダークテーマが有効になっている場合は、ピュアブラックテーマを使用します - テーマ - バックアップと復元 - モジュールリストとスコープリストをバックアップします - モジュールリストとスコープリストを復元します - バックアップ - バックアップに失敗しました:\n%s - DocumentUI を有効にしてください - 復元 - 復元に失敗しました:\n%s - ネットワーク - DNS over HTTPS - 一部の国向けの DNS ポイズニング回避策 - テーマカラー - システムテーマカラー - ランチャーアイコンを強制的に表示 - Android 10 以降、アプリはランチャーアイコンを隠すことができなくなりました。このシステム機能を無効にするには、トグルをオフにしてください。 - システム - 言語 - 翻訳貢献者 - 翻訳に貢献 - %s の翻訳にご協力ください - パラサイトマネージャーを開くことができるショートカットを作成します - ショートカットのピン留め - 現在のデフォルトランチャーはショートカットのピン留めをサポートしていません - ステータス通知 - パラサイトマネージャーを開くことができる通知を表示します - 更新チャンネル - 安定版 - ベータ版 - ナイトリービルド - - Readme - リリース - 情報 - ホームページ - ソースコード - 協力者 - アセット - ブラウザで表示 - 以前のバージョンを表示 - これ以上のリリースはありません - モジュールリポジトリの読み込みに失敗しました: %s - 更新があるモジュールを先頭に表示 - インストール済み - - %d 件のダウンロード - - - 桜色 - - ピンク - - 深紫色 - 藍色 - - 水色 - シアン - 青緑 - - ライトグリーン - ライム - 黄色 - アンバー - オレンジ色 - ディープオレンジ - 茶色 - ブルーグレー - diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml deleted file mode 100644 index 241e44cca..000000000 --- a/app/src/main/res/values-ko/strings.xml +++ /dev/null @@ -1,235 +0,0 @@ - - - - - 개요 - 모듈 - - 모듈 %d개가 활성화됨 - - 로그 - 설정 - 피드백 혹은 제안 - 정보 - 문제 보고 - 저장소 - 모든 모듈이 최신 버전입니다 - %s 에 배포됨 - %s에 업데이트됨 - - %d개의 모듈이 업데이트 가능합니다 - - %2$s 채널에 가입하세요]]> - green1052 - 설치 - LSPosed를 설치하려면 누르세요 - 설치되지 않음 - LSPosed가 설치되지 않았습니다 - 활성화됨 - 부분적으로 활성화됨 - SEPolicy가 제대로 로드되지 않았습니다 - 오류를 Magisk 개발자에게 신고해주세요]]> - System Framework 삽입 실패 - Magisk 또는 일부 Magisk 모듈 때문일 수 있습니다.
Riru 및 LSPosed 이외의 Magisk 모듈을 비활성화 하거나 전체 로그를 개발자에게 제출하세요.]]>
- 시스템 속성이 잘못됨 - 모듈은 가끔 무효화될 수 있습니다.]]> - 업데이트가 필요합니다 - 최신 버전으로 LSPosed를 설치해 주세요 - 모듈 개발자를 위한 팁 - Android Studio 에서 배포 최적화를 비활성화 하거나, 설치할때 \'gradlew installDebug\' 명령어를 사용해주세요. 그렇지 않으면 모듈 APK 가 업데이트되지 않을 것입니다. - API 버전 - Framework 버전 - 관리자 패키지 이름 - System 버전 - 장치 - 시스템 ABI - Dex 옵티마이저 래퍼 - 사용 - 사용 안 함 - 지원 - 지원되지 않음 - 안드로이드 버전이 지원되지 않습니다 - 충돌 - 마운트 실패 - SELinux가 허용됩니다 - SELinux 정책이 잘못되었습니다 - LSPosed 업데이트 - LSPosed를 업데이트 하시겠습니까? 업데이트 완료 후에 재부팅합니다 - 클립보드에 복사됨 - - LSPosed에 오신 것을 환영합니다 - 바로 가기를 만들거나 알림에서 계속 열 수 있는 기생 관리자를 사용하고 있습니다. - 알림에서 열 수 있는 내부 관리자를 사용 중 입니다. - 바로 가기 만들기 - 표시 안 함 - 기생 매니저 추천 - LSPosed는 이제 탐지를 피하기 위해 시스템 기생을 지원하며 알림에서 기생 관리자를 열 수 있습니다. 현재 응용 프로그램을 제거하는 것이 좋습니다. - - 저장 - 자세한 로그 - 모듈 로그 - 로그를 저장중입니다, 기다려주세요 - 로그가 저장되었습니다 - 저장 실패:\n%s - 로그 지우기 - 로그를 성공적으로 지웠습니다. - 맨 위로 스크롤 - 로딩 중… - 아래로 스크롤 - 리로드 - 로그를 지우지 못했습니다. - 줄 바꿈 - 자세한 로그 사용 - 자세한 로그 비활성화 - - (설명 없음) - 이 모듈에는 최신 LSPosed (%d) 버전이 필요하므로 활성화할 수 없습니다. - 이 모듈은 더 새로운 Xposed 버전 (%d) 에서 만들어졌고 따라서 몇몇 기능들이 작동하지 않을 수도 있습니다 - 이 모듈에서는 필요한 LSPosed 버전을 지정하지 않습니다. - 이 모듈은 LSPosed 버전 %1$d에 대해 생성되었지만 버전 %2$d에서 호환되지 않는 변경으로 인해 비활성화되었습니다. - 이 모듈은 SD 카드에 설치되어 있으므로 로드할 수 없습니다. 내부 스토리지로 이동하십시오. - 제거 - 모듈 설정 - 저장소에서 보기 - 이 모듈을 제거하시겠습니까? - %1$s 제거됨 - 제거 실패 - 사용자에게 모듈 추가 - %2$s 사용자에 %1$s 추가 - 모듈 추가 실패 - %s 사용자에게 설치 - %2$s 사용자에게 %1$s을(를) 설치하시겠습니까? 수동으로 설치하는 것이 좋습니다. LSPosed를 통해 강제로 설치하면 문제가 발생할 수 있습니다. - 펼치기 - 접기 - - 다시 최적화 - 최적화 중… - 최적화 완료 - 실행 - 최적화에 실패했거나 반환 값이 비어 있습니다. - 최적화 실패: - 애플리케이션 이름 - 패키지 이름 - 설치 시간 - 업데이트 시간 - 역순 - 시스템 앱 - 정렬 - 모듈 활성화 - 앱을 선택하지 않았습니다. 계속하시겠습니까? - 게임 - 모듈 - 범위 목록 저장에 실패했습니다. - 버전: %1$s - 권장 - 앱을 선택하지 않았습니다. 권장 앱을 선택하시겠습니까? - 권장 앱을 선택하시겠습니까? - LSPosed 모듈이 아직 활성화되지 않았습니다. - 권장 - 업데이트 가능: %1$s - 앱을 선택하지 않았기 때문에 %s 모듈이 비활성화되었습니다. - 시스템 프레임워크 - 백업 - 백업 - 복원 - 강제 중지 - 강제 중지? - 앱을 강제로 중지하면 잘못된 동작이 발생할 수 있습니다. - 이 변경 내용을 적용하려면 재부팅해야 합니다. - 재부팅 - 숨김 - - 다른 앱으로 보기 - 앱 정보 - ¯\\\\_(ツ)_\/¯\n아무것도 없음 - - 프레임워크 - 상세한 로그 비활성화 - 자세한 로그를 포함한 오류 신고 요청 - 블랙 다크 테마 - 다크 테마가 활성화된 경우 순수 검은색 테마를 사용합니다. - 테마 - 백업 및 복원 - 모듈 목록과 스코프 목록을 백업합니다. - 모듈 목록과 스코프 목록을 복원합니다. - 백업 - 백업 실패:\n%s - DocumentUI를 활성화하십시오 - 복원 - 복원 실패:\n%s - 네트워크 - DNS over HTTPS - 일부 국가의 DNS 포이즈닝 문제를 해결합니다 - 테마 색 - System 강조 색 - 앱에서 시작 프로그램 아이콘 표시 - Android 10 이후 앱(특히 Xposed 모듈)은 시작 프로그램 아이콘을 숨길 수 없습니다. 이 기능을 비활성화하려면 토글을 끄십시오. - 체계 - 언어 - 번역 기여자 - 번역 참여 - %s를 귀하의 언어로 번역하는 데 도움을 주세요. - 내부 관리자를 열 수 있는 바로가기 생성 - 바로 가기 고정 - 현재 기본 런쳐는 핀 바로가기를 지원하지 않습니다 - 상태 알림 - 기생 관리자를 열 수 있는 알림 표시 - 업데이트 채널 - 안정 - 베타 - 야간 빌드 - - 읽어보기 - 릴리스 - 정보 - 홈페이지 - 소스 코드 - 기여자 - 자산 - 브라우저에서 열기 - 이전 버전 표시 - 더 이상 출시되지 않음 - 모듈 저장소를 로드하지 못함: %s - 먼저 업그레이드 가능 - 설치됨 - - %d 다운로드 - - - 벚꽃 - 빨강 - 분홍 - 보라 - 짙은 보라 - 군청 - 파랑 - 밝은 파랑 - 청록 - 암청 - 초록 - 연두 - 라임 - 노랑 - 호박 - 주황색 - 짙은 주황 - 갈색 - 청회색 -
diff --git a/app/src/main/res/values-ku/strings.xml b/app/src/main/res/values-ku/strings.xml deleted file mode 100644 index d116177ae..000000000 --- a/app/src/main/res/values-ku/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - کورتەی گشتی - مۆدیوولەکان - - %d مۆدیول چالاک کراوە - %d مۆدیول چالاک کراوە - - لۆگەکان - ڕێکخستنەکان - فیدباک یان پێشنیار - دەربارە - پرسی ڕاپۆرت - کۆگا - Hemî modulên nûjen - Di %sde hate weşandin - Di %sde hate nûve kirin - - %d module nûvekirin - %d modulên nûvekirî - - bibînin Tevlî %2$s kanala me bibin]]> - null - Lêkirin - Ji bo sazkirina LSPosed bikirtînin - Ne hatiye sazkirin - LSPosed nayê Sazkirin - Çalak kirin - Qismî aktîf kirin - SEPolicy bi rêkûpêk nayê barkirin - Ji kerema xwe vê yekê ji Magisk pêşdebir re ragihînin.]]> - Derzkirina Çarçoveya Pergalê têk çû - Magisk an hin modulên Magisk-a kêm-kalîteyê ve çêbibe.
Ji kerema xwe hewl bidin ku modulên Magisk ji bilî Riru û LSPosed neçalak bikin an jî têketinek tevahî ji pêşdebiran re bişînin.]]>
- Pêşniyara pergalê xelet e - Dibe ku modul carinan betal bibin.]]> - Pêdivî ye ku nûve bike - Ji kerema xwe guhertoya herî dawî ya LSPosed saz bikin - Guhertoya API - Guhertoya çarçoveyê - Navê pakêta rêveberê - Guhertoya pergalê - Sazî - Pergala ABI - Dex Optimizer Wrapper - Enabled - Ne çalak kirin - Piştgirî kirin - Piştgirî nekirin - Guhertoya Android-ê ne razî ye - Qeza kirin - Çiya têk çû - SELinux destûr e - Siyaseta SELinux nerast e - LSPosed nûve bikin - Piştrast bike ku LSPosed nûve bike? Ev cîhaz dê piştî qedandina nûvekirinê ji nû ve dest pê bike - Li clipboardê hate kopî kirin - - Hûn bi xêr hatin LSPosed - Hûn rêveberê parazît bikar tînin, ku dikare kurtebirê biafirîne an hîn jî ji ragihandinê vebe. - Hûn rêveberê parazît bikar tînin, ku dikare ji ragihandinê vebe. - Kurtenivîsê çêbikin - Qet nîşan nedin - Rêvebirê Parazît Pêşniyar kirin - LSPosed naha parazîtkirina pergalê piştgirî dike da ku ji tespîtê dûr bixe, hûn dikarin rêveberê parazît ji ragihandinê vekin. Tê pêşniyar kirin ku serîlêdana heyî jêbirin. - - Rizgarkirin - Têketinên Verbose - Têketinên Modulan - پاشەکەوتکردنی لۆگ، تکایە چاوەڕوان بن - Têketin xilas kirin - Sazkirin bi ser neket:\n%s - Naha têketinê paqij bike - Têketin bi serkeftî hate paqij kirin. - Scroll to top - Barkirin… - Scroll to bottom - Ji nû ve barkirin - Paqijkirina têketinê bi ser neket - Peyv Wrap - Têketinê bi lêker vekir - Têketinê bi devkî neçalak bû - - (bê şirove nehat dayîn) - Vê modulê guhertoyek nû ya Xposed (%d) hewce dike û ji ber vê yekê nayê çalak kirin - Ev modul ji bo guhertoyek nû ya Xposed (%d) hatî çêkirin û ji ber vê yekê dibe ku hin fonksiyon nexebitin - Ev modul guhertoya Xposed ya ku jê re hewce dike diyar nake. - Ev modul ji bo guhertoya Xposed %1$dhate afirandin, lê ji ber guheztinên nelihev ên di guhertoya %2$d-ê de, ew hate asteng kirin. - Ev modul nikare were barkirin ji ber ku ew li ser qerta SD-ê hatî saz kirin, ji kerema xwe wê biguhezînin hilana hundurîn - Rakirin - Mîhengên Modulê - Di Repo de bibînin - Ma hûn dixwazin vê modulê rakin? - Rakir %1$s - Rakirina neserketî ye - Modulê li bikarhênerê zêde bikin - %1$s ji bikarhêner %2$sre zêde kirin - Zêdekirina modulê têk çû - Ji bikarhêner %sre saz bike - Dixwazin %1$s ji bikarhêner %2$sre saz bikin? Tête pêşniyar kirin ku bi destan saz bikin, zordariya sazkirinê bi riya LSPosed dibe ku bibe sedema pirsgirêkan. - firehkirin - jiberhevketin - - Ji nû ve xweşbîn bikin - Optimîzekirin… - Optimîzasyon qediya - Dest pê bike - Optimîzasyon têk çû: nirxa vegerê vala ye - Optimîzasyon têk çû: - Navê serîlêdanê - Navê pakêtê - Dema sazkirinê - Wextê nûve bike - Gara paşî - Sepanên pergalê - Rêzkirin - Modulê çalak bike - Te tu sepanê hilnebijart. Berdewamkirin? - Games - Modules - Hilbijartina navnîşa çarçovê bi ser neket - Versiyon: %1$s - Pêşniyar kirin - Te tu sepanê hilnebijart. Serlêdanên pêşniyarkirî hilbijêrin? - Serlêdanên pêşniyarkirî hilbijêrin? - Modula Xposed hîn nehatiye çalak kirin - Pêşniyar kirin - Nûvekirin heye: %1$s - Modula %s ji ber ku tu sepan nehat hilbijartî hate neçalak kirin. - Çarçoveya Sîstemê - Backup - Backup - Nûvdekirin - Bi zorê rawestandin - Rawestandina zorê? - Ger hûn bi zorê sepanek rawestînin, dibe ku ew xelet tevbigere. - Ji bo sepandina vê guherînê ji nû ve destpêkirinê hewce ye - Reboot - Veşartin - - Di sepana din de bibînin - Agahdariya Appê - ¯\\\\_(ツ)_\/¯\nLi vir tiştek tune - - Çarçove - Têketinên devkî neçalak bike - Daxwaza pirsgirêkan rapor bikin ku têketinên devkî tê de bin - Mijara reş reş - Ger mijara tarî çalak be, mijara reş a paqij bikar bînin - Mijad - Backup û restore - Lîsteya modulê paşvekêşîn û navnîşên çarçovê. - Lîsteya modul û navnîşên çarçovê vegerînin. - Backup - Piştgiriya bi ser neket:\n%s - Ji kerema xwe DocumentUI çalak bike - Nûvdekirin - Vegerandin bi ser neket:\n%s - Network - DNS li ser HTTPS - Li hin welatan jehrîkirina DNS-ê çareser bikin - Rengê mijarê - Rengê mijara pergalê - Bi zorê serlêdanan bikin ku îkonên destpêker nîşan bidin - Piştî Android 10, serîlêdan destûr nayê dayîn ku îkonên xwe yên destpêker veşêrin. Ji bo neçalakkirina vê taybetmendiya pergalê, guheztinê vekin. - Sîstem - Ziman - Beşdarên wergerê - Beşdarî wergerê bibin - Alîkariya me bikin ku %s wergerînin zimanê we - Kurtenivîsek çêbikin ku dikare rêveberê parazît veke - Kurtenivîs pêçayî - Destpêkera xwerû ya heyî kurtebirên pin piştgirî nake - Notification Status - Agahdariyek nîşan bide ku dikare rêveberê parazît veke - Kanalê nûve bikin - Stewr - Beta - Avakirina şevê - - Readme - Releases - Info - Homepage - Koda çavkaniyê - Hevkar - Tiştan - Di gerokê de vekin - Guhertoyên kevntir nîşan bide - Bêtir berdan - Barkirina depoya modulê têk çû: %s - Pêşî nûvekirin - Saz kirin - - %d download - %d downloads - - - Sakura - sor - Pembe - Mor - binefşî ya kûr - Indigo - Şîn - Şînê vebûyî - Cyan - Teal - Kesk - Kesk ronî - Lime - Zer - Aqût - porteqalî - porteqala kûr - qehweyî - Şîn gewr -
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml deleted file mode 100644 index 63500a800..000000000 --- a/app/src/main/res/values-lt/strings.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - Apžvalga - Moduliai - - %d modulis įjungtas - %d įjungti moduliai - %d įjungti moduliai - %d įjungti moduliai - - Žurnalai - Nustatymai - Atsiliepimai arba pasiūlymas - About-face - Pranešti apie problemą - Saugykla - Visi moduliai atnaujinti - Paskelbta adresu %s - Atnaujinta %s - - %d modulis atnaujinamas - %d atnaujinami moduliai - %d atnaujinami moduliai - %d atnaujinami moduliai - - Prisijunkite prie mūsų %2$s kanalo]]> - Ace Miller, im sorry - Įdiekite - Bakstelėkite, jei norite įdiegti LSPosed - Neįdiegta - \"LSPosed\" nėra įdiegta - Aktyvuota - Iš dalies aktyvuota - \"SEPolicy\" nėra tinkamai įkelta - Apie tai praneškite Magisk kūrėjui.]]> - Nepavyko įšvirkšti sistemos pagrindo - "Magisk" arba kai kurių nekokybiškų "Magisk" modulių.
Pabandykite išjungti kitus "Magisk" modulius, išskyrus "Riru" ir "LSPosed", arba pateikite visą žurnalą kūrėjams.]]>
- Neteisingas sistemos rekvizitas - Moduliai kartais gali būti pripažinti negaliojančiais.]]> - Reikia atnaujinti - Įdiekite naujausią \"LSPosed\" versiją - API versija - Pagrindų versija - Valdytojo paketo pavadinimas - Sistemos versija - Įrenginys - Sistemos ABI - \"Dex Optimizer Wrapper - Įjungta - Neįjungta - Palaikomas - Nepalaikomas - \"Android\" versija nepatenkinti - Sugedo - Montavimas nepavyko - \"SELinux\" yra leidžiamoji - \"SELinux\" politika yra neteisinga - Atnaujinti LSPosed - Patvirtinkite, kad atnaujintumėte LSPosed? Baigus atnaujinimą šis prietaisas bus perkrautas - Nukopijuota į iškarpinę - - Sveiki atvykę į LSPosed - Jūs naudojate parazitinį tvarkytuvą, kuris gali sukurti nuorodą arba vis dar atidaryti iš pranešimo. - Naudojate parazitinį tvarkytuvą, kuris gali būti atidarytas iš pranešimo. - Sukurti nuorodą - Niekada nerodykite - Rekomenduojama parazitinė vadybininkė - \"LSPosed\" dabar palaiko sistemos parazitavimą, kad išvengtų aptikimo, galite atidaryti parazitų tvarkyklę iš pranešimo. Rekomenduojama pašalinti dabartinę programą. - - Išsaugoti - Žurnalai su verbaline informacija - Modulių žurnalai - Įrašomas žurnalas, palaukite - Išsaugoti žurnalai - Nepavyko išsaugoti:\n%s - Išvalyti žurnalą dabar - Žurnalas sėkmingai išvalytas. - Slinkti į viršų - Įkrovimas… - Slinkite į apačią - Perkrauti - Nepavyko išvalyti žurnalo - Žodžių apvyniojimas - Įjungtas verstinis žurnalas - Išjungtas verstinis žurnalas - - (aprašymas nepateiktas) - Šis modulis reikalauja naujesnės \"Xposed\" versijos (%d), todėl negali būti aktyvuotas - Šis modulis skirtas naujesnei \"Xposed\" versijai (%d), todėl kai kurios funkcijos gali neveikti. - Šis modulis nenurodo jam reikalingos \"Xposed\" versijos. - Šis modulis buvo sukurtas \"Xposed\" versijai %1$d, tačiau dėl nesuderinamų pakeitimų versijoje %2$d, jis buvo išjungtas. - Šio modulio negalima įkelti, nes jis įdiegtas SD kortelėje, perkelkite jį į vidinę saugyklą - Pašalinti - Modulio nustatymai - Peržiūrėti Repo - Ar norite pašalinti šį modulį? - Išmontuota %1$s - Nesėkmingas pašalinimas - Pridėti modulį prie naudotojo - Pridėta %1$s naudotojui %2$s - Nepavyko pridėti modulio - Įdiegti naudotojui %s - Norite įdiegti %1$s naudotojui %2$s? Rekomenduojama įdiegti rankiniu būdu, priverstinis diegimas per LSPosed gali sukelti problemų. - išplėsti - žlugimas - - Iš naujo optimizuoti - Optimizavimas… - Optimizavimas baigtas - Paleiskite jį - Optimizavimas nepavyko: grąžinama vertė yra tuščia - Optimizavimas nepavyko: - Programos pavadinimas - Paketo pavadinimas - Diegimo laikas - Atnaujinimo laikas - Atvirkštinis - Sistemos programos - Rūšiavimas - Įjungti modulį - Nepasirinkote jokios programos. Tęsti? - Žaidimai - Moduliai - Nepavyko išsaugoti srities sąrašo - Versija: %1$s - Rekomenduojama - Nepasirinkote jokios programos. Pasirinkti rekomenduojamas programas? - Pasirinkite rekomenduojamas programas? - Xposed modulis dar nėra aktyvuotas - Rekomenduojama - Galimas atnaujinimas: %1$s - Modulis %s buvo išjungtas, nes nebuvo pasirinkta jokia programa. - Sistemos sistema - Atsarginė kopija - Atsarginė kopija - Atkurti - Priverstinis sustabdymas - Priverstinis sustojimas? - Jei priverstinai sustabdysite programą, ji gali elgtis netinkamai. - Kad šis pakeitimas būtų pritaikytas, reikia perkrauti kompiuterį - Perkraukite - Paslėpti - - Peržiūrėti kitoje programoje - Programėlės informacija - \\\\_(ツ)_\/¯\nČia nieko nėra - - Sistema - Išjungti verbalinius žurnalus - Ataskaitos klausimų prašymas įtraukti verbalinius žurnalus - Juoda tamsi tema - Naudokite grynai juodą temą, jei įjungta tamsi tema - Tema - Atsarginės kopijos kūrimas ir atkūrimas - Atsarginės kopijos modulių ir sričių sąrašai. - Atkurti modulių ir sričių sąrašus. - Atsarginė kopija - Nepavyko sukurti atsarginės kopijos:\n%s - Įjunkite DocumentUI - Atkurti - Nepavyko atkurti:\n%s - Tinklas - DNS per HTTPS - Apėjimas DNS apsinuodijimas kai kuriose šalyse - Temos spalva - Sistemos temos spalva - Priversti programas rodyti paleidiklio piktogramas - Po \"Android 10\" programėlėms neleidžiama slėpti paleidimo programos piktogramų. Norėdami išjungti šią sistemos funkciją, išjunkite perjungiklį. - Sistema - Kalba - Vertimo paslaugų teikėjai - Dalyvaukite vertimo procese - Padėkite mums išversti %s į savo kalbą - Sukurti nuorodą, kuri gali atidaryti parazitinį tvarkytuvą - Prisegtas trumpinys - Dabartinė numatytoji paleidimo programa nepalaiko prisegamų nuorodų - Pranešimas apie būseną - Rodyti pranešimą, kad galima atidaryti parazitinį tvarkytuvą - Atnaujinti kanalą - Stabilus - Beta - Naktinis kūrimas - - Readme - Leidiniai - Informacija - Pradžia - Šaltinio kodas - Bendradarbiai - Turtas - Atidaryti naršyklėje - Rodyti senesnes versijas - Jokio išleidimo - Nepavyko įkelti modulio atramos: %s - Pirmiausia atnaujinamas - Įdiegta - - %d atsisiųsti - %d Parsisiųsti - %d Parsisiųsti - %d Parsisiųsti - - - Sakura - Raudona - Rožinis - Violetinė - Tamsiai violetinė - Indigo - Mėlyna - Šviesiai mėlyna - Cyan - Teal - Žalioji - Šviesiai žalia - Lime - Geltona - Amber - Oranžinė - Tamsiai oranžinė - Ruda - Mėlyna pilka -
diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml deleted file mode 100644 index 894a37a07..000000000 --- a/app/src/main/res/values-night-v31/colors.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - @android:color/system_accent1_800 - @android:color/system_accent1_200 - diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml deleted file mode 100644 index 6c4499226..000000000 --- a/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - @color/abc_primary_text_material_light - @color/abc_primary_text_material_dark - - #F06292 - #E1F5FE - diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml deleted file mode 100644 index e36ab278d..000000000 --- a/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/values-v29/settings.xml b/app/src/main/res/values-v29/settings.xml deleted file mode 100644 index ebb9cc6ee..000000000 --- a/app/src/main/res/values-v29/settings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - true - diff --git a/app/src/main/res/values-v30/themes.xml b/app/src/main/res/values-v30/themes.xml deleted file mode 100644 index b182fe6bb..000000000 --- a/app/src/main/res/values-v30/themes.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml deleted file mode 100644 index 7da0b37bf..000000000 --- a/app/src/main/res/values-v31/colors.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - @android:color/system_accent1_0 - @android:color/system_accent1_600 - diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml deleted file mode 100644 index 0065b198d..000000000 --- a/app/src/main/res/values-vi/strings.xml +++ /dev/null @@ -1,236 +0,0 @@ - - - - - Tổng quan - Mô-đun - - %d Mô-đun đã bật - - Nhật ký - Cài đặt - Phản hồi hoặc góp ý - Giới thiệu - Báo cáo sự cố - Kho - Tất cả các modules đã được cập nhật - Xuất bản lúc %s - Cập nhật lúc %s - - %d Modules có bản cập nhật - - Tham gia kênh %2$s của chúng tôi]]> - The Primal Pea, Tuyen Nguyen (tuyennn) - Cài đặt - Ấn vào đây để cài đặt LSPosed - Chưa được cài đặt - LSPosed chưa được cài đặt - Đã được kích hoạt - Một phần đã được kích hoạt - Chính sách SELinux không được nạp đúng cách - Vui lòng báo cáo đến nhà phát triển.]]> - Không thể nhúng vào khung hệ thống - Magisk hoặc 1 số mô-đun Magisk kém chất lượng.
Hãy thử vô hiệu hóa những mô-đun Magisk đó, thử chạy riêng Riru và LSPosed mà thôi hoặc thông báo nhật ký tới những nhà phát triển.]]>
- Thông tin hệ thống không đúng - Đôi khi, các mô-đun có thể sẽ mất hiệu lực.]]> - Cần được cập nhật - Vui lòng cài đặt phiên bản mới nhất của LSPosed - Mẹo cho module developer - Vui lòng tắt tối ưu hóa triển khai trên Android Studio hoặc sử dụng lệnh `gradlew installDebug` để cài đặt. Nếu không, apk mô-đun sẽ không được cập nhật. - Phiên bản API - Phiên bản Framework - Tên gói quản lý - Phiên bản hệ thống - Thiết bị - Hệ thống ABI - Trình tối ưu Dex Wrapper - Đã bật - Chưa bật - Được hỗ trợ - Không được hỗ trợ - Phiên bản Android không hỗ trợ - Bị lỗi - Mount thất bại - Cho phép SELinux - Chính sách SELinux không chính xác - Cập nhật LSPosed - Xác nhận cập nhật LSPosed? Thiết bị này sẽ khởi động lại sau khi hoàn tất cập nhật - Đã sao chép vào bảng nhớ tạm - - Chào mừng - Đang sử dụng trình quản lý phụ thuộc có thể tạo lối tắt hoặc mở từ thông báo. - Đang sử dụng trình quản lý phụ thuộc có thể mở từ thông báo. - Tạo lối tắt - Không hiện nữa - Quản lý phụ thuộc khuyến nghị - Ứng dụng hiện hỗ trợ phụ thuộc hệ thống để tránh bị phát hiện có thể mở trình quản lý từ thông báo. -Nên gỡ cài đặt ứng dụng hiện tại. - - Lưu lại - Nhật ký Chi tiết - Nhật ký các Mô-đun - Đang lưu nhật ký, vui lòng đợi - Nhật ký đã được lưu - Lưu thất bại:\n%s - Xóa nhật ký - Nhật ký đã được xoá. - Cuộn lên trên - Đang tải… - Cuộn xuống dưới - Tải lại - Xoá nhật kí thất bại - Tự động xuống dòng - Nhật ký chi tiết đã được kích hoạt - Nhật ký chi tiết đã được vô hiệu hoá - - (chưa có mô tả) - Mô-đun này yêu cầu một phiên bản Xposed mới hơn (%d) và đó là lý do không thể được kích hoạt - Tiện ích bổ sung này được làm cho phiên bản ứng dụng mới hơn (%d) nên một số chức năng có thể không hoạt động - Mô-đun này không chỉ định phiên bản Xposed cần thiết để khởi chạy. - Mô-đun này được tạo bởi phiên bản Xposed %1$d, nhưng vì lý do không tương thích với phiên bản %2$d, nên nó đã bị vô hiệu hóa - Mô-đun này không được nạp vì nó được cài đặt trên thẻ nhớ SD, vui lòng chuyển nó vào bộ nhớ trong - Gỡ cài đặt - Cài đặt Mô-đun - Xem ở trên Kho - Bạn có muốn gỡ cài đặt mô-đun này? - Đã gỡ cài đặt %1$s - Gỡ cài đặt không thành công - Thêm mô-đun tới người dùng - Đã thêm %1$s tới người dùng %2$s - Thêm mô-đun thất bại - Cài đặt tới người dùng %s - Muốn cài %1$s tới người dùng %2$s? Khuyến cáo bạn nên cài đặt thủ công, buộc cài đặt qua LSPosed có thể xảy ra vấn đề không mong muốn. - mở - đóng - - Tối ưu lại - Đang tối ưu… - Tối ưu hoàn tất - Khởi chạy - Tối ưu thất bại: trả về giá trị trống - Tôi ưu thất bại: - Tên ứng dụng - Tên gói ứng dụng - Thời gian cài đặt - Thời gian cập nhật - Đảo ngược - Ứng dụng hệ thống - Sắp xếp - Kích hoạt mô-đun - Bạn đã không lựa chọn bất kỳ ứng dụng nào. Tiếp tục chứ? - Trò chơi - Mô-đun - Lưu danh sách phạm vi thất bại - Phiên bản: %1$s - Được khuyến cáo - Bạn đã không lựa chọn bất kỳ ứng dụng nào. Lựa chọn những ứng dụng được khuyến nghị? - Lựa chọn những ứng dụng được khuyến cáo? - Mô-đun Xposed chưa được kích hoạt - Được khuyến cáo - Cập nhật khả dụng: %1$s - Mô-đun %s đã bị vô hiệu hoá do không có ứng dụng nào được lựa chọn. - Framework Hệ thống - Sao lưu - Sao lưu - Phục hồi - Buộc dừng - Buộc dừng? - Nếu bạn buộc dừng một ứng dụng, nó có thể gặp lỗi. - Khởi động lại là bắt buộc cho thay đổi này - Khởi động lại - Ẩn - - Xem trong ứng dụng khác - Thông tin ứng dụng - ¯\\\\_(ツ)_\/¯\nKhông có gì ở đây cả - - Khung hệ thống - Vô hiệu hoá nhật ký chi tiết - Báo cáo sự cố yêu cầu bao gồm nhật ký chi tiết - Chủ đề Đen - Tối - Sử dụng chủ đề đen nếu chủ đề tối được bật - Chủ đề - Sao lưu và khôi phục - Sao lưu danh sách mô-đun và danh sách phạm vi. - Khôi phục danh sách mô-đun và danh sách phạm vi. - Sao lưu - Sao lưu thất bại:\n%s - Vui lòng kích hoạt DocumentUI - Phục hồi - Phục hồi thất bại:\n%s - Mạng - DNS qua HTTPS - Giải pháp khắc phục tình trạng giả mạo DNS ở một số quốc gia - Màu chủ đề - Màu chủ đề hệ thống - Buộc các ứng dụng hiển thị biểu tượng trên trình khởi chạy - Sau Android 10, các ứng dụng không được phép ẩn biểu tượng trên trình khởi chạy. Tắt lựa chọn này để vô hiệu tính năng này của hệ thống. - Hệ thống - Ngôn ngữ - Cộng tác viên phiên dịch - Tham gia phiên dịch - Giúp chúng tôi dịch %s sang ngôn ngữ của bạn - Tạo lối tắt để mở trình quản lý phụ thuộc - Đã ghim phím tắt - Trình khởi chạy hiện tại không hỗ trợ tạo lối tắt - Thông báo trạng thái - Hiện thông báo để mở trình quản lý phụ thuộc - Kênh cập nhật - Ổn định - Thử nghiệm - Bản dựng hàng đêm - - Đọc - Bản phát hành - Thông tin - Trang chủ - Mã nguồn - Cộng tác viên - Tài sản - Mở trong trình duyệt - Hiển thị các phiên bản cũ hơn - Không có phiên bản nào - Tải kho lưu trữ mô-đun thất bại: %s - Có thể nâng cấp trước - Đã được cài đặt - - %d lượt tải xuống - - - Hoa anh đào - Đỏ - Hồng - Tím - Tím đậm - Xanh đậm - Xanh - Xanh sáng - Lục lam - Mòng két - Xanh lá - Xanh lá sáng - Xanh chanh - Vàng - Hổ phách - Cam - Cam đậm - Nâu - Xanh xám -
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 2f3fc89c9..000000000 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,240 +0,0 @@ - - - - - 概览 - 模块 - - 已启用 %d 个模块 - - 日志 - 设置 - 反馈或建议 - 关于 - 反馈问题 - 仓库 - 所有模块均已最新 - 发布于 %s - 更新于 %s - - %d 个模块可更新 - - 加入我们的 %2$s 频道
加入我们的 QQ 频道]]>
- LSPosed -JingMatrix - 安装 - 点击安装 LSPosed - 未安装 - LSPosed 未安装 - 已激活 - 部分激活 - SEPolicy 未被正确加载 - 请将此问题报告给 Magisk 开发者]]> - 系统框架注入失败 - Magisk 或低质 Magisk 模块导致。
请尝试禁用除 Riru 和 LSPosed 外的其他 Magisk 模块,或向开发者提供完整日志。]]>
- 系统属性异常 - 模块可能会随机失效。]]> - 需要更新 - 请安装新版 LSPosed - 给模块开发者的提示 - 请在 Android Studio 上禁用部署优化,或使用 `gradlew installDebug` 命令进行安装,否则无法更新模块。 - API 版本 - 框架版本 - 管理器包名 - 系统版本 - 设备 - 系统架构 - Dex 优化器包装 - 已启用 - 未启用 - 支持 - 不支持 - 系统版本不受支持 - 崩溃 - 挂载失败 - SELinux 处于宽容模式 - SELinux 规则异常 - 更新 LSPosed - 确认更新 LSPosed? 设备将会在完成更新后自动重启。 - 已复制到剪贴板 - - 欢迎使用 LSPosed - 你正在使用寄生管理器,可创建快捷方式或继续从通知中打开。 - 你正在使用寄生管理器,可以从通知打开它。 - 创建快捷方式 - 不再显示 - 推荐使用寄生管理器 - LSPosed 现在支持系统寄生以避免检测,你可以从通知中打开寄生管理器。建议卸载当前应用。 - - 保存 - 详细日志 - 模块日志 - 正在保存日志,请稍后 - 日志已保存 - 保存失败:\n%s - 立即清空日志 - 成功清空日志。 - 滚动到顶部 - 加载中… - 滚动到底部 - 重新加载 - 日志清空失败 - 自动换行 - 详细日志已启用 - 详细日志已禁用 - - (未提供介绍) - 此模块需要更新的 Xposed 版本(%d),因此无法激活 - 此模块是为较新的 Xposed 版本(%d)设计的,因此某些功能可能无法使用 - 该模块未指定所需的 Xposed 版本 - 由于该模块开发时所基于 Xposed %1$d 版本不再兼容 %2$d 版本中的变更,该模块现已被停用 - 此模块因被安装在 SD 卡中而导致无法加载,请将其移动到内部存储 - 卸载 - 模块设置 - 在仓库中查看 - 确认卸载该模块? - 已卸载 %1$s - 卸载失败 - 安装模块到用户 - 已安装 %1$s 到用户 %2$s - 安装失败 - 安装到用户 %s - 确认安装 %1$s 到用户 %2$s?推荐手动用系统自带方法安装,通过 LSPosed 强制安装可能会导致未知异常。 - 展开 - 收起 - - 重新优化 - 优化中… - 优化完成 - 启动 - 优化失败:返回值为空 - 优化失败: - 应用名 - 包名 - 安装时间 - 更新时间 - 倒序 - 系统应用 - 排序 - 启用模块 - 未选择任何应用。继续? - 游戏 - 模块 - 作用域列表保存失败 - 版本:%1$s - 选择 - 勾选推荐 - 未选择任何应用。选择推荐的应用? - 选择推荐的应用? - 全部 - - 自动添加 - Xposed 模块尚未激活 - 推荐应用 - 可用更新:%1$s - 由于未选择任何应用,模块 %s 已被禁用。 - 系统框架 - 备份 - 备份 - 恢复 - 强行停止 - 要强行停止吗? - 强行停止某个应用可能会使其异常。 - 重启以应用此更改 - 重启系统 - 隐藏 - - 在其它应用中查看 - 应用信息 - ¯\\\\_(ツ)_\/¯\n空空如也 - - 框架 - 禁用详细日志 - 报告问题要求包含详细日志 - 纯黑主题 - 当深色主题启用时使用纯黑主题 - 主题 - 备份与恢复 - 备份模块列表与作用域列表 - 恢复模块列表与作用域列表 - 备份 - 备份失败:\n%s - 请启用文档应用 - 恢复 - 恢复失败:\n%s - 网络 - 安全 DNS(DoH) - 解决某些地区的 DNS 污染问题 - 主题颜色 - 系统主题色 - 强制显示桌面图标 - 在 Android 10 或更高版本,应用不再允许隐藏桌面图标。关闭该选项以关闭该系统功能。 - 系统 - 语言 - 译者 - 参与翻译 - 帮助我们把 %s 翻译到你的语言 - 创建一个能打开寄生管理器的快捷方式 - 已创建快捷方式 - 当前默认桌面不支持固定快捷方式 - 状态通知 - 显示一个通知以打开寄生管理器 - 模块更新通道 - 稳定版 - 测试版 - 每夜版 - - 自述文件 - 版本 - 信息 - 主页 - 源码 - 协作者 - 附件 - 在浏览器中打开 - 显示较早版本 - 无更多版本 - 模块仓库加载失败:%s - 可更新优先 - 已安装 - - %d 次下载 - - - 樱花 - 红色 - 粉色 - 紫色 - 深紫 - 靛青 - 蓝色 - 浅蓝 - 青色 - 青绿 - 绿色 - 浅绿 - 黄绿 - 黄色 - 琥珀 - 橙色 - 深橙 - 棕色 - 灰蓝 -
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml deleted file mode 100644 index 3e2ed8526..000000000 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ /dev/null @@ -1,239 +0,0 @@ - - - - - 概觀 - 模組 - - %d 個模組已啟用 - - 記錄 - 設定 - 回饋或建議 - 關於 - 回報問題 - 資料庫 - 所有模組為最新版本 - 發佈於 %s - 更新於 %s - - %d 個模組可更新 - - 加入我們的 %2$s 頻道]]> - DarKnighT0v0 - 安裝 - 點選安裝 LSPosed - 未安裝 - LSPosed 未安裝 - 已啟用 - 部分啟用 - SEPolicy 未被正確讀取 - 請將此回報給 Magisk 開發人員。]]> - 系統架構插入失敗 - Magisk 或低品質 Magisk 模組導致,
請嘗試停用除 Riru 和 LSPosed 外的 Magisk 模組,或向開發人員提供完整記錄。]]>
- 系統屬性異常 - 模組可能會隨機失效。]]> - 需要更新 - 請安裝最新版本的 LSPosed - 給模組開發人員的提示 - 請在 Android Studio 上停用部署最佳化,或使用 `gradlew installDebug` 指令進行安裝。否則模組apk將不會更新。 - API版本 - 架構版本 - 管理器包名 - 系統版本 - 裝置版本 - 系統架構 - Dex 最佳化包裝函式 - 已啟用 - 未啟用 - 支援 - 不支援 - 系統版本不受支援 - 崩潰 - 加載失敗 - SELinux 處於寬容模式 - SELinux 原則異常 - 更新 LSPosed - 確認更新LSPosed? 此裝置會於更新完成後重啟 - 已複製到剪貼簿 - - 歡迎使用 LSPosed - 您正在使用寄生管理員,您可以建立捷徑或從通知開啟。 - 您正在使用寄生管理員,它可以從通知中開啟。 - 建立捷徑 - 不再顯示 - 建議使用寄生管理員 - LSPosed 現在支援系統寄生以避免偵測,您可以從通知開啟寄生管理員。建議解除安裝目前應用程式。 - - 儲存 - 詳細記錄 - 模組記錄 - 正在保存日誌,請稍候 - 記錄已儲存 - 儲存失敗:\n%s - 立即清理記錄 - 記錄清理成功 - 移至頂端 - 正在載入… - 移至底端 - 重新載入 - 記錄清理失敗 - 自動換行 - 詳細記錄已啟用 - 詳細記錄已停用 - - (未提供描述) - 此模組需要更新版本的 Xposed 版本 (%d),因此無法被啟用 - 此模組專為較新的 Xposed 版本 (%d) 而設計,因此某些功能可能無法運作 - 該模組未指定需要的 Xposed 版本 - 此模組適用於 %1$d 版本的 Xposed ,由於版本 %2$d 的變更不相容,因此已經停用此模組 - 由於此模組被安裝在SD卡中而無法載入,請將其移動到內部儲存空間 - 解除安裝 - 模組設定 - 在存放庫中檢視 - 您確定要移除此模組嗎? - 已移除 %1$s - 移除失敗 - 為用戶安裝模組 - 已為用戶 %2$s 安裝模組 %1$s - 模組安裝失敗 - 為用戶 %s 安裝 - 確定要為用戶 %2$s 安裝 %1$s 嗎?建議手動安裝或多開,透過 LSPosed 強制安裝可能會出現問題。 - 展開 - 收起 - - 重新最佳化 - 正在最佳化… - 最佳化完成 - 執行 - 最佳化失敗或返回值為空 - 最佳化失敗: - 應用程式名稱 - 套件名稱 - 安裝時間 - 更新時間 - 遞減 - 系統應用程式 - 排序 - 啟用模組 - 未選擇任何應用程式,是否繼續? - 遊戲 - 模組 - 作用範圍清單儲存失敗 - 版本:%1$s - 選擇 - 推薦應用程式 - 未選擇任何應用程式,選擇推薦的應用程式? - 選擇推薦的應用程式? - 全部 - - 自動添加 - Xposed 模組尚未啟用 - 推薦應用程式 - 可用更新:%1$s - 由於未選擇任何應用程式,模組 %s 已被停用。 - 系統架構 - 備份 - 備份 - 還原 - 強制停止 - 確定要強制停止? - 如果您強制停止應用程式,可能會導致行為異常。 - 重啟以套用變更 - 重啟系統 - 隱藏 - - 在其他應用程式中檢視 - 應用程式資訊 - ¯\\\\_(ツ)_\/¯\n這裡甚麼都沒有 - - 框架 - 禁用詳細紀錄檔 - 回報問題要求包含詳細記錄檔 - 使用純黑深色主題 - 使用純黑色背景當深色模式已啟用 - 主題 - 備份與還原 - 備份模組清單和作用範圍清單 - 還原模組清單和作用範圍清單 - 備份 - 備份失敗:\n%s - 請啟用 DocumentUI - 還原 - 還原失敗:\n%s - 網絡 - 安全 DNS(DoH) - 解決部分地區的 DNS 中毒問題 - 主題色彩 - 系統主題色彩 - 強制應用程式在啟動器中顯示圖示 - Android 10之後不允許隱藏桌面圖示。關閉開關以禁用此功能 - 系統 - 語言 - 譯者 - 參與翻譯 - 幫助我們翻譯 %s 到您的語言 - 建立可以開啟寄生管理員的捷徑 - 捷徑已釘選 - 目前的預設啟動器不支援釘選捷徑 - 狀態通知 - 顯示通知以便開啟寄生管理員 - 更新頻道 - 穩定版 - 測試版 - 每夜構建 - - 自述文件 - 版本 - 資訊 - 首頁 - 原始程式碼 - 合作者 - 附件 - 在瀏覽器中打開 - 顯示較舊版本 - 沒有更舊版本 - 模組存放庫載入失敗:%s - 可更新優先 - 已安裝 - - %d 次下載 - - - 櫻花 - 紅色 - 粉色 - 紫色 - 深紫 - 靛青 - 藍色 - 淺藍 - 青色 - 青綠 - 綠色 - 淺綠 - 萊姆綠 - 黃色 - 琥珀 - 橙色 - 深橙 - 棕色 - 藍灰 -
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index 82de0478d..000000000 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,235 +0,0 @@ - - - - - 概觀 - 模組 - - %d 個模組已啟用 - - 日誌 - 設定 - 回饋或建議 - 關於 - 回報問題 - 倉庫 - 所有模組為最新版本 - 發佈於 %s - 更新於 %s - - %d 個模組可更新 - - 加入我們的 %2$s 頻道]]> - 孟武. 尼德霍格. 龍、david082321、beigua87 - 安裝 - 點選安裝 LSPosed - 未安裝 - LSPosed 未安裝 - 已啟用 - 部分啟用 - SEPolicy 未被正確讀取 - 請將此回報給 Magisk 開發人員。]]> - 系統框架注入失敗 - Magisk 或低品質 Magisk 模組導致,
請嘗試停用除 Riru 和 LSPosed 外的 Magisk 模組,或向開發者提供完整日誌。]]>
- 系統屬性異常 - 模組可能會隨機失效。]]> - 需要更新 - 請安裝最新版本的 LSPosed - 給模組開發人員的提示 - 請在 Android Studio 上停用部署最佳化,或使用 `gradlew installDebug` 指令進行安裝。否則模組apk將不會更新。 - API 版本 - 框架版本 - 管理器包名 - 系統版本 - 裝置版本 - 系統架構 - Dex 優化器包裝器 - 已啟用 - 未啟用 - 支援 - 不支援 - 系統版本不受支援 - 當機 - 掛載失敗 - SELinux 處於寬容模式 - SELinux 規則異常 - 更新 LSPosed - 確定要更新LSPosed嗎?更新完成後將會重啟裝置 - 已複製到剪貼簿 - - 歡迎使用 LSPosed - 您正在使用寄生管理員,您可以建立捷徑或從通知開啟。 - 您正在使用寄生管理員,它可以從通知中開啟。 - 建立捷徑 - 不再顯示 - 建議使用寄生管理員 - LSPosed 現在支援系統寄生以避免偵測,您可以從通知開啟寄生管理員。建議解除安裝目前應用程式。 - - 儲存 - 詳細日誌 - 模組日誌 - 正在保存日誌,請稍候 - 日誌已儲存 - 儲存失敗:\n%s - 立即清理日誌 - 日誌清理成功 - 移至頂端 - 正在載入…… - 移至底端 - 重新載入 - 日誌清理失敗 - 自動換行 - 詳細日誌已啟用 - 詳細日誌已禁用 - - (未提供介紹) - 該模組需要更新版本的 Xposed(%d),因此無法被啟用 - 此模組專為較新的 Xposed 版本 (%d) 而設計,因此某些功能可能無法運作 - 該模組未指定需要的 Xposed 版本 - 此模組適用於 %1$d 版本的 Xposed ,由於版本 %2$d 的變更不相容,因此已經停用此模組 - 由於此模組被安裝在SD卡中而無法載入,請將其移動到內部儲存空間 - 解除安裝 - 模組設定 - 在倉庫中檢視 - 您確定要移除此模組嗎? - 已移除 %1$s - 移除失敗 - 為使用者安裝模組 - 已為使用者 %2$s 安裝模組 %1$s - 模組安裝失敗 - 為使用者 %s 安裝 - 確定要為使用者 %2$s 安裝 %1$s 嗎?建議手動安裝或多開,透過 LSPosed 強制安裝可能會出現問題。 - 展開 - 收起 - - 重新最佳化 - 正在最佳化…… - 最佳化完成 - 執行 - 最佳化失敗或返回值為空 - 最佳化失敗: - 程式名稱 - 套件名稱 - 安裝時間 - 更新時間 - 遞減 - 系統程式 - 排序 - 啟用模組 - 未選擇任何程式,是否繼續? - 遊戲 - 模組 - 作用域列表儲存失敗 - 版本:%1$s - 推薦程式 - 未選擇任何程式,選擇推薦的程式? - 選擇推薦的程式? - Xposed 模組尚未啟用 - 推薦啟用 - 可用更新:%1$s - 由於未選擇任何程式,模組 %s 已被停用。 - 系統框架 - 備份 - 備份 - 還原 - 強制停止 - 確定要強制停止? - 如果您強制停止應用程式,可能導致行為異常。 - 需要重新啟動以套用此變更 - 重啟系統 - 隱藏 - - 在其他應用程式中檢視 - 程式資訊 - ¯\_(ツ)_/¯\n空空如也 - - 框架 - 停用詳細日誌 - 回報問題要求包含詳細日誌 - 黑色主題 - 當深色主題啟用時使用純黑色主題 - 主題 - 備份與還原 - 備份模組列表和作用域清單 - 還原模組列表和作用域清單 - 備份 - 備份失敗:\n%s - 請啟用 DocumentUI - 還原 - 還原失敗:\n%s - 網路 - 安全 DNS(DoH) - 解決部分地區的 DNS 中毒問題 - 主題強調色 - 系統主題顏色 - 強制應用程式在啟動器中顯示圖示 - 在 Android 10 之後,應用(特別是 Xposed 模組)不被允許隱藏啟動器圖示。關閉本選項以停用此功能。 - 系統 - 語言 - 譯者 - 參與翻譯 - 幫助我們翻譯 %s 到您的語言 - 建立可以開啟寄生管理員的捷徑 - 捷徑已釘選 - 目前的預設啟動器不支援釘選捷徑 - 狀態通知 - 顯示通知以便開啟寄生管理員 - 更新通道 - 穩定版 - 測試版 - 每夜構建 - - 自述檔案 - 版本 - 資訊 - 首頁 - 原始碼 - 合作者 - 附件 - 在瀏覽器中開啟 - 顯示較舊的版本 - 沒有更舊的版本 - 模組倉庫載入失敗:%s - 可更新的優先 - 已安裝 - - %d 次下載 - - - 櫻花 - 紅色 - 粉色 - 紫色 - 深紫 - 靛青 - 藍色 - 淺藍 - 青色 - 青綠 - 綠色 - 淺綠 - 萊姆綠 - 黃色 - 琥珀 - 橙色 - 深橙 - 棕色 - 藍灰 -
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml deleted file mode 100644 index 2ddb9495a..000000000 --- a/app/src/main/res/values/arrays.xml +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - @string/dark_theme_off - @string/dark_theme_on - @string/dark_theme_follow_system - - - - MODE_NIGHT_NO - MODE_NIGHT_YES - MODE_NIGHT_FOLLOW_SYSTEM - - - - SAKURA - MATERIAL_RED - MATERIAL_PINK - MATERIAL_PURPLE - MATERIAL_DEEP_PURPLE - MATERIAL_INDIGO - MATERIAL_BLUE - MATERIAL_LIGHT_BLUE - MATERIAL_CYAN - MATERIAL_TEAL - MATERIAL_GREEN - MATERIAL_LIGHT_GREEN - MATERIAL_LIME - MATERIAL_YELLOW - MATERIAL_AMBER - MATERIAL_ORANGE - MATERIAL_DEEP_ORANGE - MATERIAL_BROWN - MATERIAL_BLUE_GREY - - - - @string/color_sakura - @string/color_red - @string/color_pink - @string/color_purple - @string/color_deep_purple - @string/color_indigo - @string/color_blue - @string/color_light_blue - @string/color_cyan - @string/color_teal - @string/color_green - @string/color_light_green - @string/color_lime - @string/color_yellow - @string/color_amber - @string/color_orange - @string/color_deep_orange - @string/color_brown - @string/color_blue_grey - - - - @string/update_channel_stable - @string/update_channel_bate - @string/update_channel_nightly - - - - CHANNEL_STABLE - CHANNEL_BETA - CHANNEL_NIGHTLY - - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml deleted file mode 100644 index 6ac935b58..000000000 --- a/app/src/main/res/values/attrs.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index bb4fe7561..000000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - @color/abc_primary_text_material_dark - - @color/abc_primary_text_material_light - - #FFFFFF - #F48FB1 - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 25297efd3..000000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - 48dp - 48dp - - 6dp - diff --git a/app/src/main/res/values/integer.xml b/app/src/main/res/values/integer.xml deleted file mode 100644 index f12b67bae..000000000 --- a/app/src/main/res/values/integer.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - 0x00800007 - 0x30 - 0 - diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml deleted file mode 100644 index bd53f0275..000000000 --- a/app/src/main/res/values/settings.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - false - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index 8d3c4dbb2..000000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - Overview - Modules - - %d module enabled - %d modules enabled - - Logs - Settings - Feedback or suggestion - About - Report issue - Repository - All modules up to date - Published at %s - Updated at %s - - %d module upgradable - %d modules upgradable - - Join our %2$s channel]]> - null - Install - Tap to install LSPosed - Not installed - LSPosed is not Installed - Activated - Partially activated - SEPolicy is not loaded properly - Please report this to Magisk developer.]]> - System Framework injection failed - Magisk or some low-quality Magisk modules.
Please try to disable Magisk modules other than Riru and LSPosed or submit full log to developers.]]>
- System prop incorrect - Modules may invalidate occasionally.]]> - Need to update - Please install the latest version of LSPosed - Tips for module developer - Please disable deploy optimizations on Android Studio, or use `gradlew installDebug` command to install. Otherwise the module apk will not be updated. - API version - Framework version - Manager package name - System version - Device - System ABI - Dex Optimizer Wrapper - Enabled - Not enabled - Supported - Unsupported - Android version unsatisfied - Crashed - Mount failed - SELinux is permissive - SELinux policy is incorrect - Update LSPosed - Confirm to update LSPosed? This device will reboot after update completion - Copied to clipboard - - Welcome to LSPosed - You are using the parasitic manager, which can create shortcut or still open from notification. - You are using the parasitic manager, which can open from notification. - Create shortcut - Never show - Parasitic Manager Recommended - LSPosed now supports system parasitization to avoid detection, you can open parasitic manager from notification. It is recommended to uninstall the current application. - - Save - Verbose Logs - Modules Logs - Saving log, please wait - Logs saved - Failed to save:\n%s - Clear log now - Log successfully cleared. - Scroll to top - Loading… - Scroll to bottom - Reload - Failed to clear the log - Word Wrap - Verbose log enabled - Verbose log disabled - - (no description provided) - This module requires a newer Xposed version (%d) and thus cannot be activated - This module is designed for a newer Xposed version (%d) and thus some functionalities may not work - This module does not specify the Xposed version it needs. - This module was created for Xposed version %1$d, but due to incompatible changes in version %2$d, it has been disabled - This module cannot be loaded because it\'s installed on the SD card, please move it to internal storage - Uninstall - Module settings - View in Repo - Do you want to uninstall this module? - Uninstalled %1$s - Uninstall unsuccessful - Add module to user - Added %1$s to user %2$s - Adding module failed - Install to user %s - Want to install %1$s to user %2$s? It is recommended to install manually, forcing installation via LSPosed may cause problems. - expand - collapse - - Re-optimize - Optimizing… - Optimization complete - Launch it - Optimization failed: return value is empty - Optimization failed: - Application name - Package name - Install time - Update time - Reverse - System apps - Sorting - Enable module - You did not select any app. Continue? - Games - Modules - Failed to save scope list - Version: %1$s - Select - Recommended - You did not select any app. Select recommended apps? - Select recommended apps? - All - None - Auto-Include - Xposed module is not activated yet - Recommended - Update available: %1$s - Module %s has been disabled since no app selected. - System Framework - Backup - Backup - Restore - Force stop - Force stop? - If you force stop an app, it may misbehave. - Reboot is required for this change to apply - Reboot - Hide - - View in other app - App info - ¯\\_(ツ)_\/¯\nNothing here - - Framework - Disable verbose logs - Verbose logs are required to report issues - Black dark theme - Use the pure black theme if dark theme is enabled - Theme - Backup and restore - Backup module list and scope lists. - Restore module list and scope lists. - Backup - Failed to backup:\n%s - Please enable DocumentUI - Restore - Failed to restore:\n%s - Network - DNS over HTTPS - Workaround DNS poisoning in some nations - Theme color - System theme color - Force apps to show launcher icons - After Android 10, apps are not allowed to hide their launcher icons. Turn off the toggle to disable this system feature. - System - Language - Translation contributors - Participate in translation - Help us translate %s into your language - Create a shortcut that can open parasitic manager - Shortcut pinned - The current default launcher does not support pin shortcuts - Status Notification - Show a notification that can open parasitic manager - Update channel - Stable - Beta - Nightly build - - Readme - Releases - Info - Homepage - Source code - Collaborators - Assets - Open in browser - Show older versions - No more release - Failed to load module repo: %s - Upgradable first - Installed - - %d download - %d downloads - - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey -
diff --git a/app/src/main/res/values/strings_untranslatable.xml b/app/src/main/res/values/strings_untranslatable.xml deleted file mode 100644 index b6264e42d..000000000 --- a/app/src/main/res/values/strings_untranslatable.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - LSPosed - https://github.com/JingMatrix/LSPosed#install - https://github.com/JingMatrix/LSPosed/releases/latest - @string/module_repo - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index 3597fa5c4..000000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index 06810bdd8..000000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/values/themes_custom.xml b/app/src/main/res/values/themes_custom.xml deleted file mode 100644 index 2e3cd58e6..000000000 --- a/app/src/main/res/values/themes_custom.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/themes_override.xml b/app/src/main/res/values/themes_override.xml deleted file mode 100644 index ee230131e..000000000 --- a/app/src/main/res/values/themes_override.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/prefs.xml b/app/src/main/res/xml/prefs.xml deleted file mode 100644 index 35511c6b3..000000000 --- a/app/src/main/res/xml/prefs.xml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml deleted file mode 100644 index 8f37ef11d..000000000 --- a/app/src/main/res/xml/shortcuts.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/build.gradle.kts b/build.gradle.kts index 0b078cd04..f8c9c30f6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ import com.android.build.gradle.api.AndroidBasePlugin import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask import java.io.ByteArrayOutputStream import javax.inject.Inject +import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import org.gradle.process.ExecOperations @@ -11,6 +12,10 @@ import org.gradle.process.ExecOperations plugins { alias(libs.plugins.agp.lib) apply false alias(libs.plugins.agp.app) apply false + // Declaring the Kotlin plugin here pins the version on the buildscript classpath for + // every module. AGP 9 otherwise supplies its own, older Kotlin, and a module that + // asks for a specific version fails with "already on the classpath with an unknown + // version". The Compose stack in :manager needs the newer compiler. alias(libs.plugins.kotlin) apply false alias(libs.plugins.ktfmt) } @@ -21,12 +26,11 @@ abstract class GitCommitCountValueSource : ValueSource { + interface Parameters : ValueSourceParameters { + /** + * `owner/repo` when GitHub Actions is building this, empty otherwise. + * + * Threaded in as parameters rather than read with `System.getenv` inside [obtain], which + * the configuration cache does not see and therefore does not invalidate on. + */ + val buildRepository: Property + + /** The full SHA that was pushed, when CI knows one and it is not what is checked out. */ + val buildCommit: Property + } + + @get:Inject abstract val execOperations: ExecOperations + + /** + * Runs [command] and returns its trimmed output, or null if it failed or said nothing. + * + * `exec` *throws* when the executable cannot be started at all, which `isIgnoreExitValue` does + * not cover — `hostname` is not on every machine — so the whole call is wrapped rather than + * just its exit code inspected. + */ + private fun capture(vararg command: String): String? = + runCatching { + val output = ByteArrayOutputStream() + val result = execOperations.exec { + commandLine(command.toList()) + standardOutput = output + errorOutput = ByteArrayOutputStream() + isIgnoreExitValue = true + } + if (result.exitValue != 0) null else output.toString().trim().ifBlank { null } + } + .getOrNull() + + /** + * The machine's name, reduced to what is safe in a version string. + * + * A host name can carry anything the owner typed into it, and this value is interpolated into + * module.prop and a generated Kotlin string literal. Unknown hosts fall back to "local", which + * still reads correctly: not a CI build, and not a clean tree. + */ + private fun hostname(): String { + val raw = capture("hostname") ?: System.getenv("HOSTNAME") ?: return "local" + // The short name only. A fully qualified host name is mostly domain, and the domain is the + // part that is identical across every machine that would ever build this. + val short = raw.substringBefore('.') + val safe = short.filter { it.isLetterOrDigit() || it == '-' || it == '_' } + return safe.ifBlank { "local" } + } + + override fun obtain(): String { + // Abbreviated by git rather than by hand, so a CI build and a local build of the same + // commit + // read identically however long this repository's abbreviation happens to be. + val head = capture("git", "rev-parse", "--short", "HEAD") ?: return "unknown" + + val repository = parameters.buildRepository.getOrElse("") + if (repository.isBlank()) { + val dirty = capture("git", "status", "--porcelain", "--untracked-files=no") != null + return if (dirty) "$head+${hostname()}" else head + } + + // The pushed commit is an ancestor of the merge that was checked out, so it is in the + // repository and git will abbreviate it. The truncation is only reached if it somehow is + // not, and a slightly odd length beats reporting the merge commit or nothing at all. + val pushed = parameters.buildCommit.getOrElse("").takeIf { it.isNotBlank() } + val short = + pushed?.let { capture("git", "rev-parse", "--short", it) ?: it.take(head.length) } + ?: head + return short + "-" + repository.replace('/', '-') + } +} + +// Plain vals plus an explicit `extra.set`, rather than the `by extra(...)` delegate: Gradle 9.6 +// deprecated that syntax and drops it in Gradle 10, and every one of these declarations printed a +// deprecation warning of its own. The subprojects read them out of `rootProject.extra` by name, so +// the string here and the property name have to stay in step by hand now. +// // This defers the execution of the git commands and allows Gradle to cache the results. -val versionCodeProvider by extra(providers.of(GitCommitCountValueSource::class.java) {}) -val versionNameProvider by extra(providers.of(GitLatestTagValueSource::class.java) {}) +val versionCodeProvider = providers.of(GitCommitCountValueSource::class.java) {} +val versionHashProvider = + providers.of(GitCommitHashValueSource::class.java) { + // Set on every GitHub Actions runner and on nothing else, so the presence of either is + // the test for "this is a CI build". The workflow's own variables win because they name + // the branch that was pushed; GitHub's defaults name the run that built it. + parameters.buildRepository.set( + providers + .environmentVariable("VECTOR_BUILD_REPOSITORY") + .orElse(providers.environmentVariable("GITHUB_REPOSITORY")) + .orElse("") + ) + parameters.buildCommit.set(providers.environmentVariable("VECTOR_BUILD_COMMIT").orElse("")) + } +val versionNameProvider = providers.of(GitLatestTagValueSource::class.java) {} + +val injectedPackageName = "com.android.shell" +val injectedPackageUid = 2000 +val defaultManagerPackageName = "org.matrix.vector.manager" + +val androidTargetSdkVersion = 37 +val androidMinSdkVersion = 27 +val androidBuildToolsVersion = "37.0.0" +val androidCompileSdkVersion = 37 +val androidCompileNdkVersion = "29.0.14206865" +val androidSourceCompatibility = JavaVersion.VERSION_21 +val androidTargetCompatibility = JavaVersion.VERSION_21 + +extra.set("versionCodeProvider", versionCodeProvider) + +extra.set("versionHashProvider", versionHashProvider) + +extra.set("versionNameProvider", versionNameProvider) + +extra.set("injectedPackageName", injectedPackageName) + +extra.set("injectedPackageUid", injectedPackageUid) + +extra.set("defaultManagerPackageName", defaultManagerPackageName) + +extra.set("androidTargetSdkVersion", androidTargetSdkVersion) -val injectedPackageName by extra("com.android.shell") -val injectedPackageUid by extra(2000) -val defaultManagerPackageName by extra("org.lsposed.manager") +extra.set("androidMinSdkVersion", androidMinSdkVersion) -val androidTargetSdkVersion by extra(36) -val androidMinSdkVersion by extra(27) -val androidBuildToolsVersion by extra("36.0.0") -val androidCompileSdkVersion by extra(36) -val androidCompileNdkVersion by extra("29.0.13113456") -val androidSourceCompatibility by extra(JavaVersion.VERSION_21) -val androidTargetCompatibility by extra(JavaVersion.VERSION_21) +extra.set("androidBuildToolsVersion", androidBuildToolsVersion) + +extra.set("androidCompileSdkVersion", androidCompileSdkVersion) + +extra.set("androidCompileNdkVersion", androidCompileNdkVersion) + +extra.set("androidSourceCompatibility", androidSourceCompatibility) + +extra.set("androidTargetCompatibility", androidTargetCompatibility) subprojects { plugins.withType(AndroidBasePlugin::class.java) { @@ -80,15 +263,13 @@ subprojects { ndkVersion = androidCompileNdkVersion buildToolsVersion = androidBuildToolsVersion - buildFeatures { buildConfig = true } - externalNativeBuild { - cmake { - version = "3.29.8+" - buildStagingDirectory = layout.buildDirectory.get().asFile - } + buildFeatures.buildConfig = true + externalNativeBuild.cmake { + version = "3.29.8+" + buildStagingDirectory = layout.buildDirectory.get().asFile } - defaultConfig { + defaultConfig.apply { minSdk = androidMinSdkVersion ndk { abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")) } @@ -103,6 +284,19 @@ subprojects { listOf( "-DVERSION_CODE=${versionCodeProvider.get()}", "-DVERSION_NAME='\"${versionNameProvider.get()}\"'", + // parallel_hashmap reaches for whenever __SSE2__ is defined, + // and that header's static inline intrinsics arrive twice on the x86 ABIs: + // dex_builder.ixx and dex_helper.ixx each include phmap in their global + // module fragment, so importing dex_builder gives clang two definitions + // with the same mangled name. clang 21 (NDK r29) rejects that outright, + // where clang 20 merged them. Turning phmap's SSE2 group scan off costs + // nothing on arm, which never had it, and is set for every native module + // rather than for dex_builder alone because phmap's layout depends on the + // flag -- our own hook_bridge.cpp instantiates the same templates, and two + // group sizes in one .so would be an ODR violation the linker cannot see. + "-DPHMAP_HAVE_SSE2=0", + // phmap refuses to configure with SSSE3 but no SSE2, so both go together. + "-DPHMAP_HAVE_SSSE3=0", ) val args = @@ -123,26 +317,24 @@ subprojects { } } - buildTypes { - getByName("release") { - externalNativeBuild { - cmake { - arguments.add( - "-DDEBUG_SYMBOLS_PATH=${ + buildTypes.getByName("release").apply { + externalNativeBuild { + cmake { + arguments.add( + "-DDEBUG_SYMBOLS_PATH=${ layout.buildDirectory.dir("symbols").get().asFile.absolutePath }" - ) - } + ) } } } - lint { + lint.apply { abortOnError = true checkReleaseBuilds = false } - compileOptions { + compileOptions.apply { sourceCompatibility = androidSourceCompatibility targetCompatibility = androidTargetCompatibility } @@ -154,6 +346,33 @@ subprojects { targetCompatibility = androidTargetCompatibility } } + + // Java that is not ours to modernise, and that javac otherwise comments on twice per build -- + // once per build type, in both the debug and the release halves of `zipAll`. + // + // `:external:apache` and `:external:axml` compile vendored upstream sources (commons-lang and + // ManifestEditor); `:services:*` compile the libxposed submodule and the AIDL stubs AGP + // generates, neither of which we write. `:legacy` is the de.robv API surface itself: XResources + // exists precisely to override `getColor`, `getDrawable` and the rest of the deprecated + // Resources methods, so every one of those overrides is deliberate and none can be dropped + // while the legacy API is supported. + // + // `-nowarn` alone is not enough. The "Note: Some input files use or override a deprecated API" + // line is a *mandatory* warning summary, emitted precisely because the lint is off, and only + // `-XDsuppressNotes` silences it. + val vendoredJava = + setOf( + ":external:apache", + ":external:axml", + ":legacy", + ":services:daemon-service", + ":services:manager-service", + ) + if (path in vendoredJava) { + tasks.withType(JavaCompile::class.java).configureEach { + options.compilerArgs.addAll(listOf("-nowarn", "-XDsuppressNotes")) + } + } } tasks.register("format") { @@ -164,7 +383,14 @@ tasks.register("format") { "hiddenapi/*/build.gradle.kts", "services/*-service/build.gradle.kts", ) + // The daemon subproject is stuck on ktfmt's default (Meta) style instead of the + // kotlinLangStyle() applied everywhere else — the wrong style was set for it, but a + // bulk reformat would wreck git blame across the module, so it is kept as-is. Exclude + // its build script here so this task's kotlinLangStyle sweep does not fight + // :daemon:ktfmtFormat, which formats the daemon (scripts included) in Meta style. + exclude("daemon/**") dependsOn(":daemon:ktfmtFormat") + dependsOn(":manager:ktfmtFormat") dependsOn(":xposed:ktfmtFormat") dependsOn(":zygisk:ktfmtFormat") } diff --git a/crowdin.yml b/crowdin.yml index 17fa5c1f1..85cfcfeac 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -4,12 +4,43 @@ base_path: . base_url: 'https://api.crowdin.com' pull_request_title: '[translation] Update translation from Crowdin' preserve_hierarchy: 1 + +# Every source file is named individually. Within a path node the CLI compiles `*` to `.+`, not +# `.*`, and applies it as an anchored match, so a `*` has to consume at least one character: a +# pattern of the form `strings*.xml` cannot select `strings.xml`. A file that no pattern selects +# never becomes a candidate, and the CLI says nothing about a file it never considered. +# +# %two_letters_code% rather than %android_code%: %android_code% is region-qualified for every +# language, while a resource folder carries a region only where one is needed to disambiguate. +# Using it would mean one mapping entry per language to strip the region back off, in perpetuity, +# and a language enabled later without such an entry resolves to a folder that does not exist. +# The two-letter code already agrees with the unqualified folders, so only the exceptions need an +# entry here. +# +# These overrides must also exist in the project's language settings: `translation` is uploaded +# verbatim as the file's export pattern and expanded server-side, where this mapping has no say. +# Keep the two in step. files: - - source: /app/src/main/res/values/strings.xml - translation: /app/src/main/res/values-%two_letters_code%/%original_file_name% + - source: /manager/src/main/res/values/strings.xml + translation: /manager/src/main/res/values-%two_letters_code%/%original_file_name% type: android - dest: /app/strings.xml + languages_mapping: &resource_folders + two_letters_code: + 'id': 'in' # Android kept the superseded ISO code + 'he': 'iw' # likewise + 'pt-BR': 'pt-rBR' # the unqualified code denotes the European variant + 'zh-CN': 'zh-rCN' + 'zh-HK': 'zh-rHK' + 'zh-TW': 'zh-rTW' + - source: /manager/src/main/res/values/strings_logs.xml + translation: /manager/src/main/res/values-%two_letters_code%/%original_file_name% + type: android + languages_mapping: *resource_folders + - source: /manager/src/main/res/values/strings_store.xml + translation: /manager/src/main/res/values-%two_letters_code%/%original_file_name% + type: android + languages_mapping: *resource_folders - source: /daemon/src/main/res/values/strings.xml translation: /daemon/src/main/res/values-%two_letters_code%/%original_file_name% type: android - dest: /daemon/strings.xml + languages_mapping: *resource_folders diff --git a/daemon/README.md b/daemon/README.md index 534df888c..ab1946f44 100644 --- a/daemon/README.md +++ b/daemon/README.md @@ -14,12 +14,12 @@ src/main/ └── kotlin/org/matrix/vector/daemon/ ├── data/ # SQLite schema, immutable state cache, and file operations ├── env/ # UNIX domain socket servers and native process monitors - ├── ipc/ # AIDL endpoints (Application, Manager, Module, SystemServer) + ├── ipc/ # AIDL endpoints (Framework, Manager, ModuleApp, InjectedModule, SystemServer) ├── system/ # System binder delegates and Notification UI ├── utils/ # Context forgery, signature verification, and JNI bridges ├── Cli.kt # Command-line interface definitions ├── VectorDaemon.kt # Main entry point and looper initialization - └── VectorService.kt # Primary IDaemonService implementation + └── VectorService.kt # Primary IVectorDaemon implementation ``` ## Concurrency and State Management @@ -47,15 +47,15 @@ When a standard user application spawns, it requests framework access from the d * The target application queries the `activity` service. The Zygisk module inside `system_server` intercepts this query. * The `system_server` forwards the application's UID, PID, process name, and a newly created heartbeat `BBinder` to the daemon using the previously stored `VectorService` reference. * The daemon verifies the request against its `ConfigCache` to determine if the application is within the scope of any enabled modules. -* If approved, the daemon returns an `ApplicationService` binder, which the `system_server` passes back to the target application. +* If approved, the daemon returns an `FrameworkService` binder, which the `system_server` passes back to the target application. * The daemon links a `DeathRecipient` to the heartbeat binder to automatically clean up internal tracking maps when the application process dies. -* The target application uses the `ApplicationService` binder to fetch its specific module list, framework DEX, and obfuscation map. +* The target application uses the `FrameworkService` binder to fetch its specific module list, framework DEX, and obfuscation map. ### 3. Libxposed Module Injection Unlike target applications which request access, the daemon actively pushes its API binder to module processes. This mechanism is strictly limited to modules utilizing the modern libxposed API. * The daemon registers an `IUidObserver` with the Activity Manager to monitor process lifecycles. -* When a UID becomes active, `ModuleService` checks if the UID belongs to an enabled libxposed module. +* When a UID becomes active, `ModuleAppService` checks if the UID belongs to an enabled libxposed module. * The daemon retrieves an `IXposedService` binder. To deliver it, the daemon calls `IActivityManager.getContentProviderExternal`, targeting a synthetic authority constructed from the module's package name. * The daemon executes `IContentProvider.call` with the action `SEND_BINDER` and a `Bundle` containing the binder. This injects the binder into the module's process space before `Application.onCreate` executes, providing access to API verification, scope requests, and remote preferences. diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index 40b7e7b61..499e80978 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -1,17 +1,28 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.ide.common.signing.KeystoreHelper +import java.io.File import java.io.PrintStream import java.util.UUID +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction -val defaultManagerPackageName: String by rootProject.extra -val injectedPackageName: String by rootProject.extra -val injectedPackageUid: Int by rootProject.extra -val versionCodeProvider: Provider by rootProject.extra -val versionNameProvider: Provider by rootProject.extra +val defaultManagerPackageName = rootProject.extra["defaultManagerPackageName"] as String +val injectedPackageName = rootProject.extra["injectedPackageName"] as String +val injectedPackageUid = rootProject.extra["injectedPackageUid"] as Int +@Suppress("UNCHECKED_CAST") +val versionCodeProvider = rootProject.extra["versionCodeProvider"] as Provider +@Suppress("UNCHECKED_CAST") +val versionNameProvider = rootProject.extra["versionNameProvider"] as Provider +@Suppress("UNCHECKED_CAST") +val versionHashProvider = rootProject.extra["versionHashProvider"] as Provider plugins { alias(libs.plugins.agp.app) - alias(libs.plugins.kotlin) alias(libs.plugins.ktfmt) } @@ -27,6 +38,9 @@ android { buildConfigField("int", "MANAGER_INJECTED_UID", """$injectedPackageUid""") buildConfigField("String", "VERSION_NAME", """"${versionNameProvider.get()}"""") buildConfigField("long", "VERSION_CODE", versionCodeProvider.get()) + // The version code is the commit count on origin/master, so it is identical for a branch + // build and a master build. The hash is what tells a bug report which one it came from. + buildConfigField("String", "VERSION_HASH", """"${versionHashProvider.get()}"""") val cliToken = UUID.randomUUID() // Inject the MSB and LSB as Long constants @@ -47,53 +61,81 @@ android { namespace = "org.matrix.vector.daemon" } -android.applicationVariants.all { - val variantCapped = name.replaceFirstChar { it.uppercase() } - val variantLowered = name.lowercase() - - val outSrcDir = layout.buildDirectory.dir("generated/source/signInfo/${variantLowered}").get() - val signInfoTask = - tasks.register("generate${variantCapped}SignInfo") { - dependsOn(":app:validateSigning${variantCapped}") - val sign = - rootProject - .project(":app") - .extensions - .getByType(ApplicationExtension::class.java) - .buildTypes - .named(variantLowered) - .get() - .signingConfig - val outSrc = file("$outSrcDir/org/matrix/vector/daemon/utils/SignInfo.kt") - outputs.file(outSrc) - doLast { - outSrc.parentFile.mkdirs() - val certificateInfo = - KeystoreHelper.getCertificateInfo( - sign?.storeType, - sign?.storeFile, - sign?.storePassword, - sign?.keyPassword, - sign?.keyAlias, - ) - - PrintStream(outSrc) - .print( - """ - |package org.matrix.vector.daemon.utils - | - |object SignInfo { - | @JvmField - | val CERTIFICATE = byteArrayOf(${ - certificateInfo.certificate.encoded.joinToString(",") - }) - |}""" - .trimMargin()) +/** + * Generates a `SignInfo.kt` embedding the manager APK's signing certificate, used by the daemon to + * verify the manager's signature at runtime. + */ +abstract class GenerateSignInfoTask : DefaultTask() { + @get:Input @get:Optional abstract val storeType: Property + + @get:Input @get:Optional abstract val storeFilePath: Property + + @get:Input @get:Optional abstract val storePassword: Property + + @get:Input @get:Optional abstract val keyPassword: Property + + @get:Input @get:Optional abstract val keyAlias: Property + + @get:OutputDirectory abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val outSrc = outputDir.get().file("org/matrix/vector/daemon/utils/SignInfo.kt").asFile + outSrc.parentFile.mkdirs() + val certificateInfo = + KeystoreHelper.getCertificateInfo( + storeType.orNull, + storeFilePath.orNull?.let { File(it) }, + storePassword.orNull, + keyPassword.orNull, + keyAlias.orNull, + ) + + PrintStream(outSrc) + .print( + """ + |package org.matrix.vector.daemon.utils + | + |object SignInfo { + | @JvmField + | val CERTIFICATE = byteArrayOf(${ + certificateInfo.certificate.encoded.joinToString(",") + }) + |}""" + .trimMargin() + ) + } +} + +androidComponents { + onVariants { variant -> + val variantCapped = variant.name.replaceFirstChar { it.uppercase() } + val variantLowered = variant.name.lowercase() + + val signInfoTask = + tasks.register("generate${variantCapped}SignInfo") { + dependsOn(":manager:validateSigning${variantCapped}") + val sign = + rootProject + .project(":manager") + .extensions + .getByType(ApplicationExtension::class.java) + .buildTypes + .named(variantLowered) + .get() + .signingConfig + storeType.set(sign?.storeType) + storeFilePath.set(sign?.storeFile?.absolutePath) + storePassword.set(sign?.storePassword) + keyPassword.set(sign?.keyPassword) + keyAlias.set(sign?.keyAlias) } - } - // registeoJavaGeneratingTask(signInfoTask, outSrcDir.asFile) - kotlin.sourceSets.getByName(variantLowered) { kotlin.srcDir(signInfoTask.map { outSrcDir }) } + variant.sources.kotlin?.addGeneratedSourceDirectory( + signInfoTask, + GenerateSignInfoTask::outputDir, + ) + } } dependencies { diff --git a/daemon/proguard-rules.pro b/daemon/proguard-rules.pro index ab73abb05..94ebe3a0e 100644 --- a/daemon/proguard-rules.pro +++ b/daemon/proguard-rules.pro @@ -42,5 +42,8 @@ public static *** v(...); public static *** d(...); } +# The libxposed annotations are compile-only metadata and are not packaged +-dontwarn io.github.libxposed.annotation.** + -repackageclasses -allowaccessmodification diff --git a/daemon/src/main/jni/dex2oat.cpp b/daemon/src/main/jni/dex2oat.cpp index 32cf77d07..a45777ddf 100644 --- a/daemon/src/main/jni/dex2oat.cpp +++ b/daemon/src/main/jni/dex2oat.cpp @@ -106,6 +106,12 @@ extern "C" JNIEXPORT jboolean JNICALL Java_org_matrix_vector_daemon_env_Dex2OatServer_setSockCreateContext(JNIEnv *env, jclass, jstring contextStr) { const char *context = contextStr ? env->GetStringUTFChars(contextStr, nullptr) : nullptr; + if (contextStr && !context) { + // Only OutOfMemoryError puts us here, and it is pending: returning into Java with it still + // set would surface it at the next unrelated call. + env->ExceptionClear(); + return false; + } int ret = setsockcreatecon_raw(context); if (context) env->ReleaseStringUTFChars(contextStr, context); return ret == 0; diff --git a/daemon/src/main/jni/logcat.cpp b/daemon/src/main/jni/logcat.cpp index ac754ab9c..ea350e9dc 100644 --- a/daemon/src/main/jni/logcat.cpp +++ b/daemon/src/main/jni/logcat.cpp @@ -1,5 +1,9 @@ #include "logcat.h" +#include + +#include "logging.h" + #include #include #include @@ -241,8 +245,14 @@ void Logcat::Run() { extern "C" JNIEXPORT void JNICALL Java_org_matrix_vector_daemon_env_LogcatMonitor_runLogcat(JNIEnv* env, jobject thiz) { - jclass clazz = env->GetObjectClass(thiz); - jmethodID method = env->GetMethodID(clazz, "refreshFd", "(Z)I"); + auto clazz = lsplant::JNI_GetObjectClass(env, thiz); + auto method = lsplant::JNI_GetMethodID(env, clazz, "refreshFd", "(Z)I"); + if (!method) { + // The wrapper has already logged and cleared the NoSuchMethodError. Running with a null + // method id would abort inside the first refresh instead of saying why. + LOGE("LogcatMonitor.refreshFd is missing; not starting the log reader"); + return; + } Logcat daemon(env, thiz, method); daemon.Run(); } diff --git a/daemon/src/main/jni/obfuscation.cpp b/daemon/src/main/jni/obfuscation.cpp index d6ecc83fa..42492fa11 100644 --- a/daemon/src/main/jni/obfuscation.cpp +++ b/daemon/src/main/jni/obfuscation.cpp @@ -119,30 +119,30 @@ static void ensureInitialized(JNIEnv *env) { }); } +// Through the lsplant wrappers rather than raw JNI: each one clears a pending exception and logs +// the Java stack behind it, which is what a failed lookup here would otherwise cost. Returning a +// null jclass while leaving NoClassDefFoundError pending -- as the raw form did -- hands the next +// JNI call undefined behaviour. They also return scoped references, so the local refs this loop +// used to leak per entry are released on the spot. static jobject stringMapToJavaHashMap(JNIEnv *env, const std::map &map) { - jclass mapClass = env->FindClass("java/util/HashMap"); - if (mapClass == nullptr) return nullptr; + auto map_class = lsplant::JNI_FindClass(env, "java/util/HashMap"); + if (!map_class) return nullptr; - jmethodID init = env->GetMethodID(mapClass, "", "()V"); - jobject hashMap = env->NewObject(mapClass, init); - jmethodID put = env->GetMethodID(mapClass, "put", - "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + auto init = lsplant::JNI_GetMethodID(env, map_class, "", "()V"); + auto put = lsplant::JNI_GetMethodID(env, map_class, "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + if (!init || !put) return nullptr; - for (const auto &[key, value] : map) { - jstring keyJava = env->NewStringUTF(key.c_str()); - jstring valueJava = env->NewStringUTF(value.c_str()); - - env->CallObjectMethod(hashMap, put, keyJava, valueJava); + auto hash_map = lsplant::JNI_NewObject(env, map_class, init); + if (!hash_map) return nullptr; - env->DeleteLocalRef(keyJava); - env->DeleteLocalRef(valueJava); + for (const auto &[key, value] : map) { + auto key_java = lsplant::JNI_NewStringUTF(env, key); + auto value_java = lsplant::JNI_NewStringUTF(env, value); + lsplant::JNI_CallObjectMethod(env, hash_map, put, key_java, value_java); } - jobject hashMapGlobal = env->NewGlobalRef(hashMap); - env->DeleteLocalRef(hashMap); - env->DeleteLocalRef(mapClass); - - return hashMapGlobal; + return lsplant::JNI_NewGlobalRef(env, hash_map); } extern "C" JNIEXPORT jobject JNICALL diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt index 0809b00b6..8786ab82e 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt @@ -357,7 +357,7 @@ class DatabaseCommand { if (!force) { print( "Are you sure you want to RESET the database? All modules and scopes will be lost. (y/N): ") - val input = readLine() + val input = readlnOrNull() if (input?.lowercase() != "y") { println("Operation cancelled.") return 0 diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt index 9c5224d82..b4fad62af 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorDaemon.kt @@ -62,7 +62,12 @@ object VectorDaemon { } Log.i(TAG, "Vector daemon started: lateInject=$isLateInject, proxy=$proxyServiceName") - Log.i(TAG, "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + // The hash is here rather than in the version the manager prints: Home should stay readable, + // but every saved bug report should say exactly which commit produced the daemon that wrote it. + Log.i( + TAG, + "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) " + + "commit ${BuildConfig.VERSION_HASH}") Thread.setDefaultUncaughtExceptionHandler { _, e -> Log.e(TAG, "Uncaught exception in Daemon", e) @@ -74,7 +79,7 @@ object VectorDaemon { @Suppress("DEPRECATION") Looper.prepareMainLooper() // Squat on the proxy service name immediately, which creates the early IPC channel of - // ApplicationService for our Zygisk module during system_server specialization. + // FrameworkService for our Zygisk module during system_server specialization. SystemServerService.registerProxyService(proxyServiceName) // Start Environmental Daemons @@ -97,10 +102,17 @@ object VectorDaemon { applyNotificationWorkaround() + // Read this before `sendToBridge`, which leaves the main thread at euid 1000: the config + // database lives under a directory only root can enter, so the first process to open it has + // to do so while we still have root. On a successful injection a binder thread opens it for + // us during specialization, but when the injection fails nothing else has, and the daemon + // used to die here on an unreadable preference. + val isVerboseLog = ManagerService.isVerboseLogEnabled() + // Setup IPC channel for applications by injecting DaemonService binder sendToBridge(VectorService.asBinder(), false, systemServerMaxRetry) - if (!ManagerService.isVerboseLog()) { + if (!isVerboseLog) { LogcatMonitor.stopVerbose() } @@ -218,6 +230,22 @@ object VectorDaemon { .onFailure { Log.w(TAG, "Failed to clear system caches via reflection", it) } } + /** + * Brings the framework down and up without rebooting the device. + * + * `system_server` is forked from the *primary* zygote, so restarting that is what restarts the + * framework — on a 64/32 device the primary init service is still called `zygote` (it runs + * app_process64) and `zygote_secondary` is the 32-bit one. Restarting the secondary leaves + * system_server running, which is right for [restartSystemServer]'s own purpose and wrong for + * this one; they are separate functions for that reason. + * + * Everything on screen dies with it. The caller is expected to have said so first. + */ + fun softReboot() { + Log.w(TAG, "Soft reboot: restarting the primary zygote") + SystemProperties.set("ctl.restart", "zygote") + } + fun restartSystemServer() { Log.w(TAG, "Restarting system_server...") val restartTarget = diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 1306ff550..24bdf1f8b 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -10,33 +10,44 @@ import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder -import android.provider.Telephony import android.telephony.TelephonyManager import android.util.Log import hidden.HiddenApiBridge import io.github.libxposed.service.IXposedScopeCallback import kotlinx.coroutines.launch -import org.lsposed.lspd.models.Application -import org.lsposed.lspd.service.IDaemonService -import org.lsposed.lspd.service.ILSPApplicationService +import org.matrix.vector.ipc.ScopeEntry +import org.matrix.vector.ipc.IVectorDaemon +import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.ModuleDatabase import org.matrix.vector.daemon.data.PreferenceStore import org.matrix.vector.daemon.data.ProcessScope -import org.matrix.vector.daemon.ipc.ApplicationService +import org.matrix.vector.daemon.ipc.FrameworkService import org.matrix.vector.daemon.ipc.ManagerService -import org.matrix.vector.daemon.ipc.ModuleService +import org.matrix.vector.daemon.ipc.ModuleAppService import org.matrix.vector.daemon.system.* private const val TAG = "VectorService" -object VectorService : IDaemonService.Stub() { +object VectorService : IVectorDaemon.Stub() { private var bootCompleted = false - @Suppress("DEPRECATION") + + /** + * What the dialer broadcasts when a secret code is entered, which changed name in Q. + * + * The pre-Q action is spelled out rather than taken from `Telephony.Sms.Intents`, whose constant + * only became public API in 28 while this daemon runs from 27. It is the same string either way: + * on 8.1 the platform broadcast it from the hidden `TelephonyIntents.SECRET_CODE_ACTION`, which + * carries exactly this value, and the public constant that followed was deprecated in 29 when + * `TelephonyManager.ACTION_SECRET_CODE` replaced it. + */ private val ACTION_SECRET_CODE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) TelephonyManager.ACTION_SECRET_CODE - else Telephony.Sms.Intents.SECRET_CODE_ACTION + else "android.provider.Telephony.SECRET_CODE" + + /** Dial *#*#832867#*#* ("VECTOR" on the keypad) to open the manager. */ + private const val SECRET_CODE = "832867" override fun dispatchSystemServerContext( appThread: IBinder?, @@ -54,17 +65,17 @@ object VectorService : IDaemonService.Stub() { } } - override fun requestApplicationService( + override fun attachProcess( uid: Int, pid: Int, processName: String, heartBeat: IBinder - ): ILSPApplicationService? { + ): IFrameworkService? { if (Binder.getCallingUid() != 1000) { - Log.w(TAG, "Unauthorized requestApplicationService call") + Log.w(TAG, "Unauthorized attachProcess call") return null } - if (ApplicationService.hasRegister(uid, pid)) return null + if (FrameworkService.hasRegister(uid, pid)) return null val scope = ProcessScope(processName, uid) if (!ManagerService.tryRegisterManagerProcess(pid, uid, processName) && @@ -73,8 +84,8 @@ object VectorService : IDaemonService.Stub() { return null } - return if (ApplicationService.registerHeartBeat(uid, pid, processName, heartBeat)) { - ApplicationService + return if (FrameworkService.registerHeartBeat(uid, pid, processName, heartBeat)) { + FrameworkService } else null } @@ -148,9 +159,9 @@ object VectorService : IDaemonService.Stub() { IntentFilter(NotificationManager.moduleScopeAction).apply { addDataScheme("module") } val secretCodeFilter = - IntentFilter().apply { + IntentFilter(ACTION_SECRET_CODE).apply { addDataScheme("android_secret_code") - addDataAuthority("5776733", null) + addDataAuthority(SECRET_CODE, null) } // Define strict Android 14+ flags and the system-only BRICK permission @@ -185,15 +196,36 @@ object VectorService : IDaemonService.Stub() { // UID Observer val uidObserver = object : android.app.IUidObserver.Stub() { - override fun onUidActive(uid: Int) = ModuleService.uidStarts(uid) + override fun onUidActive(uid: Int) = ModuleAppService.uidStarts(uid) override fun onUidCachedChanged(uid: Int, cached: Boolean) { - if (!cached) ModuleService.uidStarts(uid) + if (!cached) ModuleAppService.uidStarts(uid) } - override fun onUidIdle(uid: Int, disabled: Boolean) = ModuleService.uidStarts(uid) + override fun onUidIdle(uid: Int, disabled: Boolean) = ModuleAppService.uidStarts(uid) + + override fun onUidGone(uid: Int, disabled: Boolean) = ModuleAppService.uidGone(uid) + + // Not registered for, and so never dispatched: the platform gates these on + // UID_OBSERVER_PROCSTATE, UID_OBSERVER_CAPABILITY and UID_OBSERVER_PROC_OOM_ADJ, and the + // mask below asks for none of them. They are overridden because the framework interface + // declares them and a Stub subclass that leaves one abstract dies on the first call -- + // the callback is oneway, so the resulting Error is fatal to this process rather than + // returned to anyone. Both shapes of each are here because both exist in the range this + // supports: onUidStateChanged gained its capability in 30, and onUidProcAdjChanged + // arrived in 33 and gained its adj in 34. + override fun onUidStateChanged(uid: Int, procState: Int, procStateSeq: Long) {} + + override fun onUidStateChanged( + uid: Int, + procState: Int, + procStateSeq: Long, + capability: Int + ) {} - override fun onUidGone(uid: Int, disabled: Boolean) = ModuleService.uidGone(uid) + override fun onUidProcAdjChanged(uid: Int) {} + + override fun onUidProcAdjChanged(uid: Int, adj: Int) {} } val which = @@ -263,6 +295,20 @@ object VectorService : IDaemonService.Stub() { // Otherwise, only wipe it for the user that just uninstalled it. val targetUser = if (isRemovedForAllUsers) null else userId PreferenceStore.deleteModulePrefs(moduleName, userId, group = null) + // The one preference of a module that is not stored under the module. "Never ask again" + // writes the package into a set belonging to "lspd", so the line above — which deletes + // what is filed under the module's own name — walks straight past it, and the package + // stayed blocked after it was uninstalled. Nothing else ever removes it: the CLI does not + // know the key and the manager offers no way back, so a module blocked by a mis-tap could + // never ask again on that device, and reinstalling it did not help. + // + // Asked of every package that goes for good, not only of modules, and deliberately so: + // `moduleName` here is whatever the broadcast named, and once a package is fully removed + // its metadata is gone, so this branch cannot tell a module from anything else — + // `isXposedModule` is decided below, and only for one that was still in our database. A + // package that was never blocked costs the one read of the "lspd" config row that + // [unblockScopeRequests] starts with, and nothing else. + if (isRemovedForAllUsers) unblockScopeRequests(moduleName) if (isRemovedForAllUsers && ModuleDatabase.removeModule(moduleName)) { // If it was in our DB and we successfully removed it, we treat it as an Xposed module. isXposedModule = true @@ -287,11 +333,11 @@ object VectorService : IDaemonService.Stub() { !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) && moduleName != null) { - ConfigCache.getAutoIncludeModules().forEach { xposedModule -> - val scopeList = ConfigCache.getModuleScope(xposedModule) ?: mutableListOf() + ModuleDatabase.modulesIncludingNewApps().forEach { xposedModule -> + val scopeList = ModuleDatabase.getModuleScope(xposedModule) ?: mutableListOf() val newScope = - Application().apply { + ScopeEntry().apply { this.packageName = moduleName this.userId = userId } @@ -340,9 +386,9 @@ object VectorService : IDaemonService.Stub() { // If an actual Xposed module was updated (not removed), show a system notification. if (moduleName != null && isXposedModule && !isRemovedAction && !isRemovedForAllUsers) { - val scopes = ConfigCache.getModuleScope(moduleName) ?: emptyList() + val scopes = ModuleDatabase.getModuleScope(moduleName) ?: emptyList() val isSystemModule = scopes.any { it.packageName == "system" } - val isEnabled = ManagerService.enabledModules().contains(moduleName) + val isEnabled = ManagerService.getEnabledModules().contains(moduleName) NotificationManager.notifyModuleUpdated(moduleName, userId, isEnabled, isSystemModule) } @@ -353,7 +399,6 @@ object VectorService : IDaemonService.Stub() { val data = intent.data ?: return val extras = intent.extras ?: return val callbackBinder = extras.getBinder("callback") ?: return - if (!callbackBinder.isBinderAlive) return val authority = data.encodedAuthority ?: return val parts = authority.split(":", limit = 2) @@ -361,44 +406,177 @@ object VectorService : IDaemonService.Stub() { val packageName = parts[0] val userId = parts[1].toIntOrNull() ?: return - val scopePackageName = data.path?.substring(1) ?: return // remove leading '/' + // Everything the one prompt asked about, in the order it listed them. ',' cannot occur in a + // package name, so this is the list the module named and not a guess at it. + val scopePackageNames = + data.path?.substring(1)?.split(",")?.filter { it.isNotEmpty() } ?: return // strip '/' + if (scopePackageNames.isEmpty()) return val action = data.getQueryParameter("action") ?: return + // One prompt reaches this receiver from four places — its three buttons and its delete intent + // — and a swipe or the one-hour timeout fires the delete intent whether or not a button was + // pressed first. Answering the module twice, an approval followed by a spurious "Request + // timeout", would be worse than the dismissal never reaching it, so whichever of the four + // arrives first is the one that answers and the rest are dropped. The request is identified by + // the module, its user and the set of packages it asked for; the action is deliberately not + // part of that, since the whole point is that a second *different* action must not answer + // again. + if (!NotificationManager.claimScopeAnswer(packageName, userId, scopePackageNames)) { + Log.d( + TAG, + "Ignoring $action of ${scopePackageNames.joinToString()} for $packageName:" + + " already answered") + return + } + + // A prompt outlives the process that asked for it: it sits for an hour, and the app the module + // is running inside can be killed at any point in that hour. Nothing is dropped for that any + // more. There used to be a `!callbackBinder.isBinderAlive` return above the claim, exempting + // only "never ask again", and it did more than lose an answer nobody was listening for: an + // approval the user had already given was thrown away, and because the return sat above the + // claim and the cancel, the prompt stayed on screen with live buttons that did nothing for the + // rest of the hour. An approval is a decision about the module's scope and is written down + // whether or not the module is there to hear it; deny and the timeout have nothing to record + // but still have a prompt to take down. What actually fails against a dead module is the + // callback below, and that is caught where it is made. val iCallback = IXposedScopeCallback.Stub.asInterface(callbackBinder) runCatching { - val appInfo = packageManager?.getPackageInfoCompat(scopePackageName, 0, userId) - if (appInfo == null) { - iCallback.onScopeRequestFailed("Package not found") - return + // Answered before the requested package is looked up at all, because "never ask again" is + // a decision about the *module* and not about the package this particular prompt happened + // to name. It used to sit in the when below, under the lookup, so a prompt naming a + // package that no longer resolves — uninstalled or disabled while the prompt was up, + // hidden by a profile owner, or never installed for this user — returned at "Package not + // found" and never reached blockScopeRequests: the user said stop asking, the prompt came + // down, and the module was free to ask again a second later. + if (action == "block") { + blockScopeRequests(packageName) + // The preference only stops the *next* request. A module that asked three times has a + // prompt up for each, so without this the user says "never ask again" and is left + // looking at two more questions, both still approvable. Each withdrawn request is + // answered in its own right, because each of them was asked in its own right. + NotificationManager.withdrawScopeRequests(packageName).forEach { pending -> + runCatching { pending.onScopeRequestFailed("Request blocked by configuration") } + } + // Last, and caught, because this is the only call here that reaches into the module's + // process: it may be gone by now, and neither the preference write nor the withdrawal + // above must be lost to a dead binder. They can fail in their own ways — the write goes + // through a SQLite transaction — which is why the whole branch runs inside runCatching + // as well. + runCatching { iCallback.onScopeRequestFailed("Request blocked by configuration") } + return@runCatching } + when (action) { "approve" -> { - val scopes = ConfigCache.getModuleScope(packageName) ?: mutableListOf() - if (scopes.none { it.packageName == scopePackageName && it.userId == userId }) { - scopes.add( - Application().apply { - this.packageName = scopePackageName - this.userId = userId - }) - ModuleDatabase.setModuleScope(packageName, scopes) + // "system" is the framework and not a package: it names system_server, which belongs + // to no package and resolves for nobody. The lookup below therefore came back null + // for every framework prompt, and the approval the user had just given was answered + // "Package not found" — the request was closed, its notification cancelled, and no + // row written. The normalisation to user 0 further down could never once have run. + // + // Package by package rather than all-or-nothing: the prompt may have been up for an + // hour and one of the packages it named can have been uninstalled in the meantime, + // which is no reason to throw away the user's answer about the rest. + // + // Only under "approve", because only an approval has to name something real. Deny + // and the timeout used to be refused here too, so dismissing a prompt for a package + // that had since been uninstalled told the module "Package not found" when what had + // actually happened was that the user turned it down. + val granted = + scopePackageNames.filter { + it == "system" || packageManager?.getPackageInfoCompat(it, 0, userId) != null + } + if (granted.isEmpty()) { + // Logged, because until now this said nothing anywhere: the module was told + // "Package not found", the user was told nothing at all, and the daemon kept no + // record that the press had even arrived. The framework-scope failure above was + // invisible for exactly that reason. + Log.w( + TAG, + "None of ${scopePackageNames.joinToString()} resolve for user $userId;" + + " refusing the scope request of $packageName") + // Leaving the whole function here skipped the cancel below, which used to be + // merely untidy and is now a prompt nobody can use: the request has been answered, + // so every later press of its buttons is dropped. The request is over either way, + // so the notification goes with it. + iCallback.onScopeRequestFailed("Package not found") + return@runCatching + } + val scopes = ModuleDatabase.getModuleScope(packageName) ?: mutableListOf() + var added = false + granted.forEach { scopePackageName -> + // Compared against where the row will land, not against the user who asked: the + // framework is stored under user 0 whoever requested it, so for "system" this test + // never matched and every approval appended a duplicate and rewrote the whole + // table. + val storedUserId = if (scopePackageName == "system") 0 else userId + val present = + scopes.any { it.packageName == scopePackageName && it.userId == storedUserId } + if (!present) { + scopes.add( + ScopeEntry().apply { + this.packageName = scopePackageName + this.userId = storedUserId + }) + added = true + } } - iCallback.onScopeRequestApproved(listOf(scopePackageName)) + // One write for the whole prompt, and none at all when the user approved what the + // module already had. `setModuleScope` replaces the module's rows wholesale and + // enables the module on the way through, so writing per package would rewrite the + // table once per package — leaving a window after each in which the scope is only + // partly what was agreed to — and writing unconditionally would let a module enable + // itself by asking again for what it has. + if (added) ModuleDatabase.setModuleScope(packageName, scopes) + Log.i(TAG, "Approved ${granted.joinToString()} for $packageName on user $userId") + // The packages that were granted, which is what the list in this callback is for. A + // module comparing it against what it asked for can see what it did not get. + iCallback.onScopeRequestApproved(granted) } "deny" -> iCallback.onScopeRequestFailed("Request denied by user") "delete" -> iCallback.onScopeRequestFailed("Request timeout") - "block" -> { - val blocked = - PreferenceStore.getModulePrefs("lspd", 0, "config")["scope_request_blocked"] - as? Set ?: emptySet() - PreferenceStore.updateModulePref( - "lspd", 0, "config", "scope_request_blocked", blocked + packageName) - iCallback.onScopeRequestFailed("Request blocked by configuration") - } } } - .onFailure { runCatching { iCallback.onScopeRequestFailed(it.message) } } + // onScopeRequestFailed declares @NonNull, and Throwable.message is frequently null. + .onFailure { runCatching { iCallback.onScopeRequestFailed(it.message ?: it.toString()) } } + + // Only this one request goes; a module that asked more than once has a prompt still open for + // each of its other requests, and they are answered on their own. + NotificationManager.cancelScopeRequest(packageName, userId, scopePackageNames) + } + + /** + * The modules that may not ask for scope again. + * + * Filed under "lspd" rather than under the module it names, because it records the user's decision + * about a module rather than that module's own configuration. That is also why uninstalling a + * module does not take it away — `deleteModulePrefs` deletes what is filed under the module's own + * name — and why [unblockScopeRequests] has to exist. + */ + @Suppress("UNCHECKED_CAST") + private fun blockedScopeRequests(): Set = + PreferenceStore.getModulePrefs("lspd", 0, "config")["scope_request_blocked"] as? Set + ?: emptySet() + + private fun blockScopeRequests(packageName: String) { + PreferenceStore.updateModulePref( + "lspd", 0, "config", "scope_request_blocked", blockedScopeRequests() + packageName) + } - NotificationManager.cancelNotification( - NotificationManager.SCOPE_CHANNEL_ID, packageName, userId) + /** + * Lets an uninstalled module ask again if it comes back. + * + * "Never ask again" is a button in a notification, one tap from Approve, and nothing anywhere + * undid it: the socket CLI does not know the key, the manager offers no control for it, and the + * uninstall path missed it. A module blocked by a mis-tap was blocked on that device forever, and + * reinstalling it changed nothing. Uninstalling is a deliberate enough act to count as taking it + * back — and a module that is gone has no decision left to honour. + */ + private fun unblockScopeRequests(packageName: String) { + val blocked = blockedScopeRequests() + if (packageName !in blocked) return + PreferenceStore.updateModulePref( + "lspd", 0, "config", "scope_request_blocked", blocked - packageName) + Log.i(TAG, "$packageName was uninstalled; it may ask for scope again if it returns") } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt index cfeb9d107..e81bf8310 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt @@ -12,11 +12,13 @@ import java.nio.file.attribute.PosixFilePermissions import java.util.UUID import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import org.lsposed.lspd.models.Application -import org.lsposed.lspd.models.Module +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.ipc.LoadedModule import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.VectorDaemon +import org.matrix.vector.daemon.ipc.FrameworkService import org.matrix.vector.daemon.ipc.InjectedModuleService +import org.matrix.vector.daemon.ipc.ModuleAppService import org.matrix.vector.daemon.system.* import org.matrix.vector.daemon.utils.InstallerVerifier import org.matrix.vector.daemon.utils.applySqliteHelperWorkaround @@ -32,14 +34,32 @@ object ConfigCache { var state = DaemonState() private set - val dbHelper = Database() // Kept public for PreferenceStore and ModuleDatabase + + // Module package -> the packages it claims, for modules whose module.prop fixes the scope. + // Absent means the module places no restriction on its scope. + @Volatile private var staticScopes: Map> = emptyMap() + + /** The packages [modulePackage] claims, or null when it does not fix its scope. */ + fun staticScopeOf(modulePackage: String): Set? = staticScopes[modulePackage] private val cacheUpdateChannel = Channel(Channel.CONFLATED) + // Held for the whole of a rebuild, so that only one runs at a time. The channel serialises the + // requests that arrive through it, but not the rebuild the readiness latch starts directly on a + // binder thread — two could run at once, read the database in different orders, and whichever + // reached the swap last would publish a module set assembled from a half-written configuration. + private val rebuildLock = Any() + init { VectorDaemon.scope.launch { for (request in cacheUpdateChannel) { - performCacheUpdate() + // Guarded, because the loop *is* the mechanism. A throw escaping here ends the `for`, the + // coroutine completes, and every later requestCacheUpdate() drops its request into a + // channel nobody is reading — the configuration silently stops taking effect for the life + // of the daemon, with one stack trace hours earlier as the only evidence. One bad APK + // reaching the parser is enough to cause it. + runCatching { performCacheUpdate() } + .onFailure { Log.e(TAG, "Cache update failed; the next request will try again", it) } } } applySqliteHelperWorkaround() @@ -47,39 +67,47 @@ object ConfigCache { private fun ensureCacheReady() { if (!state.isCacheReady && packageManager?.asBinder()?.isBinderAlive == true) { - synchronized(this) { + // The rebuild lock, not `this`: this is the one place that starts a rebuild without going + // through the channel, so it has to queue behind a rebuild already in flight rather than + // merely behind the short swaps that publish one. + synchronized(rebuildLock) { if (!state.isCacheReady) { Log.i(TAG, "System services are ready. Mapping modules and scopes.") updateManager(false) setupMiscPath() performCacheUpdate() - state = state.copy(isCacheReady = true) + synchronized(this) { state = state.copy(isCacheReady = true) } } } } } fun updateManager(uninstalled: Boolean) { - if (uninstalled) { - state = state.copy(managerUid = -1) - return - } - runCatching { - val info = - packageManager?.getPackageInfoCompat(BuildConfig.DEFAULT_MANAGER_PACKAGE_NAME, 0, 0) - val uid = info?.applicationInfo?.uid - val installedApkPath = info?.applicationInfo?.sourceDir - if (uid == null || installedApkPath == null) { - Log.i(TAG, "Manager is not installed") - state = state.copy(managerUid = -1) - return - } + // Resolved and verified outside the lock, then written inside it. The verification opens the + // manager's APK and checks its signature, which is not work to do while a rebuild waits — but + // the assignment itself has to be atomic against that rebuild's swap, or whichever finishes + // last wins and this one silently loses. + val resolved = + if (uninstalled) null + else + runCatching { + val info = + packageManager?.getPackageInfoCompat( + BuildConfig.DEFAULT_MANAGER_PACKAGE_NAME, 0, 0) + val uid = info?.applicationInfo?.uid + val installedApkPath = info?.applicationInfo?.sourceDir + if (uid == null || installedApkPath == null) { + Log.i(TAG, "Manager is not installed") + return@runCatching null + } - InstallerVerifier.verifyInstallerSignature(installedApkPath) - Log.i(TAG, "Manager verified and found at UID: $uid") - state = state.copy(managerUid = uid) - } - .onFailure { state = state.copy(managerUid = -1) } + InstallerVerifier.verifyInstallerSignature(installedApkPath) + Log.i(TAG, "Manager verified and found at UID: $uid") + uid + } + .getOrNull() + + synchronized(this) { state = state.copy(managerUid = resolved ?: -1) } } private fun setupMiscPath() { @@ -94,7 +122,7 @@ object ConfigCache { } else { Paths.get(pathStr) } - state = state.copy(miscPath = path) + synchronized(this) { state = state.copy(miscPath = path) } runCatching { val perms = @@ -116,219 +144,314 @@ object ConfigCache { /** Builds a completely new Immutable State and atomically swaps it. */ private fun performCacheUpdate() { - if (packageManager == null) return - - Log.d(TAG, "Executing Cache Update...") - val db = dbHelper.readableDatabase - val oldState = state - - val newModules = mutableMapOf() - val obsoleteModules = mutableSetOf() - val obsoletePaths = mutableMapOf() - - db.query( - "modules", - arrayOf("module_pkg_name", "apk_path"), - "enabled = 1", - null, - null, - null, - null) - .use { cursor -> - while (cursor.moveToNext()) { - val pkgName = cursor.getString(0) - var apkPath = cursor.getString(1) - if (pkgName == "lspd") continue - - val oldModule = oldState.modules[pkgName] - - var pkgInfo: android.content.pm.PackageInfo? = null - val users = userManager?.getRealUsers() ?: emptyList() - for (user in users) { - pkgInfo = packageManager?.getPackageInfoCompat(pkgName, MATCH_ALL_FLAGS, user.id) - if (pkgInfo?.applicationInfo != null) break - } - - if (pkgInfo?.applicationInfo == null) { - Log.w(TAG, "Failed to find package info of $pkgName") - obsoleteModules.add(pkgName) - continue - } - - val appInfo = pkgInfo.applicationInfo - - if (oldModule != null && - appInfo?.sourceDir != null && - apkPath != null && - oldModule.apkPath != null && - FileSystem.toGlobalNamespace(apkPath).exists() && - apkPath == oldModule.apkPath && - File(appInfo.sourceDir).parent == File(apkPath).parent) { - - if (oldModule.appId == -1) oldModule.applicationInfo = appInfo - newModules[pkgName] = oldModule - continue - } - - val realApkPath = getModuleApkPath(appInfo!!) - if (realApkPath == null) { - Log.w(TAG, "Failed to find path of $pkgName") - obsoleteModules.add(pkgName) - continue - } else { - apkPath = realApkPath - obsoletePaths[pkgName] = realApkPath - } + synchronized(rebuildLock) { + if (packageManager == null) return + + Log.d(TAG, "Executing Cache Update...") + val oldState = state + + val newModules = mutableMapOf() + val newStaticScopes = mutableMapOf>() + + // Which users actually hold each module, which is what bounds where it may be injected. + // + // A module is one package and one APK for the whole device — Android has no way to hold two + // different builds under one package name — so the configuration keys it by package alone: + // one enabled flag, one scope set. What genuinely varies per user is whether that package is + // installed at all, and its uid and data directory when it is. This map is that dimension, + // and the scope expansion below refuses to cross it. + val moduleUsers = mutableMapOf>() + // Deleted from the configuration: the package is not installed for any user, so what it was + // configured to do cannot mean anything. + val obsoleteModules = mutableSetOf() + val obsoletePaths = mutableMapOf() + // Kept in the configuration and reported: enabled, installed, and not loadable. The two + // used to be one set, so a module whose APK would not parse was quietly un-enabled — the + // user had asked for it, the switch went off by itself, and nothing said why. + val unloadable = mutableMapOf() + + ModuleDatabase.enabledModuleRows().forEach { row -> + val pkgName = row.packageName + var apkPath = row.apkPath + if (pkgName == "lspd") return@forEach + + val oldModule = oldState.modules[pkgName] + + var pkgInfo: android.content.pm.PackageInfo? = null + val users = userManager?.getRealUsers() ?: emptyList() + // No users means the question could not be asked, not that the answer is no. `getRealUsers` + // swallows a null binder and any failure of `IUserManager.getUsers` into an empty list, and + // an empty list here sends *every* enabled module down the not-installed path below — which + // deletes its row, its scope and its preferences. A transient failure to reach the user + // service would wipe the configuration of every module on the device. + if (users.isEmpty()) { + Log.w( + TAG, "No users available; skipping this rebuild rather than assuming nothing exists") + return + } + // Every user, not the first one that answers, because which users hold the module is what + // keeps it out of a user that never installed it. + // + // Whether the query answered is not whether this user holds the package. [MATCH_ALL_FLAGS] + // carries MATCH_ANY_USER and MATCH_UNINSTALLED_PACKAGES, deliberately - answering for + // every user is what tells "no user has this any more", which deletes the configuration, + // from "not in this user", which must not. So asking about user 0 for a module only user + // 11 holds returns the package, and a boundary built on that admitted every user and + // enforced nothing. + // + // Nor is the uid in the answer a discriminator, which is the other thing it looks like: the + // ApplicationInfo is generated for the user that was *asked about*. Measured on a device, + // for a module held only by users 11 and 12, the three queries returned 10136, 1110136 and + // 1210136 - user 0 included, though user 0 does not have it. + // + // `isPackageAvailable` is the per-user installed state and answers correctly: false, true, + // true for the same module, and the exact inverse for one installed only for user 0. It is + // the same test the target resolution below has always used. Hidden counts as held, because + // a locked private space hides its apps without ceasing to hold them. + // + // A holder wins the ApplicationInfo, so the data directory the module is handed is one + // that exists; the lowest user id among them, so it stays put as other users come and go. + var anyPkgInfo: android.content.pm.PackageInfo? = null + for (user in users.sortedBy { it.id }) { + val info = packageManager?.getPackageInfoCompat(pkgName, MATCH_ALL_FLAGS, user.id) + if (info?.applicationInfo == null) continue + if (anyPkgInfo == null) anyPkgInfo = info + if (packageManager?.isPackageAvailable(pkgName, user.id, true) != true) continue + moduleUsers[pkgName] = moduleUsers[pkgName].orEmpty() + user.id + if (pkgInfo == null) pkgInfo = info + } + // Nothing held it, but something answered: still installed somewhere as far as the package + // manager is concerned, so it is not obsolete and must not have its configuration deleted. + if (pkgInfo == null) pkgInfo = anyPkgInfo + + // Gone, not broken. No user has this package any more, so the configuration for it is + // meaningless and is cleaned up. This is the only case that deletes anything. + if (pkgInfo?.applicationInfo == null) { + Log.w(TAG, "Failed to find package info of $pkgName") + obsoleteModules.add(pkgName) + return@forEach + } - val preLoadedApk = FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled) - if (preLoadedApk != null) { - val module = - Module().apply { - packageName = pkgName - this.apkPath = apkPath - appId = appInfo.uid - applicationInfo = appInfo - service = oldModule?.service ?: InjectedModuleService(pkgName) - file = preLoadedApk - } - newModules[pkgName] = module - } else { - Log.w(TAG, "Failed to parse DEX/ZIP for $pkgName, skipping.") - obsoleteModules.add(pkgName) - } + val appInfo = pkgInfo.applicationInfo + + if (oldModule != null && + appInfo?.sourceDir != null && + apkPath != null && + oldModule.apkPath != null && + FileSystem.toGlobalNamespace(apkPath).exists() && + apkPath == oldModule.apkPath && + File(appInfo.sourceDir).parent == File(apkPath).parent) { + + // -1 is what `getModulesForSystemServer` leaves behind when it could not stat the + // module's data directory before the package manager existed. This is the first point at + // which the real answer is available, so both halves of it are filled in — the appId as + // well as the ApplicationInfo, which is what identifies the module to itself. + if (oldModule.appId == -1) { + oldModule.applicationInfo = appInfo + oldModule.appId = appInfo.uid % PER_USER_RANGE } + // This path skips re-reading the APK, so what the module claims has to be carried + // over; the new map replaces the old one wholesale and would otherwise lose it. + staticScopes[pkgName]?.let { newStaticScopes[pkgName] = it } + newModules[pkgName] = oldModule + return@forEach } - if (packageManager?.asBinder()?.isBinderAlive == true) { - obsoleteModules.forEach { ModuleDatabase.removeModule(it) } - obsoletePaths.forEach { (pkg, path) -> ModuleDatabase.updateModuleApkPath(pkg, path, true) } - } - - val newScopes = mutableMapOf>() - db.query( - "scope INNER JOIN modules ON scope.mid = modules.mid", - arrayOf("app_pkg_name", "module_pkg_name", "user_id"), - "enabled = 1", - null, - null, - null, - null) - .use { cursor -> - while (cursor.moveToNext()) { - val appPkg = cursor.getString(0) - val modPkg = cursor.getString(1) - val userId = cursor.getInt(2) - - val module = newModules[modPkg] ?: continue - - if (appPkg == "system") { - newScopes - .getOrPut(ProcessScope("system_server", 1000)) { mutableListOf() } - .add(module) - continue - } - - val pkgInfo = - packageManager?.getPackageInfoWithComponents(appPkg, MATCH_ALL_FLAGS, userId) - if (pkgInfo?.applicationInfo == null) continue - - val processNames = pkgInfo.fetchProcesses() - if (processNames.isEmpty()) continue - - val appUid = pkgInfo.applicationInfo!!.uid + val realApkPath = getModuleApkPath(appInfo!!) + if (realApkPath == null) { + // Installed, enabled, and not loadable. Deleting the row here would silently un-enable a + // module the user did enable, and they would find the switch off with no reason given. + // The configuration stands; what could not be done is recorded and reported instead. + Log.w(TAG, "Failed to find path of $pkgName") + unloadable[pkgName] = IManagerService.MODULE_LOAD_NO_APK + return@forEach + } + apkPath = realApkPath + obsoletePaths[pkgName] = realApkPath - for (processName in processNames) { - val processScope = ProcessScope(processName, appUid) - newScopes.getOrPut(processScope) { mutableListOf() }.add(module) + FileSystem.readStaticScope(apkPath)?.let { newStaticScopes[pkgName] = it } - if (modPkg == appPkg) { - val appId = appUid % PER_USER_RANGE - userManager?.getRealUsers()?.forEach { user -> - val moduleUid = user.id * PER_USER_RANGE + appId - if (moduleUid != appUid) { - val moduleSelf = ProcessScope(processName, moduleUid) - newScopes.getOrPut(moduleSelf) { mutableListOf() }.add(module) - } + when (val loaded = FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled)) { + is ModuleLoad.Loaded -> { + val module = + LoadedModule().apply { + packageName = pkgName + this.apkPath = apkPath + // An app id, as the name says, not the uid it is read from. Every reader compares + // it against `someUid % PER_USER_RANGE`, and the raw uid only agreed with that + // while the ApplicationInfo came from user 0 — which it did by luck, user 0 + // being first in the list and answering for packages it does not even hold. + // The resolution above now deliberately prefers a *holder*, so for a module only + // user 11 has this reads 1110136, and without the modulo the module would fail + // its own authentication in `ModuleAppService.ensureModule` against a caller's + // 10136 and never be sent its binder. + appId = appInfo.uid % PER_USER_RANGE + versionCode = pkgInfo.longVersionCode + applicationInfo = appInfo + service = oldModule?.service ?: InjectedModuleService(pkgName) + code = loaded.apk } - } - } + newModules[pkgName] = module + } + // As above: a module the framework will not load is broken, not unwanted. + // + // And of the reasons it will not load, one is not brokenness at all: a module built + // against libxposed API 100, which this framework refuses outright, is simply old and + // needs its author to rebuild it. That one the loader names, so it is passed on rather + // than reported as "the framework could not load it" alongside a zip that will not parse. + ModuleLoad.UnsupportedApi -> { + Log.w(TAG, "Could not load $pkgName: it targets libxposed API 100; skipping.") + unloadable[pkgName] = IManagerService.MODULE_LOAD_UNSUPPORTED_API + } + ModuleLoad.Unusable -> { + Log.w(TAG, "Could not load $pkgName; skipping.") + unloadable[pkgName] = IManagerService.MODULE_LOAD_UNUSABLE } } + } - // --- ATOMIC STATE SWAP --- - state = oldState.copy(modules = newModules, scopes = newScopes) + if (packageManager?.asBinder()?.isBinderAlive == true) { + obsoleteModules.forEach { ModuleDatabase.removeModule(it) } + obsoletePaths.forEach { (pkg, path) -> ModuleDatabase.updateModuleApkPath(pkg, path, true) } + } - Log.d(TAG, "Cache Update Complete. Map Swap successful.") - // Log.d(TAG, "cached modules:") - // newModules.forEach { (pkg, mod) -> Log.d(TAG, "$pkg ${mod.apkPath}") } + // Rows can predate the module declaring a fixed scope, or come from an older build that let + // them in. Dropping them here is what makes the scope actually fixed rather than merely + // unreachable through the manager, and it runs before the scope table is read below. + newStaticScopes.forEach { (modulePkg, claimed) -> + val dropped = ModuleDatabase.pruneScopeToClaimed(modulePkg, claimed) + if (dropped > 0) { + Log.i(TAG, "Dropped $dropped app(s) outside the static scope of $modulePkg") + } + } - // Log.d(TAG, "cached scopes:") - // newScopes.forEach { (ps, modules) -> - // Log.d(TAG, "${ps.processName}/${ps.uid}") - // modules.forEach { mod -> Log.d(TAG, "\t${mod.packageName}") } - // } - } + val newScopes = mutableMapOf>() - fun getModuleScope(packageName: String): MutableList? { - if (packageName == "lspd") return null - val result = mutableListOf() - dbHelper.readableDatabase - .query( - "scope INNER JOIN modules ON scope.mid = modules.mid", - arrayOf("app_pkg_name", "user_id"), - "modules.module_pkg_name = ?", - arrayOf(packageName), - null, - null, - null) - .use { cursor -> - while (cursor.moveToNext()) { - result.add( - Application().apply { - this.packageName = cursor.getString(0) - this.userId = cursor.getInt(1) - }) - } + // A module can reach the same process by more than one route: self rows in two users each + // propagate into the other's, and the scope derived below can name a process a row named as + // well. Twice in the list is twice loaded, so every insertion goes through here. + fun addToScope(processName: String, uid: Int, module: LoadedModule) { + val modules = newScopes.getOrPut(ProcessScope(processName, uid)) { mutableListOf() } + if (modules.none { it === module }) modules.add(module) + } + + ModuleDatabase.enabledScopeRows().forEach { scopeRow -> + val appPkg = scopeRow.appPackage + val modPkg = scopeRow.modulePackage + val userId = scopeRow.userId + + val module = newModules[modPkg] ?: return@forEach + val holders = moduleUsers[modPkg].orEmpty() + + if (appPkg == "system") { + // system_server is one process for the whole device and belongs to no user, so any user + // holding the module may hook it and the row is stored under user 0 whoever asked. It is + // the one target the boundary below does not apply to, because there is no second copy + // of it to keep a module out of. + if (holders.isNotEmpty()) addToScope("system_server", 1000, module) + return@forEach } - return result - } - fun getAutoInclude(packageName: String): Boolean { - if (packageName == "lspd") return false - - var isAutoInclude = false - dbHelper.readableDatabase - .query( - "modules", - arrayOf("auto_include"), - "module_pkg_name = ?", - arrayOf(packageName), - null, - null, - null) - .use { cursor -> - if (cursor.moveToFirst()) { - isAutoInclude = cursor.getInt(0) == 1 + // The user boundary. A row names one app instance, and reaching it means running the + // module's code in that user — so a user that never installed the module is not somewhere + // its rows may take it. A module held only by user 11 stays out of user 0's processes even + // when a row points at one. + // + // Nothing enforced this before. The row was expanded on the strength of the *target* + // resolving for that user, and the module followed wherever it pointed; a module installed + // for user 11 alone was observed loading into and hooking a user 0 app. + if (userId !in holders) return@forEach + + val pkgInfo = packageManager?.getPackageInfoWithComponents(appPkg, MATCH_ALL_FLAGS, userId) + if (pkgInfo?.applicationInfo == null) return@forEach + + val processNames = pkgInfo.fetchProcesses() + if (processNames.isEmpty()) return@forEach + + val appUid = pkgInfo.applicationInfo!!.uid + + for (processName in processNames) { + addToScope(processName, appUid, module) + + // A module in its own scope hooks itself in every user that has it — the copies share + // one APK, so what one copy hooks in itself the others may expect too. Over the users + // holding the module rather than over every user on the device: a uid in a user without + // the package names no process that can ever start, so it was only ever dead weight. + if (modPkg == appPkg) { + val appId = appUid % PER_USER_RANGE + holders.forEach { holder -> + val moduleUid = holder * PER_USER_RANGE + appId + if (moduleUid != appUid) addToScope(processName, moduleUid, module) + } } } - return isAutoInclude - } + } - fun getAutoIncludeModules(): List { - val result = mutableListOf() - ConfigCache.dbHelper.readableDatabase - .query("modules", arrayOf("module_pkg_name"), "auto_include = 1", null, null, null, null) - .use { cursor -> - val idx = cursor.getColumnIndexOrThrow("module_pkg_name") - while (cursor.moveToNext()) { - val pkgName = cursor.getString(idx) - if (pkgName != "lspd") result.add(pkgName) + // A legacy module reports being active by hooking a method in its own app, so it has to be + // in its own scope before it can say anything at all. The manager used to add that row on + // every save and hide it again on read; #796 dropped both halves, and every legacy module + // has reported itself inactive since (#816). + // + // Derived here rather than stored, so a configuration written by those builds needs no + // repair and nothing that replaces the scope table can drop it again. Legacy is the + // loader's own verdict, so a module built against API 101 keeps its own process to itself. + newModules.values + .filter { it.code?.legacy == true } + .forEach { module -> + // The users holding it, for the same reason as the self-scope above: the other users + // have no copy for the module to report itself active in. + moduleUsers[module.packageName].orEmpty().forEach { userId -> + val pkgInfo = + packageManager?.getPackageInfoWithComponents( + module.packageName, MATCH_ALL_FLAGS, userId) ?: return@forEach + val moduleUid = pkgInfo.applicationInfo?.uid ?: return@forEach + pkgInfo.fetchProcesses().forEach { processName -> + addToScope(processName, moduleUid, module) + } + } } - } - return result + + // --- ATOMIC STATE SWAP --- + // + // Against the *current* state, not against the copy taken at the top of this function. A + // rebuild takes tens of milliseconds and makes binder calls throughout, and the other three + // writers — updateManager, setupMiscPath, the readiness latch — mutate the same field from + // other threads meanwhile. Writing `oldState.copy(...)` back would revert whichever of them + // landed during the rebuild: a manager reinstalled mid-rebuild would stop being recognised as + // the manager, having been recognised a moment earlier. + // + // `oldState` is still the right thing to *read* from above: reusing an already-parsed module + // is a decision about what was loaded when the rebuild started. + synchronized(this) { + state = state.copy(modules = newModules, scopes = newScopes, unloadable = unloadable) + // Swapped here rather than sixty lines earlier, so that the claims and the modules they + // belong to become visible together. Between the two assignments a reader could see the new + // static scopes against the old module set. + staticScopes = newStaticScopes + } + + Log.d(TAG, "Cache Update Complete. Map Swap successful.") + + // Targets are removed only after the module set has been published. + (oldState.modules.keys - newModules.keys).forEach { + FrameworkService.forgetHotReloadTargets(it) + } + FrameworkService.backfillLoadedVersions() + + // Ask stale opt-in targets to load the generation that was just installed. + newModules.values.forEach { ModuleAppService.autoHotReload(it) } + // Log.d(TAG, "cached modules:") + // newModules.forEach { (pkg, mod) -> Log.d(TAG, "$pkg ${mod.apkPath}") } + + // Log.d(TAG, "cached scopes:") + // newScopes.forEach { (ps, modules) -> + // Log.d(TAG, "${ps.processName}/${ps.uid}") + // modules.forEach { mod -> Log.d(TAG, "\t${mod.packageName}") } + // } + } } - fun getModulesForProcess(processName: String, uid: Int): List { + fun getModulesForProcess(processName: String, uid: Int): List { ensureCacheReady() if (processName == "system_server") { Log.w(TAG, "Skip unexpected module queries for $processName") @@ -337,11 +460,30 @@ object ConfigCache { return state.scopes[ProcessScope(processName, uid)] ?: emptyList() } - fun getModuleByUid(uid: Int): Module? = + fun getModuleByUid(uid: Int): LoadedModule? = state.modules.values.firstOrNull { it.appId == uid % PER_USER_RANGE } - fun getModulesForSystemServer(): List { - val modules = mutableListOf() + /** + * A module's device-protected data directory, found by looking rather than by assuming user 0. + * + * This runs while system_server is starting, so there is no package manager to ask and the + * directory the installer made is the only record of the module on disk. A module installed for a + * secondary user alone has no `/data/user_de/0` entry, so hardcoding that one both left the + * module's paths pointing at nothing and made its app id -1 — which then travelled into + * `ApplicationInfo.uid` as the identity of a module about to be loaded into the system server. + * + * Lowest user id first, so the owner's copy wins when there is one. + */ + private fun resolveModuleDataDir(pkgName: String): String? { + val userDirs = FileSystem.toGlobalNamespace("/data/user_de").listFiles() ?: return null + return userDirs + .sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE } + .map { FileSystem.toGlobalNamespace("/data/user_de/${it.name}/$pkgName").absolutePath } + .firstOrNull { runCatching { Os.stat(it) }.isSuccess } + } + + fun getModulesForSystemServer(): List { + val modules = mutableListOf() if (!android.os.SELinux.checkSELinuxAccess( "u:r:system_server:s0", "u:r:system_server:s0", "process", "execmem")) { Log.e(TAG, "Skipping system_server injection: sepolicy execmem denied") @@ -350,38 +492,38 @@ object ConfigCache { val currentState = state - dbHelper.readableDatabase - .query( - "scope INNER JOIN modules ON scope.mid = modules.mid", - arrayOf("module_pkg_name", "apk_path"), - "app_pkg_name=? AND enabled=1", - arrayOf("system"), - null, - null, - null) - .use { cursor -> - while (cursor.moveToNext()) { - val pkgName = cursor.getString(0) - val apkPath = cursor.getString(1) + ModuleDatabase.systemServerModuleRows().forEach { row -> + run { + val pkgName = row.packageName + // A row with no recorded path has never been resolved; the rebuild will fill it in, + // and injecting from a null path is not something to attempt in the meantime. + val apkPath = row.apkPath ?: return@forEach val cached = currentState.modules[pkgName] if (cached != null) { + stageNativeLibrariesFor(cached) modules.add(cached) - continue + return@forEach } - val statPath = FileSystem.toGlobalNamespace("/data/user_de/0/$pkgName").absolutePath + val statPath = + resolveModuleDataDir(pkgName) + ?: FileSystem.toGlobalNamespace("/data/user_de/0/$pkgName").absolutePath val module = - Module().apply { + LoadedModule().apply { packageName = pkgName this.apkPath = apkPath - appId = runCatching { Os.stat(statPath).st_uid }.getOrDefault(-1) + // An app id, matching what the rebuild stores, so it means the same thing + // whichever user's directory answered above. + appId = + runCatching { Os.stat(statPath).st_uid % PER_USER_RANGE }.getOrDefault(-1) service = InjectedModuleService(pkgName) } - runCatching { + runCatching { @Suppress("DEPRECATION") val pkg = PackageParser().parsePackage(File(apkPath), 0, false) + // A raw parse carries no version; backfillLoadedVersions supplies it later. module.applicationInfo = pkg.applicationInfo } .onFailure { @@ -399,16 +541,37 @@ object ConfigCache { uid = module.appId } - FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled)?.let { - module.file = it + FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled).apkOrNull?.let { + module.code = it + stageNativeLibrariesFor(module) modules.add(module) // We intentionally don't mutate state.modules here. Cache update will catch it. } } } + FileSystem.pruneStagedNativeLibraries( + state.miscPath, modules.mapTo(mutableSetOf()) { it.packageName }) return modules } + /** + * Hands a module bound for system_server the copy of its native libraries it can actually map. + * + * Only that scope needs one. Every other process may execute straight out of /data/app, so the + * in-APK entries the loader already builds serve them, and staging for them would buy nothing but + * disk. A module that ships no library, or whose staging failed, keeps a null here and loads + * exactly as it did before. + */ + private fun stageNativeLibrariesFor(module: LoadedModule) { + val file = module.code ?: return + // system_server asks for its modules early enough that the cache may not have been built yet, + // and this is the same reason getPrefsPath does not trust the field either. + setupMiscPath() + val misc = state.miscPath ?: return + file.nativeLibraryDir = + FileSystem.stageNativeLibraries(misc, module.packageName, module.apkPath) + } + fun getModuleApkPath(info: ApplicationInfo): String? { val apks = mutableListOf() info.sourceDir?.let { apks.add(it) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt index d5f2b50c1..fe4a4e5e5 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt @@ -1,7 +1,7 @@ package org.matrix.vector.daemon.data import java.nio.file.Path -import org.lsposed.lspd.models.Module +import org.matrix.vector.ipc.LoadedModule import org.matrix.vector.daemon.BuildConfig data class ProcessScope(val processName: String, val uid: Int) @@ -17,6 +17,17 @@ data class DaemonState( val isCacheReady: Boolean = false, val managerUid: Int = -1, val miscPath: Path? = null, - val modules: Map = emptyMap(), - val scopes: Map> = emptyMap(), + val modules: Map = emptyMap(), + val scopes: Map> = emptyMap(), + /** + * Modules the user enabled that the framework could not load, and why. + * + * The gap between the two notions of "module" this daemon holds — what was asked for, which + * lives in the database, and what can actually be loaded, which lives here. They can disagree + * legitimately: an APK whose path cannot be resolved, or whose DEX will not parse, stays + * configured and never loads. That difference used to be discarded, and the module simply + * appeared to be off; it is reported now, because it is the one thing a reader in that + * situation needs to know. + */ + val unloadable: Map = emptyMap(), ) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt index eaed7722b..c0b4ea452 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/Database.kt @@ -30,6 +30,12 @@ class Database(context: Context? = FakeContext()) : module_pkg_name text NOT NULL UNIQUE, apk_path text NOT NULL, enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0, 1)), + -- `include new apps` everywhere else: when set, a package installed from now on is + -- added to this module's scope. The column keeps the older name on purpose. SQLite + -- gained ALTER TABLE RENAME COLUMN in 3.25 and this project supports API 27, whose + -- SQLite is older, so renaming means rebuilding the table behind a version bump — + -- and onDowngrade wipes the module configuration, so anyone who then flashed an + -- older build would lose theirs. That is a real cost for a name no user ever sees. auto_include BOOLEAN DEFAULT 0 CHECK (auto_include IN (0, 1)) ); """ diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 5eeb7a1b9..a657223d2 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -3,6 +3,7 @@ package org.matrix.vector.daemon.data import android.content.res.AssetManager import android.content.res.Resources import android.os.Binder +import android.os.Build import android.os.ParcelFileDescriptor import android.os.Process import android.os.RemoteException @@ -27,17 +28,42 @@ import java.nio.file.attribute.PosixFilePermissions import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream import kotlin.io.path.exists import kotlin.io.path.isDirectory -import org.lsposed.lspd.models.PreLoadedApk +import org.matrix.vector.ipc.ModuleCode import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.utils.ObfuscationManager private const val TAG = "VectorFileSystem" +/** + * What came of trying to load a module APK. + * + * The loader used to answer every refusal with the same null, so a module built against libxposed + * API 100 — which this framework drops outright — reached the user as the same "the framework could + * not load it" as a zip that will not parse. That refusal is the one with somewhere to go: the + * module is not broken, it is old, and only a rebuild by its author moves it. The rest really are + * indistinguishable from here. + */ +sealed interface ModuleLoad { + /** Parsed, and ready to hand to a forking process. */ + data class Loaded(val apk: ModuleCode) : ModuleLoad + + /** Declares libxposed API 100, and carries nothing else this framework can load. */ + data object UnsupportedApi : ModuleLoad + + /** Will not parse, has no init files, or names no module classes. */ + data object Unusable : ModuleLoad +} + +/** The APK when it loaded and null when it did not, for callers with nothing to say about why. */ +val ModuleLoad.apkOrNull: ModuleCode? + get() = (this as? ModuleLoad.Loaded)?.apk + object FileSystem { val basePath: Path = Paths.get("/data/adb/lspd") val logDirPath: Path = basePath.resolve("log") @@ -160,33 +186,89 @@ object FileSystem { return memory } + /** + * The packages a module claims, when its module.prop fixes the scope. Null when it does not, so + * a caller can tell "claims nothing" from "claims no restriction". + * + * staticScope is documented as "the module scope is fixed and users should not apply the module + * on apps outside the scope list". Enforcing that in the manager alone leaves the socket CLI, a + * backup restore and the module's own requestScope walking straight past it, so the daemon has + * to know about it too. + */ + fun readStaticScope(apkPath: String): Set? = + runCatching { + ZipFile(File(apkPath)).use { zip -> + val props = + Properties().apply { + zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> + runCatching { zip.getInputStream(entry).use { load(it) } } + } + } + if (!props.getProperty("staticScope").toBoolean()) return@use null + val claimed = + zip.getEntry("META-INF/xposed/scope.list")?.let { entry -> + zip.getInputStream(entry).bufferedReader().useLines { lines -> + lines.map { it.trim() }.filter { it.isNotEmpty() }.toSet() + } + } ?: emptySet() + // A module that fixes its scope and then names nothing has fixed it at "no apps at + // all": every write through here is refused, and pruneScopeToClaimed deletes the rows + // the user had already chosen on the next cache rebuild. That is a packaging mistake + // rather than an intention — a module ships staticScope=true with a scope.list it + // forgot to generate — and the cost of reading it literally is a module that can + // never hook anything, silently. So the declaration is ignored and the scope stays + // the user's; the manager's ModuleDetection ignores it too, so the picker it draws + // and the writes accepted here agree about what the module may hook. + if (claimed.isEmpty()) { + Log.w(TAG, "$apkPath fixes its scope but names nothing; ignoring staticScope") + return@use null + } + claimed + } + } + .onFailure { Log.w(TAG, "Cannot read the scope list of $apkPath", it) } + .getOrNull() + /** Parses the module APK, extracts init lists, and loads DEXes into SharedMemory. */ - fun loadModule(apkPath: String, obfuscate: Boolean): PreLoadedApk? { + fun loadModule(apkPath: String, obfuscate: Boolean): ModuleLoad { val file = File(apkPath) - if (!file.exists()) return null + if (!file.exists()) return ModuleLoad.Unusable - val preLoadedApk = PreLoadedApk() + val preLoadedApk = ModuleCode() val preLoadedDexes = mutableListOf() val moduleClassNames = mutableListOf() val moduleLibraryNames = mutableListOf() var isLegacy = false + var exceptionPassthrough = false + var targetApiVersion = 0 + var autoHotReload = false runCatching { ZipFile(file).use { zip -> - // Parse module.prop to get targetApiVersion + // module.prop is specified as Java Properties format. Parsing it by hand mishandles + // ':' as a separator, '!' comments, escapes and line continuations, and the manager + // app already reads the same file with Properties.load. val props = - zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> - zip.getInputStream(entry).bufferedReader().useLines { lines -> - lines - .filter { it.contains("=") } - .associate { - val parts = it.split("=", limit = 2) - parts[0].trim() to parts[1].trim() - } + Properties().apply { + zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> + // Properties.load rejects a malformed \uXXXX escape, which the old hand-rolled + // parser tolerated. Keep that tolerance: a bad module.prop must not make the + // APK unloadable, not least because a legacy module is selected by + // assets/xposed_init and needs no module.prop at all. + runCatching { zip.getInputStream(entry).use { load(it) } } + .onFailure { Log.w(TAG, "Malformed module.prop in $apkPath", it) } } - } ?: emptyMap() + } + + val targetApi = leadingInt(props.getProperty("targetApiVersion")) + targetApiVersion = targetApi + autoHotReload = props.getProperty("autoHotReload")?.trim().toBoolean() + // The module-wide mode ExceptionMode.DEFAULT resolves to. Anything that is not + // "passthrough" - absent, misspelled, or an explicit "protective" - keeps the + // protective default the API specifies. + exceptionPassthrough = + props.getProperty("exceptionMode")?.trim().equals("passthrough", true) - val targetApi = props["targetApiVersion"]?.toIntOrNull() ?: 0 val hasLegacyFile = zip.getEntry("assets/xposed_init") != null // Determine Loading Strategy based on Priority: API 101+ > Legacy > API 100 @@ -223,12 +305,12 @@ object FileSystem { } "UNSUPPORTED" -> { Log.w(TAG, "Module $apkPath uses API 100 which is no longer supported.") - return null + return ModuleLoad.UnsupportedApi } - else -> return null // No valid init files found + else -> return ModuleLoad.Unusable // No valid init files found } - if (moduleClassNames.isEmpty()) return null + if (moduleClassNames.isEmpty()) return ModuleLoad.Unusable // Read DEX files var secondary = 1 @@ -242,10 +324,10 @@ object FileSystem { } .onFailure { Log.e(TAG, "Failed to load module $apkPath", it) - return null + return ModuleLoad.Unusable } - if (preLoadedDexes.isEmpty()) return null + if (preLoadedDexes.isEmpty()) return ModuleLoad.Unusable // Apply obfuscation if (obfuscate) { @@ -263,9 +345,12 @@ object FileSystem { this.moduleClassNames = moduleClassNames this.moduleLibraryNames = moduleLibraryNames this.legacy = isLegacy + this.exceptionPassthrough = exceptionPassthrough + this.targetApiVersion = targetApiVersion + this.autoHotReload = autoHotReload } - return preLoadedApk + return ModuleLoad.Loaded(preLoadedApk) } /** Safely creates the log directory. If a file exists with the same name, it deletes it first. */ @@ -308,7 +393,7 @@ object FileSystem { fun getPreloadDex(obfuscate: Boolean): SharedMemory? { if (preloadDex == null) { runCatching { - FileInputStream("framework/lspd.dex").use { preloadDex = readDex(it, obfuscate) } + FileInputStream("framework/vector.dex").use { preloadDex = readDex(it, obfuscate) } } .onFailure { Log.e(TAG, "Failed to load framework dex", it) } } @@ -336,6 +421,102 @@ object FileSystem { return path } + /** + * Copies a module's native libraries out of its APK into a directory this framework owns, and + * answers with that directory. + * + * A module loaded into system_server cannot dlopen a library straight out of its own APK. + * Everything under /data/app is apk_data_file, and while system_server may read and map such a + * file it may not execute it; AOSP says why in so many words - "Executable files in /data are a + * persistence vector" - and forbids granting it. Every app domain does hold that permission, + * which is the whole reason the same module loads the same library without trouble in an ordinary + * process and fails only in system_server. + * + * The way past it is not a new rule but the one this module already ships. xposed_data is a type + * we declare ourselves, outside the data_file_type attribute that neverallow is written against, + * and `allow * xposed_data {file dir} *` already reaches every domain - system_server included. + * A copy placed under it is one system_server may map executable. Extraction has a second + * benefit: the library ends up at offset zero of an ordinary file, so it no longer has to be + * stored uncompressed and page-aligned inside the APK to be mappable at all. + * + * Note that this deliberately does not consult moduleLibraryNames. That list only names the + * libraries whose native_init we are asked to call, and a module is free to load its own + * libraries without declaring any - the module that prompted all this does exactly that. + * + * Returns null when the module ships nothing for this ABI or the copy failed, in which case the + * module still loads and only its native part fails, exactly as it does today. + */ + fun stageNativeLibraries(root: Path, packageName: String, apkPath: String): String? = + runCatching { + val apk = File(apkPath) + val dir = root.resolve("lib").resolve(packageName) + + // Re-extract only when the APK behind the copy changed. Getting this wrong in the + // lenient direction would leave system_server running a module's superseded native + // code, so the framework's own version is part of the stamp as well. + val stamp = "${apk.length()}:${apk.lastModified()}:${BuildConfig.VERSION_CODE}" + val stampFile = dir.resolve(".stamp").toFile() + if (stampFile.isFile && stampFile.readText() == stamp) return@runCatching dir.toString() + + val abis = + if (Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS + + ZipFile(apk).use { zip -> + val libraries = + zip.entries().asSequence().filter { !it.isDirectory && it.name.endsWith(".so") } + .toList() + // A module built for several ABIs keeps them in sibling directories, and only the one + // this process could load is worth copying. + val abi = + abis.firstOrNull { abi -> libraries.any { it.name.startsWith("lib/$abi/") } } + ?: return@runCatching null + + dir.toFile().deleteRecursively() + Files.createDirectories(dir) + + libraries + .filter { it.name.startsWith("lib/$abi/") } + .forEach { entry -> + val target = dir.resolve(entry.name.substringAfterLast('/')) + zip.getInputStream(entry).use { input -> + Files.newOutputStream(target).use { input.copyTo(it) } + } + Os.chmod(target.toString(), "644".toInt(8)) + } + + stampFile.writeText(stamp) + // The daemon runs with a zero umask, so every mode here is set rather than inherited. + Os.chmod(stampFile.absolutePath, "644".toInt(8)) + // The misc root is searchable but not listable; the staged tree keeps that shape, and + // the label is what actually decides whether system_server may map these files. + Os.chmod(dir.parent.toString(), "711".toInt(8)) + Os.chmod(dir.toString(), "711".toInt(8)) + setSelinuxContextRecursive(dir, "u:object_r:xposed_data:s0") + SELinux.setFileContext(dir.parent.toString(), "u:object_r:xposed_data:s0") + dir.toString() + } + } + .onFailure { Log.e(TAG, "Failed to stage the native libraries of $packageName", it) } + .getOrNull() + + /** + * Drops staged libraries belonging to modules that are no longer bound for system_server, so an + * uninstalled or rescoped module does not leave a copy of its native code behind for good. + */ + fun pruneStagedNativeLibraries(root: Path?, keep: Set) { + if (root == null) return + runCatching { + val libRoot = root.resolve("lib") + if (!libRoot.isDirectory()) return + Files.list(libRoot).use { stream -> + stream + .filter { it.fileName.toString() !in keep } + .forEach { it.toFile().deleteRecursively() } + } + } + .onFailure { Log.e(TAG, "Failed to prune staged native libraries", it) } + } + fun toGlobalNamespace(path: String): File { return if (path.startsWith("/")) File("/proc/1/root", path) else File("/proc/1/root/$path") } @@ -343,8 +524,12 @@ object FileSystem { fun getLogs(zipFd: ParcelFileDescriptor) { runCatching { ZipOutputStream(java.io.FileOutputStream(zipFd.fileDescriptor)).use { os -> + // The commit, not just the version code: the code is the commit count on master, so + // every branch build at the same depth wears the number of an official build it was + // never made from. Without it an attached archive cannot be tied to a binary. val comment = - "Vector ${BuildConfig.BUILD_TYPE} ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + "Vector ${BuildConfig.BUILD_TYPE} ${BuildConfig.VERSION_NAME} " + + "(${BuildConfig.VERSION_CODE}) ${BuildConfig.VERSION_HASH}" os.setComment(comment) os.setLevel(java.util.zip.Deflater.BEST_COMPRESSION) @@ -423,10 +608,10 @@ object FileSystem { os.write("${scope.processName}/${scope.uid}\n".toByteArray()) modules.forEach { mod -> os.write("\t${mod.packageName}\n".toByteArray()) - mod.file?.moduleClassNames?.forEach { cn -> + mod.code?.moduleClassNames?.forEach { cn -> os.write("\t\t$cn\n".toByteArray()) } - mod.file?.moduleLibraryNames?.forEach { ln -> + mod.code?.moduleLibraryNames?.forEach { ln -> os.write("\t\t$ln\n".toByteArray()) } } @@ -448,6 +633,39 @@ object FileSystem { return "${prefix}_${formatter.format(Instant.now())}.log" } + /** + * The parts still on disk for one of the two logs, oldest first. + * + * Read from the directory rather than from LogcatMonitor's LRU so that a manager opened after a + * daemon restart still sees the history: the LRU is rebuilt empty, the files are not. + */ + fun listLogParts(verbose: Boolean): List { + val prefix = if (verbose) "verbose_" else "modules_" + return runCatching { + logDirPath + .toFile() + .listFiles { file -> file.isFile && file.name.startsWith(prefix) && file.name.endsWith(".log") } + ?.map { it.name } + // The names carry an ISO-8601 timestamp, so lexicographic order is chronological. + ?.sorted() + .orEmpty() + } + .getOrDefault(emptyList()) + } + + /** + * Opens one part by name. + * + * The name arrives from an unprivileged process and is used to build a path inside a directory + * only root can read, so it is never trusted: it has to be one of the names [listLogParts] just + * returned, which rules out traversal and anything outside the log directory by construction + * rather than by pattern-matching for "..". + */ + fun openLogPart(verbose: Boolean, name: String): File? { + if (name !in listLogParts(verbose)) return null + return logDirPath.resolve(name).toFile().takeIf { it.isFile } + } + fun getNewVerboseLogPath(): File { createLogDirPath() return logDirPath.resolve(getNewLogFileName("verbose")).toFile() @@ -457,4 +675,8 @@ object FileSystem { createLogDirPath() return logDirPath.resolve(getNewLogFileName("modules")).toFile() } + + // Matches the manager's leading-integer parsing, including values such as "101.0". + private fun leadingInt(value: String?): Int = + value?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt index 40210aace..32a0acc85 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt @@ -3,15 +3,192 @@ package org.matrix.vector.daemon.data import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.util.Log -import org.lsposed.lspd.models.Application +import org.matrix.vector.ipc.ScopeEntry +import org.matrix.vector.daemon.system.NotificationManager private const val TAG = "VectorModuleDatabase" +/** + * The module configuration: what the user asked for. + * + * This is the source of truth, and the only thing here that talks to the database. It answers the + * questions a manager asks — is this module enabled, what is its scope, does it auto-include — and + * it performs every write. + * + * The distinction from [ConfigCache] is the whole point of splitting them, and getting it wrong has + * already cost one bug. There are two different notions of "module" in this daemon: + * + * * **Configuration**, here: what was asked for. Cheap, transactional, and it must always answer a + * reader with the reader's own writes already applied. + * * **Realisation**, in [ConfigCache]: what can actually be loaded into a process right now — the + * resolved APK path, the parsed DEX, the app id. Expensive to build (package manager calls, zip + * reads and DEX parsing; measured at 39 ms for five modules) and therefore rebuilt off the + * binder thread, coalesced, and *eventually* consistent. + * + * `enabledModules()` used to be answered from the realisation, which is a configuration question + * asked of a store that is allowed to lag. The manager enabled a module, read back immediately, and + * was told the state from before its own write. Every other configuration query already read the + * database; that one line was the outlier, which is why the symptom was so hard to place. + * + * The database handle lives here rather than in the cache so the dependency points the right way: + * the realisation is derived *from* the configuration, and a config query cannot be written inside + * the cache without deliberately reaching over here for a database to use. + */ object ModuleDatabase { + /** The one database handle. [ConfigCache], [PreferenceStore] and the CLI all borrow it. */ + val dbHelper = Database() + + fun getModuleScope(packageName: String): MutableList? { + if (packageName == "lspd") return null + val result = mutableListOf() + dbHelper.readableDatabase + .query( + "scope INNER JOIN modules ON scope.mid = modules.mid", + arrayOf("app_pkg_name", "user_id"), + "modules.module_pkg_name = ?", + arrayOf(packageName), + null, + null, + null) + .use { cursor -> + while (cursor.moveToNext()) { + result.add( + ScopeEntry().apply { + this.packageName = cursor.getString(0) + this.userId = cursor.getInt(1) + }) + } + } + return result + } + + fun getIncludeNewApps(packageName: String): Boolean { + if (packageName == "lspd") return false + + var isIncludeNewApps = false + dbHelper.readableDatabase + .query( + "modules", + arrayOf("auto_include"), + "module_pkg_name = ?", + arrayOf(packageName), + null, + null, + null) + .use { cursor -> + if (cursor.moveToFirst()) { + isIncludeNewApps = cursor.getInt(0) == 1 + } + } + return isIncludeNewApps + } + + fun modulesIncludingNewApps(): List { + val result = mutableListOf() + dbHelper.readableDatabase + .query("modules", arrayOf("module_pkg_name"), "auto_include = 1", null, null, null, null) + .use { cursor -> + val idx = cursor.getColumnIndexOrThrow("module_pkg_name") + while (cursor.moveToNext()) { + val pkgName = cursor.getString(idx) + if (pkgName != "lspd") result.add(pkgName) + } + } + return result + } + + /** One enabled module, as configured. The path is what was last resolved, and may be stale. */ + data class EnabledModuleRow(val packageName: String, val apkPath: String?) + + /** One line of a module's scope: which app, for which user. */ + data class ScopeRow(val appPackage: String, val modulePackage: String, val userId: Int) + + /** + * The rows [ConfigCache] rebuilds itself from. + * + * Returned as data rather than as a cursor so that the SQL stays on this side of the line. The + * cache turns these into loadable modules — resolving paths, parsing DEX — which is its job; it + * has no business also knowing the shape of the tables. + */ + fun enabledModuleRows(): List { + val rows = mutableListOf() + dbHelper.readableDatabase + .query("modules", arrayOf("module_pkg_name", "apk_path"), "enabled = 1", null, null, null, null) + .use { cursor -> + while (cursor.moveToNext()) { + rows += EnabledModuleRow(cursor.getString(0), cursor.getString(1)) + } + } + return rows + } + + /** Every scope line belonging to an enabled module. */ + fun enabledScopeRows(): List { + val rows = mutableListOf() + dbHelper.readableDatabase + .query( + "scope INNER JOIN modules ON scope.mid = modules.mid", + arrayOf("app_pkg_name", "module_pkg_name", "user_id"), + "enabled = 1", + null, + null, + null, + null) + .use { cursor -> + while (cursor.moveToNext()) { + rows += ScopeRow(cursor.getString(0), cursor.getString(1), cursor.getInt(2)) + } + } + return rows + } + + /** Enabled modules scoped to the system framework, with the path last resolved for each. */ + fun systemServerModuleRows(): List { + val rows = mutableListOf() + dbHelper.readableDatabase + .query( + "scope INNER JOIN modules ON scope.mid = modules.mid", + arrayOf("module_pkg_name", "apk_path"), + "app_pkg_name=? AND enabled=1", + arrayOf("system"), + null, + null, + null) + .use { cursor -> + while (cursor.moveToNext()) { + rows += EnabledModuleRow(cursor.getString(0), cursor.getString(1)) + } + } + return rows + } + + /** + * Which modules are enabled, straight from the database. + * + * Deliberately not from [ConfigCache]: that cache is rebuilt asynchronously — a write only + * *requests* an update through a conflated channel — so anyone asking immediately after enabling + * a module was told the state from before their own write. The manager does exactly that: it + * writes, then re-reads to confirm, and the stale answer overwrote what it had correctly recorded + * itself. The row stayed in the wrong section until the app was restarted, at which point the + * cache had long since caught up and everything looked fine. + * + * The cache is still what the injection path reads, which is what it is for — it carries the + * resolved apk paths and scopes that this query deliberately does not. + */ + fun enabledModules(): Array { + val names = mutableListOf() + dbHelper.readableDatabase + .query("modules", arrayOf("module_pkg_name"), "enabled = 1", null, null, null, null) + .use { cursor -> + while (cursor.moveToNext()) names += cursor.getString(0) + } + return names.toTypedArray() + } + fun enableModule(packageName: String): Boolean { if (packageName == "lspd") return false - val db = ConfigCache.dbHelper.writableDatabase + val db = dbHelper.writableDatabase var changed = false // First, check if it exists. If not, we need to "discover" it. @@ -26,14 +203,40 @@ object ModuleDatabase { put("apk_path", "") // defer to cache updating put("enabled", 1) } - db.insert("modules", null, values) - changed = true + // `insert` answers -1 rather than throwing: it catches the SQLException itself and logs one + // line. Taking that for granted reported a write that never landed as a success, and the + // caller acted on it — the manager left its switch on, and the shade's "not activated yet" + // notice was cancelled for a module the database had no row for. + changed = db.insert("modules", null, values) != -1L } else { val values = ContentValues().apply { put("enabled", 1) } changed = db.update("modules", values, "module_pkg_name = ?", arrayOf(packageName)) > 0 } - if (changed) ConfigCache.requestCacheUpdate() + if (changed) { + ConfigCache.requestCacheUpdate() + // The shade may be telling the user this module "is not activated yet". It has just been + // activated, and nothing else was ever going to take that notice down: it is only marked + // auto-cancel, which fires when it is tapped, and the sole cancel path belonged to the scope + // prompt. The manager cannot do it either — the AIDL exposes no cancel, and a parasitic + // manager could not cancel a notification posted as "android" in any case. + // + // It lives here, at the data layer, rather than in ManagerService because this function is + // where the activations that go through the module table converge: the manager's toggle + // (ManagerService.enableModule), the socket CLI's `modules enable`, a manager backup restore + // — which replays what it read one setModuleEnabled at a time — and setModuleScope's + // implicit enable below. Putting it one level up would cover the manager alone and leave the + // other three lying to the user. Reaching out of the database for it is the same reach + // requestCacheUpdate() above already makes, and for the same reason — a row changed, and + // something outside has to be told. + // + // Not *every* activation, though, and the exception is worth knowing: the socket CLI's + // `db restore` copies a whole database file over the live one and calls nothing here, so a + // module the restored file has enabled keeps a stale "not activated yet" notice in the shade + // until something touches it again. That is a root-shell command that replaces the + // configuration wholesale, and the notice is one of several things it does not reconcile. + NotificationManager.cancelModuleUpdated(packageName) + } return changed } @@ -41,15 +244,24 @@ object ModuleDatabase { if (packageName == "lspd") return false val values = ContentValues().apply { put("enabled", 0) } val changed = - ConfigCache.dbHelper.writableDatabase.update( + dbHelper.writableDatabase.update( "modules", values, "module_pkg_name = ?", arrayOf(packageName)) > 0 if (changed) ConfigCache.requestCacheUpdate() return changed } - fun setModuleScope(packageName: String, scope: MutableList): Boolean { + fun setModuleScope(packageName: String, scope: MutableList): Boolean { + // Last line of defence for staticScope. The manager, the socket CLI, a backup restore and a + // module's own requestScope all end up here, so refusing here covers every one of them. + ConfigCache.staticScopeOf(packageName)?.let { claimed -> + val beyond = scope.map { it.packageName }.distinct().filterNot { claimed.contains(it) } + if (beyond.isNotEmpty()) { + Log.w(TAG, "$packageName fixes its scope; refusing to add ${beyond.joinToString()}") + return false + } + } enableModule(packageName) - val db = ConfigCache.dbHelper.writableDatabase + val db = dbHelper.writableDatabase db.beginTransaction() try { val mid = @@ -60,9 +272,24 @@ object ModuleDatabase { val values = ContentValues().apply { put("mid", mid) } for (app in scope) { - if (app.packageName == "system" && app.userId != 0) continue + // A module is one package, one APK and one scope set for the whole device — Android cannot + // hold two different builds under one package name, so there is nothing here to key by + // user. What [ScopeEntry.userId] names is the *target*: which installed instance of + // [ScopeEntry.packageName] this row points at. `ConfigCache` refuses to expand a row whose + // user does not hold the module, which is what keeps a module installed for one user out of + // another user's processes. + // + // The system server is the exception and is stored against user 0 whoever asked for it: it + // is one process for the whole device belonging to no user, so a module in a work profile + // hooking the framework is hooking the same system_server as everyone else. + // + // Normalised rather than dropped, which is what this used to do. Dropping meant restoring + // a backup written by an older manager, which recorded the framework under the module's + // own user, silently lost the one target the module may have cared about — and said + // nothing about it. + val userId = if (app.packageName == "system") 0 else app.userId values.put("app_pkg_name", app.packageName) - values.put("user_id", app.userId) + values.put("user_id", userId) db.insertWithOnConflict("scope", null, values, SQLiteDatabase.CONFLICT_IGNORE) } db.setTransactionSuccessful() @@ -76,9 +303,42 @@ object ModuleDatabase { return true } + /** + * Drops every scope row of [packageName] outside [claimed]. Called from within a cache update, so + * it deliberately does not ask for another one. + * + * @return how many rows went. + */ + fun pruneScopeToClaimed(packageName: String, claimed: Set): Int { + val db = dbHelper.writableDatabase + return runCatching { + val mid = + db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?") + .apply { bindString(1, packageName) } + .simpleQueryForLong() + val placeholders = claimed.joinToString(",") { "?" } + if (claimed.isEmpty()) { + db.delete("scope", "mid = ?", arrayOf(mid.toString())) + } else { + db.delete( + "scope", + "mid = ? AND app_pkg_name NOT IN ($placeholders)", + arrayOf(mid.toString()) + claimed.toTypedArray()) + } + } + .onFailure { Log.e(TAG, "Failed to prune the scope of $packageName", it) } + .getOrDefault(0) + } + fun removeModuleScope(packageName: String, scopePackageName: String, userId: Int): Boolean { - if (packageName == "lspd" || (scopePackageName == "system" && userId != 0)) return false - val db = ConfigCache.dbHelper.writableDatabase + if (packageName == "lspd") return false + // Normalised the same way [setModuleScope] normalises it, rather than refused. The framework is + // stored against user 0 whoever asked for it, so a removal keyed on the caller's own user + // matches no row at all - which meant a module outside user 0 could take system scope and then + // never give it back. It reached that state through the very same call: removeScope returns + // nothing, so the module was told its request had been honoured. + val storedUserId = if (scopePackageName == "system") 0 else userId + val db = dbHelper.writableDatabase val mid = db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?") .apply { bindString(1, packageName) } @@ -86,7 +346,7 @@ object ModuleDatabase { db.delete( "scope", "mid = ? AND app_pkg_name = ? AND user_id = ?", - arrayOf(mid.toString(), scopePackageName, userId.toString())) + arrayOf(mid.toString(), scopePackageName, storedUserId.toString())) ConfigCache.requestCacheUpdate() return true } @@ -98,7 +358,7 @@ object ModuleDatabase { put("module_pkg_name", packageName) put("apk_path", apkPath) } - val db = ConfigCache.dbHelper.writableDatabase + val db = dbHelper.writableDatabase var count = db.insertWithOnConflict("modules", null, values, SQLiteDatabase.CONFLICT_IGNORE).toInt() @@ -121,19 +381,19 @@ object ModuleDatabase { fun removeModule(packageName: String): Boolean { if (packageName == "lspd") return false val res = - ConfigCache.dbHelper.writableDatabase.delete( + dbHelper.writableDatabase.delete( "modules", "module_pkg_name = ?", arrayOf(packageName)) > 0 if (res) ConfigCache.requestCacheUpdate() return res } - fun setAutoInclude(packageName: String, enabled: Boolean): Boolean { + fun setIncludeNewApps(packageName: String, enabled: Boolean): Boolean { if (packageName == "lspd") return false val values = ContentValues().apply { put("auto_include", if (enabled) 1 else 0) } val changed = - ConfigCache.dbHelper.writableDatabase.update( + dbHelper.writableDatabase.update( "modules", values, "module_pkg_name = ?", arrayOf(packageName)) > 0 // If the auto_include flag changes, we should rebuild the scope cache diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt index 61833f07e..bf1f4905f 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt @@ -12,7 +12,7 @@ object PreferenceStore { packageName: String, userId: Int, group: String, - db: SQLiteDatabase = ConfigCache.dbHelper.readableDatabase + db: SQLiteDatabase = ModuleDatabase.dbHelper.readableDatabase ): Map { val result = mutableMapOf() @@ -40,7 +40,7 @@ object PreferenceStore { } fun updateModulePrefs(moduleName: String, userId: Int, group: String, diff: Map) { - val db = ConfigCache.dbHelper.writableDatabase + val db = ModuleDatabase.dbHelper.writableDatabase db.beginTransaction() try { for ((key, value) in diff) { @@ -68,7 +68,7 @@ object PreferenceStore { } fun deleteModulePrefs(moduleName: String, userId: Int? = null, group: String? = null) { - val db = ConfigCache.dbHelper.writableDatabase + val db = ModuleDatabase.dbHelper.writableDatabase val whereClause = StringBuilder("module_pkg_name = ?") val whereArgs = mutableListOf(moduleName) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt index 005a87e58..7db27300f 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt @@ -14,17 +14,17 @@ import java.io.FileInputStream import java.nio.file.Files import java.nio.file.Paths import kotlinx.coroutines.launch -import org.lsposed.lspd.ILSPManagerService +import org.matrix.vector.ipc.IManagerService import org.matrix.vector.daemon.VectorDaemon private const val TAG = "VectorDex2Oat" -// Compatibility states mirrored directly from the ILSPManagerService AIDL contract. -val DEX2OAT_OK = ILSPManagerService.DEX2OAT_OK -val DEX2OAT_MOUNT_FAILED = ILSPManagerService.DEX2OAT_MOUNT_FAILED -val DEX2OAT_SEPOLICY_INCORRECT = ILSPManagerService.DEX2OAT_SEPOLICY_INCORRECT -val DEX2OAT_SELINUX_PERMISSIVE = ILSPManagerService.DEX2OAT_SELINUX_PERMISSIVE -val DEX2OAT_CRASHED = ILSPManagerService.DEX2OAT_CRASHED +// Wrapper states mirrored directly from the IManagerService AIDL contract. +val DEX2OAT_OK = IManagerService.DEX2OAT_OK +val DEX2OAT_MOUNT_FAILED = IManagerService.DEX2OAT_MOUNT_FAILED +val DEX2OAT_SEPOLICY_INCORRECT = IManagerService.DEX2OAT_SEPOLICY_INCORRECT +val DEX2OAT_SELINUX_PERMISSIVE = IManagerService.DEX2OAT_SELINUX_PERMISSIVE +val DEX2OAT_CRASHED = IManagerService.DEX2OAT_CRASHED object Dex2OatServer { private const val WRAPPER32 = "bin/dex2oat32" @@ -51,49 +51,75 @@ object Dex2OatServer { private external fun getSockPath(): String - private val selinuxObserver = - object : - FileObserver( - listOf(File("/sys/fs/selinux/enforce"), File("/sys/fs/selinux/policy")), - CLOSE_WRITE) { - override fun onEvent(event: Int, path: String?) { - synchronized(this) { - if (compatibility == DEX2OAT_CRASHED) { - stopWatching() - return + /** The nodes whose writes mean the SELinux state this daemon depends on may have moved. */ + private val SELINUX_NODES = listOf("/sys/fs/selinux/enforce", "/sys/fs/selinux/policy") + + /** + * Watches [SELINUX_NODES] on every release this daemon runs on. + * + * `FileObserver(List, int)` is API 29 and the minimum here is 27, where the only + * constructors are the single-path ones -- deprecated in 29 precisely because they were replaced + * by these. So below 29 this is one observer per node, and `stopWatching` has to reach all of + * them, which is why the two live behind an object rather than being a `FileObserver` itself. + */ + private object SelinuxObserver { + private val observers: List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + listOf( + object : FileObserver(SELINUX_NODES.map(::File), FileObserver.CLOSE_WRITE) { + override fun onEvent(event: Int, path: String?) = onSelinuxEvent() + }) + } else { + SELINUX_NODES.map { node -> + @Suppress("DEPRECATION") + object : FileObserver(node, FileObserver.CLOSE_WRITE) { + override fun onEvent(event: Int, path: String?) = onSelinuxEvent() } + } + } - val enforcing = - runCatching { - Files.newInputStream(Paths.get("/sys/fs/selinux/enforce")).use { - it.read() == '1'.code - } - } - .getOrDefault(false) - - when { - !enforcing -> { - if (compatibility == DEX2OAT_OK) doMount(false) - compatibility = DEX2OAT_SELINUX_PERMISSIVE - } - hasSePolicyErrors() -> { - if (compatibility == DEX2OAT_OK) doMount(false) - compatibility = DEX2OAT_SEPOLICY_INCORRECT - } - compatibility != DEX2OAT_OK -> { - doMount(true) - if (notMounted()) { - doMount(false) - compatibility = DEX2OAT_MOUNT_FAILED - stopWatching() - } else { - compatibility = DEX2OAT_OK + fun startWatching() = observers.forEach(FileObserver::startWatching) + + fun stopWatching() = observers.forEach(FileObserver::stopWatching) + } + + private fun onSelinuxEvent() { + synchronized(this) { + if (compatibility == DEX2OAT_CRASHED) { + SelinuxObserver.stopWatching() + return + } + + val enforcing = + runCatching { + Files.newInputStream(Paths.get("/sys/fs/selinux/enforce")).use { + it.read() == '1'.code } } - } + .getOrDefault(false) + + when { + !enforcing -> { + if (compatibility == DEX2OAT_OK) doMount(false) + compatibility = DEX2OAT_SELINUX_PERMISSIVE + } + hasSePolicyErrors() -> { + if (compatibility == DEX2OAT_OK) doMount(false) + compatibility = DEX2OAT_SEPOLICY_INCORRECT + } + compatibility != DEX2OAT_OK -> { + doMount(true) + if (notMounted()) { + doMount(false) + compatibility = DEX2OAT_MOUNT_FAILED + SelinuxObserver.stopWatching() + } else { + compatibility = DEX2OAT_OK } } } + } + } init { // Android 10 vs 11+ path differences @@ -193,8 +219,8 @@ object Dex2OatServer { } } - selinuxObserver.startWatching() - selinuxObserver.onEvent(0, null) + SelinuxObserver.startWatching() + onSelinuxEvent() // Run the socket accept loop in an IO coroutine VectorDaemon.scope.launch { runSocketLoop() } @@ -221,7 +247,11 @@ object Dex2OatServer { SELinux.setFileContext(HOOKER64, xposedFile) runCatching { - LocalServerSocket(sockPath).use { server -> + // Closed by hand rather than with `use`: LocalServerSocket only implements Closeable + // from API 28, and this daemon runs from 27. The client socket below has implemented it + // since 17, so that one keeps `use`. + val server = LocalServerSocket(sockPath) + try { setSockCreateContext(null) while (true) { // This blocks until the C++ wrapper connects @@ -235,6 +265,8 @@ object Dex2OatServer { } } } + } finally { + server.close() } } .onFailure { diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt deleted file mode 100644 index fb4f817e8..000000000 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ApplicationService.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.matrix.vector.daemon.ipc - -import android.os.IBinder -import android.os.Parcel -import android.os.ParcelFileDescriptor -import android.os.Process -import android.os.RemoteException -import android.util.Log -import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.models.Module -import org.lsposed.lspd.service.ILSPApplicationService -import org.matrix.vector.daemon.data.ConfigCache -import org.matrix.vector.daemon.data.FileSystem -import org.matrix.vector.daemon.utils.InstallerVerifier -import org.matrix.vector.daemon.utils.ObfuscationManager - -private const val TAG = "VectorAppService" - -// Hardcoded transaction code from BridgeService -const val BRIDGE_TRANSACTION_CODE = - ('_'.code shl 24) or ('V'.code shl 16) or ('E'.code shl 8) or 'C'.code -const val DEX_TRANSACTION_CODE = - ('_'.code shl 24) or ('D'.code shl 16) or ('E'.code shl 8) or 'X'.code -const val OBFUSCATION_MAP_TRANSACTION_CODE = - ('_'.code shl 24) or ('O'.code shl 16) or ('B'.code shl 8) or 'F'.code - -object ApplicationService : ILSPApplicationService.Stub() { - - data class ProcessKey(val uid: Int, val pid: Int) - - private val processes = ConcurrentHashMap() - - private class ProcessInfo(val key: ProcessKey, val processName: String, val heartBeat: IBinder) : - IBinder.DeathRecipient { - init { - heartBeat.linkToDeath(this, 0) - processes[key] = this - } - - override fun binderDied() { - heartBeat.unlinkToDeath(this, 0) - processes.remove(key) - } - } - - override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { - when (code) { - DEX_TRANSACTION_CODE -> { - val shm = FileSystem.getPreloadDex(ConfigCache.state.isDexObfuscateEnabled) ?: return false - reply?.writeNoException() - reply?.let { shm.writeToParcel(it, 0) } - reply?.writeLong(shm.size.toLong()) - return true - } - OBFUSCATION_MAP_TRANSACTION_CODE -> { - val obfuscation = ConfigCache.state.isDexObfuscateEnabled - val signatures = ObfuscationManager.getSignatures() - reply?.writeNoException() - reply?.writeInt(signatures.size * 2) - for ((key, value) in signatures) { - reply?.writeString(key) - reply?.writeString(if (obfuscation) value else key) - } - return true - } - } - return super.onTransact(code, data, reply, flags) - } - - fun registerHeartBeat(uid: Int, pid: Int, processName: String, heartBeat: IBinder): Boolean { - return runCatching { - ProcessInfo(ProcessKey(uid, pid), processName, heartBeat) - true - } - .getOrDefault(false) - } - - fun hasRegister(uid: Int, pid: Int): Boolean = processes.containsKey(ProcessKey(uid, pid)) - - private fun ensureRegistered(): ProcessInfo { - val key = ProcessKey(getCallingUid(), getCallingPid()) - val info = processes[key] - if (info == null) { - Log.w(TAG, "Unauthorized IPC call from uid=${key.uid} pid=${key.pid}") - throw RemoteException("Not registered") - } - return info - } - - private fun getAllModules(): List { - val info = ensureRegistered() - if (info.key.uid == Process.SYSTEM_UID && info.processName == "system") { - return ConfigCache.getModulesForSystemServer() - } - if (ManagerService.isRunningManager(getCallingPid(), info.key.uid)) { - return emptyList() - } - return ConfigCache.getModulesForProcess(info.processName, info.key.uid) - } - - override fun getModulesList() = getAllModules().filter { !it.file.legacy } - - override fun getLegacyModulesList() = getAllModules().filter { it.file.legacy } - - override fun isLogMuted(): Boolean = !ManagerService.isVerboseLog - - override fun getPrefsPath(packageName: String): String { - val info = ensureRegistered() - return ConfigCache.getPrefsPath(packageName, info.key.uid) - } - - override fun requestInjectedManagerBinder( - binderList: MutableList - ): ParcelFileDescriptor? { - val info = ensureRegistered() - val pid = info.key.pid - val uid = info.key.uid - - if (ManagerService.postStartManager(pid) || ConfigCache.isManager(uid)) { - binderList.add(ManagerService.obtainManagerBinder(info.heartBeat, pid, uid)) - } - - return runCatching { - // Verify the APK signature before serving it - InstallerVerifier.verifyInstallerSignature(FileSystem.managerApkPath.toString()) - ParcelFileDescriptor.open( - FileSystem.managerApkPath.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) - } - .onFailure { Log.e(TAG, "Failed to open or verify manager APK", it) } - .getOrNull() - } -} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt index c71715102..ffedb0e9f 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt @@ -3,14 +3,14 @@ package org.matrix.vector.daemon.ipc import java.io.File import java.io.FileNotFoundException import java.io.IOException -import org.lsposed.lspd.models.Application +import io.github.libxposed.service.IXposedService +import org.matrix.vector.ipc.ScopeEntry import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.CliRequest import org.matrix.vector.daemon.CliResponse import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.ModuleDatabase import org.matrix.vector.daemon.data.PreferenceStore -import org.matrix.vector.daemon.system.* object CliHandler { @@ -40,23 +40,21 @@ object CliHandler { return mapOf( "Framework Version" to BuildConfig.VERSION_NAME, "Version Code" to BuildConfig.VERSION_CODE, - "Enabled Modules" to ConfigCache.state.modules.size, + "API Version" to IXposedService.LIB_API, + "Enabled Modules" to ModuleDatabase.enabledModules().size, "Status Notification" to PreferenceStore.isStatusNotificationEnabled()) } - private fun isPackageInstalled(pkg: String, userId: Int = 0): Boolean { - return runCatching { packageManager?.getPackageInfo(pkg, 0, userId) != null } - .getOrDefault(false) - } - private fun handleModules(request: CliRequest): Any { return when (request.action) { "ls" -> { val enabledOnly = request.options["enabled"] as? Boolean ?: false val disabledOnly = request.options["disabled"] as? Boolean ?: false - // Get the current immutable snapshot of enabled modules - val enabledModuleKeys = ConfigCache.state.modules.keys + // Asked of the configuration, not of the cache. The cache holds what could be *loaded* and + // is rebuilt asynchronously, so the CLI used to report a module the user had just enabled + // as disabled, and disagree with both the manager and `ManagerService.getEnabledModules()`. + val enabledModuleKeys = ModuleDatabase.enabledModules().toSet() // Get all installed modules from the system val installed = ConfigCache.getInstalledModules() @@ -106,16 +104,29 @@ object CliHandler { val modulePkg = request.targets[0] val apps = request.targets.drop(1) + // staticScope fixes the scope, so the CLI may still read it and take apps out of it, but not + // put new ones in. Checked here as well as in the database so the answer names the packages. + fun rejectBeyondStaticScope(apps: List) { + val claimed = ConfigCache.staticScopeOf(modulePkg) ?: return + val beyond = apps.map { it.substringBefore('/') }.filterNot { claimed.contains(it) } + if (beyond.isNotEmpty()) { + throw IllegalArgumentException( + "$modulePkg fixes its scope in module.prop, so ${beyond.joinToString()} cannot be " + + "added. It claims: ${claimed.sorted().joinToString().ifEmpty { "nothing" }}.") + } + } + return when (request.action) { "ls" -> { val scope = - ConfigCache.getModuleScope(modulePkg) + ModuleDatabase.getModuleScope(modulePkg) ?: throw IllegalArgumentException("Module not found: $modulePkg") scope.map { mapOf("APP_PACKAGE" to it.packageName, "USER_ID" to it.userId) } } "add" -> { if (apps.isEmpty()) throw IllegalArgumentException("No target apps provided.") - val scope = ConfigCache.getModuleScope(modulePkg) ?: mutableListOf() + rejectBeyondStaticScope(apps) + val scope = ModuleDatabase.getModuleScope(modulePkg) ?: mutableListOf() apps.forEach { appStr -> val parts = appStr.split("/") @@ -123,7 +134,7 @@ object CliHandler { val user = parts.getOrNull(1)?.toIntOrNull() ?: 0 if (scope.none { it.packageName == pkg && it.userId == user }) { scope.add( - Application().apply { + ScopeEntry().apply { packageName = pkg userId = user }) @@ -135,13 +146,14 @@ object CliHandler { "set" -> { if (apps.isEmpty()) throw IllegalArgumentException("No target apps provided for scope overwrite.") - val scope = mutableListOf() + rejectBeyondStaticScope(apps) + val scope = mutableListOf() apps.forEach { appStr -> val parts = appStr.split("/") val pkg = parts[0] val user = parts.getOrNull(1)?.toIntOrNull() ?: 0 scope.add( - Application().apply { + ScopeEntry().apply { packageName = pkg userId = user }) @@ -172,8 +184,8 @@ object CliHandler { val key = keys[0] val value = when (key) { - "status-notification" -> ManagerService.enableStatusNotification() - "verbose-log" -> ManagerService.isVerboseLog + "status-notification" -> ManagerService.isStatusNotificationEnabled() + "verbose-log" -> ManagerService.isVerboseLogEnabled() else -> throw IllegalArgumentException("Unknown config key: $key") } mapOf("KEY" to key, "VALUE" to value) @@ -186,8 +198,8 @@ object CliHandler { ?: throw IllegalArgumentException("Value must be 'true' or 'false'.") when (key) { - "status-notification" -> ManagerService.setEnableStatusNotification(value) - "verbose-log" -> ManagerService.setVerboseLog(value) + "status-notification" -> ManagerService.setStatusNotificationEnabled(value) + "verbose-log" -> ManagerService.setVerboseLogEnabled(value) else -> throw IllegalArgumentException("Unknown config key: $key") } "Successfully set $key to $value." @@ -209,7 +221,7 @@ object CliHandler { if (dbFile.exists()) dbFile.delete() // VACUUM INTO creates a consistent, defragmented copy without long-term locking. - ConfigCache.dbHelper.writableDatabase.execSQL("VACUUM INTO '$path'") + ModuleDatabase.dbHelper.writableDatabase.execSQL("VACUUM INTO '$path'") "Database backed up successfully to: $path" } "restore" -> { @@ -220,8 +232,8 @@ object CliHandler { val sourceFile = File(path) if (!sourceFile.exists()) throw FileNotFoundException("Source file does not exist: $path") - val currentDbPath = ConfigCache.dbHelper.readableDatabase.path - ConfigCache.dbHelper.close() + val currentDbPath = ModuleDatabase.dbHelper.readableDatabase.path + ModuleDatabase.dbHelper.close() sourceFile.copyTo(File(currentDbPath), overwrite = true) ConfigCache.requestCacheUpdate() @@ -232,8 +244,8 @@ object CliHandler { "Database restored from $path. Daemon state is being refreshed." } "reset" -> { - val currentDbPath = ConfigCache.dbHelper.readableDatabase.path - ConfigCache.dbHelper.close() + val currentDbPath = ModuleDatabase.dbHelper.readableDatabase.path + ModuleDatabase.dbHelper.close() val dbFile = File(currentDbPath) val walFile = File("$currentDbPath-wal") @@ -259,7 +271,7 @@ object CliHandler { return when (request.action) { "clear" -> { val verbose = request.options["verbose"] as? Boolean ?: false - ManagerService.clearLogs(verbose) + ManagerService.startNewLogPart(verbose) "Logs cleared successfully." } // "stream" is handled in SystemServerService.kt to attach the FileDescriptor diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt new file mode 100644 index 000000000..44ed1c650 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt @@ -0,0 +1,313 @@ +package org.matrix.vector.daemon.ipc + +import android.os.IBinder +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Process +import android.os.RemoteException +import android.util.Log +import io.github.libxposed.service.HookedProcess +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import org.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IProcessChannel +import org.matrix.vector.ipc.IFrameworkService +import org.matrix.vector.daemon.data.ConfigCache +import org.matrix.vector.daemon.data.FileSystem +import org.matrix.vector.daemon.system.FIRST_APPLICATION_UID +import org.matrix.vector.daemon.system.PER_USER_RANGE +import org.matrix.vector.daemon.utils.InstallerVerifier +import org.matrix.vector.daemon.utils.ObfuscationManager + +private const val TAG = "VectorFrameworkService" + +// Hardcoded transaction code from BridgeService +const val BRIDGE_TRANSACTION_CODE = + ('_'.code shl 24) or ('V'.code shl 16) or ('E'.code shl 8) or 'C'.code +const val DEX_TRANSACTION_CODE = + ('_'.code shl 24) or ('D'.code shl 16) or ('E'.code shl 8) or 'X'.code +const val OBFUSCATION_MAP_TRANSACTION_CODE = + ('_'.code shl 24) or ('O'.code shl 16) or ('B'.code shl 8) or 'F'.code + +/** + * What an injected process asks the framework for — this project's `IFrameworkService`. + * + * Also the daemon's register of which process is running which module, because answering + * `getModules` is what makes a process a hot reload target for each module returned. See + * `IFrameworkService.aidl` for who may call what and how a caller is authenticated. + * + * Was called `ApplicationService`, which named neither the interface it implements nor anything it + * does: nothing here is about an `Application`. + */ +object FrameworkService : IFrameworkService.Stub() { + + data class ProcessKey(val uid: Int, val pid: Int) + + private val processes = ConcurrentHashMap() + + /** One module generation loaded into one process: what a hot reload request addresses. */ + class HotReloadTarget( + val id: Long, + val modulePackageName: String, + val processName: String, + val uid: Int, + val pid: Int, + @Volatile var loadedVersionCode: Long, + val hotReloadable: Boolean, + ) { + val state = AtomicInteger(HookedProcess.TARGET_STATE_UP_TO_DATE) + } + + private val hotReloadTargets = ConcurrentHashMap() + + // Ids are framework-assigned and never reused, as HookedProcess.targetId requires. + private val nextHotReloadTargetId = AtomicLong(1) + + private class ProcessInfo(val key: ProcessKey, val processName: String, val heartBeat: IBinder) : + IBinder.DeathRecipient { + val targetIds = ConcurrentHashMap() + + @Volatile var hotReloadBinder: IProcessChannel? = null + + init { + heartBeat.linkToDeath(this, 0) + processes[key] = this + } + + override fun binderDied() { + heartBeat.unlinkToDeath(this, 0) + processes.remove(key) + targetIds.values.forEach { hotReloadTargets.remove(it) } + } + } + + private fun recordHotReloadTargets(info: ProcessInfo, modules: List) { + for (module in modules) { + info.targetIds.computeIfAbsent(module.packageName) { + val id = nextHotReloadTargetId.getAndIncrement() + hotReloadTargets[id] = + HotReloadTarget( + id = id, + modulePackageName = module.packageName, + processName = info.processName, + uid = info.key.uid, + pid = info.key.pid, + loadedVersionCode = module.versionCode, + // Hot reload is specified only for modules with exactly one Java entry class. + hotReloadable = module.code.moduleClassNames.size == 1, + ) + id + } + } + } + + /** + * Whether [userId]'s copy of the module may address [target]. + * + * One module is one package and one APK, but the copies installed for two users are two apps with + * two uids and two sets of preferences, and neither has any business reloading the other's + * processes. `ConfigCache` draws the same line when it decides where a module may be injected at + * all; this is that boundary applied to reloading what is already there. + * + * The carve-out is for the AID_* uids below [FIRST_APPLICATION_UID], and it has to be: a module in + * any user may take the framework into its scope, and `ModuleDatabase.setModuleScope` stores that + * row against user 0 whoever asked for it, because system_server is one process for the whole + * device. Nothing downstream can therefore tell which user requested it - so every user holding + * the module is equally entitled to the one generation loaded there. + * + * Not `uid < PER_USER_RANGE`, which is what this said first: that admits the whole of user 0, + * every app process on a single-user device, and leaves the check doing nothing at all. + */ + private fun addressableBy(target: HotReloadTarget, userId: Int): Boolean = + target.uid < FIRST_APPLICATION_UID || target.uid / PER_USER_RANGE == userId + + // Not filtered to hot-reloadable targets: the AIDL documents this as hooked processes, and one + // that cannot be reloaded answers UNSUPPORTED rather than disappearing. + fun getHotReloadTargets(modulePackageName: String, userId: Int): List { + val installedVersion = ConfigCache.state.modules[modulePackageName]?.versionCode + return hotReloadTargets.values + .filter { it.modulePackageName == modulePackageName && addressableBy(it, userId) } + .map { target -> + HookedProcess().apply { + targetId = target.id + uid = target.uid + pid = target.pid + processName = target.processName + state = reportedState(target, installedVersion) + loadedVersionCode = target.loadedVersionCode + } + } + } + + // RELOADING and FAILED describe the last attempt and outrank a version comparison. + private fun reportedState(target: HotReloadTarget, installedVersion: Long?): Int { + val state = target.state.get() + if (state != HookedProcess.TARGET_STATE_UP_TO_DATE) return state + // Zero means unknown, not old; claiming STALE would never be satisfiable by a reload. + if (target.loadedVersionCode == 0L) return state + return if (installedVersion != null && installedVersion != target.loadedVersionCode) { + HookedProcess.TARGET_STATE_STALE + } else { + state + } + } + + // system_server records its targets before PMS exists, so they start without a version. + fun backfillLoadedVersions() { + hotReloadTargets.values + .filter { it.loadedVersionCode == 0L } + .forEach { target -> + ConfigCache.state.modules[target.modulePackageName] + ?.versionCode + ?.takeIf { it != 0L } + ?.let { target.loadedVersionCode = it } + } + } + + fun forgetHotReloadTargets(modulePackageName: String) { + hotReloadTargets.values.removeIf { it.modulePackageName == modulePackageName } + processes.values.forEach { it.targetIds.remove(modulePackageName) } + } + + fun staleHotReloadTargets(modulePackageName: String): List { + val installedVersion = ConfigCache.state.modules[modulePackageName]?.versionCode ?: return emptyList() + return hotReloadTargets.values.filter { + it.modulePackageName == modulePackageName && + it.hotReloadable && + it.loadedVersionCode != 0L && + it.loadedVersionCode != installedVersion + } + } + + fun getHotReloadTarget(targetId: Long, modulePackageName: String, userId: Int): HotReloadTarget? = + hotReloadTargets[targetId]?.takeIf { + it.modulePackageName == modulePackageName && addressableBy(it, userId) + } + + /** + * Whether the process behind [target] is still the registered one. + * + * The heartbeat's DeathRecipient is what actually knows a process died. The exception a + * transaction throws does not: a frozen but perfectly alive target fails a transaction the same + * way a dead one does, and reporting that as PROCESS_DIED would be a lie the module app has no + * way to check. + */ + fun isProcessRegistered(target: HotReloadTarget): Boolean = + processes.containsKey(ProcessKey(target.uid, target.pid)) + + // Reloads are serialized per target, so check and transition must be one atomic step. + fun beginHotReload(target: HotReloadTarget): Boolean { + while (true) { + val current = target.state.get() + if (current == HookedProcess.TARGET_STATE_RELOADING) return false + if (target.state.compareAndSet(current, HookedProcess.TARGET_STATE_RELOADING)) return true + } + } + + fun endHotReload(target: HotReloadTarget, state: Int, loadedVersionCode: Long? = null) { + loadedVersionCode?.let { target.loadedVersionCode = it } + target.state.set(state) + } + + fun getHotReloadBinder(target: HotReloadTarget): IProcessChannel? = + processes[ProcessKey(target.uid, target.pid)]?.hotReloadBinder + + override fun attachProcessChannel(channel: IProcessChannel) { + // Synchronous on purpose: a oneway transaction arrives with getCallingPid() == 0, and this + // registry is keyed on (uid, pid). See the note on the AIDL. + val info = ensureRegistered() + info.hotReloadBinder = channel + Log.d(TAG, "Process channel attached for ${info.processName} (pid=${info.key.pid})") + } + + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + when (code) { + DEX_TRANSACTION_CODE -> { + val shm = FileSystem.getPreloadDex(ConfigCache.state.isDexObfuscateEnabled) ?: return false + reply?.writeNoException() + reply?.let { shm.writeToParcel(it, 0) } + reply?.writeLong(shm.size.toLong()) + return true + } + OBFUSCATION_MAP_TRANSACTION_CODE -> { + val obfuscation = ConfigCache.state.isDexObfuscateEnabled + val signatures = ObfuscationManager.getSignatures() + reply?.writeNoException() + reply?.writeInt(signatures.size * 2) + for ((key, value) in signatures) { + reply?.writeString(key) + reply?.writeString(if (obfuscation) value else key) + } + return true + } + } + return super.onTransact(code, data, reply, flags) + } + + fun registerHeartBeat(uid: Int, pid: Int, processName: String, heartBeat: IBinder): Boolean { + return runCatching { + ProcessInfo(ProcessKey(uid, pid), processName, heartBeat) + true + } + .getOrDefault(false) + } + + fun hasRegister(uid: Int, pid: Int): Boolean = processes.containsKey(ProcessKey(uid, pid)) + + private fun ensureRegistered(): ProcessInfo { + val key = ProcessKey(getCallingUid(), getCallingPid()) + val info = processes[key] + if (info == null) { + Log.w(TAG, "Unauthorized IPC call from uid=${key.uid} pid=${key.pid}") + throw RemoteException("Not registered") + } + return info + } + + private fun getAllModules(): List { + val info = ensureRegistered() + if (info.key.uid == Process.SYSTEM_UID && info.processName == "system") { + return ConfigCache.getModulesForSystemServer() + } + if (ManagerService.isRunningManager(getCallingPid(), info.key.uid)) { + return emptyList() + } + return ConfigCache.getModulesForProcess(info.processName, info.key.uid) + } + + override fun getModules() = + getAllModules().filter { !it.code.legacy }.also { recordHotReloadTargets(ensureRegistered(), it) } + + override fun getLegacyModules() = getAllModules().filter { it.code.legacy } + + override fun isLogMuted(): Boolean = !ManagerService.isVerboseLogEnabled() + + override fun getPrefsPath(packageName: String): String { + val info = ensureRegistered() + return ConfigCache.getPrefsPath(packageName, info.key.uid) + } + + override fun openManagerApk(): ParcelFileDescriptor? { + ensureRegistered() + return runCatching { + // Verify the APK signature before serving it + InstallerVerifier.verifyInstallerSignature(FileSystem.managerApkPath.toString()) + ParcelFileDescriptor.open( + FileSystem.managerApkPath.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + } + .onFailure { Log.e(TAG, "Failed to open or verify manager APK", it) } + .getOrNull() + } + + override fun requestManagerService(): IBinder? { + val info = ensureRegistered() + val pid = info.key.pid + val uid = info.key.uid + // postStartManager compares the caller against the pid the daemon launched the manager into, + // so this reports a decision already taken rather than making one. It is its own call because it + // answers a different question from the one that opens the APK, not because it costs anything. + if (!ManagerService.postStartManager(pid) && !ConfigCache.isManager(uid)) return null + return ManagerService.obtainManagerBinder(info.heartBeat, pid, uid) + } +} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt index cff900d01..e5436c593 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt @@ -8,8 +8,8 @@ import android.util.Log import io.github.libxposed.service.IXposedService import java.io.Serializable import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.service.ILSPInjectedModuleService -import org.lsposed.lspd.service.IRemotePreferenceCallback +import org.matrix.vector.ipc.IModuleService +import org.matrix.vector.ipc.IRemotePreferenceCallback import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.data.PreferenceStore @@ -17,10 +17,20 @@ import org.matrix.vector.daemon.system.PER_USER_RANGE private const val TAG = "VectorInjectedModuleService" -class InjectedModuleService(private val packageName: String) : ILSPInjectedModuleService.Stub() { +/** + * A module's service as an **injected process** sees it — this project's `IModuleService`. + * + * The counterpart to [ModuleAppService], and see `IModuleService.aidl` for why the two differ: this + * side may only read the module's remote files, because the process holding it runs as the app it + * was injected into rather than as the module. + */ +class InjectedModuleService(private val packageName: String) : IModuleService.Stub() { - // Tracks active RemotePreferenceCallbacks linked by config group - private val callbacks = ConcurrentHashMap>() + // Tracks active RemotePreferenceCallbacks linked by config group. Preferences are stored per + // Android user, so a registration is only interested in updates made by its own user. + private data class Subscriber(val userId: Int, val callback: IRemotePreferenceCallback) + + private val callbacks = ConcurrentHashMap>() override fun getFrameworkProperties(): Long { var prop = IXposedService.PROP_CAP_SYSTEM or IXposedService.PROP_CAP_REMOTE @@ -41,24 +51,29 @@ class InjectedModuleService(private val packageName: String) : ILSPInjectedModul if (callback != null) { val groupCallbacks = callbacks.getOrPut(group) { ConcurrentHashMap.newKeySet() } - groupCallbacks.add(callback) - runCatching { callback.asBinder().linkToDeath({ groupCallbacks.remove(callback) }, 0) } + val subscriber = Subscriber(userId, callback) + groupCallbacks.add(subscriber) + runCatching { callback.asBinder().linkToDeath({ groupCallbacks.remove(subscriber) }, 0) } .onFailure { Log.w(TAG, "requestRemotePreferences linkToDeath failed", it) } } return bundle } - override fun openRemoteFile(path: String): ParcelFileDescriptor { - FileSystem.ensureModuleFilePath(path) + override fun openRemoteFile(path: String): ParcelFileDescriptor? { + // XposedInterface#openRemoteFile documents FileNotFoundException for a missing *or* forbidden + // path. Returning null lets VectorContext raise exactly that; throwing here surfaced a + // RemoteException for a missing file and an IllegalArgumentException for a rejected path. val userId = Binder.getCallingUid() / PER_USER_RANGE return runCatching { + FileSystem.ensureModuleFilePath(path) val dir = FileSystem.resolveModuleDir(packageName, "files", userId, -1) ParcelFileDescriptor.open(dir.resolve(path).toFile(), ParcelFileDescriptor.MODE_READ_ONLY) } - .getOrElse { throw RemoteException(it.message) } + .onFailure { Log.w(TAG, "Cannot open remote file $path for $packageName: ${it.message}") } + .getOrNull() } - override fun getRemoteFileList(): Array { + override fun getRemoteFileNames(): Array { val userId = Binder.getCallingUid() / PER_USER_RANGE return runCatching { val dir = FileSystem.resolveModuleDir(packageName, "files", userId, -1) @@ -67,11 +82,13 @@ class InjectedModuleService(private val packageName: String) : ILSPInjectedModul .getOrElse { throw RemoteException(it.message) } } - // Called by ModuleService when prefs are updated globally - fun onUpdateRemotePreferences(group: String, diff: Bundle) { + // Called by ModuleAppService when the module app has changed the group for one Android user. + fun onUpdateRemotePreferences(group: String, userId: Int, diff: Bundle) { val groupCallbacks = callbacks[group] ?: return - for (callback in groupCallbacks) { - runCatching { callback.onUpdate(diff) }.onFailure { groupCallbacks.remove(callback) } + for (subscriber in groupCallbacks) { + if (subscriber.userId != userId) continue + runCatching { subscriber.callback.onRemotePreferencesChanged(diff) } + .onFailure { groupCallbacks.remove(subscriber) } } } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 2d215c8c6..96251dffc 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -13,6 +13,7 @@ import android.content.pm.VersionedPackage import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.Settings import android.os.IBinder import android.os.ParcelFileDescriptor import android.os.SELinux @@ -23,10 +24,14 @@ import hidden.HiddenApiBridge import io.github.libxposed.service.IXposedService import java.io.File import java.util.concurrent.CountDownLatch -import org.lsposed.lspd.ILSPManagerService -import org.lsposed.lspd.models.Application -import org.lsposed.lspd.models.UserInfo +import java.util.concurrent.TimeUnit +import org.matrix.vector.ipc.DeviceUser +import org.matrix.vector.ipc.IFrameworkInstallReceiver +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.ipc.ModuleLoadFailure +import org.matrix.vector.ipc.ScopeEntry import org.matrix.vector.daemon.BuildConfig +import org.matrix.vector.daemon.VectorDaemon import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.data.ModuleDatabase @@ -34,14 +39,28 @@ import org.matrix.vector.daemon.data.PreferenceStore import org.matrix.vector.daemon.env.Dex2OatServer import org.matrix.vector.daemon.env.LogcatMonitor import org.matrix.vector.daemon.system.* +import org.matrix.vector.daemon.utils.InstallerVerifier import org.matrix.vector.daemon.utils.PackageOptimizer +import org.matrix.vector.daemon.utils.RootImplementation import org.matrix.vector.daemon.utils.applyXspaceWorkaround import org.matrix.vector.daemon.utils.getRealUsers import rikka.parcelablelist.ParcelableListSlice private const val TAG = "VectorManagerService" -object ManagerService : ILSPManagerService.Stub() { +object ManagerService : IManagerService.Stub() { + + /** AOSP's switch for the synthesised launcher entries Android 10 introduced. */ + private const val SHOW_HIDDEN_ICON_APPS = "show_hidden_icon_apps_enabled" + + /** + * How long [uninstallPackage] waits for the package installer to report back. + * + * Generous rather than tight: the work is real and a loaded device can take a while over it. What + * it exists to bound is the case where the status never comes at all. + */ + private const val UNINSTALL_TIMEOUT_SECONDS = 60L + private var managerPid = -1 private var pendingManager = false @@ -53,9 +72,18 @@ object ManagerService : ILSPManagerService.Stub() { class ManagerGuard(private val binder: IBinder, val pid: Int, val uid: Int) : IBinder.DeathRecipient { + // system_server dispatches the 3-argument callback up to Android 16 and the + // 4-argument one from Android 17 on. private val connection = object : android.app.IServiceConnection.Stub() { override fun connected(name: ComponentName?, service: IBinder?, dead: Boolean) {} + + override fun connected( + name: ComponentName?, + service: IBinder?, + session: android.app.IBinderSession?, + dead: Boolean + ) {} } init { @@ -138,7 +166,7 @@ object ManagerService : ILSPManagerService.Stub() { } intent.categories?.clear() - intent.addCategory("org.lsposed.manager.LAUNCH_MANAGER") + intent.addCategory("${BuildConfig.DEFAULT_MANAGER_PACKAGE_NAME}.LAUNCH_MANAGER") intent.setPackage(BuildConfig.MANAGER_INJECTED_PKG_NAME) managerIntent = Intent(intent) } @@ -149,22 +177,9 @@ object ManagerService : ILSPManagerService.Stub() { fun openManager(withData: Uri?) { val intent = getManagerIntent() ?: return val launchIntent = Intent(intent).apply { data = withData } - runCatching { - activityManager?.startActivityAsUserWithFeature( - SystemContext.appThread, - "android", - null, - launchIntent, - launchIntent.type, - null, - null, - 0, - 0, - null, - null, - 0) - } - .onFailure { Log.e(TAG, "Failed to open manager", it) } + // Negative results are `ActivityManager.START_*` errors, the positive ones are all successes. + val result = activityManager?.startActivityAsUserCompat(launchIntent, 0) ?: -1 + if (result < 0) Log.e(TAG, "Failed to open manager: $result") } /** Fixes permissions for the WebView cache. */ @@ -212,11 +227,15 @@ object ManagerService : ILSPManagerService.Stub() { fun isRunningManager(pid: Int, uid: Int): Boolean = pid == managerPid && ConfigCache.isManager(uid) - override fun getXposedApiVersion() = IXposedService.LIB_API + override fun getProtocolVersion() = IManagerService.PROTOCOL_VERSION + + override fun getLibxposedApiVersion() = IXposedService.LIB_API + + override fun getFrameworkVersionCode() = BuildConfig.VERSION_CODE - override fun getXposedVersionCode() = BuildConfig.VERSION_CODE + override fun getFrameworkVersionName() = BuildConfig.VERSION_NAME - override fun getXposedVersionName() = BuildConfig.VERSION_NAME + override fun getBuildStamp(): String? = BuildConfig.VERSION_HASH.takeIf { it.isNotBlank() } override fun getInstalledPackagesFromAllUsers( flags: Int, @@ -226,48 +245,93 @@ object ManagerService : ILSPManagerService.Stub() { packageManager?.getInstalledPackagesFromAllUsers(flags, filterNoProcess) ?: emptyList()) } - override fun enabledModules() = ConfigCache.state.modules.keys.toTypedArray() - - override fun enableModule(packageName: String) = ModuleDatabase.enableModule(packageName) + override fun getEnabledModules() = ModuleDatabase.enabledModules().toList() + + /** + * The unloadable map, as a list of rows. + * + * The map is exactly what the pair this replaced sent one key and one lookup at a time, so the + * conversion is the whole of the merge. A reason of 0 is never stored — [ConfigCache] only ever + * writes one of the three failures — so the AIDL's promise that 0 never travels holds without a + * filter here. + */ + override fun getModuleLoadFailures(): List = + ConfigCache.state.unloadable.map { (pkgName, why) -> + ModuleLoadFailure().apply { + packageName = pkgName + reason = why + } + } - override fun disableModule(packageName: String) = ModuleDatabase.disableModule(packageName) + override fun setModuleEnabled(packageName: String, enabled: Boolean) = + if (enabled) ModuleDatabase.enableModule(packageName) + else ModuleDatabase.disableModule(packageName) - override fun setModuleScope(packageName: String, scope: MutableList) = + override fun setModuleScope(packageName: String, scope: MutableList) = ModuleDatabase.setModuleScope(packageName, scope) - override fun getModuleScope(packageName: String) = ConfigCache.getModuleScope(packageName) + override fun getModuleScope(packageName: String) = ModuleDatabase.getModuleScope(packageName) - override fun isVerboseLog() = PreferenceStore.isVerboseLogEnabled() || BuildConfig.DEBUG + // Reports the setting, not the setting OR'd with the build type. It used to be + // `|| BuildConfig.DEBUG`, which made the value unwritable on a debug daemon: the manager could + // never read false, so its switch snapped back on every tap and had to be greyed out. The OR was + // redundant anyway — `isVerboseLogEnabled()` already defaults to true — so a debug build still + // logs verbosely out of the box, and now a developer can also turn it off. + override fun isVerboseLogEnabled() = PreferenceStore.isVerboseLogEnabled() - override fun setVerboseLog(enabled: Boolean) { + override fun setVerboseLogEnabled(enabled: Boolean) { PreferenceStore.setVerboseLog(enabled) - if (isVerboseLog()) LogcatMonitor.startVerbose() else LogcatMonitor.stopVerbose() + if (isVerboseLogEnabled()) LogcatMonitor.startVerbose() else LogcatMonitor.stopVerbose() } - override fun getVerboseLog() = - LogcatMonitor.getVerboseLog()?.let { + override fun getLogParts(verbose: Boolean): List = FileSystem.listLogParts(verbose) + + override fun getLogPart(verbose: Boolean, name: String): ParcelFileDescriptor? = + FileSystem.openLogPart(verbose, name)?.let { ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY) } - override fun getModulesLog(): ParcelFileDescriptor? { - LogcatMonitor.checkLogFile() - return LogcatMonitor.getModulesLog()?.let { - ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY) - } + /** + * The part being written on one of the two streams. + * + * The two calls this replaces were not symmetric: only the modules one asked + * [LogcatMonitor.checkLogFile] to re-open a descriptor the reader had lost. That asymmetry is + * kept exactly as it was rather than tidied away, because levelling it either way changes when a + * lost descriptor is repaired, and that is a decision about the log rather than about this + * merge. + */ + override fun getLiveLogPart(verbose: Boolean): ParcelFileDescriptor? { + if (!verbose) LogcatMonitor.checkLogFile() + val file = if (verbose) LogcatMonitor.getVerboseLog() else LogcatMonitor.getModulesLog() + return file?.let { ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY) } } - override fun clearLogs(verbose: Boolean): Boolean { + override fun startNewLogPart(verbose: Boolean) { LogcatMonitor.refresh(verbose) - return true } - override fun getPackageInfo(packageName: String, flags: Int, uid: Int) = - packageManager?.getPackageInfoCompat(packageName, flags, uid) - override fun forceStopPackage(packageName: String, userId: Int) { activityManager?.forceStopPackage(packageName, userId) } + override fun softReboot() = VectorDaemon.softReboot() + + /** + * The flashed manager APK, verified, for the manager to install as an ordinary app. + * + * The same file and the same check as [FrameworkService.openManagerApk], which + * serves it to the host process for injection — one APK, one signature gate, whichever way it + * leaves the module directory. + */ + override fun getManagerApk(): ParcelFileDescriptor? = + runCatching { + InstallerVerifier.verifyInstallerSignature(FileSystem.managerApkPath.toString()) + ParcelFileDescriptor.open( + FileSystem.managerApkPath.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + } + .onFailure { Log.e(TAG, "Failed to open or verify manager APK", it) } + .getOrNull() + override fun reboot() { powerManager?.reboot(false, null, false) } @@ -317,18 +381,31 @@ object ManagerService : ILSPManagerService.Stub() { .getOrNull() ?: return false val pkg = VersionedPackage(packageName, PackageManager.VERSION_CODE_HIGHEST) - val flag = if (userId == -1) 0x00000002 else 0 // DELETE_ALL_USERS flag + val allUsers = userId == IManagerService.ALL_USERS + val flag = if (allUsers) 0x00000002 else 0 // DELETE_ALL_USERS flag runCatching { packageManager ?.packageInstaller - ?.uninstall(pkg, "android", flag, intentSender, if (userId == -1) 0 else userId) + ?.uninstall(pkg, "android", flag, intentSender, if (allUsers) 0 else userId) } .onFailure { return false } - latch.await() + // Bounded, because this runs on a binder thread and the status is a broadcast the package + // installer may never send — a device-policy refusal, a user removed mid-uninstall, a wedged + // system service. An unbounded wait held that thread for the life of the daemon, and enough of + // them exhaust the pool, at which point every call from the manager and from every injected + // process queues behind an uninstall nobody is still watching. + // + // A timeout is not a failure of the uninstall, only of our knowledge of it, so it answers false + // for the same reason a refusal does: the caller must not be told a package is gone on the + // strength of a status that never arrived. + if (!latch.await(UNINSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + Log.w(TAG, "No uninstall status for $packageName after ${UNINSTALL_TIMEOUT_SECONDS}s") + return false + } return result } @@ -336,27 +413,19 @@ object ManagerService : ILSPManagerService.Stub() { SELinux.checkSELinuxAccess( "u:r:dex2oat:s0", "u:object_r:dex2oat_exec:s0", "file", "execute_no_trans") - override fun getUsers(): List { + override fun getUsers(): List { return userManager?.getRealUsers()?.map { - UserInfo().apply { + DeviceUser().apply { id = it.id name = it.name } } ?: emptyList() } - override fun installExistingPackageAsUser(packageName: String, userId: Int): Int { - return runCatching { - packageManager?.installExistingPackageAsUser(packageName, userId, 0, 0, null) ?: -110 - } - .getOrDefault(-110) - } + override fun isSystemServerAttached() = SystemServerService.systemServerRequested - override fun systemServerRequested() = SystemServerService.systemServerRequested - - override fun startActivityAsUserWithFeature(intent: Intent, userId: Int): Int { - if (!intent.getBooleanExtra("lsp_no_switch_to_user", false)) { - intent.removeExtra("lsp_no_switch_to_user") + override fun startActivityAsUser(intent: Intent, userId: Int, noUserSwitch: Boolean): Int { + if (!noUserSwitch) { val currentUser = activityManager?.currentUser val parent = userManager?.getProfileParent(userId)?.id ?: userId if (currentUser != null && currentUser.id != parent) { @@ -367,19 +436,7 @@ object ManagerService : ILSPManagerService.Stub() { wm?.lockNow(null) } } - return activityManager?.startActivityAsUserWithFeature( - SystemContext.appThread, - "android", - null, - intent, - intent.type, - null, - null, - 0, - 0, - null, - null, - userId) ?: -1 + return activityManager?.startActivityAsUserCompat(intent, userId) ?: -1 } override fun queryIntentActivitiesAsUser( @@ -392,35 +449,65 @@ object ManagerService : ILSPManagerService.Stub() { ?: emptyList()) } - override fun dex2oatFlagsLoaded() = + override fun isDex2OatInliningDisabled() = SystemProperties.get("dalvik.vm.dex2oat-flags").contains("--inline-max-code-units=0") - override fun setHiddenIcon(hide: Boolean) { - val args = - Bundle().apply { - putString("value", if (hide) "0" else "1") - putString("_user", "0") - } - runCatching { - val provider = - activityManager - ?.getContentProviderExternal("settings", 0, SystemContext.token, null) - ?.provider - provider?.call("android", "settings", "PUT_global", "show_hidden_icon_apps_enabled", args) - } - .onFailure { Log.w(TAG, "setHiddenIcon failed", it) } + /** + * Android 10 and later synthesise a launcher entry for an installed app that declares none, and + * `show_hidden_icon_apps_enabled` is the switch for that: 1 shows them, 0 leaves them hidden. + * + * Read and written by running `settings`, which is neither laziness nor a shortcut. Two in-process + * routes were tried on a Pixel 6 running Android 17 and both are closed to this process: + * + * * The original code called `IContentProvider.call` with the pre-Android-12 signature, the one + * without an `AttributionSource`. That method has not existed since Android 12, so every press + * threw `NoSuchMethodError` — logged at warning level, swallowed, and the switch moved anyway. + * * Going through `ActivityThread.getSystemContext().getContentResolver()` then fails at the + * other end: `SecurityException: Unable to find app for caller … when getting content provider + * settings`. The daemon has an ActivityThread but no application record, so the system will + * not hand it a provider. + * + * The command is stable across versions in a way that the hidden binder interface demonstrably is + * not, and the daemon is root, so it is entitled to run it. This codebase already shells out for + * module installs and for dex2oat. + */ + override fun setForcedLauncherIcons(force: Boolean) { + runCatching { settingsCommand("put", if (force) "1" else "0") } + .onFailure { Log.w(TAG, "setForcedLauncherIcons failed", it) } } - override fun getLogs(zipFd: ParcelFileDescriptor) { + override fun isForcedLauncherIcons(): Boolean = + runCatching { + // Unset must read as the platform default of 1, not as "off" — otherwise the switch + // shows the opposite of what the system is doing on every device where nobody has + // touched it. + settingsCommand("get")?.trim().let { it.isNullOrEmpty() || it == "null" || it == "1" } + } + .getOrDefault(true) + + private fun settingsCommand(verb: String, value: String? = null): String? { + val command = buildList { + add("settings") + add(verb) + add("global") + add(SHOW_HIDDEN_ICON_APPS) + value?.let { add(it) } + } + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + process.waitFor() + return output.ifBlank { null } + } + + override fun writeBugReport(zipFd: ParcelFileDescriptor) { FileSystem.getLogs(zipFd) } - override fun restartFor(intent: Intent) {} // No-op matching original - override fun enableStatusNotification() = PreferenceStore.isStatusNotificationEnabled() + override fun isStatusNotificationEnabled() = PreferenceStore.isStatusNotificationEnabled() - override fun setEnableStatusNotification(enable: Boolean) { - val isEnabled = enableStatusNotification() + override fun setStatusNotificationEnabled(enable: Boolean) { + val isEnabled = isStatusNotificationEnabled() PreferenceStore.setStatusNotification(enable) if (isEnabled && !enable) { NotificationManager.cancelStatusNotification() @@ -432,11 +519,38 @@ object ManagerService : ILSPManagerService.Stub() { override fun optimizePackage(packageName: String) = PackageOptimizer.optimize(packageName) - override fun getDex2OatWrapperCompatibility() = + override fun getDex2OatWrapperState() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Dex2OatServer.compatibility else 0 - override fun setAutoInclude(packageName: String, enabled: Boolean) = - ModuleDatabase.setAutoInclude(packageName, enabled) + override fun setIncludeNewApps(packageName: String, enabled: Boolean) = + ModuleDatabase.setIncludeNewApps(packageName, enabled) + + override fun getIncludeNewApps(packageName: String) = ModuleDatabase.getIncludeNewApps(packageName) + + override fun getRootImplementation() = RootImplementation.implementation + - override fun getAutoInclude(packageName: String) = ConfigCache.getAutoInclude(packageName) + override fun installFrameworkZip(zipPath: String, receiver: IFrameworkInstallReceiver) { + // Off the binder thread: a flash takes seconds to minutes, and holding a binder thread for its + // duration starves everything else the manager asks of the daemon meanwhile — including the + // log reads the install screen is doing to show what is happening. + Thread { + val exit = + RootImplementation.install(zipPath) { line -> + runCatching { receiver.onLine(line) } + .onFailure { + // The manager went away mid-flash. Keep installing — stopping now would + // leave the module tree half-written — and keep logging, which is the only + // record left. + Log.w(TAG, "Install receiver is gone; continuing", it) + } + } + runCatching { receiver.onFinished(exit) } + .onFailure { Log.w(TAG, "Could not report install result", it) } + } + .apply { + name = "vector-framework-install" + start() + } + } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt new file mode 100644 index 000000000..916ac2cb4 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleAppService.kt @@ -0,0 +1,728 @@ +package org.matrix.vector.daemon.ipc + +import android.content.AttributionSource +import android.os.Binder +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.os.RemoteException +import android.os.SystemClock +import android.util.Log +import io.github.libxposed.service.HookedProcess +import io.github.libxposed.service.IHotReloadCallback +import io.github.libxposed.service.IXposedScopeCallback +import io.github.libxposed.service.IXposedService +import java.io.Serializable +import java.util.Collections +import java.util.WeakHashMap +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.matrix.vector.ipc.HotReloadOutcome +import org.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IHotReloadOutcomeReceiver +import org.matrix.vector.daemon.BuildConfig +import org.matrix.vector.daemon.data.ConfigCache +import org.matrix.vector.daemon.data.FileSystem +import org.matrix.vector.daemon.data.ModuleDatabase +import org.matrix.vector.daemon.data.PreferenceStore +import org.matrix.vector.daemon.system.NotificationManager +import org.matrix.vector.daemon.system.ProcessFreezer +import org.matrix.vector.daemon.system.PER_USER_RANGE +import org.matrix.vector.daemon.system.activityManager + +private const val TAG = "VectorModuleAppService" + +/** + * A module's service as its own **app** sees it — libxposed's `IXposedService`. + * + * One of two services a module gets, and the name says which. [InjectedModuleService] is the other: + * the same module seen from inside a process it was injected into. They deliberately differ in what + * they allow — a module app may write its remote files, a hooked process may only read them, + * because a hooked process runs as the app it was injected into rather than as the module — so + * which one a reader is looking at has to be legible from the class name. + * + * See `IModuleService.aidl` for the other side of that distinction. + */ +class ModuleAppService(private val loadedModule: LoadedModule) : IXposedService.Stub() { + + companion object { + // Per-target serialization lives on the target itself; this only keeps one slow target from + // delaying another. + private val hotReloadExecutor = + Executors.newCachedThreadPool { r -> Thread(r, "vector-hot-reload") } + + // How long a target gets to answer. Generous, because the whole point is that the callee runs + // module code - but finite, because binder is not, and a target left in RELOADING answers every + // later request with IN_PROGRESS for as long as the process lives. + private const val RELOAD_TIMEOUT_SECONDS = 30L + + /** + * The uids whose module app is holding a binder we handed it. + * + * A binder belongs to the *process* that received it, but a uid can outlive any one of its + * processes: an app with a `:remote` or crash-handler process, or a shared user id, keeps its + * uid alive when the process we served is reaped, so no [uidGone] arrives and the replacement + * process would be refused here forever. That was unreachable while the reference below pinned + * every module app at foreground priority and nothing ever reaped it. Giving the reference back + * makes it the ordinary case, so entries are also dropped by [linkDelivery] when the process + * that took the binder dies. + * + * Recorded on a *successful* send rather than on the attempt: a failed send leaves nothing on + * the other side, and treating it as delivered meant the one module that most needed another + * attempt never got one. + */ + private val uidSet = ConcurrentHashMap.newKeySet() + + /** The uids a send is running for right now, so the three observer callbacks agree on one. */ + private val sending = ConcurrentHashMap.newKeySet() + + /** + * What tells [uidSet] that a delivery is over: the provider binder we spoke to, and the + * recipient watching it. Held because a `DeathRecipient` nothing references is one the runtime + * may collect before it ever fires. + */ + private val deliveries = ConcurrentHashMap>() + + private val serviceMap = + Collections.synchronizedMap(WeakHashMap()) + + /** + * Consecutive failed sends per uid, and when the last one was. + * + * A module app that dies before it can publish its provider is not a transient failure to be + * retried at the speed of the uid observer. It happens — an app that crashes on start, or one + * another module deliberately kills, as in #889 where a module in a third module's scope took + * its host down on every launch — and the delivery below *starts the process*, so retrying is + * not a passive act: it feeds the very loop it is failing on. Fourteen starts in seventy-six + * seconds were observed that way, six of them ours. + * + * Per uid and not per package, because `getModuleByUid` matches on the app id: one module + * installed for two users is one `LoadedModule` under two uids, and keying by name would let a + * crash-looping copy in a work profile throttle the healthy copy in user 0, and let either + * one's success wipe the other's run. + * + * Once [MAX_CONSECUTIVE_BINDER_FAILURES] have piled up the retries are throttled to one per + * [BINDER_RETRY_COOLDOWN_MS] — the count is held at the ceiling rather than reset by the + * attempt that the cooldown lets through, or the ceiling would simply be re-climbed and three + * more attempts allowed every minute for ever. A run is forgotten after + * [BINDER_FAILURE_RUN_MS] without a failure, so an occasional one never accumulates. Throttled + * rather than abandoned, and cleared by the first success, because the app may simply have been + * mid-update or out of memory; a module written off for good on three failures would be a worse + * bug than the one this is fixing. + */ + private val binderFailures = ConcurrentHashMap() + + private class FailureRun(val count: Int, val atElapsed: Long) + + private const val MAX_CONSECUTIVE_BINDER_FAILURES = 3 + private const val BINDER_RETRY_COOLDOWN_MS = 60_000L + private const val BINDER_FAILURE_RUN_MS = 10 * BINDER_RETRY_COOLDOWN_MS + + // The delivery blocks in getContentProviderExternal until the app publishes its provider or + // AMS gives up on it, and it runs from an IUidObserver callback - one binder thread, serving + // every uid transition on the device. A module app that never publishes therefore stalls the + // delivery of every *other* module's binder behind it: eight and a half seconds, measured, on + // a device where one module app was crash-looping. One thread per module keeps that local. + private val binderExecutor = + Executors.newCachedThreadPool { r -> Thread(r, "vector-module-binder") } + + fun uidClear() { + uidSet.clear() + } + + fun uidStarts(uid: Int) { + if (uid in uidSet || !sending.add(uid)) return + val module = ConfigCache.getModuleByUid(uid) + if (module?.code?.legacy != false) { + sending.remove(uid) + return + } + if (isThrottled(uid)) { + sending.remove(uid) + return + } + val service = serviceMap.getOrPut(module) { ModuleAppService(module) } + // Off the observer thread, and never inline: see [binderExecutor]. Caught, because a uid + // left in [sending] by a rejected submission is one this never looks at again. + runCatching { + binderExecutor.execute { + try { + val delivered = service.sendBinder(uid) + if (delivered != null) { + uidSet.add(uid) + binderFailures.remove(uid) + linkDelivery(uid, delivered) + } else { + recordFailure(uid, module.packageName) + } + } finally { + sending.remove(uid) + } + } + } + .onFailure { + sending.remove(uid) + Log.w(TAG, "Could not schedule the binder delivery for ${module.packageName}", it) + } + } + + /** + * Watches the process that took the binder, so [uidSet] forgets the uid when it dies. + * + * [uidGone] is not enough on its own — it only fires when the *uid* has no processes left — + * and this is what makes a second delivery to a restarted module app possible. A death + * recipient on a proxy is not a client of anything, so unlike the provider reference it puts + * no floor under the process's priority. + */ + private fun linkDelivery(uid: Int, provider: IBinder) { + val recipient = IBinder.DeathRecipient { uidSet.remove(uid) } + runCatching { + provider.linkToDeath(recipient, 0) + deliveries.put(uid, provider to recipient)?.let { (old, previous) -> + runCatching { old.unlinkToDeath(previous, 0) } + } + } + // Already dead, which is an answer in itself: whatever took the binder is gone, so the + // uid must not stay marked as served. + .onFailure { uidSet.remove(uid) } + } + + /** True while a uid has spent its attempts and its cooldown has not elapsed. */ + private fun isThrottled(uid: Int): Boolean { + val run = binderFailures[uid] ?: return false + if (run.count < MAX_CONSECUTIVE_BINDER_FAILURES) return false + return SystemClock.elapsedRealtime() - run.atElapsed < BINDER_RETRY_COOLDOWN_MS + } + + private fun recordFailure(uid: Int, modulePkg: String) { + var crossed = false + // Read-modify-write in one step. Two threads cannot be here for one uid while [sending] + // holds, but that is an invariant of another field and not one to build arithmetic on. + binderFailures.compute(uid) { _, previous -> + val now = SystemClock.elapsedRealtime() + val count = + when { + // A run is forgotten only after a long quiet spell, not after one cooldown. Forgetting + // it at the cooldown meant the attempt the cooldown let through reset the count, so + // the ceiling was re-climbed and three more attempts allowed every minute, for ever. + previous == null || now - previous.atElapsed >= BINDER_FAILURE_RUN_MS -> 1 + // Held at the ceiling rather than growing without bound: what the number decides is + // only whether we are throttled, and pinning it here is what makes the cooldown mean + // one attempt rather than another three. + else -> minOf(previous.count + 1, MAX_CONSECUTIVE_BINDER_FAILURES) + } + crossed = count == MAX_CONSECUTIVE_BINDER_FAILURES && (previous?.count ?: 0) < count + FailureRun(count, now) + } + // Once, on the way past the ceiling. The failures themselves are already logged one by one + // in sendBinder; what is worth saying here is that we have stopped trying, which is the part + // a reader chasing a module that never receives its service cannot otherwise see. + if (crossed) { + Log.w( + TAG, + "$modulePkg/$uid failed to take its binder $MAX_CONSECUTIVE_BINDER_FAILURES times in" + + " a row; retrying at most once every ${BINDER_RETRY_COOLDOWN_MS / 1000}s") + } + } + + fun uidGone(uid: Int) { + uidSet.remove(uid) + // A send that never returns — `provider.call` runs the module's own onServiceBind, with no + // deadline — would otherwise leave the uid here for the life of the daemon, and every later + // delivery for it refused at the top of uidStarts. + sending.remove(uid) + deliveries.remove(uid)?.let { (binder, recipient) -> + runCatching { binder.unlinkToDeath(recipient, 0) } + } + } + + // Drives the same cycle as a service request, so onHotReloading can still refuse it. + fun autoHotReload(module: LoadedModule) { + if (!module.code.autoHotReload) return + val service = serviceMap.getOrPut(module) { ModuleAppService(module) } + FrameworkService.staleHotReloadTargets(module.packageName).forEach { target -> + if (target.hotReloadable && FrameworkService.beginHotReload(target)) { + Log.d(TAG, "Auto hot reloading ${module.packageName} in ${target.processName}") + hotReloadExecutor.execute { service.runHotReload(target, null, null) } + } + } + } + } + + /** + * Forges a ContentProvider call to force the module's target app process to receive this Binder + * IPC endpoint without standard Context.bindService() limits. + * + * Called only from [uidStarts], on [binderExecutor] rather than on the uid observer, because + * `getContentProviderExternal` blocks until the app publishes its provider or the platform gives + * up waiting for it. + * + * @return the provider binder of the process that took it, or null if nobody did. The caller + * counts the failures — nothing else distinguishes "the app has its service" from "we asked and + * nobody answered", and conflating the two is what let a module app that dies on every launch + * be started again a second later, for as long as it kept dying — and watches the binder, so + * that the process dying is what makes the next one eligible. + */ + private fun sendBinder(uid: Int): IBinder? { + val name = loadedModule.packageName + val userId = uid / PER_USER_RANGE + val authority = name + AUTHORITY_SUFFIX + // Identifies our reference to the provider so it can be given back, which it never was. That + // reference counts as a live client of the provider — `ContentProviderRecord`'s + // `hasConnectionOrHandle` is `!connections.isEmpty() || hasExternalProcessHandles()`, and the + // second half counts external references with and without a token — and the platform draws two + // conclusions from a live client. + // + // The host is pinned. `OomAdjuster.computeOomAdjLSP` raises a process publishing such a + // provider to FOREGROUND_APP_ADJ and PROCESS_STATE_IMPORTANT_FOREGROUND, recorded as + // `adjType=ext-provider`. So this held every module app on the device at foreground priority + // for as long as it lived, never cached and never trimmed — and a uid kept out of the + // background is a uid that keeps being reported active, which is what wakes the delivery again. + // + // And the host is restarted. A process that dies while a provider of its is still launching is + // restarted for it, `MAX_RETRY_COUNT` = 3 times per provider record, after which the record is + // dropped and the platform gives up. Taking the reference again builds a fresh record with the + // count back at zero, so re-acquiring on every uid callback is what turned the platform's + // bounded retry into an unbounded one — see the throttle in [binderFailures]. + // + // A real token rather than null also buys a death link: the platform builds a handle object + // around it and releases the reference itself if we die. A null token is only a counter, with + // nothing to link, which is why the old leak was permanent rather than merely long. + val token = Binder() + return runCatching { + // The tag argument arrived in Q, replacing the three-argument form rather than + // overloading it, so each side of that line is a NoSuchMethodError on the other. The + // branch was in the Java daemon and was lost in the Kotlin rewrite (#597), which means + // no modern module has been handed its service on 8.1 or 9 since — swallowed, because + // the error lands in the runCatching below and reads as an ordinary failed delivery. + val provider = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + activityManager?.getContentProviderExternal(authority, userId, token, "vector") + } else { + activityManager?.getContentProviderExternal(authority, userId, token) + }?.provider + + if (provider == null) { + Log.d(TAG, "No service provider for $name") + return@runCatching null + } + + val extra = Bundle().apply { putBinder("binder", asBinder()) } + val reply: Bundle? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + provider.call( + AttributionSource.Builder(1000).setPackageName("android").build(), + authority, + SEND_BINDER, + null, + extra) + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + provider.call("android", null, authority, SEND_BINDER, null, extra) + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + provider.call("android", authority, SEND_BINDER, null, extra) + } else { + provider.call("android", SEND_BINDER, null, extra) + } + + if (reply != null) { + Log.d(TAG, "Sent module binder to $name") + provider.asBinder() + } else { + Log.w(TAG, "Failed to send module binder to $name") + null + } + } + .onFailure { Log.w(TAG, "Failed to send module binder for uid $uid", it) } + // Unconditionally, and not only when a provider came back. The platform registers the + // external client *before* it waits for the app to publish, and the two returns that + // matter here — the app died while launching, and the wait timed out — come after that + // registration with the reference still held. Those are exactly the returns a module app + // that dies on every start produces, so releasing only on success would have left the + // restart loop this method exists to stop completely intact. + // + // Asking when nothing was registered is not free of consequence, only of harm. If the app + // is not running there is no record and the platform returns quietly; if it is, the record + // exists under this authority whether or not our acquire got as far as registering, and + // the platform logs that something tried to remove an external reference it does not have. + // A line in its log against the loop this stops is the right side of that trade. + .also { releaseProvider(authority, token, userId) } + .getOrNull() + } + + /** + * Gives back the reference [sendBinder] took, whatever became of the call in between. + * + * The user id has to be named, and can be from Q. The plain form is all that API 27 and 28 have, + * and there the platform resolves the name against the *caller's* user, which is the daemon's: + * a reference taken for a module in a secondary user cannot be given back at all, and asking + * anyway would decrement the token-less counter of whatever record user 0 has under that name. + * So on those two releases a secondary user's reference is left to the token instead — the + * platform links the handle to the token's death, so the reference goes when the daemon does. + */ + private fun releaseProvider(authority: String, token: Binder, userId: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && userId != 0) { + Log.d(TAG, "Cannot release the reference for $authority in user $userId before Q") + return + } + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + activityManager?.removeContentProviderExternalAsUser(authority, token, userId) + } else { + activityManager?.removeContentProviderExternal(authority, token) + } + } + .onFailure { Log.w(TAG, "Failed to release the provider reference for $authority", it) } + } + + private fun ensureModule(): Int { + val appId = Binder.getCallingUid() % PER_USER_RANGE + if (loadedModule.appId != appId) { + throw RemoteException( + "Module ${loadedModule.packageName} is not for uid ${Binder.getCallingUid()}") + } + return Binder.getCallingUid() / PER_USER_RANGE + } + + override fun getApiVersion() = ensureModule().let { IXposedService.LIB_API } + + override fun getFrameworkName() = ensureModule().let { BuildConfig.FRAMEWORK_NAME } + + /** + * The whole version, not just its name. + * + * The interface promises a module "the framework version" as a string, and what goes in it is + * this implementation's to decide. "2.0" was true and useless: the number that identifies a + * build is the commit count, and even that is shared by every branch built at the same depth, so + * a module author reading a bug report could not tell which framework produced it. The manager's + * status page grew the exact build for that reason; a module author receives bug reports too. + * + * The parenthesised group stays purely numeric and [getFrameworkVersionCode] still answers with + * the number on its own, so nothing that wants to *compare* versions has any reason to parse + * this string. + */ + override fun getFrameworkVersion() = + ensureModule().let { + buildString { + append(BuildConfig.VERSION_NAME) + append(" (").append(BuildConfig.VERSION_CODE).append(")") + BuildConfig.VERSION_HASH.takeIf { hash -> hash.isNotBlank() } + ?.let { hash -> append(" ").append(hash) } + } + } + + override fun getFrameworkVersionCode() = ensureModule().let { BuildConfig.VERSION_CODE } + + override fun getFrameworkProperties(): Long { + ensureModule() + var prop = IXposedService.PROP_CAP_SYSTEM or IXposedService.PROP_CAP_REMOTE + if (ConfigCache.state.isDexObfuscateEnabled) + prop = prop or IXposedService.PROP_RT_API_PROTECTION + return prop + } + + override fun getScope(): List { + val userId = ensureModule() + // The caller's own user, and the framework row that belongs to none. The scope set is one set + // for the whole module, but the other two calls on this interface are not: [requestScope] asks + // for the caller's user and [removeScope] gives back the caller's user. Returning every row + // meant a copy in user 11 was shown user 0's packages, which it could neither have asked for + // nor give back - the removal is keyed on its own user and would match nothing. + // + // The scope table has one row per (app, user), so a module held by several users saw the same + // package repeatedly. A scope is a set of package names. + return ModuleDatabase.getModuleScope(loadedModule.packageName) + ?.filter { it.userId == userId || it.packageName == "system" } + ?.map { it.packageName } + ?.distinct() ?: emptyList() + } + + /** + * One request, one question, one answer. + * + * The AIDL hands over a list and takes a single [IXposedScopeCallback] for it, and the javadoc on + * the client's `OnScopeEventListener` says its listener runs "when the request is completed" — + * singular — with `onScopeRequestApproved` taking the *packages* that were approved. This used to + * put one prompt per package on screen, each answered in its own right, so a module asking for + * three packages made the user answer three questions and then fired that one listener three + * times. A module that took the first answer as the answer acted on a third of it. + * + * So the whole list goes up as one prompt and Approve answers for all of it. What the user gives + * away in one press is what the prompt lists, which is why it lists all of them rather than a + * count, and why the packages are sorted and deduplicated first: it is a set that is being agreed + * to, the same set asked for twice is the same question, and `NotificationManager` identifies a + * prompt by the set it names. + */ + override fun requestScope(packages: List, callback: IXposedScopeCallback) { + val userId = ensureModule() + val requested = packages.distinct().sorted() + if (requested.isEmpty()) { + // Nothing was asked for, so the request is trivially satisfied. Returning without touching + // the callback would leave the module waiting forever. + callback.onScopeRequestApproved(emptyList()) + return + } + // A module that fixed its own scope in module.prop does not get to ask for more of it at + // runtime. Prompting the user here would make "fixed" mean nothing. + ConfigCache.staticScopeOf(loadedModule.packageName)?.let { claimed -> + val beyond = requested.filterNot { claimed.contains(it) } + if (beyond.isNotEmpty()) { + callback.onScopeRequestFailed( + "This module declares a static scope, so ${beyond.joinToString()} cannot be added") + return + } + } + if (!PreferenceStore.isScopeRequestBlocked(loadedModule.packageName)) { + NotificationManager.requestModuleScope(loadedModule.packageName, userId, requested, callback) + } else { + callback.onScopeRequestFailed("Scope request blocked by user configuration") + } + } + + override fun removeScope(packages: List) { + val userId = ensureModule() + packages.forEach { pkg -> + runCatching { ModuleDatabase.removeModuleScope(loadedModule.packageName, pkg, userId) } + .onFailure { Log.e(TAG, "Error removing scope for $pkg", it) } + } + } + + override fun getRunningTargets(): List { + val userId = ensureModule() + return FrameworkService.getHotReloadTargets(loadedModule.packageName, userId) + } + + override fun hotReloadModule(targetId: Long, data: Bundle?, callback: IHotReloadCallback?) { + // The user id matters as much as the app id here: ensureModule only proves the caller shares + // the module's app id, which every copy of it does. The copies are one module and one APK, but + // they are separate apps with separate uids and separate preferences, and the boundary that + // keeps a module out of a user that never installed it applies to reloading too. Without this, + // the copy in user 11 could reload user 0's processes. + val userId = ensureModule() + // SecurityException is reserved by the AIDL for exactly these two conditions, so it must not be + // raised for anything else on this path - a module-thrown SecurityException in particular has + // to reach the caller as a FAILED result, not as "invalid target id". + val target = + FrameworkService.getHotReloadTarget(targetId, loadedModule.packageName, userId) + ?: throw SecurityException("Target $targetId is not a target of ${loadedModule.packageName}") + + if (!target.hotReloadable) { + // Hot reload is specified only for modules declaring exactly one Java entry class. + report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Module has no single Java entry class") + return + } + + if (!FrameworkService.beginHotReload(target)) { + report(callback, IXposedService.HOT_RELOAD_IN_PROGRESS, "A reload is already running") + return + } + + // The AIDL asks implementations to validate and enqueue promptly and report through the + // callback. Running the cycle inline would pin this binder thread for its whole duration and + // ANR a module app that called from its main thread. + hotReloadExecutor.execute { runHotReload(target, data, callback) } + } + + private fun runHotReload( + target: FrameworkService.HotReloadTarget, + data: Bundle?, + callback: IHotReloadCallback?, + ) { + var status = IXposedService.HOT_RELOAD_FAILED + var message: String? = "Hot reload did not run" + var refreeze: (() -> Unit)? = null + var loadedVersion: Long? = null + val answered = CountDownLatch(1) + var outcome: HotReloadOutcome? = null + + try { + val binder = FrameworkService.getHotReloadBinder(target) + if (binder == null) { + status = IXposedService.HOT_RELOAD_UNSUPPORTED + message = "Process ${target.processName} has no hot reload entry point" + return + } + val newModule = ConfigCache.state.modules[loadedModule.packageName] + if (newModule == null) { + status = IXposedService.HOT_RELOAD_UNSUPPORTED + message = "No installed generation of ${loadedModule.packageName} to load" + return + } + + // A cached target is usually frozen, and a transaction to a frozen process is not delivered. + // Thawing first is what keeps that case from being reported as a refusal. A device with no + // app freezer at all - anything before the cgroup v2 freezer - is the ordinary path, not a + // failure, so a null here only means "nothing to do". + refreeze = ProcessFreezer.thaw(target.pid) + if (ProcessFreezer.isFrozen(target.pid)) { + // Say so now rather than spending the timeout on a transaction that will not be delivered. + // Not a refusal either: the message is what tells the two apart. + status = IXposedService.HOT_RELOAD_FAILED + message = "Process ${target.processName} is frozen and could not be thawed" + return + } + + val callbackStub = + object : IHotReloadOutcomeReceiver.Stub() { + override fun onOutcome(result: HotReloadOutcome?) { + outcome = result + answered.countDown() + } + } + binder.hotReload(loadedModule.packageName, data, newModule, callbackStub) + + // Bounded, because the callee runs arbitrary module code and binder has no timeout of its + // own: without this a module that never returns from onHotReloading would leave the target + // RELOADING for the life of the process, and every later request would answer IN_PROGRESS. + if (!answered.await(RELOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + status = + if (FrameworkService.isProcessRegistered(target)) IXposedService.HOT_RELOAD_FAILED + else IXposedService.HOT_RELOAD_PROCESS_DIED + message = + if (status == IXposedService.HOT_RELOAD_PROCESS_DIED) { + "Process ${target.processName} died during hot reload" + } else { + "Process ${target.processName} did not answer within ${RELOAD_TIMEOUT_SECONDS}s" + } + return + } + + val answer = + outcome + ?: run { + status = IXposedService.HOT_RELOAD_FAILED + message = "Process ${target.processName} answered with nothing" + return + } + + status = answer.status + // Whether the generation was swapped is not the same question as whether the reload + // succeeded: onHotReloaded runs after the swap is committed, so a throw from it leaves the + // process on the new code and still reports FAILED. Recording the version the target is + // actually running is what keeps getRunningTargets() honest about it. + if (answer.generationChanged) loadedVersion = newModule.versionCode + // A null message is reserved for a refusal, so anything else gets one supplied. + message = + answer.message + ?: if (status == IXposedService.HOT_RELOAD_FAILED && !answer.refused) { + "Hot reload failed without a diagnostic message" + } else { + null + } + } catch (t: Throwable) { + // Deliberately not keyed on DeadObjectException: a frozen-but-alive target answers a + // transaction with exactly that, so the exception type says nothing about whether the process + // is gone. The heartbeat registry does - it is driven by a DeathRecipient. + val gone = !FrameworkService.isProcessRegistered(target) + status = + if (gone) IXposedService.HOT_RELOAD_PROCESS_DIED else IXposedService.HOT_RELOAD_FAILED + message = + if (gone) "Process ${target.processName} died during hot reload" + else "${t.javaClass.name}: ${t.message ?: "no message"}" + Log.e(TAG, "Hot reload of ${loadedModule.packageName} failed", t) + } finally { + refreeze?.invoke() + FrameworkService.endHotReload(target, stateFor(status), loadedVersion) + report(callback, status, message) + } + } + + private fun stateFor(status: Int): Int = + when (status) { + IXposedService.HOT_RELOAD_SUCCEEDED -> HookedProcess.TARGET_STATE_UP_TO_DATE + IXposedService.HOT_RELOAD_FAILED -> HookedProcess.TARGET_STATE_FAILED + // Unsupported and process-died say nothing about the generation the target is running, so + // the reported state falls back to comparing versions. + else -> HookedProcess.TARGET_STATE_UP_TO_DATE + } + + private fun report(callback: IHotReloadCallback?, status: Int, message: String?) { + runCatching { callback?.onHotReloadResult(status, message) } + .onFailure { Log.w(TAG, "Cannot deliver hot reload result to ${loadedModule.packageName}", it) } + } + + override fun requestRemotePreferences(group: String): Bundle { + val userId = ensureModule() + return Bundle().apply { + putSerializable( + "map", + PreferenceStore.getModulePrefs(loadedModule.packageName, userId, group) as Serializable) + } + } + + @Suppress("DEPRECATION") + override fun updateRemotePreferences(group: String, diff: Bundle) { + val userId = ensureModule() + val values = mutableMapOf() + + // RemotePreferences.Editor always writes this key, and sets it for edit().clear(). Ignoring it + // left every key the module app just cleared in place. + if (diff.getBoolean("clear", false)) { + PreferenceStore.deleteModulePrefs(loadedModule.packageName, userId, group) + } + + diff.getSerializable("delete")?.let { deletes -> + (deletes as Set<*>).forEach { values[it as String] = null } + } + diff.getSerializable("put")?.let { puts -> + (puts as Map<*, *>).forEach { (k, v) -> values[k as String] = v } + } + + runCatching { + PreferenceStore.updateModulePrefs(loadedModule.packageName, userId, group, values) + (loadedModule.service as? InjectedModuleService) + ?.onUpdateRemotePreferences(group, userId, diff) + } + .getOrElse { throw RemoteException(it.message) } + } + + override fun deleteRemotePreferences(group: String) { + val userId = ensureModule() + PreferenceStore.deleteModulePrefs(loadedModule.packageName, userId, group) + // Hooked processes hold an in-process cache of the group; without this they keep serving the + // deleted values until their process restarts. + (loadedModule.service as? InjectedModuleService) + ?.onUpdateRemotePreferences(group, userId, Bundle().apply { putBoolean("clear", true) }) + } + + override fun listRemoteFiles(): Array { + val userId = ensureModule() + return runCatching { + FileSystem.resolveModuleDir( + loadedModule.packageName, "files", userId, Binder.getCallingUid()) + .toFile() + .list() ?: emptyArray() + } + .getOrElse { throw RemoteException(it.message) } + } + + override fun openRemoteFile(path: String): ParcelFileDescriptor { + val userId = ensureModule() + FileSystem.ensureModuleFilePath(path) + return runCatching { + val file = + FileSystem.resolveModuleDir( + loadedModule.packageName, "files", userId, Binder.getCallingUid()) + .resolve(path) + .toFile() + ParcelFileDescriptor.open( + file, ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_READ_WRITE) + } + .getOrElse { throw RemoteException(it.message) } + } + + override fun deleteRemoteFile(path: String): Boolean { + val userId = ensureModule() + FileSystem.ensureModuleFilePath(path) + return runCatching { + FileSystem.resolveModuleDir( + loadedModule.packageName, "files", userId, Binder.getCallingUid()) + .resolve(path) + .toFile() + .delete() + } + .getOrElse { throw RemoteException(it.message) } + } +} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt deleted file mode 100644 index 26d0a25bb..000000000 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ /dev/null @@ -1,213 +0,0 @@ -package org.matrix.vector.daemon.ipc - -import android.content.AttributionSource -import android.os.Binder -import android.os.Build -import android.os.Bundle -import android.os.ParcelFileDescriptor -import android.os.RemoteException -import android.util.Log -import io.github.libxposed.service.IXposedScopeCallback -import io.github.libxposed.service.IXposedService -import java.io.Serializable -import java.util.Collections -import java.util.WeakHashMap -import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.models.Module -import org.matrix.vector.daemon.BuildConfig -import org.matrix.vector.daemon.data.ConfigCache -import org.matrix.vector.daemon.data.FileSystem -import org.matrix.vector.daemon.data.ModuleDatabase -import org.matrix.vector.daemon.data.PreferenceStore -import org.matrix.vector.daemon.system.NotificationManager -import org.matrix.vector.daemon.system.PER_USER_RANGE -import org.matrix.vector.daemon.system.activityManager - -private const val TAG = "VectorModuleService" - -class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { - - companion object { - private val uidSet = ConcurrentHashMap.newKeySet() - private val serviceMap = Collections.synchronizedMap(WeakHashMap()) - - fun uidClear() { - uidSet.clear() - } - - fun uidStarts(uid: Int) { - if (uidSet.add(uid)) { - val module = ConfigCache.getModuleByUid(uid) - if (module?.file?.legacy == false) { - val service = serviceMap.getOrPut(module) { ModuleService(module) } - service.sendBinder(uid) - } - } - } - - fun uidGone(uid: Int) { - uidSet.remove(uid) - } - } - - /** - * Forges a ContentProvider call to force the module's target app process to receive this Binder - * IPC endpoint without standard Context.bindService() limits. - */ - private fun sendBinder(uid: Int) { - val name = loadedModule.packageName - runCatching { - val userId = uid / PER_USER_RANGE - val authority = name + AUTHORITY_SUFFIX - val provider = - activityManager?.getContentProviderExternal(authority, userId, null, null)?.provider - - if (provider == null) { - Log.d(TAG, "No service provider for $name") - return - } - - val extra = Bundle().apply { putBinder("binder", asBinder()) } - val reply: Bundle? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - provider.call( - AttributionSource.Builder(1000).setPackageName("android").build(), - authority, - SEND_BINDER, - null, - extra) - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - provider.call("android", null, authority, SEND_BINDER, null, extra) - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { - provider.call("android", authority, SEND_BINDER, null, extra) - } else { - provider.call("android", SEND_BINDER, null, extra) - } - - if (reply != null) Log.d(TAG, "Sent module binder to $name") - else Log.w(TAG, "Failed to send module binder to $name") - } - .onFailure { Log.w(TAG, "Failed to send module binder for uid $uid", it) } - } - - private fun ensureModule(): Int { - val appId = Binder.getCallingUid() % PER_USER_RANGE - if (loadedModule.appId != appId) { - throw RemoteException( - "Module ${loadedModule.packageName} is not for uid ${Binder.getCallingUid()}") - } - return Binder.getCallingUid() / PER_USER_RANGE - } - - override fun getApiVersion() = ensureModule().let { IXposedService.LIB_API } - - override fun getFrameworkName() = ensureModule().let { BuildConfig.FRAMEWORK_NAME } - - override fun getFrameworkVersion() = ensureModule().let { BuildConfig.VERSION_NAME } - - override fun getFrameworkVersionCode() = ensureModule().let { BuildConfig.VERSION_CODE } - - override fun getFrameworkProperties(): Long { - ensureModule() - var prop = IXposedService.PROP_CAP_SYSTEM or IXposedService.PROP_CAP_REMOTE - if (ConfigCache.state.isDexObfuscateEnabled) - prop = prop or IXposedService.PROP_RT_API_PROTECTION - return prop - } - - override fun getScope(): List { - ensureModule() - return ConfigCache.getModuleScope(loadedModule.packageName)?.map { it.packageName } - ?: emptyList() - } - - override fun requestScope(packages: List, callback: IXposedScopeCallback) { - val userId = ensureModule() - if (!PreferenceStore.isScopeRequestBlocked(loadedModule.packageName)) { - packages.forEach { pkg -> - NotificationManager.requestModuleScope(loadedModule.packageName, userId, pkg, callback) - } - } else { - callback.onScopeRequestFailed("Scope request blocked by user configuration") - } - } - - override fun removeScope(packages: List) { - val userId = ensureModule() - packages.forEach { pkg -> - runCatching { ModuleDatabase.removeModuleScope(loadedModule.packageName, pkg, userId) } - .onFailure { Log.e(TAG, "Error removing scope for $pkg", it) } - } - } - - override fun requestRemotePreferences(group: String): Bundle { - val userId = ensureModule() - return Bundle().apply { - putSerializable( - "map", - PreferenceStore.getModulePrefs(loadedModule.packageName, userId, group) as Serializable) - } - } - - @Suppress("DEPRECATION") - override fun updateRemotePreferences(group: String, diff: Bundle) { - val userId = ensureModule() - val values = mutableMapOf() - - diff.getSerializable("delete")?.let { deletes -> - (deletes as Set<*>).forEach { values[it as String] = null } - } - diff.getSerializable("put")?.let { puts -> - (puts as Map<*, *>).forEach { (k, v) -> values[k as String] = v } - } - - runCatching { - PreferenceStore.updateModulePrefs(loadedModule.packageName, userId, group, values) - (loadedModule.service as? InjectedModuleService)?.onUpdateRemotePreferences(group, diff) - } - .getOrElse { throw RemoteException(it.message) } - } - - override fun deleteRemotePreferences(group: String) { - PreferenceStore.deleteModulePrefs(loadedModule.packageName, ensureModule(), group) - } - - override fun listRemoteFiles(): Array { - val userId = ensureModule() - return runCatching { - FileSystem.resolveModuleDir( - loadedModule.packageName, "files", userId, Binder.getCallingUid()) - .toFile() - .list() ?: emptyArray() - } - .getOrElse { throw RemoteException(it.message) } - } - - override fun openRemoteFile(path: String): ParcelFileDescriptor { - val userId = ensureModule() - FileSystem.ensureModuleFilePath(path) - return runCatching { - val file = - FileSystem.resolveModuleDir( - loadedModule.packageName, "files", userId, Binder.getCallingUid()) - .resolve(path) - .toFile() - ParcelFileDescriptor.open( - file, ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_READ_WRITE) - } - .getOrElse { throw RemoteException(it.message) } - } - - override fun deleteRemoteFile(path: String): Boolean { - val userId = ensureModule() - FileSystem.ensureModuleFilePath(path) - return runCatching { - FileSystem.resolveModuleDir( - loadedModule.packageName, "files", userId, Binder.getCallingUid()) - .resolve(path) - .toFile() - .delete() - } - .getOrElse { throw RemoteException(it.message) } - } -} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index dc229ad22..c0db44073 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -1,19 +1,28 @@ package org.matrix.vector.daemon.ipc +import android.os.Binder import android.os.Build import android.os.IBinder import android.os.IServiceCallback import android.os.Parcel import android.os.ServiceManager import android.util.Log -import org.lsposed.lspd.service.ILSPApplicationService -import org.lsposed.lspd.service.ILSPSystemServerService +import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.* import org.matrix.vector.daemon.system.getSystemServiceManager private const val TAG = "VectorSystemServer" -object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipient { +/** + * The daemon's end of the one handshake system_server gets. + * + * A plain [Binder] rather than an AIDL stub on purpose. system_server never holds an interface for + * this - it reaches the daemon by transacting [BRIDGE_TRANSACTION_CODE] on whatever binder the + * hijacked service name resolves to, which [onTransact] answers directly. An AIDL interface here + * would generate a dispatch table nothing ever entered, and would have to state a descriptor that + * nothing ever checks. + */ +object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null @@ -24,6 +33,8 @@ object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipi // Register as the service name early to setup an IPC for `system_server`. Log.d(TAG, "Registering bridge service for `system_server` with name `$serviceName`.") + // `IServiceManager.registerForNotifications` is only available since Android R. + // On older platforms we simply let the real service replace our proxy in servicemanager. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val callback = object : IServiceCallback.Stub() { @@ -39,28 +50,40 @@ object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipi override fun asBinder(): IBinder = this } - runCatching { - getSystemServiceManager().registerForNotifications(serviceName, callback) - ServiceManager.addService(serviceName, this) - proxyServiceName = serviceName - } + runCatching { getSystemServiceManager().registerForNotifications(serviceName, callback) } .onFailure { Log.e(TAG, "Failed to register IServiceCallback", it) } } + + // The Zygisk module polls this name during `system_server` specialization, + // so it must be claimed on every supported platform. + runCatching { + ServiceManager.addService(serviceName, this) + proxyServiceName = serviceName + } + .onFailure { Log.e(TAG, "Failed to register proxy service `$serviceName`", it) } } - override fun requestApplicationService( + /** + * Registers system_server and answers with its framework service, or null if this is not + * system_server. Only ever called from [onTransact] below. + */ + private fun attachProcess( uid: Int, pid: Int, processName: String, - heartBeat: IBinder? - ): ILSPApplicationService? { - if (uid != 1000 || heartBeat == null || processName != "system") return null - systemServerRequested = true + processLifeToken: IBinder? + ): IFrameworkService? { + if (uid != 1000 || processLifeToken == null || processName != "system") return null - // Return the ApplicationService singleton if successfully registered - return if (ApplicationService.registerHeartBeat(uid, pid, processName, heartBeat)) { - ApplicationService - } else null + // Latched only once the registration has actually succeeded, not on the way in. It used to be + // set immediately after the gate above, so a registration that then failed — registerHeartBeat + // answers false when the life token cannot be linked to death — still read as attached. The + // symptom was the worst kind: the manager's status page reported the framework as present in + // system_server while no module hooking the system ever loaded, which sends a reader looking + // at their module instead of at the injection. + if (!FrameworkService.registerHeartBeat(uid, pid, processName, processLifeToken)) return null + systemServerRequested = true + return FrameworkService } override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { @@ -76,9 +99,9 @@ object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipi val uid = data.readInt() val pid = data.readInt() val processName = data.readString() ?: "" - val heartBeat = data.readStrongBinder() + val processLifeToken = data.readStrongBinder() - val service = requestApplicationService(uid, pid, processName, heartBeat) + val service = attachProcess(uid, pid, processName, processLifeToken) if (service != null) { reply?.writeNoException() reply?.writeStrongBinder(service.asBinder()) @@ -88,7 +111,7 @@ object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipi } DEX_TRANSACTION_CODE, OBFUSCATION_MAP_TRANSACTION_CODE -> { - return ApplicationService.onTransact(code, data, reply, flags) + return FrameworkService.onTransact(code, data, reply, flags) } else -> { return super.onTransact(code, data, reply, flags) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/NotificationManager.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/NotificationManager.kt index 56e9a80c2..8eaa9574d 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/NotificationManager.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/NotificationManager.kt @@ -15,6 +15,7 @@ import android.graphics.drawable.LayerDrawable import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.SystemClock import android.util.Log import io.github.libxposed.service.IXposedScopeCallback import java.util.UUID @@ -28,6 +29,33 @@ private const val STATUS_CHANNEL_ID = "vector_status" private const val UPDATED_CHANNEL_ID = "vector_module_updated" private const val STATUS_NOTIF_ID = BuildConfig.MANAGER_INJECTED_UID +/** + * How long a scope request stays on screen before the platform takes it down for us and fires its + * delete intent, which reports the timeout back to the module. + * + * An hour, as it was when the prompt was first written. A module that asked and was ignored gets a + * definite answer eventually instead of a callback that never fires. + */ +private const val SCOPE_REQUEST_TIMEOUT_MS = 60L * 60 * 1000 + +/** + * How many prompts one module may have waiting for an answer at once. + * + * Nothing else bounds them: these are enqueued as "android", which NotificationManagerService + * exempts from its per-package limit, and they are IMPORTANCE_HIGH, so a module calling + * `IXposedService.requestScope` in a loop would get a heads-up prompt per call, each sitting for + * [SCOPE_REQUEST_TIMEOUT_MS]. It is a *call* that costs a place, not a package: one request is one + * prompt however many packages it names, which is what makes a single Approve able to answer for + * all of them. + * + * Sixteen because it is far above anything an honest module asks for in one go, and low enough that + * the worst a module can do to the shade is a screenful. It bounds what is *unanswered*, not what + * may be asked over time: answering a prompt frees its place at once, so a module that asks a few + * questions and waits for them never meets it. A module that does meet it is told so rather than + * left waiting, because a callback that never fires is the failure this whole path exists to avoid. + */ +private const val MAX_OPEN_SCOPE_REQUESTS_PER_MODULE = 16 + object NotificationManager { val openManagerAction = UUID.randomUUID().toString() val moduleScopeAction = UUID.randomUUID().toString() @@ -85,7 +113,7 @@ object NotificationManager { } private fun getNotificationIcon(): Icon { - return Icon.createWithBitmap(getBitmap(R.drawable.ic_notification)) + return Icon.createWithBitmap(getBitmap(R.drawable.ic_statue_monochrome)) } fun notifyStatusNotification() { @@ -122,27 +150,260 @@ object NotificationManager { } } - fun cancelNotification(channel: String, modulePkg: String, moduleUserId: Int) { - runCatching { - // We use the module package name's hash code as the notification ID - // to match how we enqueued it in requestModuleScope and notifyModuleUpdated. - val notifId = modulePkg.hashCode() + /** + * What tells one scope request apart from another. + * + * The platform identifies a notification by (package, tag, id, user), and everything posted here + * is posted as "android", so the tag and the id are the only room we have. Tagging with the + * module package alone meant a module that asked for three packages posted three notifications + * that each replaced the one before it: only the last request was ever answerable, the earlier + * ones were never granted and their callbacks were never called at all. One module running under + * two users collided in exactly the same way. + * + * The requested packages are named as a set rather than one at a time, because one call to + * `requestScope` is one prompt. Canonical order is the caller's job — see `ModuleAppService`, + * which sorts — so that the same request asked twice lands on the same tag and replaces its own + * prompt instead of stacking a second copy of the same question. + */ + private fun scopeTag(modulePkg: String, moduleUserId: Int, scopePkgs: List) = + "$modulePkg:$moduleUserId:${scopePkgs.joinToString(",")}" + /** Cancels what we posted under [tag]; the id is derived from it exactly as it is on enqueue. */ + private fun cancelByTag(tag: String) { + runCatching { + val notifId = tag.hashCode() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - nm?.cancelNotificationWithTag("android", "android", modulePkg, notifId, 0) + nm?.cancelNotificationWithTag("android", "android", tag, notifId, 0) } else { - nm?.cancelNotificationWithTag("android", modulePkg, notifId, 0) + nm?.cancelNotificationWithTag("android", tag, notifId, 0) } } - .onFailure { Log.e(TAG, "Failed to cancel notification", it) } + .onFailure { Log.e(TAG, "Failed to cancel notification $tag", it) } + } + + /** + * Takes down the prompt for one (module, user, requested set) once it has been answered. + * + * It has to name the requested set, because a module that asked twice for different sets has a + * prompt for each and answering one of them must not clear the other. + */ + fun cancelScopeRequest(modulePkg: String, moduleUserId: Int, scopePkgs: List) = + cancelByTag(scopeTag(modulePkg, moduleUserId, scopePkgs)) + + /** + * The "not activated yet" half of [notifyModuleUpdated], which is the half that can go stale. + * + * Only the package, because the one place that knows the notice has become wrong — + * `ModuleDatabase.enableModule`, reached from the manager, the socket CLI and a backup restore — + * is given a package name and nothing else. + */ + private fun notActivatedTag(modulePkg: String) = "$modulePkg:not-activated" + + /** + * Takes down the "module is not activated yet" notice for [modulePkg], because it now is. + * + * Deliberately not the *other* thing [notifyModuleUpdated] posts. "Module updated, force stop and + * restart the apps in its scope" is still true after the module has been enabled — nothing in the + * daemon knows whether the user has restarted those apps — so it is left alone, and the two are + * kept under separate tags so that this cancel cannot reach it. Tagging both the same way meant + * editing a module's scope silently erased a restart reminder the user had not acted on yet. + */ + fun cancelModuleUpdated(modulePkg: String) = cancelByTag(notActivatedTag(modulePkg)) + + /** + * The scope prompts that are on screen and still unanswered, oldest first. + * + * Bookkeeping for the receiver, but it lives beside the prompt because only what was posted can + * be answered and only the poster knows what that is. Two things need it. + * + * One prompt reaches the receiver from four places — its three buttons and its delete intent — + * and a swipe or the one-hour timeout fires the delete intent whether or not a button was pressed + * first. Claiming here is what makes the first arrival the one that answers, so a module cannot + * be told its request was approved and then that it timed out. + * + * And "never ask again" has to be able to take down the module's *other* prompts, which it can + * only do if something remembers they are up. + * + * Bounded twice, and neither bound may be silent. Nothing in here is ever "long abandoned": every + * prompt carries [SCOPE_REQUEST_TIMEOUT_MS], whose expiry fires the delete intent and claims the + * entry, so an entry that is still here is a live question in front of the user. Losing one + * without answering it is therefore not harmless — [claim] would refuse its buttons, so Approve + * would write nothing, "never ask again" would block nothing and the module would be told + * nothing, all while the notification stayed on screen for up to an hour. So [post] hands back + * whatever it had to give up, and the caller takes it off the screen and fails its callback. + * + * [MAX_OPEN_SCOPE_REQUESTS_PER_MODULE] is the bound that does the work, and it refuses the *new* + * prompt rather than dropping an old one, because the questions already in front of the user are + * the ones worth keeping. [MAX_ENTRIES] is only a backstop against an unbounded number of + * modules in a daemon that runs as long as the device does; it sits far above the per-module + * ceiling precisely so that one module's burst cannot reach as far as another module's prompts, + * which is what a shared sixty-four entries let it do. + */ + private object OutstandingScopeRequests { + private const val MAX_ENTRIES = 512 + + /** One live question, and when it was asked; see [countOf] for what the time is for. */ + private class Open(val callback: IXposedScopeCallback, val postedAt: Long) + + private val open = LinkedHashMap() + + /** A prompt that left [open] unanswered: it has to come off the screen and be told so. */ + class Abandoned(val tag: String, val callback: IXposedScopeCallback) + + /** + * How many prompts [modulePkg] still has open, under any user. The ':' matters here for the + * same reason it does in [claimAllOf]. + * + * Anything older than [SCOPE_REQUEST_TIMEOUT_MS] is not counted, because by then the platform + * has taken the prompt down whatever else happened to it. An entry is removed when its prompt + * is answered or dismissed, and both of those reach us — but neither is guaranteed: an enqueue + * the platform drops, or a cancellation that never fires the delete intent, leaves a question + * nobody will ever answer holding one of the module's places. Without the age, a module could + * be locked out of asking for the rest of the boot by prompts that are not on screen. The entry + * itself is left alone: if its buttons somehow still arrive, they should still work. + */ + private fun countOf(modulePkg: String): Int { + val stillOnScreen = SystemClock.elapsedRealtime() - SCOPE_REQUEST_TIMEOUT_MS + return open.count { (tag, entry) -> + tag.startsWith("$modulePkg:") && entry.postedAt > stillOnScreen + } + } + + /** + * Registers the prompt for [tag] before it goes up. + * + * @return the prompts that had to be given up to make room, which the caller must abandon with + * no lock held, or null when [tag] is the one that must not go up at all because its module + * is already holding [MAX_OPEN_SCOPE_REQUESTS_PER_MODULE] unanswered prompts. + */ + @Synchronized + fun post(tag: String, callback: IXposedScopeCallback): List? { + // Re-posting under a tag replaces what was there: a prompt going up is unanswered by + // definition, whatever became of the last one that asked the same thing. The one it replaces + // is dropped without an answer on purpose — it is the same question, of the same module, + // still on screen under the same tag, and telling the module its request failed would fire + // the listener it is about to be asked with. For the same reason it does not count as a new + // prompt against the ceiling: the shade gains nothing. + val replaced = open.remove(tag) != null + // A package name cannot contain a ':', so the first segment of the tag is the module the + // ceiling belongs to. Per module rather than per (module, user), because all of these are + // enqueued into the same shade whichever user the module runs as. + val modulePkg = tag.substringBefore(':') + if (!replaced && countOf(modulePkg) >= MAX_OPEN_SCOPE_REQUESTS_PER_MODULE) return null + open[tag] = Open(callback, SystemClock.elapsedRealtime()) + val abandoned = mutableListOf() + while (open.size > MAX_ENTRIES) { + val oldest = open.keys.first() + abandoned += Abandoned(oldest, open.remove(oldest)!!.callback) + } + return abandoned + } + + /** Takes the right to answer one prompt; null when something already has. */ + @Synchronized fun claim(tag: String): IXposedScopeCallback? = open.remove(tag)?.callback + + /** Takes every prompt [modulePkg] still has up, in one go, so nothing can answer them after. */ + @Synchronized + fun claimAllOf(modulePkg: String): Map { + // The ':' matters: without it "com.foo" would claim the prompts of "com.foobar" as well. + val mine = open.filterKeys { it.startsWith("$modulePkg:") } + mine.keys.forEach { open.remove(it) } + return mine.mapValues { it.value.callback } + } + } + + /** + * Claims the right to answer the prompt for one (module, user, requested set). + * + * @return true for the first caller, false for every later one — the module's + * [IXposedScopeCallback] must be called exactly once per request. + */ + fun claimScopeAnswer(modulePkg: String, moduleUserId: Int, scopePkgs: List) = + OutstandingScopeRequests.claim(scopeTag(modulePkg, moduleUserId, scopePkgs)) != null + + /** + * Withdraws every prompt [modulePkg] still has on screen and hands back their callbacks, so the + * caller can tell each of those requests it will not be granted. + * + * What makes "never ask again" mean what it says. A module that asked three times has a prompt + * for each of those requests; answering the user's "stop asking" by leaving two more questions on + * screen — both still approvable — would be answering it with the opposite. + * + * They are claimed before they are cancelled, and that order is load-bearing, though not for the + * reason this comment used to give: a cancel we ask for ourselves never fires the delete intent. + * `cancelNotificationWithTag` ends in NotificationManagerService's `cancelNotification` with its + * `sendDelete` argument hard-coded false and the reason `REASON_APP_CANCEL`; only a user's swipe + * (`onNotificationClear`) and the `setTimeoutAfter` expiry (`REASON_TIMEOUT`) pass `sendDelete` + * true. What the ordering really defends against is the window the cancel itself opens: it is a + * binder round trip into system_server that only *schedules* the removal on a handler, so these + * prompts keep live Approve, Deny and "never ask again" buttons for a while after we have asked + * for them to go — a tap already in flight, or a swipe or the hourly timeout landing at the same + * moment, would otherwise answer a request we are in the middle of withdrawing. Claiming first + * makes every one of those arrive at a closed door. + * + * Each withdrawn request is handed back on its own, and that is now one failure per + * `requestScope` call rather than one per package. It used to be per package, which put a module + * in an awkward spot: `requestScope` supplies one callback for the whole list, those failures all + * landed on the same binder, and the shipped client library does not collapse them — its + * `OnScopeEventListener` wrapper calls the listener every time and merely drops its map entry + * afterwards, so a module written to the singular javadoc ("invoked when the request is + * completed") ran its handler once per package. Batching the prompt is what fixed that, here and + * everywhere else on this path: the answer is now shaped like the question the module asked. + */ + fun withdrawScopeRequests(modulePkg: String): List { + val withdrawn = OutstandingScopeRequests.claimAllOf(modulePkg) + withdrawn.keys.forEach { cancelByTag(it) } + return withdrawn.values.toList() + } + + /** + * Takes a prompt off the screen and tells its module that request is over, for the cases where + * nobody asked us to: it was given up to make room, or it never made it onto the screen at all. + * + * Both halves are binder calls — one into the notification manager, one into the module — so this + * is deliberately reached with no lock of [OutstandingScopeRequests] held. The callback is + * `oneway`, but the module it points at can be dead by now, and that must not stop the rest of a + * batch from being cleaned up. + */ + private fun abandonScopeRequest(dropped: OutstandingScopeRequests.Abandoned, reason: String) { + cancelByTag(dropped.tag) + runCatching { dropped.callback.onScopeRequestFailed(reason) } + .onFailure { Log.w(TAG, "Could not tell ${dropped.tag} that its request was dropped", it) } } fun requestModuleScope( modulePkg: String, moduleUserId: Int, - scopePkg: String, + scopePkgs: List, callback: IXposedScopeCallback ) { + val tag = scopeTag(modulePkg, moduleUserId, scopePkgs) + // Registered before the notification is built, let alone posted: the buttons are live from the + // moment the platform accepts it, and a prompt the receiver does not know about is one whose + // answer it drops. Registering is also what enforces the ceiling, so there is no point + // rendering an icon for a prompt that is not going up. + val abandoned = OutstandingScopeRequests.post(tag, callback) + if (abandoned == null) { + Log.w( + TAG, + "$modulePkg is already waiting on $MAX_OPEN_SCOPE_REQUESTS_PER_MODULE scope prompts;" + + " not asking about ${scopePkgs.joinToString()}") + // Refused, not ignored. The module is told rather than left holding a callback that can + // never fire, and the message names what did not make it so a module developer can see it. + runCatching { + callback.onScopeRequestFailed( + "Too many scope requests are already waiting for an answer from the user," + + " so ${scopePkgs.joinToString()} was not asked about") + } + .onFailure { Log.w(TAG, "Could not tell $modulePkg its request was refused", it) } + return + } + abandoned.forEach { + Log.w(TAG, "Giving up the scope prompt ${it.tag}: too many are open across all modules") + abandonScopeRequest( + it, "Scope request dropped: too many are waiting for an answer on this device") + } + val context = FakeContext() val userName = userManager?.getUserName(moduleUserId) ?: moduleUserId.toString() @@ -154,7 +415,12 @@ object NotificationManager { Uri.Builder() .scheme("module") .encodedAuthority("$modulePkg:$moduleUserId") - .encodedPath(scopePkg) + // The whole list, because one request is one prompt and one answer. ',' is a + // legal path character and cannot occur in a package name, so the receiver can + // split it back apart; it is also what keeps two requests naming different sets + // on separate PendingIntents, which are identified by their intent and not by + // the extras that carry the callback. + .encodedPath(scopePkgs.joinToString(",")) .appendQueryParameter("action", actionParams) .build() putExtras(Bundle().apply { putBinder("callback", callback.asBinder()) }) @@ -171,7 +437,10 @@ object NotificationManager { .setContentTitle(context.getString(R.string.xposed_module_request_scope_title)) .setContentText( context.getString( - R.string.xposed_module_request_scope_content, modulePkg, userName, scopePkg)) + R.string.xposed_module_request_scope_content, + modulePkg, + userName, + scopePkgs.joinToString())) .setSmallIcon(getNotificationIcon()) .addAction( Notification.Action.Builder( @@ -189,6 +458,14 @@ object NotificationManager { context.getString(R.string.never_ask_again), createActionIntent("block", 6)) .build()) + // Swiping the prompt away, or leaving it alone until it expires, has to answer the + // module too. Without a delete intent the "delete" branch of dispatchModuleScope was + // unreachable and a dismissed prompt left the module's IXposedScopeCallback waiting + // for an answer that could no longer arrive from anywhere. It takes a request code of + // its own because that is half of what identifies a PendingIntent: 4, 5 and 6 are the + // buttons above, and 1 and 3 the status and module-updated notifications. + .setDeleteIntent(createActionIntent("delete", 7)) + .setTimeoutAfter(SCOPE_REQUEST_TIMEOUT_MS) .setAutoCancel(true) .setStyle( Notification.BigTextStyle() @@ -197,13 +474,33 @@ object NotificationManager { R.string.xposed_module_request_scope_content, modulePkg, userName, - scopePkg))) + // The whole list, wrapped over as many lines as it takes. Approve + // answers for all of it at once, so all of it is what the user is + // agreeing to; the collapsed line above is one line whatever we put in + // it, and this is where a request naming more packages than fit there + // becomes readable. Comma-separated rather than one per line because + // the string this fills is a sentence and the list sits mid-way + // through it. + scopePkgs.joinToString()))) .build() .apply { extras.putString("android.substName", BuildConfig.FRAMEWORK_NAME) } createChannels() - runCatching { - nm?.enqueueNotificationWithTag("android", opPkg, modulePkg, modulePkg.hashCode(), notif, 0) + val enqueued = + runCatching { + val service = checkNotNull(nm) { "the notification manager is not available" } + service.enqueueNotificationWithTag("android", opPkg, tag, tag.hashCode(), notif, 0) + } + .onFailure { Log.e(TAG, "Failed to post the scope prompt $tag", it) } + .isSuccess + if (!enqueued) { + // The registration above outlives a failed enqueue, and it would then hold one of the + // module's places against a prompt that is not on screen and that nothing can ever answer — + // a module could wedge itself out of asking again on the strength of prompts the user never + // saw. Give the place back and tell the module instead. + OutstandingScopeRequests.claim(tag)?.let { pending -> + runCatching { pending.onScopeRequestFailed("Could not show the scope request") } + } } } @@ -235,13 +532,36 @@ object NotificationManager { modulePackageName, userName) + // Which user the link opens, which is not necessarily the one whose update raised this. + // + // Every notice this function posts is enqueued for user 0 — see the call at the end — so the + // reader tapping one is standing in user 0 whichever user's PACKAGE_REPLACED fired it. A link + // naming a secondary user therefore lands them somewhere they cannot see: a module installed + // in a private space as well as the main user raised two of these, the tag is the package + // alone so the second overwrote the first, and the survivor pointed into a profile that is + // locked more often than not. The scope editor then listed that profile's apps, which for a + // locked private space is nothing at all, and blamed a filter nobody had set. + // + // Preferring user 0 gives up nothing, because this id does not choose a configuration. A + // module is one package and one APK for the whole device with one scope set and one enabled + // flag; what the id selects is only which user's apps are offered as targets. So the same + // rule applies to the "not activated yet" half, where the act waiting to be done — turning + // the module on — is likewise device-wide. + // + // The triggering user is kept when the module is not in user 0 at all, which is a module + // that lives only in a secondary profile. There is no better answer for that one, and the + // notice is at least still about somewhere the module exists. + val linkUserId = + if (packageManager?.isPackageAvailable(modulePackageName, 0, true) == true) 0 + else moduleUserId + val intent = Intent(openManagerAction).apply { setPackage("android") data = Uri.Builder() .scheme("module") - .encodedAuthority("$modulePackageName:$moduleUserId") + .encodedAuthority("$modulePackageName:$linkUserId") .build() } val pi = @@ -261,9 +581,25 @@ object NotificationManager { .apply { extras.putString("android.substName", BuildConfig.FRAMEWORK_NAME) } createChannels() + // The two notices this function posts are told apart, because only one of them can be made + // wrong by something the user does later: "not activated yet" stops being true the moment the + // module is activated, and [cancelModuleUpdated] takes it down from there; "force stop the apps + // in its scope" stays true until the user does it, which nothing here can observe. One tag for + // both meant activating a module also erased the restart reminder. + // + // Neither carries the user id, and that is deliberate: the cancel is reached from + // ModuleDatabase.enableModule, which is given a package name and nothing more. The price is + // that a module installed for two users shows one notice rather than two — which is what it did + // before this as well. The collision with the scope prompt is gone either way, now that a scope + // tag carries its ":user:target" suffix. + // + // What that price used to include, and no longer does: the two raisings overwrite each other, + // so the surviving notice carried whichever user's link happened to be written last. That is + // what [linkUserId] above is for — the tag stays user-free and the destination stops depending + // on the order two broadcasts arrived in. + val tag = if (enabled) modulePackageName else notActivatedTag(modulePackageName) runCatching { - nm?.enqueueNotificationWithTag( - "android", opPkg, modulePackageName, modulePackageName.hashCode(), notif, 0) + nm?.enqueueNotificationWithTag("android", opPkg, tag, tag.hashCode(), notif, 0) } } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt new file mode 100644 index 000000000..09770e613 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt @@ -0,0 +1,85 @@ +package org.matrix.vector.daemon.system + +import android.util.Log +import java.io.File + +private const val TAG = "VectorFreezer" + +/** + * Thaws a frozen process for the duration of a daemon-initiated transaction. + * + * Android freezes cached processes, and a module's hooked targets are usually cached ones. A binder + * transaction is not delivered to a frozen process, so without this a hot reload of a backgrounded + * target fails without ever running module code - indistinguishable from the module returning false + * from onHotReloading, which is the one case the API reserves a null message for. + */ +object ProcessFreezer { + + /** + * The freezer file for one process, or null when this device has none for it. + * + * Where it lives is the kernel's answer rather than ours: the `0::` line of `/proc//cgroup` + * is that process's own cgroup v2 path relative to the mount point, so reading it is the one form + * that holds whatever the layout is. A guessed list does not - SM-A145R on Android 15 uses + * `/uid_/pid_`, with no `apps/` or `system/` above it, and the paths this was first + * written with matched nothing at all there. + * + * Only the process's own group is ever returned. The uid-level group holds every process of the + * app, and thawing there would move processes this reload has no business touching. minSdk is 27, + * and the cgroup v2 freezer does not exist across that whole range, so null is an ordinary answer + * rather than a failure. + */ + private fun freezeFile(pid: Int): File? { + val path = + runCatching { + File("/proc/$pid/cgroup") + .readLines() + .firstOrNull { it.startsWith("0::") } + ?.removePrefix("0::") + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "/" } + } + .getOrNull() ?: return null + + // A group shared with the whole uid is not ours to thaw. + if (!path.contains("/pid_")) return null + + return File("/sys/fs/cgroup$path/cgroup.freeze").takeIf { it.exists() } + } + + fun isFrozen(pid: Int): Boolean = + runCatching { freezeFile(pid)?.readText()?.trim() == "1" }.getOrDefault(false) + + /** + * Thaws the process if it is frozen, and returns the action that puts it back. Null when nothing + * was changed - either there is no freezer here or the process was already running. + * + * The restore re-reads the file rather than writing "1" blindly: the framework's own app + * compaction owns this state too, and if it has thawed the process meanwhile - because the user + * brought the app to the foreground - freezing it again from here would stop a process the system + * believes is running. + */ + fun thaw(pid: Int): (() -> Unit)? { + val file = freezeFile(pid) ?: return null + val wasFrozen = runCatching { file.readText().trim() == "1" }.getOrDefault(false) + if (!wasFrozen) return null + + val thawed = runCatching { file.writeText("0") }.isSuccess + if (!thawed) { + Log.w(TAG, "Cannot thaw pid=$pid through ${file.path}") + return null + } + + Log.d(TAG, "Thawed pid=$pid for a daemon transaction") + return { + runCatching { + if (file.readText().trim() == "0") { + file.writeText("1") + } else { + Log.d(TAG, "Left pid=$pid alone: something else changed its freezer state") + } + } + .onFailure { Log.w(TAG, "Cannot re-freeze pid=$pid", it) } + } + } +} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt index 6aa5997ec..b4dd39e5a 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt @@ -20,6 +20,15 @@ import org.matrix.vector.daemon.utils.getRealUsers private const val TAG = "VectorSystem" const val PER_USER_RANGE = 100000 + +/** + * The first uid handed to an installed app, as `android.os.Process.FIRST_APPLICATION_UID`. + * + * Below it are the AID_* uids, which carry no user component and are the same process for the whole + * device however many users exist. Above it, a uid is `user * PER_USER_RANGE + appId`, so dividing + * by [PER_USER_RANGE] is only a user test for uids on this side of the line. + */ +const val FIRST_APPLICATION_UID = 10000 const val MATCH_ANY_USER = 0x00400000 // PackageManager.MATCH_ANY_USER const val MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS or @@ -309,6 +318,34 @@ fun IActivityManager.broadcastIntentCompat(intent: Intent) { .onFailure { Log.e(TAG, "broadcastIntent failed", it) } } +// `startActivityAsUserWithFeature` only arrived in Android R: on older platforms the same call +// exists without the calling feature id, and using the newer one throws `NoSuchMethodError`. +fun IActivityManager.startActivityAsUserCompat(intent: Intent, userId: Int): Int { + val appThread = SystemContext.appThread + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + startActivityAsUserWithFeature( + appThread, + "android", + null, + intent, + intent.type, + null, + null, + 0, + 0, + null, + null, + userId) + } else { + startActivityAsUser( + appThread, "android", intent, intent.type, null, null, 0, 0, null, null, userId) + } + } + .onFailure { Log.e(TAG, "startActivityAsUser failed", it) } + .getOrDefault(-1) +} + fun IUserManager.getUserName(userId: Int): String { return runCatching { getUserInfo(userId)?.name }.getOrNull() ?: userId.toString() } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/RootImplementation.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/RootImplementation.kt new file mode 100644 index 000000000..d985b4355 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/RootImplementation.kt @@ -0,0 +1,190 @@ +package org.matrix.vector.daemon.utils + +import android.util.Log +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import org.matrix.vector.ipc.IFrameworkInstallReceiver +import org.matrix.vector.ipc.IManagerService + +private const val TAG = "VectorRootInstaller" + +/** + * Which root implementation is managing this device, and how to flash through it. + * + * Detection is by binary: the binary has to exist to do the flashing anyway, and asking it is one + * process spawn for a question asked once. The version it reports is quoted back to the user and + * nothing more — whether the zygisk loader will run on this device is the loader's own decision, + * taken before the daemon exists, so a daemon that is running has already passed it. + * + * A binary that exists but *fails* is not an implementation: a device can carry a leftover + * `/data/adb/magisk/magisk` from a previous root manager, and it exits 1 with "Cannot connect to + * daemon". Requiring a clean exit is what stops that from being reported as a second root + * implementation and turning a working KernelSU device into ROOT_MULTIPLE. + */ +object RootImplementation { + + /** + * Where each implementation keeps its binary. + * + * Tried by absolute path as well as by name because the daemon does not inherit a login shell's + * PATH: it is started by the root implementation's own init stage, and on some of them PATH holds + * nothing but /system/bin. Falling back to the well-known locations turns "we could not detect + * root" into "there really is no root" for the cases that matter. + */ + private val MAGISK_PATHS = listOf("magisk", "/data/adb/magisk/magisk") + private val KSUD_PATHS = listOf("ksud", "/data/adb/ksud") + private val APD_PATHS = listOf("apd", "/data/adb/apd") + + /** Detected once: three process spawns is not something to repeat on every screen open. */ + private val detected: Detection by lazy { detect() } + + /** + * [binary] is the path detection actually got an answer from, and the one the flash then uses. + * + * Not re-derived at install time: `ksud` may be on the daemon's PATH or only at /data/adb/ksud, + * and the version probe already established which. Guessing again invites the flash to fail on a + * device where detection succeeded. + */ + data class Detection(val implementation: Int, val version: String?, val binary: String? = null) + + val implementation: Int + get() = detected.implementation + + private fun detect(): Detection { + val magisk = detectMagisk() + val ksu = detectKernelSu() + val apatch = detectApatch() + + val found = listOfNotNull(magisk, ksu, apatch) + if (found.size > 1) { + // Not a failure to detect — a device with two root implementations installed, where + // flashing through either is a coin toss about which one owns the module tree. + Log.w(TAG, "Multiple root implementations: ${found.joinToString { it.version ?: "?" }}") + return Detection(IManagerService.ROOT_MULTIPLE, found.joinToString { it.version ?: "?" }) + } + + val only = found.firstOrNull() ?: return Detection(IManagerService.ROOT_NONE, null) + Log.i(TAG, "Root implementation: ${only.version} via ${only.binary}") + return only + } + + /** Null when this implementation is not present; otherwise which it is and where it lives. */ + private fun detectMagisk(): Detection? { + val (binary, raw) = run(MAGISK_PATHS, "-V") ?: return null + val code = raw.trim().toIntOrNull() ?: return null + val name = run(MAGISK_PATHS, "-v")?.second?.trim()?.lineSequence()?.firstOrNull() + return Detection(IManagerService.ROOT_MAGISK, "Magisk ${name ?: code}", binary) + } + + /** + * KernelSU. `ksud -V` prints a *build hash* rather than a version code — on a real device it + * answers `ksud 64e3761d` — so what is quoted back to the user is that hash. + */ + private fun detectKernelSu(): Detection? { + val (binary, raw) = run(KSUD_PATHS, "-V") ?: return null + val build = raw.trim().substringAfter("ksud ").trim() + return Detection(IManagerService.ROOT_KERNELSU, "KernelSU ($build)", binary) + } + + /** + * APatch. `apd -V` prints "apd ", so the second field is the version; when it is not a + * number the whole line is quoted instead, because a parser that did not recognise a version + * string says nothing about the device. + */ + private fun detectApatch(): Detection? { + val (binary, raw) = run(APD_PATHS, "-V") ?: return null + val output = raw.trim() + val code = output.split(Regex("\\s+")).getOrNull(1)?.toIntOrNull() + return Detection(IManagerService.ROOT_APATCH, "APatch ${code ?: "($output)"}", binary) + } + + /** + * First candidate that starts and exits cleanly wins, returned with the path that worked. + * + * A non-zero exit reads as absent, which is what keeps a stale Magisk binary from a previous root + * manager out of the results. + */ + private fun run(candidates: List, vararg args: String): Pair? { + for (path in candidates) { + val result = + runCatching { + val process = ProcessBuilder(listOf(path) + args).redirectErrorStream(false).start() + val output = + BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() } + if (process.waitFor() == 0 && output.isNotBlank()) path to output else null + } + .getOrNull() + if (result != null) return result + } + return null + } + + /** + * The command that installs a module zip, for the implementation in charge. + * + * These are the same three the project's gradle install tasks use, so a zip that flashes from a + * developer's machine flashes the same way from the device. + */ + private fun installCommand(zipPath: String): List? { + val binary = detected.binary ?: return null + return when (implementation) { + IManagerService.ROOT_MAGISK -> listOf(binary, "--install-module", zipPath) + IManagerService.ROOT_KERNELSU -> listOf(binary, "module", "install", zipPath) + IManagerService.ROOT_APATCH -> listOf(binary, "module", "install", zipPath) + else -> null + } + } + + /** + * Runs the installer, handing every line to [onLine] and to the daemon's log. + * + * Both, not either: the screen is where a user reads a failure, and the log is where a + * maintainer reads it afterwards from a bug report — including the case where the flash left the + * device unable to boot the manager at all. + * + * Blocks until the installer exits. The caller runs it off the binder thread. + */ + fun install(zipPath: String, onLine: (String) -> Unit): Int { + val zip = File(zipPath) + if (!zip.isFile || !zip.canRead()) { + val message = "Refusing to flash $zipPath: not a readable file" + Log.e(TAG, message) + onLine(message) + return IFrameworkInstallReceiver.INSTALL_NO_SUCH_FILE + } + + val command = + installCommand(zipPath) + ?: run { + val message = "No usable root implementation to flash through (code $implementation)" + Log.e(TAG, message) + onLine(message) + return IFrameworkInstallReceiver.INSTALL_NO_ROOT + } + + Log.i(TAG, "Flashing ${zip.name} with: ${command.joinToString(" ")}") + onLine("$ ${command.joinToString(" ")}") + + return runCatching { + // Merged, because an installer's diagnostics go to stderr and its progress to stdout, + // and reading them on two threads would interleave them in an order that is not the + // order they happened in. + val process = ProcessBuilder(command).redirectErrorStream(true).start() + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + reader.lineSequence().forEach { line -> + Log.i(TAG, line) + onLine(line) + } + } + val exit = process.waitFor() + Log.i(TAG, "Installer exited with $exit") + exit + } + .getOrElse { + Log.e(TAG, "Installer could not be started", it) + onLine("Could not start the installer: ${it.message}") + IFrameworkInstallReceiver.INSTALL_NOT_EXECUTED + } + } +} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt index fcaa9afa0..11408df79 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt @@ -38,9 +38,24 @@ fun IUserManager.getRealUsers(): List { return users } -/** Android 16 DP1 SystemUI FeatureFlag and Notification Builder workaround. */ +/** + * Notification.Builder workaround for the SystemUI feature flags. + * + * Android 16 reads an aconfig flag from the `systemui` container while constructing a + * `Notification`, and the generated `FeatureFlagsImpl` only reads it once, guarded by + * `systemui_is_cached`. The read goes out to `content://settings/config`, which the daemon cannot + * reach: it has an ActivityThread but no application record, so the system refuses it a provider + * and the constructor throws `SecurityException`. Setting the guard leaves the flags at their + * defaults and skips the read entirely. + * + * The read belongs to Android 16 and is gone again in Android 17, where the constructor sets the + * fields unconditionally. It reaches Android 15 all the same, on vendor builds that took the change + * without taking the SDK level: #96 and #880 are both Xiaomi HyperOS on 15. The test therefore + * spans the versions where the field can exist rather than naming one, and a device that never had + * it fails with a `ClassNotFoundException` that is ignored. + */ fun applyNotificationWorkaround() { - if (Build.VERSION.SDK_INT == 36) { + if (Build.VERSION.SDK_INT in Build.VERSION_CODES.VANILLA_ICE_CREAM..36) { runCatching { val feature = Class.forName("android.app.FeatureFlagsImpl") val field = feature.getDeclaredField("systemui_is_cached").apply { isAccessible = true } diff --git a/daemon/src/main/res/drawable/ic_baseline_block_24.xml b/daemon/src/main/res/drawable/ic_baseline_block_24.xml deleted file mode 100644 index 1e478d2aa..000000000 --- a/daemon/src/main/res/drawable/ic_baseline_block_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/daemon/src/main/res/drawable/ic_baseline_check_24.xml b/daemon/src/main/res/drawable/ic_baseline_check_24.xml deleted file mode 100644 index cf143d4d5..000000000 --- a/daemon/src/main/res/drawable/ic_baseline_check_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/daemon/src/main/res/drawable/ic_baseline_close_24.xml b/daemon/src/main/res/drawable/ic_baseline_close_24.xml deleted file mode 100644 index 844b6b62e..000000000 --- a/daemon/src/main/res/drawable/ic_baseline_close_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/daemon/src/main/res/drawable/ic_notification.xml b/daemon/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index 5ef738b46..000000000 --- a/daemon/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/daemon/src/main/res/drawable/ic_statue_monochrome.xml b/daemon/src/main/res/drawable/ic_statue_monochrome.xml new file mode 100644 index 000000000..7144f80d9 --- /dev/null +++ b/daemon/src/main/res/drawable/ic_statue_monochrome.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/daemon/src/main/res/values-af/strings.xml b/daemon/src/main/res/values-af/strings.xml index 8b9d290e5..be37aff03 100644 --- a/daemon/src/main/res/values-af/strings.xml +++ b/daemon/src/main/res/values-af/strings.xml @@ -1,12 +1,12 @@ - Xposed module is not activated yet + Module is nog nie geaktiveer nie %1$s is geïnstalleer, maar is nog nie geaktiveer nie - %1$s 已為用戶 %2$s 安裝,但尚未激活 - %d module enabled + %1$s is vir gebruiker %2$s geïnstalleer, maar is nog nie geaktiveer nie + Module opgedateer %s is opgedateer, forseer asseblief stop en herbegin programme binne die omvang daarvan - Xposed-module is opgedateer, stelselherlaai vereis + Module is opgedateer, stelselherlaai vereis %s is opgedateer, aangesien die omvang System Framework bevat, vereis herlaai om veranderinge toe te pas Module-opdatering voltooi Vector status diff --git a/daemon/src/main/res/values-ar/strings.xml b/daemon/src/main/res/values-ar/strings.xml index 153b6a658..1c4a16eee 100644 --- a/daemon/src/main/res/values-ar/strings.xml +++ b/daemon/src/main/res/values-ar/strings.xml @@ -1,18 +1,18 @@ - وحدة Xposed لم يتم تفعيلها بعد + الوحدة لم يتم تفعيلها بعد %1$s تم التثبيت ولكنه لم يفعل بعد %1$s تم تثبيته للمستخدم %2$s ولكن لم يتم تفعيله بعد - وحدة Xposed تم تحديثها + تم تحديث الوحدة %s تم تحديثه، يرجى فرض إيقاف وإعادة تشغيل التطبيقات في نطاقه - تم تحديث وحدة Xposed، مطلوب إعادة تشغيل النظام + تم تحديث الوحدة، مطلوب إعادة تشغيل النظام %s تم تحديثه، نظراً لأن النطاق يحتوي على إطار النظام، يتطلب إعادة التشغيل لتطبيق التغييرات اكتمل تحديث الوحدة حالة Vector تم تحميل Vector اضغط على الإشعار لفتح المدير - Scope Request + طلب النطاق %1$s على %2$s طلبات المستخدم لإضافة %3$s إلى نطاقه. طلب النطاق اوافق diff --git a/daemon/src/main/res/values-bg/strings.xml b/daemon/src/main/res/values-bg/strings.xml index af52dbf0e..dc85a8fc3 100644 --- a/daemon/src/main/res/values-bg/strings.xml +++ b/daemon/src/main/res/values-bg/strings.xml @@ -1,21 +1,21 @@ - Модулът Xposed все още не е активиран + Модулът все още не е активиран %1$s е инсталиран, но все още не е активиран %1$s е инсталиран на потребител %2$s, но все още не е активиран - Актуализиран модул Xposed + Актуализиран модул %s е актуализиран, моля, спрете принудително и рестартирайте приложенията в неговия обхват - Актуализиран модул Xposed, изисква се рестартиране на системата + Актуализиран модул, изисква се рестартиране на системата %s е актуализиран, тъй като обхватът съдържа System Framework, необходимо е рестартиране, за да се приложат промените Актуализация на модула е завършена - Предложен статус на LSP + Статус на Vector Vector заредени Докоснете известието, за да отворите мениджъра Заявка за обхват %1$s при заявки от страна на потребителя %2$s за добавяне на %3$s към неговия обхват. Искане за обхват - Одобряване на + Одобряване Отказ Никога не питайте diff --git a/daemon/src/main/res/values-bn/strings.xml b/daemon/src/main/res/values-bn/strings.xml index 36914d24a..d710f42b0 100644 --- a/daemon/src/main/res/values-bn/strings.xml +++ b/daemon/src/main/res/values-bn/strings.xml @@ -1,15 +1,15 @@ - Xposed মডিউল এখনও সক্রিয় করা হয় নি + মডিউল এখনও সক্রিয় করা হয় নি %1$s ইনস্টল করা হয়েছে, কিন্তু এখনও সক্রিয় করা হয়নি %1$s ব্যবহারকারী %2$sএ ইনস্টল করা হয়েছে, কিন্তু এখনও সক্রিয় করা হয়নি - এক্সপোজড মডিউল আপডেট করা হয়েছে + মডিউল আপডেট করা হয়েছে %s আপডেট করা হয়েছে, অনুগ্রহ করে এর সুযোগে অ্যাপগুলিকে জোর করে থামান এবং পুনরায় চালু করুন - Xposed মডিউল আপডেট করা হয়েছে, সিস্টেম রিবুট প্রয়োজন + মডিউল আপডেট করা হয়েছে, সিস্টেম রিবুট প্রয়োজন %s আপডেট করা হয়েছে, যেহেতু সুযোগে সিস্টেম ফ্রেমওয়ার্ক রয়েছে, পরিবর্তনগুলি প্রয়োগ করার জন্য রিবুট প্রয়োজন মডিউল আপডেট সম্পূর্ণ - LSPপোজড স্ট্যাটাস + Vector স্ট্যাটাস Vector লোড ম্যানেজার খুলতে বিজ্ঞপ্তিতে ট্যাপ করুন সুযোগ অনুরোধ diff --git a/daemon/src/main/res/values-ca/strings.xml b/daemon/src/main/res/values-ca/strings.xml index b5d53bc16..97bbfc42d 100644 --- a/daemon/src/main/res/values-ca/strings.xml +++ b/daemon/src/main/res/values-ca/strings.xml @@ -1,12 +1,12 @@ - El mòdul Xposed encara no està activat + El mòdul encara no està activat %1$s s\'ha instal·lat, però encara no està activat %1$s s\'ha instal·lat a l\'usuari %2$s, però encara no està activat - Mòdul Xposed actualitzat + Mòdul actualitzat %s s\'ha actualitzat, si us plau, força l\'aturada i reinici de les aplicacions del seu abast - Mòdul Xposed actualitzat, cal reiniciar el sistema + Mòdul actualitzat, cal reiniciar el sistema %s s\'ha actualitzat, ja que l\'abast conté System Framework, cal reiniciar per aplicar els canvis S\'ha completat l\'actualització del mòdul Estat Vector diff --git a/daemon/src/main/res/values-cs/strings.xml b/daemon/src/main/res/values-cs/strings.xml index fd96e36b9..1d705e0e9 100644 --- a/daemon/src/main/res/values-cs/strings.xml +++ b/daemon/src/main/res/values-cs/strings.xml @@ -1,16 +1,16 @@ - Xposed modul ještě není aktivován + Modul ještě není aktivován %1$s byl nainstalován, ale ještě není aktivován %1$s byl nainstalován uživateli %2$s, ale ještě není aktivován - Xposed modul byl aktualizován + Modul byl aktualizován %s byl aktualizován, prosím, násilně zastavte aplikace a restartujte je - Xposed modul aktualizován, je vyžadován restart systému + Modul aktualizován, je vyžadován restart systému %s byl aktualizován, a protože se provedly změny v souvislosti se Systémovým Frameworkem, je vyžadován restart pro aplikaci změn Aktualizace modulu dokončena Stav Vector - LPosed načten + Vector načten Klepnutím na oznámení otevřete správce Žádost o rozsah %1$s pro uživatele %2$s požaduje přidání %3$s do jeho rozsahu. diff --git a/daemon/src/main/res/values-da/strings.xml b/daemon/src/main/res/values-da/strings.xml index 4bd015211..fe3d68b7f 100644 --- a/daemon/src/main/res/values-da/strings.xml +++ b/daemon/src/main/res/values-da/strings.xml @@ -1,12 +1,12 @@ - Xposed modul er endnu ikke aktiveret + Modulet er endnu ikke aktiveret %1$s er blevet installeret, men er ikke aktiveret endnu %1$s er blevet installeret på brugeren %2$s, men er endnu ikke aktiveret - Xposed modul opdateret + Modul opdateret %s er blevet opdateret, gennemtving stop og genstart apps i dets anvendelsesområde - Xposed modul opdateret, system genstart kræves + Modul opdateret, system genstart kræves %s er blevet opdateret, da anvendelsesområdet indeholder System Framework, krævede genstart for at anvende ændringer Modulopdatering afsluttet Vector status diff --git a/daemon/src/main/res/values-de/strings.xml b/daemon/src/main/res/values-de/strings.xml index f2e35f8e8..2dcebc1c3 100644 --- a/daemon/src/main/res/values-de/strings.xml +++ b/daemon/src/main/res/values-de/strings.xml @@ -1,12 +1,12 @@ - Das Xposed-Modul ist noch nicht aktiviert + Das Modul ist noch nicht aktiviert %1$s wurde installiert, ist aber noch nicht aktiviert %1$s wurde unter dem Benutzer %2$s installiert, ist aber noch nicht aktiviert - Xposed-Modul aktualisiert + Modul aktualisiert %s wurde aktualisiert, bitte Stopp erzwingen und die Apps in deren Scope neu starten - Xposed-Modul aktualisiert, Systemneustart erforderlich + Modul aktualisiert, Systemneustart erforderlich %s wurde aktualisiert, da der Geltungsbereich System-Framework enthält, ist ein Neustart erforderlich, damit die Änderungen übernommen werden Modulaktualisierung abgeschlossen Vector-Status diff --git a/daemon/src/main/res/values-el/strings.xml b/daemon/src/main/res/values-el/strings.xml index a2e25dd6b..b7d60af81 100644 --- a/daemon/src/main/res/values-el/strings.xml +++ b/daemon/src/main/res/values-el/strings.xml @@ -1,12 +1,12 @@ - Το Xposed πρόσθετο δεν έχει ενεργοποιηθεί ακόμα + Το πρόσθετο δεν έχει ενεργοποιηθεί ακόμα %1$s έχει εγκατασταθεί, αλλά δεν έχει ενεργοποιηθεί ακόμα %1$s έχει εγκατασταθεί στον χρήστη %2$s, αλλά δεν έχει ενεργοποιηθεί ακόμη - Το πρόσθετο Xposed ενημερώθηκε + Το πρόσθετο ενημερώθηκε %s ενημερώθηκε, παρακαλώ κλείστε εξαναγκαστικά και επανεκκινήστε τις εφαρμογές στο πεδίο εφαρμογής της - Το πρόσθετο Xposed ενημερώθηκε, απαιτείται επανεκκίνηση συστήματος + Το πρόσθετο ενημερώθηκε, απαιτείται επανεκκίνηση συστήματος %s έχει ενημερωθεί, δεδομένου ότι το πεδίο εφαρμογής περιέχει Πλαίσιο Συστήματος, απαιτείται επανεκκίνηση για να εφαρμοστούν οι αλλαγές Η ενημέρωση πρόσθετου ολοκληρώθηκε Κατάσταση Vector diff --git a/daemon/src/main/res/values-es/strings.xml b/daemon/src/main/res/values-es/strings.xml index f0e39ee78..0f2c3628b 100644 --- a/daemon/src/main/res/values-es/strings.xml +++ b/daemon/src/main/res/values-es/strings.xml @@ -1,19 +1,19 @@ - El módulo Xposed aún no está activado + El módulo aún no está activado %1$s ha sido instalado, pero aún no está activado %1$s ha sido instalado al usuario %2$s, pero no está activado todavía - Módulo Xpose actualizado + Módulo actualizado %s ha sido actualizado, fuerce la detención y el reinicio de las aplicaciones en su alcance - Módulo Xposed actualizado, es necesario reiniciar el sistema + Módulo actualizado, es necesario reiniciar el sistema %s ha sido actualizado, ya que el ámbito contiene la estructura del sistema, requiere reiniciar para aplicar cambios Módulo de actualización completo - LSPosición de estado + Estado de Vector Vector cargado Toca la notificación para abrir el gestor Solicitud de alcance - %1$s cuando el usuario %2$s solicita añadir %3$s a su ámbito. + %1$s en el usuario %2$s solicita añadir %3$s a su ámbito. Solicitud de alcance Aprobar Denegar diff --git a/daemon/src/main/res/values-et/strings.xml b/daemon/src/main/res/values-et/strings.xml index da3e617f2..e40c86bab 100644 --- a/daemon/src/main/res/values-et/strings.xml +++ b/daemon/src/main/res/values-et/strings.xml @@ -1,12 +1,12 @@ - Xposed moodul ei ole aktiveeritud + Moodul ei ole aktiveeritud %1$s on paigaldatud, kuid ei ole aktiveeritud %1$s on paigaldatud kasutajale %2$s, kuid ei ole aktiveeritud - Xposed moodul uuendatud + Moodul uuendatud %s on uuendatud, siis peatage ja taaskäivitage rakendused, mis kuuluvad selle kohaldamisalasse. - Xposed moodul uuendatud, süsteemi taaskäivitamine vajalik + Moodul uuendatud, süsteemi taaskäivitamine vajalik %s on uuendatud, kuna reguleerimisala sisaldab System Framework, vajalik taaskäivitamine, et rakendada muudatusi Mooduli uuendamine lõpetatud Vectori staatus diff --git a/daemon/src/main/res/values-fa/strings.xml b/daemon/src/main/res/values-fa/strings.xml index cda35c5fe..7836af554 100644 --- a/daemon/src/main/res/values-fa/strings.xml +++ b/daemon/src/main/res/values-fa/strings.xml @@ -1,12 +1,12 @@ - ماژول Xposed هنوز فعال نشده است + ماژول هنوز فعال نشده است %1$s نصب شده اما هنوز فعال نشده است %1$s برای کاربر %2$s نصب شده اما هنوز فعال نشده است - ماژول Xposed به‌روزرسانی شد + ماژول به‌روزرسانی شد %s به‌روزرسانی شده است، لطفاً برنامه‌های مربوطه را به‌زور متوقف و مجدداً راه‌اندازی کنید - ماژول Xposed به‌روزرسانی شد، نیاز به راه‌اندازی مجدد سیستم + ماژول به‌روزرسانی شد، نیاز به راه‌اندازی مجدد سیستم %s به‌روزرسانی شده است؛ از آنجا که محدوده شامل چارچوب سیستم است، برای اعمال تغییرات نیاز به راه‌اندازی مجدد سیستم است به‌روزرسانی ماژول کامل شد وضعیت Vector diff --git a/daemon/src/main/res/values-fi/strings.xml b/daemon/src/main/res/values-fi/strings.xml index b21473c9e..822990cba 100644 --- a/daemon/src/main/res/values-fi/strings.xml +++ b/daemon/src/main/res/values-fi/strings.xml @@ -1,12 +1,12 @@ - Xposed moduuli ei ole vielä aktivoitu + Moduuli ei ole vielä aktivoitu %1$s on asennettu, mutta sitä ei ole vielä aktivoitu. %1$s on asennettu käyttäjälle %2$s, mutta sitä ei ole vielä aktivoitu. - Xposed moduuli päivitetty + Moduuli päivitetty %s on päivitetty, paina pysäytä ja käynnistä sovellukset uudelleen sen laajuudessa - Xposed moduuli päivitetty, järjestelmän uudelleenkäynnistys vaaditaan + Moduuli päivitetty, järjestelmän uudelleenkäynnistys vaaditaan %s on päivitetty, koska soveltamisala sisältää järjestelmän kehyksen, vaaditaan uudelleenkäynnistys muutosten käyttöön Moduulin päivitys valmis Vector status diff --git a/daemon/src/main/res/values-fr/strings.xml b/daemon/src/main/res/values-fr/strings.xml index 0c3564c4b..bd82d7ede 100644 --- a/daemon/src/main/res/values-fr/strings.xml +++ b/daemon/src/main/res/values-fr/strings.xml @@ -1,12 +1,12 @@ - Le module Vector n\’est pas encore actif + Le module n\’est pas encore actif %1$s a été installé, mais n\'a pas été encore activé %1$s a été installé pour l\'utilisateur %2$s, mais n\'a pas été encore activé - Module Xposed mis à jour - %s a été mis à jour, merci de forcer l\’arrêt ou de redémarrer les applis dans leurs champs d\’application - Module Xposed mis à jour, redémarrage du système requis + Module mis à jour + %s a été mis à jour, merci de forcer l’arrêt et de redémarrer les applis dans leurs champs d’application + Module mis à jour, redémarrage du système requis %s a été mis à jour, étant donné que le champ d\'application est étendu au sous système, un redémarrage est nécessaire pour appliquer les changements Mise à jour du module terminée Statut Vector diff --git a/daemon/src/main/res/values-hi/strings.xml b/daemon/src/main/res/values-hi/strings.xml index b18a1f757..c7e0ad5b1 100644 --- a/daemon/src/main/res/values-hi/strings.xml +++ b/daemon/src/main/res/values-hi/strings.xml @@ -9,7 +9,7 @@ एक्सपोज़ड मॉड्यूल अपडेट किया गया, सिस्टम रीबूट की आवश्यकता है %s को अपडेट कर दिया गया है, क्योंकि स्कोप में सिस्टम फ्रेमवर्क है, परिवर्तनों को लागू करने के लिए रीबूट की आवश्यकता है मॉड्यूल अद्यतन पूर्ण - एलएसपोस्ड स्थिति + Vector की स्थिति Vector लोड किया गया मैनेजर खोलने के लिए नोटिफिकेशन पर टैप करें गुंजाइश अनुरोध diff --git a/daemon/src/main/res/values-hr/strings.xml b/daemon/src/main/res/values-hr/strings.xml index 963882940..63ca87da4 100644 --- a/daemon/src/main/res/values-hr/strings.xml +++ b/daemon/src/main/res/values-hr/strings.xml @@ -1,12 +1,12 @@ - Xposed modul još nije aktiviran + Modul još nije aktiviran %1$s je instaliran, ali još nije aktiviran %1$s je instaliran korisniku %2$s, ali još nije aktiviran - Modul Xposed ažuriran + Modul ažuriran %s je ažuriran, prisilno zaustavite i ponovno pokrenite aplikacije u njegovom opsegu - Xposed modul je ažuriran, potrebno je ponovno pokretanje sustava + Modul je ažuriran, potrebno je ponovno pokretanje sustava %s je ažuriran, budući da opseg sadrži System Framework, potrebno je ponovno pokretanje za primjenu promjena Ažuriranje modula dovršeno Vector status diff --git a/daemon/src/main/res/values-hu/strings.xml b/daemon/src/main/res/values-hu/strings.xml index f834c508e..6324bbc7a 100644 --- a/daemon/src/main/res/values-hu/strings.xml +++ b/daemon/src/main/res/values-hu/strings.xml @@ -1,12 +1,12 @@ - Az Xposed modul még nincs aktiválva + A modul még nincs aktiválva %1$s telepítve lett, de még nincs aktiválva. - %1$s telepítve lett a %2$sfelhasználóhoz, de még nincs aktiválva. - Xposed modul frissítve + %1$s telepítve lett a %2$s felhasználóhoz, de még nincs aktiválva. + Modul frissítve %s frissítésre került, kérjük, kényszerítse az alkalmazások leállítását és újraindítását a hatókörében. - Xposed modul frissítve, rendszer újraindítás szükséges + Modul frissítve, rendszer újraindítás szükséges %s frissítve lett, mivel a hatókör tartalmazza a System Framework-et, a változások alkalmazásához szükséges újraindítás szükséges. A modul frissítése befejeződött Vector állapot diff --git a/daemon/src/main/res/values-in/strings.xml b/daemon/src/main/res/values-in/strings.xml index f061972db..efcf234d4 100644 --- a/daemon/src/main/res/values-in/strings.xml +++ b/daemon/src/main/res/values-in/strings.xml @@ -1,12 +1,12 @@ - Modul Xposed belum diaktifkan + Modul belum diaktifkan %1$s sudah diinstal, tetapi belum diaktifkan %1$s telah diinstal ke pengguna %2$s, tetapi belum diaktifkan - Modul xposed diperbarui + Modul diperbarui %s telah diperbarui, harap paksa berhenti dan mulai ulang aplikasi dalam cakupannya - Modul Xposed diperbarui, diperlukan memulai ulang sistem + Modul diperbarui, diperlukan memulai ulang sistem %s telah diperbarui, karena cakupannya berisi Kerangka Sistem, diperlukan mulai ulang untuk menerapkan perubahan Pembaruan modul selesai Status Vector diff --git a/daemon/src/main/res/values-it/strings.xml b/daemon/src/main/res/values-it/strings.xml index 9c611e04f..f295f99ff 100644 --- a/daemon/src/main/res/values-it/strings.xml +++ b/daemon/src/main/res/values-it/strings.xml @@ -1,12 +1,12 @@ - Il modulo Xposed non è ancora attivo + Il modulo non è ancora attivo %1$s è stato installato, ma non è ancora attivo %1$s è stato installato sull\'utente %2$s, ma non è ancora attivo - Modulo Xposed aggiornato + Modulo aggiornato %s è stato aggiornato, arresta e riavvia le applicazioni per le quali è abilitato - Modulo Xposed aggiornato, è necessario il riavvio del sistema + Modulo aggiornato, è necessario il riavvio del sistema %s è stato aggiornato. Poiché è abilitato per il framework di sistema, è necessario riavviare per applicare le modifiche Aggiornamento del modulo completato Stato Vector diff --git a/daemon/src/main/res/values-iw/strings.xml b/daemon/src/main/res/values-iw/strings.xml index a37e77231..d4b3315b0 100644 --- a/daemon/src/main/res/values-iw/strings.xml +++ b/daemon/src/main/res/values-iw/strings.xml @@ -1,21 +1,21 @@ - מודול Vector עדיין לא הופעל + המודול עדיין לא הופעל %1$s הותקן, אך אינו מופעל עדיין %1$s הותקן למשתמש %2$s, אך אינו מופעל עדיין - מודול Vector עודכן + המודול עודכן %s עודכן - מודול Xposed עודכן, נדרש אתחול המערכת + המודול עודכן, נדרש אתחול המערכת %s עודכן, מכיוון שההיקף מכיל System Framework, נדרש אתחול כדי להחיל שינויים עדכון המודול הושלם - סטטוס LSPost + סטטוס Vector Vector נטען הקש על ההודעה כדי לפתוח את המנהל - Xposed_מודול_מבקש_כותרת_תחום + בקשת היקף %1$s על משתמש %2$s מבקש להוסיף %3$s להיקף שלו. - תחום_שם_ערוץ - אישור_תחום + בקשת היקף + אישור לְהַכּחִישׁ לעולם אל תשאל diff --git a/daemon/src/main/res/values-ja/strings.xml b/daemon/src/main/res/values-ja/strings.xml index 9a567bbfd..ae7aa8c89 100644 --- a/daemon/src/main/res/values-ja/strings.xml +++ b/daemon/src/main/res/values-ja/strings.xml @@ -1,12 +1,12 @@ - Xposed モジュールが有効化されていません + モジュールが有効化されていません %1$s はインストールされましたが、 有効化されていません %1$s はユーザー %2$s にインストールされましたが、 有効化されていません - Xposed モジュールが更新されました + モジュールが更新されました %s が更新されました。スコープ内のアプリを強制停止してから再起動してください - Xposed モジュールが更新されました。システムの再起動が必要です + モジュールが更新されました。システムの再起動が必要です %s が更新されました。スコープにシステムフレームワークが含まれているため、変更を適用するには再起動が必要です モジュールの更新完了通知 Vector のステータス通知 diff --git a/daemon/src/main/res/values-ko/strings.xml b/daemon/src/main/res/values-ko/strings.xml index 25be4ca5a..b78fb04a0 100644 --- a/daemon/src/main/res/values-ko/strings.xml +++ b/daemon/src/main/res/values-ko/strings.xml @@ -1,15 +1,15 @@ - Xposed 모듈이 아직 활성화되지 않았습니다. + 모듈이 아직 활성화되지 않았습니다. %1$s이(가) 설치되었지만 아직 활성화되지 않았습니다. 사용자 %2$s님에게 %1$s 이(가) 설치되었지만 아직 활성화되지 않았습니다. - Xposed 모듈 업데이트 + 모듈 업데이트 %s이(가) 업데이트되었습니다. - Xposed 모듈이 업데이트되었습니다, 재부팅이 필요합니다. + 모듈이 업데이트되었습니다, 재부팅이 필요합니다. 범위에 시스템 프레임워크가 포함되어 있으므로 %s 이 업데이트되었습니다. 변경 사항을 적용하려면 재부팅해야 합니다. 모듈 업데이트 완료 - LS포즈 상태 + Vector 상태 Vector 로드됨 알림을 탭하여 관리자 열기 범위 요청 diff --git a/daemon/src/main/res/values-ku/strings.xml b/daemon/src/main/res/values-ku/strings.xml index aef735d9b..47e7888e0 100644 --- a/daemon/src/main/res/values-ku/strings.xml +++ b/daemon/src/main/res/values-ku/strings.xml @@ -1,20 +1,20 @@ - Modula Xposed hîn nehatiye çalak kirin + Modul hîn nehatiye çalak kirin %1$s hatiye saz kirin, lê hîn nehatiye aktîfkirin %1$s ji bikarhêner %2$sre hate saz kirin, lê hêj nehatiye çalak kirin - Modula Xposed hate nûve kirin + Modul hate nûve kirin %s hate nûve kirin, ji kerema xwe bi zorê sepanan rawestînin û di çarçoveya wê de ji nû ve bidin destpêkirin - Modula Xposed hate nûve kirin, pêdivî ye ku pergalê ji nû ve dest pê bike + Modul hate nûve kirin, pêdivî ye ku pergalê ji nû ve dest pê bike %s hate nûve kirin, ji ber ku çarçove Çarçoveya Pergalê dihewîne, ji bo sepandina guhertinan ji nû ve destpêkirinê hewce dike Nûvekirina modulê qediya - statûya LSP - LSP hate barkirin + Statûya Vector + Vector hate barkirin Daxuyaniyê bikirtînin da ku rêveberê vekin - Scope Daxwaza + Daxwaza qadê %1$s li ser bikarhêner %2$s daxwaz dike ku %3$s li qada xwe zêde bike. - Daxwaza Scope + Daxwaza qadê Destûrdan Înkarkirin Never Ask diff --git a/daemon/src/main/res/values-lt/strings.xml b/daemon/src/main/res/values-lt/strings.xml index 60a491ffd..2e7cf50a1 100644 --- a/daemon/src/main/res/values-lt/strings.xml +++ b/daemon/src/main/res/values-lt/strings.xml @@ -1,16 +1,16 @@ - Xposed modulis dar nėra aktyvuotas + Modulis dar nėra aktyvuotas \"%1$s\" buvo įdiegta, tačiau liko dar nesuaktyvuota \"%1$s\" buvo įdiegta vartotojui \"%2$s\", tačiau liko dar nesuaktyvuota - Atnaujintas Xposed modulis + Atnaujintas modulis %s buvo atnaujintas, priverstinai sustabdykite ir iš naujo paleiskite jo taikymo srityje esančias programas - Atnaujintas \"Xposed\" modulis, reikalingas sistemos perkrovimas + Atnaujintas modulis, reikalingas sistemos perkrovimas %s buvo atnaujintas, nes srityje yra System Framework, reikalingas perkrovimas, kad būtų galima taikyti pakeitimus Modulio atnaujinimas baigtas - LSPatvirtintas statusas - LSPpateiktas pakrautas + Vector statusas + Vector įkeltas Bakstelėkite pranešimą, kad atidarytumėte tvarkytuvę Apimties prašymas %1$s pagal naudotojo %2$s užklausas įtraukti %3$s į jo taikymo sritį. diff --git a/daemon/src/main/res/values-nl/strings.xml b/daemon/src/main/res/values-nl/strings.xml index 333730fa9..21ab1c899 100644 --- a/daemon/src/main/res/values-nl/strings.xml +++ b/daemon/src/main/res/values-nl/strings.xml @@ -1,12 +1,12 @@ - Vector module is nog niet geactiveerd + Module is nog niet geactiveerd %1$s is geïnstalleerd, maar nog niet geactiveerd %1$s is geïnstalleerd bij gebruiker %2$s, maar is nog niet geactiveerd - Vector module bijgewerkt - %1$s is geupdate - Xposed-module bijgewerkt, systeem opnieuw opstarten vereist + Module bijgewerkt + %s is geupdate + Module bijgewerkt, systeem opnieuw opstarten vereist %s is bijgewerkt, omdat het bereik een systeemkader bevat, moet je opnieuw opstarten om wijzigingen toe te passen Module update voltooid Vector status diff --git a/daemon/src/main/res/values-no/strings.xml b/daemon/src/main/res/values-no/strings.xml index c30428507..1a285008e 100644 --- a/daemon/src/main/res/values-no/strings.xml +++ b/daemon/src/main/res/values-no/strings.xml @@ -1,16 +1,16 @@ - Xposed modul er ikke aktivert enda + Modulen er ikke aktivert enda %1$s er installert, men er ikke aktivert ennå %1$s har blitt installert til bruker %2$s, men er ikke aktivert ennå - Xposed modul er oppdatert + Modulen er oppdatert %s har blitt oppdatert, vennligst tvang-stopp og start apper på nytt i virkeområdet - Xposed modul oppdatert, system-omstart kreves + Modul oppdatert, system-omstart kreves %s er blitt oppdatert, siden omfanget inneholder systemramme, nødvendig for omstart av endringene Moduloppdatering fullført - LSPosert status - LSPosert lastet + Vector-status + Vector lastet Trykk på varselet for å åpne administrator Forespørsel om omfang %1$s på bruker %2$s ber om å legge til %3$s i omfanget. diff --git a/daemon/src/main/res/values-pl/strings.xml b/daemon/src/main/res/values-pl/strings.xml index 07c40cec7..cdad41459 100644 --- a/daemon/src/main/res/values-pl/strings.xml +++ b/daemon/src/main/res/values-pl/strings.xml @@ -1,12 +1,12 @@ - Moduł Xposed nie jest jeszcze aktywowany + Moduł nie jest jeszcze aktywowany %1$s został zainstalowany, ale nie jest jeszcze aktywny %1$s został zainstalowany na użytkowniku %2$s, ale nie jest jeszcze aktywowany - Moduł Xposed zaktualizowany + Moduł zaktualizowany %s został zaktualizowany, wymuś zatrzymanie i ponownie uruchom aplikacje w jego zakresie - Zaktualizowano moduł Xposed, wymagane ponowne uruchomienie systemu + Zaktualizowano moduł, wymagane ponowne uruchomienie systemu %s został zaktualizowany, ponieważ zakres zawiera System Framework, wymagany restart aby zastosować zmiany Aktualizowanie modułu zakończone Status Vector diff --git a/daemon/src/main/res/values-ro/strings.xml b/daemon/src/main/res/values-ro/strings.xml index e50349466..bfd823866 100644 --- a/daemon/src/main/res/values-ro/strings.xml +++ b/daemon/src/main/res/values-ro/strings.xml @@ -1,12 +1,12 @@ - Modulul Xposed nu este încă activat + Modulul nu este încă activat Modulul %1$s este instalat, dar nu este încă activat Modulul %1$s a fost instalat pentru utilizatorul %2$s, dar nu este încă activat - Modulul Xposed a fost actualizat + Modulul a fost actualizat Modulul %s a fost actualizat, vă rugăm să reporniți aplicațiile din cadrul configurației sale - Modulul Xposed a fost actualizat, este necesară repornirea sistemului + Modulul a fost actualizat, este necesară repornirea sistemului Modulul %s a fost actualizat. Este necesară repornirea dispozitivului, deoarece Sistemul Android face parte din configurația modulului. Actualizarea modulelor este completă Stare Vector diff --git a/daemon/src/main/res/values-ru/strings.xml b/daemon/src/main/res/values-ru/strings.xml index bca097f38..f2227bca9 100644 --- a/daemon/src/main/res/values-ru/strings.xml +++ b/daemon/src/main/res/values-ru/strings.xml @@ -1,12 +1,12 @@ - Модуль Xposed пока не активирован + Модуль пока не активирован %1$s установлен, но пока не активирован %1$s установлен (пользователь %2$s), но пока не активирован - Модуль Xposed обновлён + Модуль обновлён %s обновлён, выполните остановку приложений в его «охвате» и перезапустите их - Модуль Xposed обновлён, требуется перезагрузка устройства + Модуль обновлён, требуется перезагрузка устройства %s обновлён; ввиду того, что системный фреймворк (System Framework) в его «охвате», требуется перезагрузка для применения изменений Обновление модуля завершено Статус Vector diff --git a/daemon/src/main/res/values-si/strings.xml b/daemon/src/main/res/values-si/strings.xml index 5a2bf8bbe..919d56b7b 100644 --- a/daemon/src/main/res/values-si/strings.xml +++ b/daemon/src/main/res/values-si/strings.xml @@ -1,16 +1,16 @@ - Xposed මොඩියුලය තවම සක්‍රිය කර නැත + මොඩියුලය තවම සක්‍රිය කර නැත %1$s ස්ථාපනය කර ඇත, නමුත් තවමත් සක්රිය කර නැත පරිශීලක %2$sවෙත %1$s ස්ථාපනය කර ඇත, නමුත් තවමත් සක්‍රිය කර නොමැත - Xposed මොඩියුලය යාවත්කාලීන කරන ලදී + මොඩියුලය යාවත්කාලීන කරන ලදී %s යාවත්කාලීන කර ඇත, කරුණාකර එහි විෂය පථය තුළ යෙදුම් බලහත්කාරයෙන් නතර කර නැවත ආරම්භ කරන්න - Xposed මොඩියුලය යාවත්කාලීන කරන ලදි, පද්ධතිය නැවත ආරම්භ කිරීම අවශ්‍යයි + මොඩියුලය යාවත්කාලීන කරන ලදි, පද්ධතිය නැවත ආරම්භ කිරීම අවශ්‍යයි %s යාවත්කාලීන කර ඇත, විෂය පථයේ පද්ධති රාමුව අඩංගු බැවින්, වෙනස්කම් යෙදීමට නැවත පණගැන්වීම අවශ්‍ය වේ මොඩියුල යාවත්කාලීන කිරීම සම්පූර්ණයි - එල්එස්පී තත්ත්වය - LSPposed පටවා ඇත + Vector තත්ත්වය + Vector පටවා ඇත කළමනාකරු විවෘත කිරීමට දැනුම්දීම තට්ටු කරන්න විෂය පථය ඉල්ලීම පරිශීලක %2$s හි %1$s එහි විෂය පථයට %3$s එකතු කරන ලෙස ඉල්ලා සිටී. diff --git a/daemon/src/main/res/values-sk/strings.xml b/daemon/src/main/res/values-sk/strings.xml index 9e71934c0..23c42a3ad 100644 --- a/daemon/src/main/res/values-sk/strings.xml +++ b/daemon/src/main/res/values-sk/strings.xml @@ -1,19 +1,19 @@ - Modul Xposed ešte nie je aktivovaný + Modul ešte nie je aktivovaný %1$s bol nainštalovaný, ale ešte nie je aktivovaný %1$s bol nainštalovaný na stránke používateľa %2$s, ale ešte nie je aktivovaný. - Aktualizovaný modul Xposed + Aktualizovaný modul %s bola aktualizovaná, vynúťte si zastavenie a reštartovanie aplikácií v jej rozsahu - Aktualizácia modulu Xposed, potrebný reštart systému + Aktualizácia modulu, potrebný reštart systému %s bola aktualizovaná, pretože rozsah obsahuje System Framework, potrebný reštart na uplatnenie zmien Aktualizácia modulu dokončená - LSPonúkaný stav + Stav Vector Vector naložené Ťuknutím na oznámenie otvorte správcu Žiadosť o rozsah - %1$s na žiadosti používateľa %2$s o pridanie stránky %3$s do jej rozsahu. + %1$s na žiadosti používateľa %2$s o pridanie %3$s do jej rozsahu. Žiadosť o rozsah Schváliť Odmietnuť diff --git a/daemon/src/main/res/values-sv/strings.xml b/daemon/src/main/res/values-sv/strings.xml index e5b63bc52..26ee34f2f 100644 --- a/daemon/src/main/res/values-sv/strings.xml +++ b/daemon/src/main/res/values-sv/strings.xml @@ -1,19 +1,19 @@ - Xposed modul är inte aktiverad än + Modulen är inte aktiverad än %1$s har installerats, men är ännu inte aktiverad. %1$s har installerats för användaren %2$s, men är ännu inte aktiverad. - Xposed modul uppdaterad + Modul uppdaterad %s har uppdaterats. Tvinga stopp och starta om appar i dess omfattning - Xposed modul uppdaterad, systemomstart krävs + Modul uppdaterad, systemomstart krävs %s har uppdaterats, eftersom omfattningen innehåller Systemramverk, krävs omstart för att tillämpa ändringar Uppdatering av modulen slutförd Vector status Vector laddad Tryck på meddelandet för att öppna administratören Begäran om tillämpningsområde - %1$s om användaren %2$s begär att %3$s ska läggas till i dess räckvidd. + %1$s för användaren %2$s begär att %3$s ska läggas till i dess räckvidd. Begäran om tillämpningsområde Godkänna Förneka diff --git a/daemon/src/main/res/values-th/strings.xml b/daemon/src/main/res/values-th/strings.xml index 38ac73703..6b958886f 100644 --- a/daemon/src/main/res/values-th/strings.xml +++ b/daemon/src/main/res/values-th/strings.xml @@ -1,12 +1,12 @@ - โมดูล Xposed ยังไม่ได้เปิดใช้งาน + โมดูลยังไม่ได้เปิดใช้งาน %1$s ถูกติดตั้งแล้ว แต่ยังไม่ได้เปิดใช้งาน ติดตั้ง %1$s ให้กับผู้ใช้แล้ว %2$s แต่ยังไม่ได้เปิดใช้งาน - โมดูล Xposed อัปเดตแล้ว + โมดูลอัปเดตแล้ว %s ได้รับการอัปเดตแล้ว โปรดบังคับหยุดและรีสตาร์ทแอปที่อยู่ใน Scope. - อัปเดตโมดูล Xposed จำเป็นต้องรีสตาร์ทเครื่อง + อัปเดตโมดูล จำเป็นต้องรีสตาร์ทเครื่อง %s ได้รับการอัปเดตแล้ว เนื่องจาก Scope มี System Framework จึงจำเป็นต้องรีสตาร์ทเครื่องเพื่อใช้การเปลี่ยนแปลง การอัปเดตโมดูลเสร็จสมบูรณ์ สถานะ Vector diff --git a/daemon/src/main/res/values-tr/strings.xml b/daemon/src/main/res/values-tr/strings.xml index e28cbb835..e07f09eeb 100644 --- a/daemon/src/main/res/values-tr/strings.xml +++ b/daemon/src/main/res/values-tr/strings.xml @@ -1,12 +1,12 @@ - Xposed modülü henüz aktif değil! + Modül henüz aktif değil! %1$s kuruldu, ancak henüz etkinleştirilmedi - %1$s, kullanıcıya %2$syüklendi, ancak henüz etkinleştirilmedi - Xposed modülü güncellendi + %1$s, kullanıcıya %2$s yüklendi, ancak henüz etkinleştirilmedi + Modül güncellendi %s güncellendi, lütfen kapsamındaki uygulamaları durdurmaya ve yeniden başlatmaya zorlayın - Xposed modülü güncellendi, sistemin yeniden başlatılması gerekiyor + Modül güncellendi, sistemin yeniden başlatılması gerekiyor %s Güncelleme kapsamı Sistem Çerçevesi içerdiğinden, değişiklikleri uygulamak için yeniden başlatma gereklidir Modül güncellemesi tamamlandı Vector durumu diff --git a/daemon/src/main/res/values-uk/strings.xml b/daemon/src/main/res/values-uk/strings.xml index 73daa8ed7..1e6badc2e 100644 --- a/daemon/src/main/res/values-uk/strings.xml +++ b/daemon/src/main/res/values-uk/strings.xml @@ -1,20 +1,20 @@ - Модуль Xposed ще не активований + Модуль ще не активований %1$s було встановлено, але ще не активовано %1$s було встановлено до користувача %2$s, але ще не активовано - Модуль Xposed оновлено + Модуль оновлено %s було оновлено, будь ласка, примусово перезапустіть програми з області модуля - Модуль Xposed оновлено, потрібно перезавантаження системи + Модуль оновлено, потрібно перезавантаження системи %s було оновлено, оскільки область містить System Framework, необхідне перезавантаження для застосування змін Оновлення модуля завершено Статус Vector Vector завантажено Натисніть на повідомлення, щоб відкрити менеджер Запит на визначення обсягу робіт - %1$s на запит користувача %2$s з проханням додати %3$s до своєї області видимості. - Запит обсягу робіт + %1$s у користувача %2$s просить додати %3$s до своєї області видимості. + Запит області видимості Затвердити Відхилити Ніколи не питай diff --git a/daemon/src/main/res/values-ur/strings.xml b/daemon/src/main/res/values-ur/strings.xml index fc9739ed4..5fd903a76 100644 --- a/daemon/src/main/res/values-ur/strings.xml +++ b/daemon/src/main/res/values-ur/strings.xml @@ -1,16 +1,16 @@ - Xposed ماڈیول ابھی تک چالو نہیں ہوا ہے۔ + ماڈیول ابھی تک چالو نہیں ہوا ہے۔ %1$s انسٹال ہو چکا ہے، لیکن ابھی تک چالو نہیں ہوا ہے۔ %1$s کو صارف %2$sپر انسٹال کر دیا گیا ہے، لیکن ابھی تک فعال نہیں ہوا ہے۔ - Xposed ماڈیول کو اپ ڈیٹ کر دیا گیا۔ + ماڈیول کو اپ ڈیٹ کر دیا گیا۔ %s کو اپ ڈیٹ کر دیا گیا ہے، براہ کرم اس کے دائرہ کار میں ایپس کو زبردستی روکنے اور دوبارہ شروع کریں۔ - Xposed ماڈیول کو اپ ڈیٹ کر دیا گیا، سسٹم ریبوٹ درکار ہے۔ + ماڈیول کو اپ ڈیٹ کر دیا گیا، سسٹم ریبوٹ درکار ہے۔ %s کو اپ ڈیٹ کر دیا گیا ہے، چونکہ دائرہ کار میں سسٹم فریم ورک ہے، تبدیلیاں لاگو کرنے کے لیے ریبوٹ کی ضرورت ہے۔ ماڈیول اپ ڈیٹ مکمل ہو گیا۔ - ایل ایس پیز کی حیثیت - ایل ایس پیز لوڈ شدہ + Vector کی حیثیت + Vector لوڈ شدہ مینیجر کو کھولنے کے لیے نوٹیفکیشن کو تھپتھپائیں۔ دائرہ کار کی درخواست صارف %2$s پر %1$s اپنے دائرہ کار میں %3$s شامل کرنے کی درخواست کرتا ہے۔ diff --git a/daemon/src/main/res/values-vi/strings.xml b/daemon/src/main/res/values-vi/strings.xml index f8f6e96f1..c47d6a88d 100644 --- a/daemon/src/main/res/values-vi/strings.xml +++ b/daemon/src/main/res/values-vi/strings.xml @@ -1,16 +1,16 @@ - Mô-đun Xposed chưa được kích hoạt + Mô-đun chưa được kích hoạt %1$s đã được cài đặt, nhưng chưa được kích hoạt %1$s vừa được cài đặt cho người dùng %2$s, nhưng chưa được kích hoạt - Mô-đun Xposed đã được cập nhật + Mô-đun đã được cập nhật %s đã được cập nhật, xin hãy buộc dừng và khởi động lại ứng dụng liên quan - Mô-đun Xposed đã được cập nhật, yêu cầu khởi động lại hệ thống + Mô-đun đã được cập nhật, yêu cầu khởi động lại hệ thống %s đã được cập nhật, vì phạm vi bao gồm Framework Hệ thống, thì khởi động lại là cần thiết để áp dụng các thay đổi Tiện ích bổ sung cập nhật hoàn tất - Trạng thái hoạt động - Ứng dụng đã được tải + Trạng thái Vector + Vector đã được tải Nhấn để mở trình quản lý Yêu cầu phạm vi %1$s khi người dùng %2$s yêu cầu thêm %3$s vào phạm vi của nó. diff --git a/daemon/src/main/res/values-zh-rCN/strings.xml b/daemon/src/main/res/values-zh-rCN/strings.xml index ace294bba..f2e45cdf6 100644 --- a/daemon/src/main/res/values-zh-rCN/strings.xml +++ b/daemon/src/main/res/values-zh-rCN/strings.xml @@ -1,12 +1,12 @@ - Xposed 模块尚未激活 + 模块尚未激活 %1$s 已安装,但尚未激活 %1$s 已安装到用户 %2$s,但尚未激活 - Xposed 模块已更新 + 模块已更新 %s 已更新,请强行停止并重新打开其作用域内的应用 - Xposed 模块已更新,需要重新启动 + 模块已更新,需要重新启动 %s 已更新,由于作用域包含系统框架,需重启以应用更改 模块更新完成 Vector 状态 diff --git a/daemon/src/main/res/values-zh-rHK/strings.xml b/daemon/src/main/res/values-zh-rHK/strings.xml index 02b34d381..e43db67ca 100644 --- a/daemon/src/main/res/values-zh-rHK/strings.xml +++ b/daemon/src/main/res/values-zh-rHK/strings.xml @@ -1,14 +1,14 @@ - Xposed 模組尚未啟用 + 模組尚未啟用 %1$s 已安裝,但尚未啟用 %1$s 已安裝到用戶 %2$s,但尚未啟用 - Xposed 模組已更新 + 模組已更新 %s 已更新,請強制停止並重新開啟其作用範圍內的應用程式 - Xposed 模組已更新,需要重新啟動。 + 模組已更新,需要重新啟動。 %s 已更新,由於作用範圍包含系統架構,需要重新啟動以套用修改。 - 模块更新完成 + 模組更新完成 Vector 狀態 Vector 已載入 輕觸通知以開啟管理員 diff --git a/daemon/src/main/res/values-zh-rTW/strings.xml b/daemon/src/main/res/values-zh-rTW/strings.xml index d085f2620..13cd4e321 100644 --- a/daemon/src/main/res/values-zh-rTW/strings.xml +++ b/daemon/src/main/res/values-zh-rTW/strings.xml @@ -1,12 +1,12 @@ - Xposed 模組尚未啟用 + 模組尚未啟用 %1$s 已安裝,但尚未啟用 %1$s 已安裝到使用者 %2$s,但尚未啟用 - Xposed 模組已更新 + 模組已更新 %s 已更新,請強制停止並重新打開其作用域內的程式 - Xposed 模組已更新,需要重新啟動。 + 模組已更新,需要重新啟動。 %s 已更新,由於作用域包含系統框架,需要重新啟動以套用修改。 模組更新完成 Vector 狀態 diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt index 184ce4f25..49ad0d3f4 100644 --- a/external/CMakeLists.txt +++ b/external/CMakeLists.txt @@ -23,5 +23,9 @@ option(FMT_INSTALL OFF) add_subdirectory(dobby) add_subdirectory(fmt) add_subdirectory(lsplant/lsplant/src/main/jni) -target_compile_options(lsplant_static PUBLIC -Wno-gnu-anonymous-struct) +target_compile_options(lsplant_static PUBLIC -Wno-gnu-anonymous-struct + # lsplant.cc's `operator""_uarr()` is a string literal operator template, which is a GNU + # extension clang warns about under -Wpedantic. Upstream code we do not own, and the + # warning was printed once per ABI on every build. + -Wno-gnu-string-literal-operator-template) target_compile_definitions(fmt-header-only INTERFACE FMT_USE_LOCALE=0 FMT_USE_FLOAT=0 FMT_USE_DOUBLE=0 FMT_USE_LONG_DOUBLE=0 FMT_USE_BITINT=0) diff --git a/external/apache/build.gradle.kts b/external/apache/build.gradle.kts index 2127d2f0e..2c6dd431f 100644 --- a/external/apache/build.gradle.kts +++ b/external/apache/build.gradle.kts @@ -1,5 +1,5 @@ -val androidSourceCompatibility: JavaVersion by rootProject.extra -val androidTargetCompatibility: JavaVersion by rootProject.extra +val androidSourceCompatibility = rootProject.extra["androidSourceCompatibility"] as JavaVersion +val androidTargetCompatibility = rootProject.extra["androidTargetCompatibility"] as JavaVersion plugins { id("java-library") diff --git a/external/apache/commons-lang b/external/apache/commons-lang index 675ab08d0..8f8f3b26e 160000 --- a/external/apache/commons-lang +++ b/external/apache/commons-lang @@ -1 +1 @@ -Subproject commit 675ab08d0eb62b7d2edd43fe42512c896e84bcd6 +Subproject commit 8f8f3b26e8cb81e0879fc676068db1212652dcaf diff --git a/external/axml/build.gradle.kts b/external/axml/build.gradle.kts index a650b878f..513ebc443 100644 --- a/external/axml/build.gradle.kts +++ b/external/axml/build.gradle.kts @@ -1,5 +1,5 @@ -val androidSourceCompatibility: JavaVersion by rootProject.extra -val androidTargetCompatibility: JavaVersion by rootProject.extra +val androidSourceCompatibility = rootProject.extra["androidSourceCompatibility"] as JavaVersion +val androidTargetCompatibility = rootProject.extra["androidTargetCompatibility"] as JavaVersion plugins { id("java-library") diff --git a/external/fmt b/external/fmt index 0e078f6ed..e2243a6a7 160000 --- a/external/fmt +++ b/external/fmt @@ -1 +1 @@ -Subproject commit 0e078f6ed0624be8babc43bd145371d9f3a08aab +Subproject commit e2243a6a7f4a8c09e9233a253f3d1f71e45d8cef diff --git a/gradle.properties b/gradle.properties index 78858b29f..b31ced96f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,4 +12,7 @@ # org.gradle.parallel=true android.useAndroidX=true -android.nonFinalResIds=false + +# The Compose compiler in :manager needs more than the 512 MB Gradle gives a daemon by +# default; without this the daemon dies mid-build with "daemon disappeared unexpectedly". +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 530c2248d..e1d09d2b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,60 +1,93 @@ [versions] -agp = "8.13.1" -kotlin = "2.3.10" -nav = "2.9.7" -appcenter = "5.0.5" -glide = "5.0.5" -okhttp = "5.3.2" -ktfmt = "0.25.0" -coroutines = "1.10.2" +agp = "9.3.1" +kotlin = "2.4.10" +okhttp = "5.4.0" +ktfmt = "0.26.0" +coroutines = "1.11.0" +lifecycle = "2.11.0" +coil = "3.5.0" +# Material 3 Expressive is not in any stable material3 release; the expressive +# APIs graduate through the 1.5.0 alpha line. Pinned explicitly OVER the Compose +# BOM, which resolves material3 to 1.4.0. +m3 = "1.5.0-alpha25" +nav3 = "1.1.5" +navigationevent = "1.1.2" +# Governs every androidx.compose.* artifact below; none of them pin a version. +compose-bom = "2026.06.01" [plugins] agp-lib = { id = "com.android.library", version.ref = "agp" } agp-app = { id = "com.android.application", version.ref = "agp" } kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -nav-safeargs = { id = "androidx.navigation.safeargs", version.ref = "nav" } -autoresconfig = { id = "dev.rikka.tools.autoresconfig", version = "1.2.2" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } -materialthemebuilder = { id = "dev.rikka.tools.materialthemebuilder", version = "1.5.1" } -lsplugin-apksign = { id = "org.lsposed.lsplugin.apksign", version = "1.4" } [libraries] -rikkax-appcompat = { module = "dev.rikka.rikkax.appcompat:appcompat", version = "1.6.1" } -rikkax-core = { module = "dev.rikka.rikkax.core:core", version = "1.4.1" } -rikkax-insets = { module = "dev.rikka.rikkax.insets:insets", version = "1.3.0" } -rikkax-layoutinflater = { module = "dev.rikka.rikkax.layoutinflater:layoutinflater", version = "1.3.0" } -rikkax-material = { module = "dev.rikka.rikkax.material:material", version = "2.7.2" } -rikkax-material-preference = { module = "dev.rikka.rikkax.material:material-preference", version = "2.0.0" } +# Build-only: reads the optimised DEX in checkXResourcesIsolation. +smali-dexlib2 = { group = "com.android.tools.smali", name = "smali-dexlib2", version = "3.0.9" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.19.0" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.13.0" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.2.0" } +# Needed to make the in-app browser honour the app's own light/dark choice. +androidx-webkit = { group = "androidx.webkit", name = "webkit", version = "1.16.0" } + +# Compose. The BOM governs every artifact here — do not pin these individually, +# or BOM alignment is silently defeated. +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "m3" } +androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "m3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +# Navigation 3. Stable since Nov 2025; the back stack is a plain observable list +# of NavKey objects rather than route strings. +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } +androidx-navigationevent-compose = { group = "androidx.navigationevent", name = "navigationevent-compose", version.ref = "navigationevent" } + +# GitHub avatars on Home. App icons do NOT go through Coil: they come straight +# from PackageManager as Drawables via a small LRU cache (see AppIconCache). +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } + +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" } + rikkax-parcelablelist = { module = "dev.rikka.rikkax.parcelablelist:parcelablelist", version = "2.0.1" } -rikkax-recyclerview = { module = "dev.rikka.rikkax.recyclerview:recyclerview-ktx", version = "1.3.2" } -rikkax-widget-borderview = { module = "dev.rikka.rikkax.widget:borderview", version = "1.1.0" } -rikkax-widget-mainswitchbar = { module = "dev.rikka.rikkax.widget:mainswitchbar", version = "1.0.2" } - -androidx-activity = { module = "androidx.activity:activity", version = "1.12.4" } -androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" } -androidx-browser = { module = "androidx.browser:browser", version = "1.9.0" } -androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.2.1" } -androidx-core = { module = "androidx.core:core", version = "1.17.0" } -androidx-fragment = { module = "androidx.fragment:fragment", version = "1.8.9" } -androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "nav" } -androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui", version.ref = "nav" } -androidx-preference = { module = "androidx.preference:preference", version = "1.2.1" } -androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.4.0" } -androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version = "1.2.0" } - -glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } -glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } + +androidx-annotation = { module = "androidx.annotation:annotation", version = "1.10.0" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-dnsoverhttps = { group = "com.squareup.okhttp3", name = "okhttp-dnsoverhttps", version.ref = "okhttp" } -okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } agp-apksig = { group = "com.android.tools.build", name = "apksig", version.ref = "agp" } -appiconloader = { module = "me.zhanghai.android.appiconloader:appiconloader", version = "1.5.0" } -material = { module = "com.google.android.material:material", version = "1.12.0" } -gson = { module = "com.google.code.gson:gson", version = "2.13.2" } -hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "6.1" } -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +gson = { module = "com.google.code.gson:gson", version = "2.14.0" } +# The libxposed API sources vendored under xposed/libxposed and services/libxposed carry +# @SinceApi and @InternalApi from API 102 onwards. Both are CLASS-retained metadata with no +# runtime behaviour, so this is compileOnly everywhere and never reaches a device. +libxposed-annotation = { module = "io.github.libxposed:annotation", version = "1.0.0" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } picocli = { module = "info.picocli:picocli", version = "4.7.7" } + +[bundles] +compose = [ + "androidx-activity-compose", + "androidx-compose-ui", + "androidx-compose-ui-graphics", + "androidx-compose-ui-tooling-preview", + "androidx-compose-material3", + "androidx-compose-material3-adaptive-navigation-suite", + "androidx-compose-material-icons-extended", + "androidx-lifecycle-viewmodel-compose", + "androidx-navigation3-runtime", + "androidx-navigation3-ui", + "androidx-lifecycle-viewmodel-navigation3", + "androidx-navigationevent-compose", +] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 61285a659..b1b8ef56b 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f78a6af..a9db11550 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index adff685a0..379a6582e 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob//platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/gradlew.bat b/gradlew.bat index c4bdd3ab8..a51ec4f58 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,29 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/hiddenapi/bridge/src/main/java/hidden/ByteBufferDexClassLoader.java b/hiddenapi/bridge/src/main/java/hidden/ByteBufferDexClassLoader.java index 622eb709b..9115dbfd5 100644 --- a/hiddenapi/bridge/src/main/java/hidden/ByteBufferDexClassLoader.java +++ b/hiddenapi/bridge/src/main/java/hidden/ByteBufferDexClassLoader.java @@ -13,8 +13,4 @@ public ByteBufferDexClassLoader(ByteBuffer[] dexFiles, ClassLoader parent) { public ByteBufferDexClassLoader(ByteBuffer[] dexFiles, String librarySearchPath, ClassLoader parent) { super(dexFiles, librarySearchPath, parent); } - - public String getLdLibraryPath() { - return super.getLdLibraryPath(); - } } diff --git a/hiddenapi/bridge/src/main/java/hidden/HiddenApiBridge.java b/hiddenapi/bridge/src/main/java/hidden/HiddenApiBridge.java index 8055bf378..744f0c99a 100644 --- a/hiddenapi/bridge/src/main/java/hidden/HiddenApiBridge.java +++ b/hiddenapi/bridge/src/main/java/hidden/HiddenApiBridge.java @@ -1,39 +1,14 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package hidden; import android.app.ActivityManager; -import android.content.BroadcastReceiver; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInstaller; import android.content.res.AssetManager; import android.content.res.CompatibilityInfo; import android.content.res.Resources; import android.content.res.ResourcesImpl; import android.os.Binder; import android.os.Build; -import android.os.Environment; -import android.os.Handler; import android.os.IBinder; import android.os.UserHandle; import android.system.ErrnoException; @@ -43,7 +18,6 @@ import androidx.annotation.RequiresApi; -import java.io.File; import java.io.FileDescriptor; public class HiddenApiBridge { @@ -59,32 +33,10 @@ public static void Resources_setImpl(Resources resources, ResourcesImpl impl) { resources.setImpl(impl); } - public static int PackageInstaller_SessionParams_installFlags(PackageInstaller.SessionParams params) { - return params.installFlags; - } - - public static void PackageInstaller_SessionParams_installFlags(PackageInstaller.SessionParams params, int flags) { - params.installFlags = flags; - } - public static IBinder Context_getActivityToken(Context ctx) { return ctx.getActivityToken(); } - public static File Environment_getDataProfilesDePackageDirectory(int userId, String packageName) { - return Environment.getDataProfilesDePackageDirectory(userId, packageName); - } - - public static Intent Context_registerReceiverAsUser(Context ctx, BroadcastReceiver receiver, UserHandle user, - IntentFilter filter, String broadcastPermission, Handler scheduler) { - - return ctx.registerReceiverAsUser(receiver, user, filter, broadcastPermission, scheduler); - } - - public static UserHandle UserHandle_ALL() { - return UserHandle.ALL; - } - public static UserHandle UserHandle(int h) { return new UserHandle(h); } diff --git a/hiddenapi/stubs/build.gradle.kts b/hiddenapi/stubs/build.gradle.kts index 92fcaf36d..0c64b0bd6 100644 --- a/hiddenapi/stubs/build.gradle.kts +++ b/hiddenapi/stubs/build.gradle.kts @@ -1,6 +1,4 @@ +// No source/target override: the root build sets both to 21 for every Java project, and this one +// is `compileOnly` everywhere it is used, so nothing here ever reaches a device. Pinning it to 8 +// only earned three "source value 8 is obsolete" warnings on every build. plugins { `java-library` } - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} diff --git a/hiddenapi/stubs/src/main/java/android/app/IActivityController.java b/hiddenapi/stubs/src/main/java/android/app/IActivityController.java deleted file mode 100644 index a88ac8fa4..000000000 --- a/hiddenapi/stubs/src/main/java/android/app/IActivityController.java +++ /dev/null @@ -1,66 +0,0 @@ -package android.app; - -import android.content.Intent; -import android.os.Binder; -import android.os.Bundle; -import android.os.IBinder; -import android.os.IInterface; - -public interface IActivityController extends IInterface { - /** - * The system is trying to start an activity. Return true to allow - * it to be started as normal, or false to cancel/reject this activity. - */ - boolean activityStarting(Intent intent, String pkg); - - /** - * The system is trying to return to an activity. Return true to allow - * it to be resumed as normal, or false to cancel/reject this activity. - */ - boolean activityResuming(String pkg); - - /** - * An application process has crashed (in Java). Return true for the - * normal error recovery (app crash dialog) to occur, false to kill - * it immediately. - */ - boolean appCrashed(String processName, int pid, - String shortMsg, String longMsg, - long timeMillis, String stackTrace); - - /** - * Early call as soon as an ANR is detected. - */ - int appEarlyNotResponding(String processName, int pid, String annotation); - - /** - * An application process is not responding. Return 0 to show the "app - * not responding" dialog, 1 to continue waiting, or -1 to kill it - * immediately. - */ - int appNotResponding(String processName, int pid, String processStats); - - /** - * The system process watchdog has detected that the system seems to be - * hung. Return 1 to continue waiting, or -1 to let it continue with its - * normal kill. - */ - int systemNotResponding(String msg); - - /** - * 360 phones - */ - boolean moveTaskToFront(String pkg, int task, int flags, Bundle options); - - abstract class Stub extends Binder implements IActivityController { - - public static IActivityController asInterface(IBinder obj) { - throw new UnsupportedOperationException(); - } - - @Override - public IBinder asBinder() { - throw new UnsupportedOperationException(); - } - } -} diff --git a/hiddenapi/stubs/src/main/java/android/app/IActivityManager.java b/hiddenapi/stubs/src/main/java/android/app/IActivityManager.java index 0a70a72c4..62a8e511a 100644 --- a/hiddenapi/stubs/src/main/java/android/app/IActivityManager.java +++ b/hiddenapi/stubs/src/main/java/android/app/IActivityManager.java @@ -1,29 +1,9 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package android.app; import android.content.IIntentReceiver; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.UserInfo; -import android.content.res.Configuration; import android.os.Binder; import android.os.Bundle; import android.os.IBinder; @@ -60,16 +40,6 @@ int broadcastIntent(IApplicationThread caller, Intent intent, String resultData, Bundle map, String[] requiredPermissions, int appOp, Bundle options, boolean serialized, boolean sticky, int userId) throws RemoteException; - int startActivity(IApplicationThread caller, String callingPackage, Intent intent, - String resolvedType, IBinder resultTo, String resultWho, int requestCode, - int flags, ProfilerInfo profilerInfo, Bundle options) throws RemoteException; - - @RequiresApi(30) - int startActivityWithFeature(IApplicationThread caller, String callingPackage, - String callingFeatureId, Intent intent, String resolvedType, - IBinder resultTo, String resultWho, int requestCode, int flags, - ProfilerInfo profilerInfo, Bundle options) throws RemoteException; - int startActivityAsUser(IApplicationThread caller, String callingPackage, Intent intent, String resolvedType, IBinder resultTo, String resultWho, int requestCode, int flags, ProfilerInfo profilerInfo, @@ -83,8 +53,6 @@ int startActivityAsUserWithFeature(IApplicationThread caller, String callingPack void forceStopPackage(String packageName, int userId) throws RemoteException; - boolean startUserInBackground(int userid) throws RemoteException; - Intent registerReceiver(IApplicationThread caller, String callerPackage, IIntentReceiver receiver, IntentFilter filter, String requiredPermission, int userId, int flags) throws RemoteException; @@ -117,20 +85,19 @@ int bindService(IApplicationThread caller, IBinder token, Intent service, UserInfo getCurrentUser() throws RemoteException; - void setActivityController(IActivityController watcher, boolean imAMonkey) throws RemoteException; - @RequiresApi(29) ContentProviderHolder getContentProviderExternal(String name, int userId, IBinder token, String tag) throws RemoteException; ContentProviderHolder getContentProviderExternal(String name, int userId, IBinder token) throws RemoteException; - Configuration getConfiguration() throws RemoteException; + void removeContentProviderExternal(String name, IBinder token) throws RemoteException; + + @RequiresApi(29) + void removeContentProviderExternalAsUser(String name, IBinder token, int userId) throws RemoteException; void registerUidObserver(IUidObserver observer, int which, int cutpoint, String callingPackage) throws RemoteException; abstract class Stub extends Binder implements IActivityManager { - public static int TRANSACTION_setActivityController; - public static IActivityManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); } diff --git a/hiddenapi/stubs/src/main/java/android/app/IApplicationThread.java b/hiddenapi/stubs/src/main/java/android/app/IApplicationThread.java index 0b0146fab..eddfe4fb8 100644 --- a/hiddenapi/stubs/src/main/java/android/app/IApplicationThread.java +++ b/hiddenapi/stubs/src/main/java/android/app/IApplicationThread.java @@ -1,22 +1,3 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package android.app; import android.os.Binder; diff --git a/hiddenapi/stubs/src/main/java/android/app/IBinderSession.java b/hiddenapi/stubs/src/main/java/android/app/IBinderSession.java new file mode 100644 index 000000000..7a81d5c08 --- /dev/null +++ b/hiddenapi/stubs/src/main/java/android/app/IBinderSession.java @@ -0,0 +1,9 @@ +package android.app; + +import android.os.IInterface; + +/** + * Stub of {@code android.app.IBinderSession}, added in Android 17 (API 37), + * where it appears as a parameter of {@link IServiceConnection#connected}. + */ +public interface IBinderSession extends IInterface {} diff --git a/hiddenapi/stubs/src/main/java/android/app/INotificationManager.java b/hiddenapi/stubs/src/main/java/android/app/INotificationManager.java index 8a73e0e91..c90fed798 100644 --- a/hiddenapi/stubs/src/main/java/android/app/INotificationManager.java +++ b/hiddenapi/stubs/src/main/java/android/app/INotificationManager.java @@ -19,13 +19,6 @@ void enqueueNotificationWithTag(String pkg, String opPkg, String tag, int id, void createNotificationChannelsForPackage(String pkg, int uid, ParceledListSlice channelsList) throws RemoteException; - void updateNotificationChannelForPackage(String pkg, int uid, NotificationChannel channel); - - @RequiresApi(30) - NotificationChannel getNotificationChannelForPackage(String pkg, int uid, String channelId, String conversationId, boolean includeDeleted) throws RemoteException; - - NotificationChannel getNotificationChannelForPackage(String pkg, int uid, String channelId, boolean includeDeleted) throws RemoteException; - abstract class Stub extends Binder implements INotificationManager { public static INotificationManager asInterface(IBinder obj) { throw new UnsupportedOperationException(); diff --git a/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java b/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java index a531e1bc1..034ec8acc 100644 --- a/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java +++ b/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java @@ -6,8 +6,13 @@ import android.os.IInterface; public interface IServiceConnection extends IInterface { + + /** Declared by the framework up to Android 16. */ void connected(ComponentName name, IBinder service, boolean dead); + /** Declared by the framework from Android 17 (API 37) on. */ + void connected(ComponentName name, IBinder service, IBinderSession session, boolean dead); + abstract class Stub extends Binder implements IServiceConnection { public static IServiceConnection asInterface(IBinder obj) { diff --git a/hiddenapi/stubs/src/main/java/android/app/IUidObserver.java b/hiddenapi/stubs/src/main/java/android/app/IUidObserver.java index 676e509a6..38af361bd 100644 --- a/hiddenapi/stubs/src/main/java/android/app/IUidObserver.java +++ b/hiddenapi/stubs/src/main/java/android/app/IUidObserver.java @@ -2,14 +2,49 @@ import android.os.Binder; +/** + * The union of every method this interface has carried across API 27 to 37, rather than the four + * the daemon happens to listen for. + * + * The real framework class is what a `Stub` subclass extends at runtime, so a method left out here + * is one the subclass cannot override and the platform still finds abstract. The interface is + * `oneway`, so AMS never waits and never sees the failure; an `AbstractMethodError` escaping the + * callback is routed by `JavaBBinder::onTransact` to `report_java_lang_error`, which is fatal, and + * the daemon's own uncaught handler would end the process anyway. That is a loud death for a + * callback nobody asked for. + * + * Nothing dispatches the two absent from the old file today: `onUidStateChanged` and + * `onUidProcAdjChanged` are gated on UID_OBSERVER_PROCSTATE, UID_OBSERVER_CAPABILITY and + * UID_OBSERVER_PROC_OOM_ADJ, and `VectorService` registers for ACTIVE, GONE, IDLE and CACHED. So + * this is insurance against a wider mask -- ours or an OEM's -- not a live crash. + * + * Both signatures are declared for the two methods that changed shape. Only one of each pair + * exists in any given release, and the other is then an ordinary unused method on the subclass. + */ public interface IUidObserver { + /** API 27..37. */ void onUidGone(int uid, boolean disabled); + /** API 27..37. */ void onUidActive(int uid); + /** API 27..37. */ void onUidIdle(int uid, boolean disabled); + /** API 27..29; replaced by the capability overload in 30. */ + void onUidStateChanged(int uid, int procState, long procStateSeq); + + /** API 30..37. */ + void onUidStateChanged(int uid, int procState, long procStateSeq, int capability); + + /** API 33 only; replaced by the adj overload in 34. */ + void onUidProcAdjChanged(int uid); + + /** API 34..37. */ + void onUidProcAdjChanged(int uid, int adj); + + /** API 27..37. */ void onUidCachedChanged(int uid, boolean cached); abstract class Stub extends Binder implements IUidObserver { diff --git a/hiddenapi/stubs/src/main/java/android/app/LoadedApk.java b/hiddenapi/stubs/src/main/java/android/app/LoadedApk.java index 32f294c1e..bd8c7a6bd 100644 --- a/hiddenapi/stubs/src/main/java/android/app/LoadedApk.java +++ b/hiddenapi/stubs/src/main/java/android/app/LoadedApk.java @@ -3,8 +3,6 @@ import android.content.pm.ApplicationInfo; public final class LoadedApk { - private ClassLoader mDefaultClassLoader; - public ApplicationInfo getApplicationInfo() { throw new UnsupportedOperationException("STUB"); } @@ -16,8 +14,4 @@ public ClassLoader getClassLoader() { public String getPackageName() { throw new UnsupportedOperationException("STUB"); } - - public String getResDir() { - throw new UnsupportedOperationException("STUB"); - } } diff --git a/hiddenapi/stubs/src/main/java/android/app/ProfilerInfo.java b/hiddenapi/stubs/src/main/java/android/app/ProfilerInfo.java index 6b5056f07..88ed94d90 100644 --- a/hiddenapi/stubs/src/main/java/android/app/ProfilerInfo.java +++ b/hiddenapi/stubs/src/main/java/android/app/ProfilerInfo.java @@ -1,22 +1,3 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package android.app; public class ProfilerInfo { diff --git a/hiddenapi/stubs/src/main/java/android/content/BroadcastReceiver.java b/hiddenapi/stubs/src/main/java/android/content/BroadcastReceiver.java deleted file mode 100644 index d70f080aa..000000000 --- a/hiddenapi/stubs/src/main/java/android/content/BroadcastReceiver.java +++ /dev/null @@ -1,5 +0,0 @@ -package android.content; - -public abstract class BroadcastReceiver { - public abstract void onReceive(Context context, Intent intent); -} diff --git a/hiddenapi/stubs/src/main/java/android/content/Context.java b/hiddenapi/stubs/src/main/java/android/content/Context.java index fc22c4492..1084a435f 100644 --- a/hiddenapi/stubs/src/main/java/android/content/Context.java +++ b/hiddenapi/stubs/src/main/java/android/content/Context.java @@ -1,15 +1,9 @@ package android.content; -import android.os.Handler; import android.os.IBinder; -import android.os.UserHandle; public class Context { public IBinder getActivityToken() { throw new UnsupportedOperationException("STUB"); } - public Intent registerReceiverAsUser(BroadcastReceiver receiver, UserHandle user, - IntentFilter filter, String broadcastPermission, Handler scheduler) { - throw new UnsupportedOperationException("STUB"); - } } diff --git a/hiddenapi/stubs/src/main/java/android/content/IIntentReceiver.java b/hiddenapi/stubs/src/main/java/android/content/IIntentReceiver.java index 233fce190..d4311032f 100644 --- a/hiddenapi/stubs/src/main/java/android/content/IIntentReceiver.java +++ b/hiddenapi/stubs/src/main/java/android/content/IIntentReceiver.java @@ -1,22 +1,3 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package android.content; import android.os.Binder; diff --git a/hiddenapi/stubs/src/main/java/android/content/Intent.java b/hiddenapi/stubs/src/main/java/android/content/Intent.java index 48c55e8f0..459e57566 100644 --- a/hiddenapi/stubs/src/main/java/android/content/Intent.java +++ b/hiddenapi/stubs/src/main/java/android/content/Intent.java @@ -1,22 +1,3 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package android.content; public class Intent { diff --git a/hiddenapi/stubs/src/main/java/android/content/pm/IPackageManager.java b/hiddenapi/stubs/src/main/java/android/content/pm/IPackageManager.java index 15578dc7c..af158e886 100644 --- a/hiddenapi/stubs/src/main/java/android/content/pm/IPackageManager.java +++ b/hiddenapi/stubs/src/main/java/android/content/pm/IPackageManager.java @@ -30,42 +30,6 @@ PackageInfo getPackageInfo(String packageName, int flags, int userId) PackageInfo getPackageInfo(String packageName, long flags, int userId) throws RemoteException; - int getPackageUid(String packageName, int flags, int userId) throws RemoteException; - - @RequiresApi(33) - int getPackageUid(String packageName, long flags, int userId) throws RemoteException; - - String[] getPackagesForUid(int uid) - throws RemoteException; - - ParceledListSlice getInstalledApplications(int flags, int userId) - throws RemoteException; - - @RequiresApi(33) - ParceledListSlice getInstalledApplications(long flags, int userId) - throws RemoteException; - - int getUidForSharedUser(String sharedUserName) - throws RemoteException; - - void grantRuntimePermission(String packageName, String permissionName, int userId) - throws RemoteException; - - void revokeRuntimePermission(String packageName, String permissionName, int userId) - throws RemoteException; - - int getPermissionFlags(String permissionName, String packageName, int userId) - throws RemoteException; - - void updatePermissionFlags(String permissionName, String packageName, int flagMask, int flagValues, int userId) - throws RemoteException; - - int checkPermission(String permName, String pkgName, int userId) - throws RemoteException; - - int checkUidPermission(String permName, int uid) - throws RemoteException; - IPackageInstaller getPackageInstaller() throws RemoteException; int installExistingPackageAsUser(String packageName, int userId, int installFlags, diff --git a/hiddenapi/stubs/src/main/java/android/content/pm/PackageInfo.java b/hiddenapi/stubs/src/main/java/android/content/pm/PackageInfo.java index f087c27c9..654296045 100644 --- a/hiddenapi/stubs/src/main/java/android/content/pm/PackageInfo.java +++ b/hiddenapi/stubs/src/main/java/android/content/pm/PackageInfo.java @@ -1,6 +1,4 @@ package android.content.pm; public class PackageInfo { - - public String overlayTarget; } diff --git a/hiddenapi/stubs/src/main/java/android/content/pm/PackageInstaller.java b/hiddenapi/stubs/src/main/java/android/content/pm/PackageInstaller.java deleted file mode 100644 index ab6b0de4d..000000000 --- a/hiddenapi/stubs/src/main/java/android/content/pm/PackageInstaller.java +++ /dev/null @@ -1,7 +0,0 @@ -package android.content.pm; - -public class PackageInstaller { - public static class SessionParams { - public int installFlags = 0; - } -} diff --git a/hiddenapi/stubs/src/main/java/android/content/pm/PackageManager.java b/hiddenapi/stubs/src/main/java/android/content/pm/PackageManager.java deleted file mode 100644 index cb035235f..000000000 --- a/hiddenapi/stubs/src/main/java/android/content/pm/PackageManager.java +++ /dev/null @@ -1,9 +0,0 @@ -package android.content.pm; - -import java.util.List; - -public class PackageManager { - public List getInstalledPackagesAsUser(int flags, int userId) { - throw new UnsupportedOperationException("STUB"); - } -} diff --git a/hiddenapi/stubs/src/main/java/android/os/Bundle.java b/hiddenapi/stubs/src/main/java/android/os/Bundle.java index 24323c6aa..49ff9ee20 100644 --- a/hiddenapi/stubs/src/main/java/android/os/Bundle.java +++ b/hiddenapi/stubs/src/main/java/android/os/Bundle.java @@ -1,22 +1,3 @@ -/* - * This file is part of LSPosed. - * - * LSPosed is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2021 LSPosed Contributors - */ - package android.os; public class Bundle { diff --git a/hiddenapi/stubs/src/main/java/android/os/Environment.java b/hiddenapi/stubs/src/main/java/android/os/Environment.java deleted file mode 100644 index f769f46d0..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/Environment.java +++ /dev/null @@ -1,9 +0,0 @@ -package android.os; - -import java.io.File; - -public class Environment { - public static File getDataProfilesDePackageDirectory(int userId, String packageName) { - throw new IllegalArgumentException("STUB"); - } -} diff --git a/hiddenapi/stubs/src/main/java/android/os/Handler.java b/hiddenapi/stubs/src/main/java/android/os/Handler.java deleted file mode 100644 index dd6327221..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/Handler.java +++ /dev/null @@ -1,4 +0,0 @@ -package android.os; - -public class Handler { -} diff --git a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java index 7a1b19617..1e1193ee9 100644 --- a/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/IServiceManager.java @@ -2,8 +2,6 @@ public interface IServiceManager extends IInterface { - void tryUnregisterService(java.lang.String name, android.os.IBinder service); - IBinder getService(String name); public void registerForNotifications(String name, IServiceCallback cb); diff --git a/hiddenapi/stubs/src/main/java/android/os/IUserManager.java b/hiddenapi/stubs/src/main/java/android/os/IUserManager.java index f2295d0b7..96f3e7e54 100644 --- a/hiddenapi/stubs/src/main/java/android/os/IUserManager.java +++ b/hiddenapi/stubs/src/main/java/android/os/IUserManager.java @@ -2,15 +2,9 @@ import android.content.pm.UserInfo; -import androidx.annotation.RequiresApi; - import java.util.List; public interface IUserManager extends IInterface { - @RequiresApi(26) - boolean isUserUnlocked(int userId) - throws RemoteException; - List getUsers(boolean excludeDying) throws RemoteException; @@ -21,8 +15,6 @@ List getUsers(boolean excludePartial, boolean excludeDying, boolean ex UserInfo getProfileParent(int userId) throws RemoteException; - boolean isUserUnlockingOrUnlocked(int userId) throws RemoteException; - abstract class Stub extends Binder implements IUserManager { public static IUserManager asInterface(IBinder obj) { diff --git a/hiddenapi/stubs/src/main/java/android/os/Parcelable.java b/hiddenapi/stubs/src/main/java/android/os/Parcelable.java deleted file mode 100644 index 5c716ee51..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/Parcelable.java +++ /dev/null @@ -1,10 +0,0 @@ -package android.os; - -public interface Parcelable { - interface Creator{ - public T createFromParcel(Parcel source); - public T[] newArray(int size); - } - void writeToParcel(Parcel dest, int flags); - int describeContents(); -} diff --git a/hiddenapi/stubs/src/main/java/android/os/ResultReceiver.java b/hiddenapi/stubs/src/main/java/android/os/ResultReceiver.java deleted file mode 100644 index 358ae780f..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/ResultReceiver.java +++ /dev/null @@ -1,4 +0,0 @@ -package android.os; - -public class ResultReceiver { -} diff --git a/hiddenapi/stubs/src/main/java/android/os/ShellCallback.java b/hiddenapi/stubs/src/main/java/android/os/ShellCallback.java deleted file mode 100644 index fe342213a..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/ShellCallback.java +++ /dev/null @@ -1,25 +0,0 @@ -package android.os; - -public class ShellCallback implements Parcelable { - public static final Parcelable.Creator CREATOR = new Creator() { - @Override - public ShellCallback createFromParcel(Parcel source) { - throw new IllegalArgumentException("STUB"); - } - - @Override - public ShellCallback[] newArray(int size) { - throw new IllegalArgumentException("STUB"); - } - }; - - @Override - public void writeToParcel(Parcel dest, int flags) { - throw new IllegalArgumentException("STUB"); - } - - @Override - public int describeContents() { - throw new IllegalArgumentException("STUB"); - } -} diff --git a/hiddenapi/stubs/src/main/java/android/os/ShellCommand.java b/hiddenapi/stubs/src/main/java/android/os/ShellCommand.java deleted file mode 100644 index 56dac90c2..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/ShellCommand.java +++ /dev/null @@ -1,33 +0,0 @@ -package android.os; - -import java.io.FileDescriptor; -import java.io.InputStream; -import java.io.PrintWriter; - -public abstract class ShellCommand { - public int exec(Binder target, FileDescriptor in, FileDescriptor out, FileDescriptor err, - String[] args, ShellCallback callback, ResultReceiver resultReceiver) { - throw new IllegalArgumentException("STUB!"); - } - - public abstract int onCommand(String cmd); - public abstract void onHelp(); - - public String getNextOption(){ - throw new IllegalArgumentException("STUB!"); - } - - public String getNextArgRequired() { - throw new IllegalArgumentException("STUB!"); - } - - public PrintWriter getErrPrintWriter() { - throw new IllegalArgumentException("STUB!"); - } - public PrintWriter getOutPrintWriter() { - throw new IllegalArgumentException("STUB!"); - } - public InputStream getRawInputStream() { - throw new IllegalArgumentException("STUB!"); - } -} diff --git a/hiddenapi/stubs/src/main/java/android/os/UserHandle.java b/hiddenapi/stubs/src/main/java/android/os/UserHandle.java index 310fe2463..0c5f51cd2 100644 --- a/hiddenapi/stubs/src/main/java/android/os/UserHandle.java +++ b/hiddenapi/stubs/src/main/java/android/os/UserHandle.java @@ -1,17 +1,8 @@ package android.os; -import android.annotation.NonNull; - public class UserHandle { public UserHandle(int h) { throw new RuntimeException("STUB"); } - - public int getIdentifier() { - throw new RuntimeException("STUB"); - } - - public static final @NonNull - UserHandle ALL = null; } diff --git a/hiddenapi/stubs/src/main/java/android/os/UserManager.java b/hiddenapi/stubs/src/main/java/android/os/UserManager.java deleted file mode 100644 index 1dda5b631..000000000 --- a/hiddenapi/stubs/src/main/java/android/os/UserManager.java +++ /dev/null @@ -1,11 +0,0 @@ -package android.os; - -import android.content.pm.UserInfo; - -import java.util.List; - -public class UserManager { - public List getUsers() { - throw new UnsupportedOperationException("STUB"); - } -} diff --git a/hiddenapi/stubs/src/main/java/android/permission/IPermissionManager.java b/hiddenapi/stubs/src/main/java/android/permission/IPermissionManager.java deleted file mode 100644 index 595f4756e..000000000 --- a/hiddenapi/stubs/src/main/java/android/permission/IPermissionManager.java +++ /dev/null @@ -1,8 +0,0 @@ -package android.permission; - -import java.util.List; - -public interface IPermissionManager { - - List getSplitPermissions(); -} diff --git a/hiddenapi/stubs/src/main/java/android/webkit/WebViewFactoryProvider.java b/hiddenapi/stubs/src/main/java/android/webkit/WebViewFactoryProvider.java deleted file mode 100644 index 43b5fd5e3..000000000 --- a/hiddenapi/stubs/src/main/java/android/webkit/WebViewFactoryProvider.java +++ /dev/null @@ -1,4 +0,0 @@ -package android.webkit; - -public class WebViewFactoryProvider { -} diff --git a/hiddenapi/stubs/src/main/java/com/android/server/LocalServices.java b/hiddenapi/stubs/src/main/java/com/android/server/LocalServices.java deleted file mode 100644 index 06717abf4..000000000 --- a/hiddenapi/stubs/src/main/java/com/android/server/LocalServices.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.android.server; - -public class LocalServices { - - public static T getService(Class type) { - throw new UnsupportedOperationException("STUB"); - } -} diff --git a/hiddenapi/stubs/src/main/java/com/android/server/SystemService.java b/hiddenapi/stubs/src/main/java/com/android/server/SystemService.java deleted file mode 100644 index 2f678e8a2..000000000 --- a/hiddenapi/stubs/src/main/java/com/android/server/SystemService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.android.server; - -public abstract class SystemService { -} diff --git a/hiddenapi/stubs/src/main/java/com/android/server/SystemServiceManager.java b/hiddenapi/stubs/src/main/java/com/android/server/SystemServiceManager.java deleted file mode 100644 index d9e2a920e..000000000 --- a/hiddenapi/stubs/src/main/java/com/android/server/SystemServiceManager.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.android.server; - -import java.util.ArrayList; - -public class SystemServiceManager { - private final ArrayList mServices = new ArrayList<>(); -} diff --git a/hiddenapi/stubs/src/main/java/com/android/server/am/ActivityManagerService.java b/hiddenapi/stubs/src/main/java/com/android/server/am/ActivityManagerService.java deleted file mode 100644 index 2d51101f2..000000000 --- a/hiddenapi/stubs/src/main/java/com/android/server/am/ActivityManagerService.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.android.server.am; - -import com.android.server.SystemService; - -public class ActivityManagerService { - public static final class Lifecycle extends SystemService { - public ActivityManagerService getService() { - throw new UnsupportedOperationException("STUB"); - } - private ProcessRecord findProcessLocked(String process, int userId, String callName) { - throw new UnsupportedOperationException("STUB"); - } - } -} diff --git a/hiddenapi/stubs/src/main/java/com/android/server/am/ProcessRecord.java b/hiddenapi/stubs/src/main/java/com/android/server/am/ProcessRecord.java deleted file mode 100644 index 8947ce02f..000000000 --- a/hiddenapi/stubs/src/main/java/com/android/server/am/ProcessRecord.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.android.server.am; - -public class ProcessRecord { - String processName = null; -} diff --git a/hiddenapi/stubs/src/main/java/dalvik/system/BaseDexClassLoader.java b/hiddenapi/stubs/src/main/java/dalvik/system/BaseDexClassLoader.java index 4979dc44f..dd2aec20a 100644 --- a/hiddenapi/stubs/src/main/java/dalvik/system/BaseDexClassLoader.java +++ b/hiddenapi/stubs/src/main/java/dalvik/system/BaseDexClassLoader.java @@ -10,8 +10,4 @@ public BaseDexClassLoader(ByteBuffer[] dexFiles, ClassLoader parent) { public BaseDexClassLoader(ByteBuffer[] dexFiles, String librarySearchPath, ClassLoader parent) { throw new RuntimeException("Stub!"); } - - public String getLdLibraryPath() { - throw new RuntimeException("Stub!"); - } } diff --git a/hiddenapi/stubs/src/main/java/dalvik/system/VMRuntime.java b/hiddenapi/stubs/src/main/java/dalvik/system/VMRuntime.java deleted file mode 100644 index 692b45694..000000000 --- a/hiddenapi/stubs/src/main/java/dalvik/system/VMRuntime.java +++ /dev/null @@ -1,15 +0,0 @@ -package dalvik.system; - -public class VMRuntime { - - public static VMRuntime getRuntime() { - throw new RuntimeException("Stub!"); - } - - // Use `Process.is64Bit()` instead - public native boolean is64Bit(); - - public native String vmInstructionSet(); - - public native boolean isJavaDebuggable(); -} diff --git a/hiddenapi/stubs/src/main/java/sun/misc/CompoundEnumeration.java b/hiddenapi/stubs/src/main/java/sun/misc/CompoundEnumeration.java deleted file mode 100644 index 26eeedc10..000000000 --- a/hiddenapi/stubs/src/main/java/sun/misc/CompoundEnumeration.java +++ /dev/null @@ -1,34 +0,0 @@ -package sun.misc; - -import java.util.Enumeration; -import java.util.NoSuchElementException; - -public class CompoundEnumeration implements Enumeration { - private final Enumeration[] enums; - private int index = 0; - - public CompoundEnumeration(Enumeration[] enums) { - this.enums = enums; - } - - private boolean next() { - while (index < enums.length) { - if (enums[index] != null && enums[index].hasMoreElements()) { - return true; - } - index++; - } - return false; - } - - public boolean hasMoreElements() { - return next(); - } - - public E nextElement() { - if (!next()) { - throw new NoSuchElementException(); - } - return enums[index].nextElement(); - } -} diff --git a/legacy/README.md b/legacy/README.md index 78fbe7a30..d41b2aabd 100644 --- a/legacy/README.md +++ b/legacy/README.md @@ -24,7 +24,7 @@ The `LegacyDelegateImpl` satisfies the `LegacyFrameworkDelegate` interface, acti ## Module Initialization -Legacy modules are loaded during the initialization phase via `XposedInit.loadLegacyModules()`. The framework queries the daemon (`VectorServiceClient.INSTANCE.getLegacyModulesList()`) to retrieve the list of enabled APK paths. +Legacy modules are loaded during the initialization phase via `XposedInit.loadLegacyModules()`. The framework queries the daemon (`VectorServiceClient.INSTANCE.getLegacyModules()`) to retrieve the list of enabled APK paths. Modules are not loaded using standard Android mechanism. To prevent detection via `ClassLoader.getParent()` chain-walking and to eliminate residual file descriptors, `XposedInit.loadModule` utilizes `VectorModuleClassLoader`. This classloader loads the module APK directly into memory, isolating the module's execution environment from the host application's classpath. diff --git a/legacy/consumer-rules.pro b/legacy/consumer-rules.pro index 7da43889d..2698c9b56 100644 --- a/legacy/consumer-rules.pro +++ b/legacy/consumer-rules.pro @@ -1,5 +1,14 @@ +# Keeping a class also keeps the optimiser from merging it into another one, so the resource types +# below stay classes of their own. That says nothing about the classes the optimiser *invents*: a +# lambda written anywhere becomes a class, and classes of the same shape are merged afterwards, so a +# reference can end up in a class no one wrote and no rule here names. Types whose super class is +# generated at runtime must not travel that way, since they cannot be resolved until the device has +# built the super class and the runtime never retries a failed resolution. That invariant is checked +# against the optimised dex by checkXResourcesIsolationRelease, not from this file. -keep class android.** { *; } -keep class de.robv.android.xposed.** { *; } -# Workaround to bypass verification of in-memory built class xposed.dummy.XResourcesSuperClass +# The in-memory built class xposed.dummy.XResourcesSuperClass exists only on the device, so the +# class below is a deliberate split: it isolates the reference to XResources from its owner, which +# would otherwise be verified long before that super class exists. -keepclassmembers class org.matrix.vector.legacy.LegacyDelegateImpl$ResourceProxy { *; } diff --git a/legacy/src/main/java/android/content/res/XResources.java b/legacy/src/main/java/android/content/res/XResources.java index 833ea926e..d1e53636f 100644 --- a/legacy/src/main/java/android/content/res/XResources.java +++ b/legacy/src/main/java/android/content/res/XResources.java @@ -75,7 +75,8 @@ public class XResources extends XResourcesSuperClass { private static final WeakHashMap sXmlInstanceDetails = new WeakHashMap<>(); private static final String EXTRA_XML_INSTANCE_DETAILS = "xmlInstanceDetails"; - private static final ThreadLocal> sIncludedLayouts = ThreadLocal.withInitial(() -> new LinkedList<>()); + // No lambda, and no anonymous ThreadLocal either. See the note above [includedLayouts]. + private static final ThreadLocal> sIncludedLayouts = new ThreadLocal<>(); private static final HashMap sResDirLastModified = new HashMap<>(); private static final HashMap sResDirPackageNames = new HashMap<>(); @@ -92,11 +93,53 @@ public XResources(ClassLoader classLoader, String resDir) { if (resDir != null) { synchronized (sReplacementsCacheMap) { - mReplacementsCache = sReplacementsCacheMap.computeIfAbsent(resDir, k -> new byte[128]); + // Not computeIfAbsent: its mapping function would be a lambda, which this class may + // not create. See the note above [includedLayouts]. Under this lock the two spellings + // describe the same operation. + byte[] cache = sReplacementsCacheMap.get(resDir); + if (cache == null) { + cache = new byte[128]; + sReplacementsCacheMap.put(resDir, cache); + } + mReplacementsCache = cache; } } } + /** + * The thread's stack of `LayoutInflater.parseInclude` calls, created on first use. + * + * Written the long way on purpose. `ThreadLocal.withInitial(() -> ...)`, and an anonymous + * `ThreadLocal` overriding `initialValue`, would each add a class that names `XResources`, and + * this class may not be named by classes it does not control. + * + * The reason is its super class. {@link xposed.dummy.XResourcesSuperClass} is in no dex; it is + * generated at runtime, because it has to inherit from whichever `Resources` subclass the + * platform actually provides. A type whose super class does not yet exist cannot be resolved, + * and a resolution failure is remembered: the runtime marks the class erroneous and re-throws + * for every later attempt. So the fragility is not local. It travels along references — anything + * naming `XResources` is unusable in the same window, and stays unusable afterwards. + * + * A whole-program optimiser extends that reach in a way the source cannot show. Each lambda + * becomes a class of its own, and classes of the same shape are then merged, so two lambdas + * written in unrelated files can end up as one class. A lambda here is therefore not a private + * detail of this file: it is a reference to `XResources` placed inside a class that arbitrary + * code may instantiate, and every such instantiation inherits the window above. + * + * Keep rules cannot express this — they govern names and members, not which classes an optimiser + * invents. So the rule is stated here, next to what it protects, and checked where it is really + * decided: `checkXResourcesIsolationRelease` reads the optimised dex and fails the build if any + * class outside resource hooking has come to name one of these types. + */ + private static LinkedList includedLayouts() { + LinkedList layouts = sIncludedLayouts.get(); + if (layouts == null) { + layouts = new LinkedList<>(); + sIncludedLayouts.set(layouts); + } + return layouts; + } + /** Dummy, will never be called (objects are transferred to this class only). */ // private XResources() { // throw new UnsupportedOperationException(); @@ -220,12 +263,12 @@ protected void afterHookedMethod(MethodHookParam param) throws Throwable { final XC_MethodHook parseIncludeHook = new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - sIncludedLayouts.get().push(param); + includedLayouts().push(param); } @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { - sIncludedLayouts.get().pop(); + includedLayouts().pop(); if (param.hasThrowable()) return; @@ -962,7 +1005,7 @@ public XmlResourceParser getLayout(int id) throws NotFoundException { sXmlInstanceDetails.put(result, details); // if we were called inside LayoutInflater.parseInclude, store the details for it - MethodHookParam top = sIncludedLayouts.get().peek(); + MethodHookParam top = includedLayouts().peek(); if (top != null) top.setObjectExtra(EXTRA_XML_INSTANCE_DETAILS, details); } diff --git a/legacy/src/main/java/de/robv/android/xposed/XSharedPreferences.java b/legacy/src/main/java/de/robv/android/xposed/XSharedPreferences.java index e3976013c..654fc6030 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XSharedPreferences.java +++ b/legacy/src/main/java/de/robv/android/xposed/XSharedPreferences.java @@ -6,7 +6,7 @@ import android.os.Environment; import android.preference.PreferenceManager; -import org.lsposed.lspd.util.Utils.Log; +import org.matrix.vector.util.Log; import org.matrix.vector.impl.core.VectorServiceClient; import org.matrix.vector.impl.utils.VectorMetaDataReader; import org.matrix.vector.legacy.BuildConfig; diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java b/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java index 5e85d36c6..7b53cdac1 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedBridge.java @@ -5,6 +5,7 @@ import android.content.res.TypedArray; import android.util.Log; +import org.matrix.vector.util.Utils; import org.matrix.vector.impl.hooks.VectorNativeHooker; import org.matrix.vector.impl.hooks.VectorLegacyCallback; import org.matrix.vector.nativebridge.HookBridge; @@ -137,7 +138,11 @@ public synchronized static void log(String text) { * @param t The Throwable object for the stack trace. */ public synchronized static void log(Throwable t) { - String logStr = Log.getStackTraceString(t); + // Written out in full because this file also imports android.util.Log, and it is the + // framework's own that is wanted: the platform's returns an empty string for any + // UnknownHostException cause chain, so a module logging a failed request landed an empty + // line in the modules log. + String logStr = org.matrix.vector.util.Log.getStackTraceString(t); Log.e(TAG, logStr); } @@ -174,13 +179,17 @@ public static void deoptimizeMethod(Member deoptimizedMethod) { */ public static XC_MethodHook.Unhook hookMethod(Member hookMethod, XC_MethodHook callback) { if (!(hookMethod instanceof Executable)) { - throw new IllegalArgumentException("Only methods and constructors can be hooked: " + hookMethod); + throw new IllegalArgumentException("Only methods and constructors can be hooked, not " + hookMethod); } else if (Modifier.isAbstract(hookMethod.getModifiers())) { - throw new IllegalArgumentException("Cannot hook abstract methods: " + hookMethod); + throw new IllegalArgumentException(hookMethod + " is abstract: it has no body to hook. Hook the concrete override instead."); } else if (hookMethod.getDeclaringClass().getClassLoader() == XposedBridge.class.getClassLoader()) { - throw new IllegalArgumentException("Do not allow hooking inner methods"); + throw new IllegalArgumentException(hookMethod + " belongs to Vector itself. Hooking the framework would hook the code running your hook."); } else if (hookMethod.getDeclaringClass() == Method.class && hookMethod.getName().equals("invoke")) { - throw new IllegalArgumentException("Cannot hook Method.invoke"); + throw new IllegalArgumentException("Method.invoke cannot be hooked: Vector calls it to run the original of every hooked method, so a hook here would call itself forever."); + } else if (hookMethod.getDeclaringClass() == Object.class && hookMethod.getName().equals("getClass")) { + // Since AGP 9, R8 compiles Kotlin null checks into Object.getClass(), so the dispatch + // calls it entering every hooked method; a hook here re-enters the dispatch. See #798. + throw new IllegalArgumentException("Object.getClass cannot be hooked: Vector's dispatch calls it entering every hooked method, so a hook here would call itself forever."); } if (callback == null) { diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java b/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java index a3bf8dd26..969bd7e90 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedHelpers.java @@ -8,6 +8,7 @@ import org.apache.commons.lang3.ClassUtilsX; import org.apache.commons.lang3.reflect.MemberUtilsX; +import org.matrix.vector.nativebridge.HookBridge; import java.io.ByteArrayOutputStream; import java.io.FileInputStream; @@ -1149,12 +1150,11 @@ public static short getShortField(Object obj, String fieldName) { * Sets the value of a static object field in the given class. See also {@link #findField}. */ public static void setStaticObjectField(Class clazz, String fieldName, Object value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).set(null, value); + field.set(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1164,12 +1164,11 @@ public static void setStaticObjectField(Class clazz, String fieldName, Object * Sets the value of a static {@code boolean} field in the given class. See also {@link #findField}. */ public static void setStaticBooleanField(Class clazz, String fieldName, boolean value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setBoolean(null, value); + field.setBoolean(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1179,12 +1178,11 @@ public static void setStaticBooleanField(Class clazz, String fieldName, boole * Sets the value of a static {@code byte} field in the given class. See also {@link #findField}. */ public static void setStaticByteField(Class clazz, String fieldName, byte value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setByte(null, value); + field.setByte(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1194,12 +1192,11 @@ public static void setStaticByteField(Class clazz, String fieldName, byte val * Sets the value of a static {@code char} field in the given class. See also {@link #findField}. */ public static void setStaticCharField(Class clazz, String fieldName, char value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setChar(null, value); + field.setChar(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1209,12 +1206,11 @@ public static void setStaticCharField(Class clazz, String fieldName, char val * Sets the value of a static {@code double} field in the given class. See also {@link #findField}. */ public static void setStaticDoubleField(Class clazz, String fieldName, double value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setDouble(null, value); + field.setDouble(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1224,12 +1220,11 @@ public static void setStaticDoubleField(Class clazz, String fieldName, double * Sets the value of a static {@code float} field in the given class. See also {@link #findField}. */ public static void setStaticFloatField(Class clazz, String fieldName, float value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setFloat(null, value); + field.setFloat(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1239,12 +1234,11 @@ public static void setStaticFloatField(Class clazz, String fieldName, float v * Sets the value of a static {@code int} field in the given class. See also {@link #findField}. */ public static void setStaticIntField(Class clazz, String fieldName, int value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setInt(null, value); + field.setInt(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1254,12 +1248,11 @@ public static void setStaticIntField(Class clazz, String fieldName, int value * Sets the value of a static {@code long} field in the given class. See also {@link #findField}. */ public static void setStaticLongField(Class clazz, String fieldName, long value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setLong(null, value); + field.setLong(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1269,12 +1262,11 @@ public static void setStaticLongField(Class clazz, String fieldName, long val * Sets the value of a static {@code short} field in the given class. See also {@link #findField}. */ public static void setStaticShortField(Class clazz, String fieldName, short value) { + Field field = findField(clazz, fieldName); try { - findField(clazz, fieldName).setShort(null, value); + field.setShort(null, value); } catch (IllegalAccessException e) { - // should not happen - XposedBridge.log(e); - throw new IllegalAccessError(e.getMessage()); + setStaticFinalField(field, value, e); } catch (IllegalArgumentException e) { throw e; } @@ -1282,6 +1274,37 @@ public static void setStaticShortField(Class clazz, String fieldName, short v //################################################################################################# + /** + * Writes a static field reflection has just refused to write. + * + * Android 17 rejects every reflective write to a static final field, whatever the Field's + * accessible flag says, so on that release the nine setters above would do nothing but throw + * for a module doing what modules have always done -- spoofing android.os.Build being the + * common one. The framework drops the field's final flag and lets reflection write it after + * all, which is the whole of the difference: the value, the conversions and the type checking + * are still reflection's. + * + * Only reached once reflection has thrown, so nothing changes on the releases that allow the + * write, and a genuine access failure -- or a runtime this cannot read -- still ends in the + * IllegalAccessError it always did. + */ + private static void setStaticFinalField(Field field, Object value, IllegalAccessException cause) { + if (HookBridge.makeFieldWritable(field, field.getModifiers())) { + try { + // Boxed, whatever the field's type: Field.set unboxes for a primitive field and + // widens like the typed setter that has just failed would have. + field.set(null, value); + return; + } catch (IllegalAccessException retried) { + cause = retried; + } + } + XposedBridge.log(cause); + throw new IllegalAccessError(cause.getMessage()); + } + + //################################################################################################# + /** * Returns the value of a static object field in the given class. See also {@link #findField}. */ diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java index 9c23e3380..eddc5e477 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java @@ -24,8 +24,8 @@ import org.matrix.vector.impl.utils.VectorModuleClassLoader; import org.matrix.vector.nativebridge.NativeAPI; import org.matrix.vector.nativebridge.ResourcesHook; -import org.lsposed.lspd.models.PreLoadedApk; -import org.lsposed.lspd.util.Utils.Log; +import org.matrix.vector.ipc.ModuleCode; +import org.matrix.vector.util.Log; import java.io.File; import java.lang.ref.WeakReference; @@ -204,11 +204,11 @@ public static Map> getLoadedModules() { } public static void loadLegacyModules() { - var moduleList = VectorServiceClient.INSTANCE.getLegacyModulesList(); + var moduleList = VectorServiceClient.INSTANCE.getLegacyModules(); moduleList.forEach(module -> { var apk = module.apkPath; var name = module.packageName; - var file = module.file; + var file = module.code; loadedModules.put(name, Optional.of(apk)); // temporarily add it for XSharedPreference if (!loadModule(name, apk, file)) { loadedModules.remove(name); @@ -216,9 +216,16 @@ public static void loadLegacyModules() { }); } + private static final AtomicBoolean modulesLoaded = new AtomicBoolean(false); + public static void loadModules(ActivityThread at) { + // A late-injected system server calls this directly because its ActivityThread.attach + // already ran; guard so the normal path cannot load a second generation on top. + if (!modulesLoaded.compareAndSet(false, true)) { + return; + } var packages = (ArrayMap) XposedHelpers.getObjectField(at, "mPackages"); - VectorServiceClient.INSTANCE.getModulesList().forEach(module -> { + VectorServiceClient.INSTANCE.getModules().forEach(module -> { loadedModules.put(module.packageName, Optional.empty()); if (!VectorModuleManager.INSTANCE.loadModule(module, startsSystemServer, VectorServiceClient.INSTANCE.getProcessName())) { loadedModules.remove(module.packageName); @@ -280,10 +287,17 @@ private static boolean initModule(ClassLoader mcl, String apk, List modu * Load a module from an APK by calling the init(String) method for all classes defined * in assets/xposed_init. */ - private static boolean loadModule(String name, String apk, PreLoadedApk file) { + private static boolean loadModule(String name, String apk, ModuleCode file) { Log.v(TAG, "Loading legacy module " + name + " from " + apk); var sb = new StringBuilder(); + // In system_server the in-APK entries below can only ever be refused: /data/app is + // apk_data_file, which that domain may read and map but never execute. The daemon stages a + // copy under a label we own for exactly this reason, and it has to come first, because + // findLibrary answers with the first candidate it can open. + if (startsSystemServer && file.nativeLibraryDir != null) { + sb.append(file.nativeLibraryDir).append(File.pathSeparator); + } var abis = Process.is64Bit() ? Build.SUPPORTED_64_BIT_ABIS : Build.SUPPORTED_32_BIT_ABIS; for (String abi : abis) { sb.append(apk).append("!/lib/").append(abi).append(File.pathSeparator); diff --git a/legacy/src/main/java/org/matrix/vector/Startup.java b/legacy/src/main/java/org/matrix/vector/Startup.java index f47fff407..5ca637175 100644 --- a/legacy/src/main/java/org/matrix/vector/Startup.java +++ b/legacy/src/main/java/org/matrix/vector/Startup.java @@ -1,7 +1,7 @@ package org.matrix.vector; -import org.lsposed.lspd.service.ILSPApplicationService; -import org.lsposed.lspd.util.Utils; +import org.matrix.vector.ipc.IFrameworkService; +import org.matrix.vector.util.Utils; import org.matrix.vector.impl.core.VectorStartup; import org.matrix.vector.impl.di.VectorBootstrap; import org.matrix.vector.legacy.LegacyDelegateImpl; @@ -20,7 +20,7 @@ public static void bootstrapXposed(boolean systemServerStarted) { } } - public static void initXposed(boolean isSystem, String processName, String appDir, ILSPApplicationService service) { + public static void initXposed(boolean isSystem, String processName, String appDir, IFrameworkService service) { // Establish the Dependency Injection contract VectorBootstrap.INSTANCE.init(new LegacyDelegateImpl()); diff --git a/legacy/src/main/java/org/matrix/vector/legacy/LegacyDelegateImpl.java b/legacy/src/main/java/org/matrix/vector/legacy/LegacyDelegateImpl.java index 885d40878..8500d52dc 100644 --- a/legacy/src/main/java/org/matrix/vector/legacy/LegacyDelegateImpl.java +++ b/legacy/src/main/java/org/matrix/vector/legacy/LegacyDelegateImpl.java @@ -2,7 +2,7 @@ import android.content.res.XResources; -import org.lsposed.lspd.util.Utils; +import org.matrix.vector.util.Utils; import org.matrix.vector.impl.core.VectorServiceClient; import org.matrix.vector.impl.di.LegacyFrameworkDelegate; import org.matrix.vector.impl.di.LegacyPackageInfo; diff --git a/magisk-loader/update/changelog.md b/magisk-loader/update/changelog.md deleted file mode 100644 index a5bb3a2fd..000000000 --- a/magisk-loader/update/changelog.md +++ /dev/null @@ -1,16 +0,0 @@ -🎉 **Release: Vector 2.0** 🎉 - -Welcome to Vector 2.0! As part of our ongoing transition, the project has officially been renamed from `LSPosed` to `Vector`. While our major internal refactoring is still underway, we are releasing 2.0 now to provide a stable, feature-complete environment for those relying on legacy libxposed APIs. - -### 📚 libxposed API 100 & 101 -With the recent publication of libxposed API 101, the ecosystem is moving toward a new standard with significant breaking changes. Because API 100 was never officially published, **Vector 2.0 serves as the definitive implementation of the API 100 era**, built from the exact commit prior to the API 101 jump. - -### 🏗️ Architecture & API Updates -* **Vector & Zygisk Overhaul:** Officially renamed and modularized the project, featuring a completely rewritten, modern Zygisk architecture. -* **API 100 Finalization:** Completed all remaining libxposed API 100 features, including comprehensive support for static initializers, constructor hooking, and centralized logging. - - -### ⚙️ Core Engine & System Enhancements -* 🔓 **Bypassed Bionic `LD_PRELOAD` Restrictions:** Resolved fatal namespace errors on Android 10 by loading the `dex2oat` hook library via a `memfd_create` tmpfs-backed file descriptor, bypassing the linker's namespace checks. -* 🛡️ **Reflection Parity Overhaul:** Completely rebuilt the `invokeSpecialMethod` backend to improve performance, enhance robustness, and mirror standard Java reflection behavior. -* ⏱️ **Late Injection Standalone Launch:** Added native support for manual late injection (triggered by NeoZygisk), without relying on Magisk's early-init phase—highly useful for AOSP debug builds. diff --git a/magisk-loader/update/zygisk.json b/magisk-loader/update/zygisk.json deleted file mode 100644 index 96fc577a8..000000000 --- a/magisk-loader/update/zygisk.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": "v2.0", - "versionCode": 3021, - "zipUrl": "https://github.com/JingMatrix/LSPosed/releases/download/v2.0/Vector-v2.0-Release.zip", - "changelog": "https://raw.githubusercontent.com/JingMatrix/LSPosed/master/zygisk/changelog.md" -} diff --git a/manager/README.md b/manager/README.md new file mode 100644 index 000000000..be6613108 --- /dev/null +++ b/manager/README.md @@ -0,0 +1,106 @@ +# Vector Manager + +The manager app: Jetpack Compose, one activity, configuring the root daemon over Binder. It holds +no privilege of its own — everything it does to the device, it asks the daemon to do. It replaces +`:app`, which is deleted. + +This file covers what the code cannot tell you on its own: the constraints it is built around, and +the places where a mistake fails silently rather than loudly. The rest is in the code. + +## The parasitic model + +The manager normally runs injected into `com.android.shell` rather than installed. Its +`AndroidManifest.xml` is never registered, so nothing declared there exists at runtime: no +`ContentProvider`, and therefore no `androidx.startup` and nothing that self-registers through +`InitializationProvider`; no `FileProvider`; and no per-app language API, since +`setApplicationLocales` is keyed on an installed package. The language override is applied in +composition instead, which is why it takes effect without a restart. + +Everything is therefore initialised explicitly, from the activity. The same APK also installs as an +ordinary app for development, and both modes have to work — so anything that assumes one of them is +a bug waiting for the other. + +Its memory is `com.android.shell`'s memory. That is the reason behind decisions that would look +paranoid in a normal app: the log reader indexes byte offsets and pages a window rather than +holding a file, and the module scan is cached rather than repeated. + +## How the binder arrives + +The framework loads `.Constants` out of the injected dex by reflection and calls the +static `setBinder(IBinder)`. Nothing in this APK calls it, so R8 is told to keep it in +`proguard-rules.pro`. Rename that class or method and the handshake breaks at runtime with no +compile error anywhere — the app simply comes up reporting no framework. + +Order is not fixed. The binder can arrive before the activity exists, or the activity can start +before any binder does. `ServiceLocator.attach()` is idempotent and `bind()` is a plain assignment +to a `StateFlow`, so either order is safe, and repositories collect that flow rather than being +handed a binder — a late arrival, or a reconnection, makes them re-read instead of leaving them +with whatever they managed to fetch before there was a daemon. + +## Talking to the daemon + +`ipc/DaemonClient` wraps every AIDL call in `runIpc`, which moves it to `Dispatchers.IO` and returns +a `Result`. The interface is +`services/manager-service/src/main/aidl/org/matrix/vector/ipc/IManagerService.aidl`, and it is the +source of truth for what each call means: read the method's documentation there before calling it. + +Two properties of Binder shape most of the mistakes made here, and the AIDL spells out what each +method does about them. A proxy returns a *default* for a transaction the daemon does not implement +rather than throwing, so `0`, `null` and empty are indistinguishable from real answers — see +`getProtocolVersion` and `ROOT_UNKNOWN` there. And a call that succeeded is not a call that did +anything: several of these return a `boolean` the daemon uses to refuse, and dropping it turns a +refusal into a silent success. + +The daemon owns the truth. When a write and a read disagree, the read is usually coming from the +daemon's asynchronous cache while the write went to its database. + +## Logging + +Log under `Constants.TAG`, and only under it. `daemon/src/main/jni/logcat.cpp` routes any tag +beginning `Vector` into the daemon's verbose stream, so those lines reach the Logs screen and travel +in the zip export a user attaches to a report. A file-local tag is ordinary Android practice and +would land nowhere. The conventions — prefixes, levels, what never belongs in a message — are on +`Constants.TAG` itself. + +Crashes are written to `cacheDir/crash` because that is where `FileSystem.getLogs` already collects +them from, in both of the manager's homes. + +## Strings + +`res/values/strings.xml`, `strings_logs.xml` and `strings_store.xml`, translated into 18 locales. +`crowdin.yml` points at this module and at the daemon's, and `manager/build.gradle.kts` merges +`../daemon/src/main/res`, so a name collision between the two is a build error. + +Nothing user-visible is hard-coded in a composable, and identifiers that must not be translated +carry `translatable="false"`. The build scans `values-*` folders containing a `strings.xml` to +produce `BuildConfig.TRANSLATIONS`, so a locale Crowdin adds needs no code change. + +Changing what a string *means* needs a new key. Reword it in place and eighteen translations go on +asserting the old meaning until someone notices, which can be a long time. + +## Building and running + +```sh +./gradlew :manager:assembleDebug # the APK +./gradlew :zygisk:zipDebug # the module zip, which contains it +./gradlew ktfmtFormat # formatting is ktfmt; CI does not check it +``` + +The version code is `git rev-list --count refs/remotes/origin/master`, so a branch build and a +master build can share one; `module.prop` and the status page carry a build stamp, which is often +the only way to tell two builds apart on a device. It names where the build came from as well as +what it was built from. The commit leads and what follows says where: `93d66473-JingMatrix-Vector` +for a CI build, the bare `93d66473` for a local one, and `93d66473+thinkpad` — the machine that made +it — when the tree was not clean. + +Debug builds add a second launcher activity — a demo mode with scripted device states, in +`src/debug` and absent from release builds, so it cannot be used to make a release report a healthy +framework. It does mean `monkey -c LAUNCHER` picks one of the two at random: + +```sh +adb shell am start -n org.matrix.vector.manager/.ui.MainActivity +``` + +There is no test source set anywhere in this repository, and CI runs `zipAll` and nothing else. A +green tick means it compiles and packages. Everything else is verified by running it against a real +daemon on a device. diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts new file mode 100644 index 000000000..b42d4e705 --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,164 @@ +plugins { + alias(libs.plugins.agp.app) + // Kotlin itself comes from AGP 9's built-in support — applying + // org.jetbrains.kotlin.android is an error since AGP 9.0. Its *version* is taken from + // the Kotlin plugin on the buildscript classpath, which the root build pins to the + // catalog's version (declared there with `apply false`). That matters here: Coil 3.5 + // ships class metadata an older compiler refuses to read. + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ktfmt) +} + +ktfmt { kotlinLangStyle() } + +kotlin { + compilerOptions { + // Material 3 Expressive has not landed in a stable material3 release; the + // expressive surface is gated behind these annotations even in 1.5.0-alpha25. + // Opting in once here beats sprinkling @OptIn through every screen. + optIn.addAll( + "androidx.compose.material3.ExperimentalMaterial3Api", + "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", + "androidx.compose.animation.ExperimentalSharedTransitionApi", + "androidx.compose.foundation.layout.ExperimentalLayoutApi", + ) + } +} + +// The daemon compiles this module's signing certificate into SignInfo.kt and verifies +// the manager.apk it serves against it at runtime, so :manager must be signed with the +// same key as the rest of the module or InstallerVerifier rejects it. +// +// This is what org.lsposed.lsplugin.apksign did, written out: the same four Gradle properties, a +// store file resolved against the root project, the same signing config on every build type, and +// the same fall back to the debug key when there is no keystore -- which is every build that is +// not CI's, since the workflow appends these properties to gradle.properties itself. The plugin +// read them through Project.getProperties, deprecated in Gradle 9 and gone in Gradle 10, and 1.4 +// is its last release, so the deprecation warning it printed four times per build had no version +// to upgrade to. +val keystore = providers.gradleProperty("androidStoreFile").map { rootProject.file(it) }.orNull +val signed = keystore?.exists() == true + +if (!signed) { + // `info` rather than a print: a local build has no keystore by design and says so on every + // single run, which is noise in the one place a real warning has to be noticed. + logger.info( + "No keystore at ${keystore?.absolutePath ?: "androidStoreFile"}; signing with debug" + ) +} + +val defaultManagerPackageName = rootProject.extra["defaultManagerPackageName"] as String +val injectedPackageName = rootProject.extra["injectedPackageName"] as String +@Suppress("UNCHECKED_CAST") +val versionHashProvider = rootProject.extra["versionHashProvider"] as Provider + +android { + namespace = defaultManagerPackageName + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + applicationId = defaultManagerPackageName + // Which build this manager is: the repository on CI, the machine when built locally from + // a modified tree, and the short commit either way. See GitCommitHashValueSource. + // The version code is `git rev-list --count origin/master`, so a branch build and the + // official build of the same depth are indistinguishable by number — and the manager and + // the daemon are flashed separately, so they can be different builds of the same number. + // This is what tells them apart, and the status page shows both. + buildConfigField("String", "VERSION_HASH", """"${versionHashProvider.get()}"""") + buildConfigField("String", "MANAGER_PACKAGE_NAME", "\"$defaultManagerPackageName\"") + buildConfigField("String", "INJECTED_PACKAGE_NAME", "\"$injectedPackageName\"") + + // The languages this module is actually translated into, listed from the resource folders + // that carry our own strings.xml. AssetManager.getLocales() cannot answer this: it reports + // every locale any dependency ships a resource for — AndroidX alone drags in dozens — plus + // the pseudo-locales, so a picker built from it offers languages the app has never seen. + // + // English is added by hand because it is not in a `values-xx` folder to be found: it lives + // in `values/`, the base the others fall back to. Scanning alone therefore listed every + // language the app has *except* the one it is written in — and a picker without English is + // one that someone who switched to Polish to see how it looked cannot use to switch back. + val translations = + (listOf("en") + + file("src/main/res") + .listFiles() + .orEmpty() + .filter { it.isDirectory && it.name.startsWith("values-") } + .filter { File(it, "strings.xml").exists() } + .map { it.name.removePrefix("values-").replace("-r", "-") }) + .sorted() + buildConfigField("String", "TRANSLATIONS", "\"${translations.joinToString(",")}\"") + } + + // ic_launcher.xml references @drawable/ic_statue_monochrome, which lives in the + // daemon's resources. Any name collision between the two resource sets becomes a + // build error, so keep additions on the daemon side namespaced. + sourceSets { getByName("main") { res.directories.add("../daemon/src/main/res") } } + + packaging { + resources { + excludes += "META-INF/**" + // Java resources only, so it is inert against the Android artifact, which ships its + // public suffix list under assets/. Pinning the JVM variant would move that list back + // to okhttp3/internal/publicsuffix/ and this line would then delete it. + excludes += "okhttp3/**" + excludes += "kotlin/**" + excludes += "**.properties" + excludes += "**.bin" + } + } + + dependenciesInfo.includeInApk = false + + if (signed) { + signingConfigs.create("apksign") { + storeFile = keystore + storePassword = providers.gradleProperty("androidStorePassword").orNull + keyAlias = providers.gradleProperty("androidKeyAlias").orNull + keyPassword = providers.gradleProperty("androidKeyPassword").orNull + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles("proguard-rules.pro") + } + // Every build type, not just release: the daemon reads this config back off whichever + // variant it is building to embed the certificate, so debug has to carry one too. + configureEach { + signingConfig = signingConfigs.getByName(if (signed) "apksign" else "debug") + } + } +} + +dependencies { + implementation(projects.services.managerService) + + implementation(libs.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.dnsoverhttps) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.webkit) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.kotlinx.coroutines.android) + + // The Compose BOM aligns every androidx.compose.* artifact; none of them is + // pinned individually in the version catalog. + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + // Tooling dependencies, debug builds only, for UI previews. + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/manager/proguard-rules.pro b/manager/proguard-rules.pro new file mode 100644 index 000000000..d56c113ca --- /dev/null +++ b/manager/proguard-rules.pro @@ -0,0 +1,61 @@ +# The zygisk hooker reaches the manager entirely by reflection: it loads +# ".Constants" out of the injected dex and invokes the static +# setBinder(IBinder) on it. Neither the class nor the method has a call site inside +# this APK, so R8 would otherwise remove or rename both and the binder handshake +# would fail silently at runtime. +-keep class org.matrix.vector.manager.Constants { + public static boolean setBinder(android.os.IBinder); +} + +# ParasiticManagerHooker redirects the resolved activity to this class by name. +-keep class org.matrix.vector.manager.ui.MainActivity { (); } + +# AIDL stubs and the parcelables crossing the daemon boundary. +-keep class org.matrix.vector.ipc.** { *; } +-keep class rikka.parcelablelist.** { *; } + +# kotlinx.serialization keeps generated serializers reachable from the companion. +-keepclassmembers class **$$serializer { *** descriptor; } +-keepclasseswithmembers class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Gson models are constructed reflectively from field names. +-keepclassmembers class org.matrix.vector.manager.data.model.** { ; } + +# An enum's constants are reached reflectively: Enum.valueOf asks the class for its values() +# method by name. Kotlin no longer calls that method itself — `entries` compiles to its own +# synthetic field — so the last call site is usually gone and R8 shrinks values() away, which +# leaves every enum in the APK undeserializable. Compose saved instance state is what reaches it +# here: Parcel has no enum case and java.lang.Enum is Serializable, so a saved enum is written +# as VAL_SERIALIZABLE, and restoring the activity after its process died threw +# NoSuchMethodException on the navigation suite's own state value (#871). AGP's +# proguard-android-optimize.txt carries this stanza, but that file has not been on the +# proguardFiles list since #263, so it has to be written out here. +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# The same restore path reaches Parcelables, and finds their CREATOR by a reflective field lookup +# that R8 cannot see either, so it drops the field from every class that does not otherwise +# reference it. Compose keeps its own state in one: a `mutableStateOf` that survives process death +# is a ParcelableSnapshotMutableState, and reading it back threw BadParcelableException. The legacy +# manager carried this rule by hand; the rewrite in #796 did not bring it across. +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +# OkHttp / Okio ship analysis-only references to optional platform classes. +-dontwarn okhttp3.internal.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** + +# androidx.window compiles against the OEM window extensions and the older sidecar +# interface. Neither ships in the SDK — they are provided by the device at runtime, and +# on a device that has neither the library falls back — so R8 sees the references as +# unresolvable and refuses to complete. The navigation suite scaffold pulls the library +# in, so the manager inherits them whether or not it ever asks about a folding screen. +-dontwarn androidx.window.extensions.** +-dontwarn androidx.window.sidecar.** diff --git a/manager/src/debug/AndroidManifest.xml b/manager/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..65019ecde --- /dev/null +++ b/manager/src/debug/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt new file mode 100644 index 000000000..ccba13ef4 --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt @@ -0,0 +1,157 @@ +package org.matrix.vector.manager.demo + +import kotlinx.coroutines.launch +import androidx.lifecycle.lifecycleScope +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.VectorApp +import org.matrix.vector.manager.ui.theme.LocalizedContent +import org.matrix.vector.manager.ui.theme.VectorTheme + +/** + * The way in to the scripted states, and the reason this whole thing is a separate source set. + * + * It exists only in `src/debug`, so a release build does not merely branch around it — there is no + * such class to compile and no such activity in the merged manifest. That distinction matters more + * here than in an ordinary app: a demo mode that could be switched on in a release build would be a + * way to make the manager report the framework as healthy when it is not, which is the one lie this + * app must never be able to tell. A reviewer can confirm it by looking for + * `org.matrix.vector.manager.demo` in a release APK's classes and finding nothing. + * + * It hosts the app itself rather than launching MainActivity, and that is not a stylistic choice. + * The first version did launch it, and every scenario silently did nothing: `ParasiticManagerHooker` + * intercepts the manager activity starting and hands the *real* binder to `Constants.setBinder`, + * overwriting whatever was bound a moment earlier. Even "no daemon at all" came up reporting a + * healthy framework — the failure mode a test harness can least afford, since it looks like a pass. + * Rendering VectorApp here means no manager activity is ever launched, so nothing re-binds behind + * us. + */ +class DemoActivity : ComponentActivity() { + + /** + * Captured before anything is bound, so a scenario can delegate what it does not script. + * + * Frequently null: the hooker sends the binder when the app's class loader is first asked for, + * which happens after this activity is constructed. That is fine — a scenario with no real + * daemon behind it simply has empty lists, and the scripted answers are the point. + */ + private val realService = ServiceLocator.service.value + + /** + * What the demo insists the binder is, and whether it is currently insisting. + * + * `ParasiticManagerHooker` hands the real binder to `Constants.setBinder` when the manager's + * class loader is first obtained — which is *after* a scenario has been chosen, so a single + * bind was quietly undone a moment later and every scenario reported a healthy framework. This + * re-asserts the choice whenever something else replaces it. It settles immediately: the next + * emission is the pinned value, which the collector then ignores. + */ + private var pinned: IManagerService? = null + + private var pinning = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + ServiceLocator.attach(this) + + lifecycleScope.launch { + ServiceLocator.service.collect { current -> + if (pinning && current !== pinned) ServiceLocator.bind(pinned) + } + } + + setContent { + var scenario by remember { mutableStateOf(null) } + + if (scenario == null) { + VectorTheme { ScenarioList { picked -> scenario = install(picked) } } + } else { + // Back returns to the picker rather than leaving, so trying six states in a row is + // not six trips through the launcher. The real binder goes back on the way out, so + // the app is never left holding a fake after the demo is done with it. + BackHandler { + // Stop insisting first, or the collector would immediately undo this. + pinning = false + ServiceLocator.bind(realService) + scenario = null + } + LocalizedContent { VectorTheme { VectorApp() } } + } + } + } + + private fun install(scenario: DemoScenario): DemoScenario { + if (scenario.id == "healthy") { + // Deliberately does not bind: realService is usually null here, and forcing that would + // break the app rather than restore it. Letting go is enough — whatever the hooker + // bound is the real thing. + pinning = false + pinned = null + return scenario + } + pinned = + if (!scenario.connected) null else FakeManagerService(scenario, realService) + pinning = true + ServiceLocator.bind(pinned) + return scenario + } +} + +@Composable +private fun ScenarioList(onPick: (DemoScenario) -> Unit) { + Scaffold(modifier = Modifier.fillMaxSize()) { padding -> + LazyColumn(modifier = Modifier.padding(padding)) { + item { + Column(Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp)) { + Text( + "Demo states", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Device states that cannot be reached without breaking the phone. " + + "The scripted answers stop at the binder; everything above it is " + + "real. Back returns here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider() + } + items(DEMO_SCENARIOS, key = { it.id }) { scenario -> + ListItem( + modifier = Modifier.clickable { onPick(scenario) }, + supportingContent = { Text(scenario.summary) }, + ) { Text(scenario.title) } + } + } + } +} diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt new file mode 100644 index 000000000..b407e9e26 --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt @@ -0,0 +1,199 @@ +package org.matrix.vector.manager.demo + +import org.matrix.vector.ipc.IManagerService + +/** + * A device state the manager cannot otherwise be shown. + * + * Only states that need a *broken or unusual system* are here. Anything reachable by using the app + * normally — an empty search, airplane mode, a module with nothing selected — is deliberately + * absent: those get found the first day a build is in anyone's hands, and scripting them would be + * upkeep with no return. + * + * Everything downstream of the binder runs for real against these values: the status derivation, + * the issue list, the update logic, the install screen. This scripts what the *device* says, not + * what the UI shows, so a bug in how the manager reacts is still a bug you can see here. + */ +data class DemoScenario( + val id: String, + val title: String, + val summary: String, + + /** False binds nothing at all, which is how the framework reads as not activated. */ + val connected: Boolean = true, + + /** Delay on every status call. Non-zero is the only way to hold "Checking…" still. */ + val stallMillis: Long = 0, + val sepolicyLoaded: Boolean = true, + val systemServerAttached: Boolean = true, + val dex2OatInliningDisabled: Boolean = true, + val dex2OatWrapperState: Int = IManagerService.DEX2OAT_OK, + + /** + * What the framework claims to implement. + * + * Lowering it is how a module becomes incompatible without fabricating a module: the real ones + * on the device declare a real minimum, and the framework simply stops meeting it. + */ + val libxposedApiVersion: Int = -1, + val frameworkVersionCode: Long = -1, + val rootImplementation: Int = IManagerService.ROOT_MAGISK, + val install: InstallScript = InstallScript.SUCCEEDS, + + /** + * What version the device claims its installed modules are. + * + * The same trick the framework update uses, one level down: whether a module is out of date is + * decided by comparing the store catalogue against the version the *daemon* reports, so + * reporting an old one turns every module the catalogue knows into an update. The catalogue, + * the releases and the APKs are all genuinely the store's — the only lie is the number this + * device claims to be on, which is the one thing that cannot be arranged without keeping a + * stack of outdated module APKs around to install. + */ + val moduleVersions: ModuleVersionScript = ModuleVersionScript.REAL, +) { + + /** Whether installed module versions are passed through or rewritten. */ + enum class ModuleVersionScript { + REAL, + OUTDATED, + } + + /** + * How a flash behaves when the install screen asks for one. + * + * Reachable through this seam after all, which was not obvious: whether an update *exists* is + * decided by comparing the release list against the installed version code — and that version + * comes from the daemon, not from GitHub. Reporting an old one is enough to make a real + * release look like an update, so the whole flow can be exercised without faking any network + * traffic. The release list itself is genuinely GitHub's, which makes this closer to the real + * thing than a canned one would be. + */ + enum class InstallScript { + SUCCEEDS, + + /** + * Fails after output has already been streamed. + * + * The one worth having: a flash that fails *before* it starts is a message, but a flash + * that dies halfway leaves the module tree in whatever state the installer reached, and + * that is the case the screen has to report usefully. + */ + FAILS_PARTWAY, + + /** Refused outright, with no output. */ + NO_ROOT, + } + + companion object { + /** -1 means "whatever the real daemon says", so a scenario only lies where it means to. */ + const val PASS_THROUGH = -1 + } +} + +/** + * The menu. + * + * Ordered by how hard the state is to reach honestly, not by severity: the ones at the top cannot + * be produced on a working phone at all. + */ +val DEMO_SCENARIOS: List = + listOf( + DemoScenario( + id = "healthy", + title = "Healthy", + summary = "Pass everything through to the real daemon. The way back.", + ), + DemoScenario( + id = "sepolicy", + title = "SELinux policy not loaded", + summary = "Degraded, one cause. Needs a root implementation that skipped our rules.", + sepolicyLoaded = false, + ), + DemoScenario( + id = "system-server", + title = "System framework injection failed", + summary = "Degraded, one cause. Normally needs another root module interfering.", + systemServerAttached = false, + ), + DemoScenario( + id = "dex2oat", + title = "Dex optimizer wrapper unavailable", + summary = "Degraded, one cause. Needs system properties removed or changed.", + dex2OatInliningDisabled = false, + dex2OatWrapperState = IManagerService.DEX2OAT_MOUNT_FAILED, + ), + DemoScenario( + id = "all-issues", + title = "All three causes at once", + summary = "Whether the issue list reads as a list or as a wall.", + sepolicyLoaded = false, + systemServerAttached = false, + dex2OatInliningDisabled = false, + dex2OatWrapperState = IManagerService.DEX2OAT_SEPOLICY_INCORRECT, + ), + DemoScenario( + id = "inactive", + title = "Framework not activated", + summary = "No daemon at all. Every screen that needs one has to say so.", + connected = false, + ), + DemoScenario( + id = "checking", + title = "Checking, held still", + summary = "The transient state on arrival, stalled for eight seconds.", + stallMillis = 8_000, + ), + DemoScenario( + id = "api-too-old", + title = "Framework below what modules need", + summary = "API 82. Installed modules that need more become incompatible.", + libxposedApiVersion = 82, + ), + DemoScenario( + id = "root-none", + title = "No root implementation", + summary = "Nothing to flash through. The install path must refuse, not fail.", + rootImplementation = IManagerService.ROOT_NONE, + install = DemoScenario.InstallScript.NO_ROOT, + ), + DemoScenario( + id = "root-multiple", + title = "Two root implementations fighting", + summary = "Flashing through either would be a guess, and must be named as such.", + rootImplementation = IManagerService.ROOT_MULTIPLE, + install = DemoScenario.InstallScript.NO_ROOT, + ), + DemoScenario( + id = "root-ksu", + title = "KernelSU", + summary = "The install path quotes the implementation it found.", + rootImplementation = IManagerService.ROOT_KERNELSU, + ), + DemoScenario( + id = "root-apatch", + title = "APatch", + summary = "As above, third implementation.", + rootImplementation = IManagerService.ROOT_APATCH, + ), + DemoScenario( + id = "update-available", + title = "An update is available", + summary = "Reports version 1, so a real release becomes an update. Shows the picker.", + frameworkVersionCode = 1, + ), + DemoScenario( + id = "install-fails", + title = "Flash that dies halfway", + summary = "Output already streamed, then a non-zero exit. The case that bites.", + frameworkVersionCode = 1, + install = DemoScenario.InstallScript.FAILS_PARTWAY, + ), + DemoScenario( + id = "modules-outdated", + title = "Every module is out of date", + summary = + "Reports old versions, so the store is ahead of all of them. Installs are real.", + moduleVersions = DemoScenario.ModuleVersionScript.OUTDATED, + ), + ) diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt new file mode 100644 index 000000000..a0e7964f0 --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt @@ -0,0 +1,321 @@ +package org.matrix.vector.manager.demo + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.ResolveInfo +import android.os.Build +import android.os.ParcelFileDescriptor +import org.matrix.vector.ipc.DeviceUser +import org.matrix.vector.ipc.IFrameworkInstallReceiver +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.ipc.ModuleLoadFailure +import org.matrix.vector.ipc.ScopeEntry +import rikka.parcelablelist.ParcelableListSlice +import org.matrix.vector.manager.data.model.versionCodeCompat + +/** + * The daemon, as a script. + * + * The fake sits at the *binder*, which is the boundary between this app and the privileged system, + * and is the reason a demo mode is worth having at all: everything from here inwards — DaemonClient, + * the repositories, the view models, the status derivation that turns three booleans into an issue + * list — runs exactly as it does in production. What is faked is what the device says about itself, + * not what the manager concludes. A bug in the concluding is still visible. + * + * Two other seams were considered and rejected. Faking a repository would have meant the status + * derivation never ran, which is the code most likely to be wrong. Faking a view model would have + * meant testing the screen against a pipeline that was not there — and the bugs this week lived in + * the pipeline, not the screen. + * + * Anything the scenario does not have an opinion about is delegated to the real daemon when one is + * connected, so the module list, the app list and the logs stay real while the framework's health + * is a lie. With no daemon present the delegating calls return empties rather than throwing, so the + * demo is still usable on a device with no Vector installed. + * + * Subclassing `Stub()` rather than proxying the interface is deliberate on both counts: DaemonClient + * checks `asBinder().isBinderAlive`, which only a real Binder answers — and a new AIDL method breaks + * this file's compilation, which is the point. A fake that silently kept working while the daemon + * grew a new question would quietly stop covering it. + */ +class FakeManagerService( + private val scenario: DemoScenario, + private val real: IManagerService?, +) : IManagerService.Stub() { + + /** + * What each package's version was when the scenario started. + * + * The scenario claims everything is out of date, and something has to decide when to stop + * claiming it. Recording the first answer per package and comparing against it means the lie + * ends exactly when the package actually changes — so an install performed by the manager is + * visible in the manager, which is the behaviour worth testing here. + */ + private val baselineVersions = java.util.concurrent.ConcurrentHashMap() + + private fun stall() { + if (scenario.stallMillis > 0) Thread.sleep(scenario.stallMillis) + } + + /** + * Neither scripted nor delegated. + * + * This class *is* this build's `Stub`, so the generation it answers to is the one this file was + * compiled against — the same answer the daemon of this build gives. Passing the real daemon's + * number through would report a peer's protocol for a peer that is not the one on the other end + * of these transactions. + */ + override fun getProtocolVersion(): Int = IManagerService.PROTOCOL_VERSION + + // ---- what the scenario exists to lie about ------------------------------------------------ + + override fun isSepolicyLoaded(): Boolean { + stall() + return scenario.sepolicyLoaded + } + + override fun isSystemServerAttached(): Boolean { + stall() + return scenario.systemServerAttached + } + + override fun isDex2OatInliningDisabled(): Boolean { + stall() + return scenario.dex2OatInliningDisabled + } + + override fun getDex2OatWrapperState(): Int = scenario.dex2OatWrapperState + + override fun getLibxposedApiVersion(): Int = + scenario.libxposedApiVersion.takeIf { it != DemoScenario.PASS_THROUGH } + ?: real?.libxposedApiVersion + ?: 0 + + override fun getFrameworkVersionCode(): Long = + scenario.frameworkVersionCode.takeIf { it != DemoScenario.PASS_THROUGH.toLong() } + ?: real?.frameworkVersionCode + ?: 0L + + override fun getRootImplementation(): Int = scenario.rootImplementation + + /** + * Passed through, because a scenario that lied about the build stamp would be testing the + * *mismatch* warning rather than the states this harness exists for. Add a field here when + * there is a scenario that needs one. + */ + override fun getBuildStamp(): String? = real?.buildStamp + + + /** + * A flash, without a flash. + * + * Emits on its own thread and never blocks the caller, because the real one does not either — + * a screen that only works when the lines arrive on the binder thread would pass here and hang + * on a device. + */ + override fun installFrameworkZip(zipPath: String?, receiver: IFrameworkInstallReceiver?) { + if (receiver == null) return + Thread { + fun say(line: String) { + runCatching { receiver.onLine(line) } + Thread.sleep(220) + } + when (scenario.install) { + DemoScenario.InstallScript.NO_ROOT -> { + runCatching { + receiver.onFinished(IFrameworkInstallReceiver.INSTALL_NO_ROOT) + } + } + DemoScenario.InstallScript.SUCCEEDS -> { + say("- Target: $zipPath") + say("- Extracting module files") + say("- Device is arm64-v8a API 36") + say("- Installing Vector") + say("- Setting permissions") + say("- Done. Reboot to apply.") + runCatching { receiver.onFinished(0) } + } + DemoScenario.InstallScript.FAILS_PARTWAY -> { + say("- Target: $zipPath") + say("- Extracting module files") + say("- Device is arm64-v8a API 36") + say("- Installing Vector") + say("! Failed to copy zygisk binary: No space left on device") + runCatching { receiver.onFinished(1) } + } + } + } + .start() + } + + // ---- everything else is the real device, when there is one --------------------------------- + + /** + * The installed package list, optionally rewritten to look old. + * + * This one call is where "is there an update for this module" is really decided: the catalogue + * says what the newest version is, and the comparison is against what this returns. Reporting + * a low version here is therefore the whole of the "modules are out of date" scenario, and it + * has the property that makes these scenarios worth having — nothing downstream is faked. The + * catalogue is the real one, the releases are real, the APK that gets installed is real, and so + * is the install. + * + * The rewrite is applied to every package rather than to modules alone, because telling them + * apart means opening APKs and this is the daemon's side of the wire, where that answer is not + * known. The visible cost is that the demo's module rows all read `0.1-demo` — which is the + * signal that the scenario is on, and no worse than the arbitrary number it replaces. + */ + override fun getInstalledPackagesFromAllUsers( + flags: Int, + filterNoProcess: Boolean, + ): ParcelableListSlice { + val actual = + real?.getInstalledPackagesFromAllUsers(flags, filterNoProcess) + ?: return ParcelableListSlice(emptyList()) + if (scenario.moduleVersions == DemoScenario.ModuleVersionScript.REAL) return actual + // Mutated in place: these are already unparcelled copies belonging to this process, not the + // daemon's own objects. + val rewritten = + actual.list.map { info -> + val baseline = baselineVersions.putIfAbsent(info.packageName, info.versionCodeCompat) + if (baseline != null && baseline != info.versionCodeCompat) { + // This one has genuinely changed under us since the scenario started, which + // for a demo means the manager just installed it. Reporting the truth from + // here is what makes this a test rather than a picture: the row has to stop + // being out of date, the count has to drop, and the panel has to notice + // without being left and re-entered. Keep lying and the install always looks + // like it did nothing. + return@map info + } + info.also { + it.setVersionCodeCompat(1) + it.versionName = "0.1-demo" + } + } + return ParcelableListSlice(rewritten) + } + + override fun getEnabledModules(): MutableList = real?.enabledModules ?: mutableListOf() + + /** + * The empty list is the whole answer for a device with nothing wrong: a module absent from it + * loaded, so no daemon means nothing to report rather than a state to invent. + */ + override fun getModuleLoadFailures(): MutableList = + real?.moduleLoadFailures ?: mutableListOf() + + override fun setModuleEnabled(packageName: String?, enabled: Boolean): Boolean = + real?.setModuleEnabled(packageName, enabled) ?: false + + override fun setModuleScope(packageName: String?, scope: MutableList?): Boolean = + real?.setModuleScope(packageName, scope) ?: false + + /** + * Null is handed on rather than flattened, because the daemon answers it only for the + * framework's own pseudo-module row, which is not the same answer as a module with nothing + * scoped to it — and a fake that collapsed the two would hide a refusal from the very code this + * demo exists to exercise. The empty list is the no-daemon answer alone. + */ + override fun getModuleScope(packageName: String?): MutableList? = + if (real == null) mutableListOf() else real.getModuleScope(packageName) + + override fun isVerboseLogEnabled(): Boolean = real?.isVerboseLogEnabled ?: false + + override fun setVerboseLogEnabled(enabled: Boolean) { + real?.setVerboseLogEnabled(enabled) + } + + override fun getLiveLogPart(verbose: Boolean): ParcelFileDescriptor? = + real?.getLiveLogPart(verbose) + + override fun getLogParts(verbose: Boolean): MutableList = + real?.getLogParts(verbose) ?: mutableListOf() + + override fun getLogPart(verbose: Boolean, name: String?): ParcelFileDescriptor? = + real?.getLogPart(verbose, name) + + /** + * Delegated, so the offer to install the manager is live or dead exactly as it really is. + * + * Null when there is no daemon behind the demo, which is the same answer the real one gives + * when it cannot serve the APK — and the status page renders that case rather than crashing. + */ + override fun getManagerApk(): ParcelFileDescriptor? = real?.managerApk + + override fun getFrameworkVersionName(): String? = real?.frameworkVersionName + + override fun startNewLogPart(verbose: Boolean) { + real?.startNewLogPart(verbose) + } + + override fun forceStopPackage(packageName: String?, userId: Int) { + real?.forceStopPackage(packageName, userId) + } + + /** Not delegated. A demo build must not be able to reboot the device by accident. */ + override fun reboot() = Unit + + override fun uninstallPackage(packageName: String?, userId: Int): Boolean = + real?.uninstallPackage(packageName, userId) ?: false + + override fun getUsers(): MutableList = real?.users ?: mutableListOf() + + override fun startActivityAsUser(intent: Intent?, userId: Int, noUserSwitch: Boolean): Int = + // -1, not 0: the AIDL documents 0..99 as "the activity started", so a benign-looking + // 0 would report a successful start with no daemon behind it. + real?.startActivityAsUser(intent, userId, noUserSwitch) ?: -1 + + override fun queryIntentActivitiesAsUser( + intent: Intent?, + flags: Int, + userId: Int, + ): ParcelableListSlice = + real?.queryIntentActivitiesAsUser(intent, flags, userId) ?: ParcelableListSlice(emptyList()) + + override fun softReboot() { + // Deliberately inert: a demo that could restart the framework would take the phone down + // with it, and every screen this scenario exists to show would go with it. + } + + override fun isForcedLauncherIcons(): Boolean = real?.isForcedLauncherIcons ?: true + + override fun setForcedLauncherIcons(force: Boolean) { + real?.setForcedLauncherIcons(force) + } + + override fun writeBugReport(zipFd: ParcelFileDescriptor?) { + real?.writeBugReport(zipFd) + } + + override fun optimizePackage(packageName: String?): Boolean = + real?.optimizePackage(packageName) ?: false + + // `?: true` to match the daemon, whose PreferenceStore reads this one `?: true` when nobody has + // set it — the same reason isForcedLauncherIcons above answers true. A fallback here is not a + // failed read: it is handed upstream as a *successful* answer, so answering false would leave + // the status page's switch — and the ManagerPresence field HomeViewModel fills from the same + // call — showing the opposite of what an untouched device with a real daemon behind it says. + override fun isStatusNotificationEnabled(): Boolean = real?.isStatusNotificationEnabled ?: true + + override fun setStatusNotificationEnabled(enabled: Boolean) { + real?.setStatusNotificationEnabled(enabled) + } + + override fun getIncludeNewApps(packageName: String?): Boolean = + real?.getIncludeNewApps(packageName) ?: false + + override fun setIncludeNewApps(packageName: String?, enable: Boolean): Boolean = + real?.setIncludeNewApps(packageName, enable) ?: false +} + +/** + * The write half of [versionCodeCompat], which exists only here. + * + * `setLongVersionCode` is API 28 and the app's minimum is 27, so below that the deprecated `int` + * field is the field. Nothing in the real manager ever writes a version code -- only this fake, + * which rewrites the daemon's answers to script the demo. + */ +@Suppress("DEPRECATION") +private fun PackageInfo.setVersionCodeCompat(value: Long) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) longVersionCode = value + else versionCode = value.toInt() +} diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml new file mode 100644 index 000000000..08a9c25a2 --- /dev/null +++ b/manager/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt b/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt new file mode 100644 index 000000000..89d09ea24 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt @@ -0,0 +1,97 @@ +package org.matrix.vector.manager + +import android.os.IBinder +import kotlin.system.exitProcess +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.di.ServiceLocator + +/** + * The one entry point the framework reaches by reflection. + * + * `ParasiticManagerHooker.sendBinderToManager` loads `.Constants` out of the + * injected dex and invokes the static `setBinder(IBinder)` below. Nothing inside this APK calls it, + * so R8 must be told to keep both — see `proguard-rules.pro`. Renaming this class or the method + * breaks the handshake silently, at runtime, with no compile error anywhere. + */ +object Constants { + /** + * The only tag the manager logs under, and it is not arbitrary. + * + * `logcat.cpp` routes any tag beginning `Vector` into the daemon's **verbose** stream, so with + * verbose logging on everything logged here appears in the Verbose tab beside the daemon's own + * lines and travels in the zip export — the place a reader already looks. A file-local tag + * would be ordinary Android practice and would land nowhere; there are none in this app. + * + * Nothing logs with it directly. [logE], [logW] and [logI] hold it, and the conventions for + * what a message says and which level it says it at are documented on them. + */ + const val TAG = "VectorManager" + + @JvmStatic + fun setBinder(binder: IBinder): Boolean { + // The interface's fully qualified name is its binder descriptor, and this APK can be older + // or newer than the framework that pushed the binder: `getManagerApk` exists so the manager + // can be installed as an ordinary app, and an installed copy survives every later flash. + // + // Nothing about that mismatch is loud on its own. `Stub.asInterface` wraps any binder in a + // proxy without checking, the binder stays alive so `isBinderAlive()` keeps answering true, + // and every transaction then throws SecurityException out of the daemon's + // `enforceInterface` — which `DaemonClient.runIpc` turns into a failed Result and every + // screen draws as empty. So ask first. + // + // This one question is exempt by construction: INTERFACE_TRANSACTION sits outside the + // FIRST_CALL_TRANSACTION..LAST_CALL_TRANSACTION band the generated dispatcher checks the + // token for, so it is answered across any mismatch. It can still throw — the call is remote + // and the daemon may have died between the push and here — and a throw is not evidence of a + // mismatch, so it falls through to binding and lets linkToDeath below report the death. + val theirDescriptor = runCatching { binder.interfaceDescriptor }.getOrNull() + if (theirDescriptor != null && theirDescriptor != IManagerService.DESCRIPTOR) { + logE( + "ipc: the daemon speaks $theirDescriptor, this manager speaks " + + "${IManagerService.DESCRIPTOR}; refusing to bind" + ) + ServiceLocator.bindMismatch(theirDescriptor) + return false + } + + val service = IManagerService.Stub.asInterface(binder) + + // A matching descriptor means the two ends agree on what this interface is called, not on + // what is in it. Transaction ids follow declaration order, so a daemon built from a + // different revision of the AIDL maps the same numbers to different methods, and every call + // would land somewhere plausible and wrong -- which is worse than failing, because nothing + // throws. getProtocolVersion is declared first and is therefore transaction zero in every + // revision, so it is the one question both ends are guaranteed to agree on. A daemon too + // old to implement it answers 0 out of an untouched reply parcel, which is below the floor + // and refused for the right reason. + val theirProtocol = runCatching { service.protocolVersion }.getOrNull() + if (theirProtocol != null && theirProtocol != IManagerService.PROTOCOL_VERSION) { + logE( + "ipc: the daemon speaks protocol $theirProtocol, this manager speaks " + + "${IManagerService.PROTOCOL_VERSION}; refusing to bind" + ) + ServiceLocator.bindMismatch("protocol $theirProtocol") + return false + } + + ServiceLocator.bind(service) + + try { + // If the daemon dies the manager is holding a dead binder and every screen would + // silently show empty state, which reads as "you have no modules" rather than "the + // framework is gone". Exiting is blunt but honest. + binder.linkToDeath( + { + logW("ipc: daemon binder died, manager exiting") + exitProcess(0) + }, + 0, + ) + } catch (e: Exception) { + logE("ipc: linkToDeath on the daemon binder failed, exiting the manager process", e) + exitProcess(0) + } + + return binder.isBinderAlive + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt b/manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt new file mode 100644 index 000000000..235d1b7ec --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/Logging.kt @@ -0,0 +1,98 @@ +package org.matrix.vector.manager + +import android.util.Log +import java.io.PrintWriter +import java.io.StringWriter + +/** + * The manager's logging, which exists because the platform's own quietly loses stack traces. + * + * `android.util.Log.e(tag, msg, tr)` does not print `tr` when anything in its cause chain is an + * [java.net.UnknownHostException]. This is deliberate upstream — `printlns` in AOSP's `Log.java` + * breaks out of the cause walk with the comment "this is to reduce the amount of log spew that + * apps do in the non-error condition of the network being unavailable" — and + * [Log.getStackTraceString] returns `""` for the same reason. The message still prints, so nothing + * looks wrong; only the trace is gone. + * + * For an app whose network use is a DoH resolver, a module store, and a GitHub feed, that filter + * removes traces from precisely the failures worth reporting. Its worst case was `dns: DoH lookup + * of $host failed` — OkHttp's `DnsOverHttps` signals failure by throwing `UnknownHostException`, so + * the one log line whose entire purpose is explaining a DNS failure was the one guaranteed to + * arrive without a trace. + * + * So the trace is formatted here and appended to the message, and only [Log.println] — which has no + * filter — is called. Nothing in the manager calls `android.util.Log` directly; [Constants.TAG] + * belongs to these three functions. + * + * The conventions the tag used to carry: + * + * `logcat.cpp` routes any tag beginning `Vector` into the daemon's **verbose** stream, so with + * verbose logging on everything logged here appears in the Verbose tab beside the daemon's own + * lines and travels in the zip export — the place a reader already looks. A file-local tag would be + * ordinary Android practice and would land nowhere; there are none in this app. + * + * A message is `area: lowercase phrase naming the operation and its subject`, where the area is one + * of ipc, dns, apps, modules, backup, restore, scope, store, update, feed, status, framework, logs, + * actions, report, splash. The subject matters: "modules: enable of $packageName failed" can be + * acted on, "failed to enable module" cannot. + * + * The `Throwable` is always the last argument — never `e.message`, which discards the stack. + * Nothing secret is ever interpolated: no SAF `Uri` beyond its authority, no third-party query + * string. + * + * Levels are [logE] when something the user asked for did not happen and nothing else will explain + * it, [logW] for a degraded path recovered from, and [logI] for a one-off milestone worth having in + * a bug report — counts, versions, the endpoint chosen. `d` and `v` are not offered, because + * release builds ship them. + * + * A [kotlinx.coroutines.CancellationException] is never logged. Navigating away from a screen + * cancels its scope, and a log that fires every time someone presses back is a log nobody reads; + * any `runCatching` or broad `catch` that can see one rethrows or skips it first. + */ +fun logE(msg: String, tr: Throwable? = null) = emit(Log.ERROR, msg, tr) + +/** A degraded path recovered from. See [logE] for the conventions. */ +fun logW(msg: String, tr: Throwable? = null) = emit(Log.WARN, msg, tr) + +/** A one-off milestone worth having in a bug report. See [logE] for the conventions. */ +fun logI(msg: String, tr: Throwable? = null) = emit(Log.INFO, msg, tr) + +/** + * A throwable as text, cause chain and all, with none of [Log.getStackTraceString]'s filtering. + * + * Shared with [org.matrix.vector.manager.data.log.CrashRecorder], which writes the same text to + * disk and had the same trace go missing for the same reason. + */ +fun stackTraceOf(tr: Throwable): String = + StringWriter().also { tr.printStackTrace(PrintWriter(it)) }.toString().trimEnd() + +/** + * One log entry per [PAYLOAD_LIMIT] characters, split on line boundaries. + * + * `Log.println` hands the whole string to liblog, which truncates it at the kernel logger's payload + * size; the platform's `Log.e(tag, msg, tr)` avoids that by writing through a line-breaking writer. + * Since we are no longer using it, we break the lines: a deep trace — and Compose produces very + * deep ones — would otherwise lose its tail, which is where the frames that name our own code live. + */ +private fun emit(priority: Int, msg: String, tr: Throwable?) { + val text = if (tr == null) msg else "$msg\n${stackTraceOf(tr)}" + if (text.length <= PAYLOAD_LIMIT) { + Log.println(priority, Constants.TAG, text) + return + } + val entry = StringBuilder(PAYLOAD_LIMIT) + for (line in text.lineSequence()) { + if (entry.isNotEmpty() && entry.length + 1 + line.length > PAYLOAD_LIMIT) { + Log.println(priority, Constants.TAG, entry.toString()) + entry.setLength(0) + } + if (entry.isNotEmpty()) entry.append('\n') + // A single line over the limit is passed through and truncated by liblog. Stack frames are + // never that long, and splitting mid-line would corrupt the one thing being preserved. + entry.append(line) + } + if (entry.isNotEmpty()) Log.println(priority, Constants.TAG, entry.toString()) +} + +/** Under the ~4068-byte logger payload, with room for the tag and the two terminators. */ +private const val PAYLOAD_LIMIT = 4000 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt new file mode 100644 index 000000000..10a2403ee --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt @@ -0,0 +1,233 @@ +package org.matrix.vector.manager.data.github + +import java.io.File +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.matrix.vector.manager.logW + +/** + * Every commit the app has ever seen, kept on disk. + * + * `/commits` returns at most a hundred commits per request and no date window widens that, so + * "from the start of the project" can only be answered by walking the history backwards a page at + * a time and remembering what came back. This is where it is remembered. + * + * ## Why an append-only file + * + * A commit that is not at the tip never changes. Its message, its author and its date are fixed the + * moment it is buried under another commit, so the overwhelming majority of this archive is + * immutable and rewriting it would be pure waste. New pages are *appended*, one JSON object per + * line, which costs the length of the chunk rather than the length of the history — the difference + * between writing 20 KB and rewriting 600 KB, every time, on a phone. + * + * The head is the exception. The newest commits can still be amended, rebased or force-pushed away, + * so the refresh rewrites that window on every run. Appending a second copy of a commit is + * therefore normal rather than a bug, and [read] resolves it: later lines win, so the freshest + * record of any SHA is the one that survives. The file is compacted when the duplicates outgrow the + * real content, which for a normal refresh rhythm is rarely. + * + * ## Why SHA is the key + * + * It is the only identifier git guarantees. Dates collide — several commits share a second, and the + * page boundary is *chosen* by date, so the same commit legitimately arrives twice — and positions + * shift as new work lands. Keying on the SHA makes an overlapping page harmless, which in turn lets + * the backfill cursor be a date rather than a page number. That matters: page numbers are + * invalidated by every new commit, dates are not. + */ +class CommitArchive(private val file: File, private val stateFile: File, private val json: Json) { + + /** + * Where the backwards walk has reached. + * + * [oldestSeenEpochSeconds] is the cursor — the next request asks for commits at or before it — + * and [complete] records that a request came back empty, which is the only signal that the + * history has genuinely run out. All of it is persisted because the walk is spread across + * sessions: an anonymous client gets sixty requests an hour, and a few thousand commits is + * thirty of them, so finishing in one sitting is neither possible nor polite. + * + * [pageWithinCursor] is what makes the walk correct on a repository that has been squashed or + * imported. This one has 100+ commits stamped with the same second — `2023-02-26T08:48:49Z` — + * and a cursor alone cannot get past them: asking for commits at or before that second returns + * the same hundred every time, and the walk stalls a fifth of the way through history while + * looking exactly like success. Inside such a plateau the walk pages by number instead, which + * is safe here in a way it is not in general: the page window is anchored by `until` at a + * moment in the past, so new commits land outside it and cannot shift it. + */ + @Serializable + data class State( + val oldestSeenEpochSeconds: Long = 0, + val complete: Boolean = false, + /** Purely for the log line that explains a slow or stalled backfill. */ + val pagesFetched: Int = 0, + /** Which page of the current cursor's timestamp to ask for next; 1 unless in a plateau. */ + val pageWithinCursor: Int = 1, + /** + * The walk that wrote this file. + * + * A cursor left behind by a superseded walk is worse than no cursor at all: a walk that + * stalls records `complete: true`, and honouring that would mean never looking again. + * Anything not written by the current walk is re-walked from the top. + */ + val algorithm: Int = 0, + ) + + companion object { + /** Bump when the walk changes in a way that invalidates a saved cursor. */ + const val ALGORITHM = 1 + } + + fun state(): State = + runCatching { json.decodeFromString(stateFile.readText()) } + .getOrDefault(State()) + .let { if (it.algorithm == ALGORITHM) it else State(algorithm = ALGORITHM) } + + fun writeState(state: State) { + runCatching { stateFile.writeText(json.encodeToString(state.copy(algorithm = ALGORITHM))) } + } + + /** + * Everything held, newest first, one record per SHA. + * + * Later lines win, which is what makes appending a rewritten head window correct rather than + * corrupting: the newest copy of a commit is the one that ends up furthest down the file. + */ + fun read(): List { + parsed?.let { + return it + } + return parse().also { parsed = it } + } + + /** + * The parsed file, held for as long as it is unchanged. + * + * One load reads this twice — once to lay out the feed, once to know what the backfill already + * has — and a backfill reads it again after appending. At three thousand commits that is three + * passes over megabytes of JSON per visit to the foot of the list, all of it to reproduce a + * list that has not changed. Every writer here invalidates it, so there is no way to hold a + * stale copy. + */ + @Volatile private var parsed: List? = null + + private fun parse(): List { + if (!file.isFile) return emptyList() + val byShaLatestWins = LinkedHashMap() + var total = 0 + var skipped = 0 + var firstFailure: Throwable? = null + runCatching { + file.forEachLine { line -> + if (line.isBlank()) return@forEachLine + total++ + runCatching { json.decodeFromString(line) } + .onFailure { e -> + skipped++ + if (firstFailure == null) firstFailure = e + } + .getOrNull() + // A truncated final line — a process killed mid-append — costs that one commit + // and nothing else. It is why this is a line format and not one JSON document. + ?.let { byShaLatestWins[it.sha] = it } + } + } + lineCount = total + // Ordered by the author date, which is the date the feed prints beside every row. It is + // deliberately not the committer date the backfill walks on: that cursor is a minimum over + // a whole page, never the end of this list, so the two orders never have to agree. + val unique = byShaLatestWins.values.sortedByDescending { it.commit.author.date } + // Exactly one bad line is the truncated tail above and is not worth saying anything about; + // more than one is systematic — a renamed field would make the whole archive read as empty. + if (skipped > 1) { + logW("feed: skipped $skipped of $total archive lines", firstFailure) + // Repaired here, where it is found, rather than left to [compactIfWasteful]: that runs + // only during a backfill, which happens only if someone scrolls to the foot of + // history, so damage would otherwise be re-skipped on every launch instead of being + // cleared once. Rewriting what parsed is the whole repair — the damage is unreadable + // text between two records and there is nothing in it to recover — and it happens + // once, because the next parse finds nothing to skip. + rewrite(unique) + } + return unique + } + + /** + * Appends a chunk. Duplicates are expected and are resolved on read, not here. + * + * Serialised against every other writer, and it has to be. `appendText` opens its own stream + * per call and a hundred commits is far more than one buffer, so two overlapping appends + * interleave at a buffer boundary rather than one following the other, splicing a commit + * message into the middle of the next record. Each such tear costs two commits and is + * permanent. Overlap is reachable: `ServiceLocator.prefetch` launches `load()`, which appends + * the head window, while the home screen can be running `backfill()`. + */ + fun append(commits: List) { + if (commits.isEmpty()) return + synchronized(writeLock) { + parsed = null + runCatching { + file.parentFile?.mkdirs() + file.appendText( + commits.joinToString("\n", postfix = "\n") { json.encodeToString(it) } + ) + } + .onFailure { e -> + logW("feed: appending ${commits.size} commits to the archive failed", e) + } + } + } + + /** + * Rewrites the file with one line per commit, dropping the superseded copies. + * + * Only worth doing when the duplicates have grown past the content itself, which takes a great + * many refreshes — the head window is a hundred commits and a full history is thousands. + */ + fun compactIfWasteful() { + if (!file.isFile) return + synchronized(writeLock) { + // read() first: it is memoised, and parsing is what sets [lineCount]. Counting the + // lines any other way means a second pass over megabytes of JSON to answer a question + // the parse has already answered. + val unique = read() + if (lineCount <= unique.size * 2) return + rewrite(unique) + } + } + + /** + * Replaces the file with exactly [unique], one record per line. + * + * Through a temporary and a rename, so a reader either sees the whole old file or the whole + * new one and never a half-written replacement. Shared by compaction and by repair because + * they are the same operation: both write back only what could be read. + */ + private fun rewrite(unique: List) { + synchronized(writeLock) { + runCatching { + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText( + unique.joinToString("\n", postfix = "\n") { json.encodeToString(it) } + ) + tmp.renameTo(file) + parsed = unique + lineCount = unique.size + } + .onFailure { e -> + logW("feed: rewriting the archive failed", e) + } + } + } + + /** Lines the last parse walked, so compaction need not read the file again to count them. */ + @Volatile private var lineCount = 0 + + /** + * Taken by everything that writes the file. + * + * Readers do not take it. A reader that catches a half-written final line loses that one + * record and says so, which is the behaviour a line format is chosen for; a *writer* that + * catches another writer loses records in the middle of the file, permanently. + */ + private val writeLock = Any() +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/FeedLayout.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/FeedLayout.kt new file mode 100644 index 000000000..ee230081c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/FeedLayout.kt @@ -0,0 +1,204 @@ +package org.matrix.vector.manager.data.github + +import java.util.Calendar +import java.util.Locale +import kotlin.math.sqrt + +/** + * What the rail draws, in order. + * + * The rail is deliberately **not** a branch graph. This project squash-merges, so its history is + * linear by construction — there were zero merge commits in the last 44 — and drawing lanes would + * be inventing structure that does not exist. What the data *does* carry, and a uniform list + * throws away, is time and the reader's own position in it. That is what these items encode. + */ +sealed interface FeedItem { + + /** A commit. The rail spans its full height; elapsed time is carried by [Gap] below it. */ + data class Commit( + val commit: TimelineCommit, + val isFirst: Boolean, + val isLast: Boolean, + ) : FeedItem + + /** + * The elapsed time between two commits, as rail. + * + * A separate row rather than a trailing segment inside the commit row: a commit row's height is + * set by its text, so a segment drawn inside it stops short of the next node and the rail + * breaks visibly between same-day commits. + */ + data class Gap(val days: Int, val afterSha: String) : FeedItem + + /** + * The line between what the reader is running and what they are not. + * + * `versionCode` is `git rev-list --count`, so a commit's distance from HEAD is exactly its + * version number — no guessing and no extra endpoint. Everything above this line is what an + * update would actually bring, named commit by commit, which is the question a framework + * user actually has. + */ + data class InstalledMarker( + val versionCode: Long, + val commitsAhead: Int, + /** + * True when the installed build is *past* the head of master. + * + * That cannot happen to anyone running a published build, so it means the framework was + * built locally or from another branch. Worth saying: the feed below is then not the + * history of what is installed, and neither an issue report nor a bisect against it means + * what the reader would assume. + */ + val aheadOfMaster: Boolean = false, + ) : FeedItem + + /** + * Where the rail crosses into an earlier month, with what that month amounted to. + * + * A bare month name is just a scroll landmark. With the month's own totals it becomes the + * summary layer of the timeline: you can read the project's shape by skimming the separators + * without reading a single commit. + */ + data class MonthMarker( + /** Stable across languages: a grouping key and a list key, never shown. */ + val key: String, + /** `Calendar.MONTH`, named at draw time in whatever language the reader chose. */ + val month: Int, + /** Null in the current year, where the year would be noise. */ + val year: Int?, + val commits: Int, + val people: Int, + ) : FeedItem + + /** + * Every bot commit in the window, folded into one row at the foot of the rail. + * + * Gathered out of the timeline rather than left in place: dependency bumps arrive in bursts and + * would otherwise be most of what the rail shows, pushing the human commits — which are what + * the reader came for — off the screen. + */ + data class Bots(val count: Int, val commits: List) : FeedItem +} + +object FeedLayout { + + /** Below this a gap is just normal cadence and gets no label. */ + const val QUIET_THRESHOLD_DAYS = 14 + + fun build(feed: CommunityFeed, installedVersionCode: Long): List { + val visible = feed.commits.filterNot { it.isBot } + if (visible.isEmpty()) return emptyList() + + val bots = feed.commits.filter { it.isBot } + val items = ArrayList(visible.size * 2 + 8) + + // Each commit's month, worked out once, and its totals accumulated in a second sweep. + // + // The alternative — re-scanning every commit at each month boundary to total it — is + // O(commits × months) with a fresh Calendar per comparison. That is cheap on a six-month + // window and thousands of allocations on the full archive, which is the case that has to + // stay fast. + val months = ArrayList(visible.size) + val calendar = Calendar.getInstance() + visible.forEach { commit -> + calendar.timeInMillis = commit.epochSeconds * 1000 + months += monthKey(calendar) + } + val commitsPerMonth = HashMap() + val peoplePerMonth = HashMap>() + visible.forEachIndexed { index, commit -> + val key = months[index] + commitsPerMonth[key] = (commitsPerMonth[key] ?: 0) + 1 + val people = peoplePerMonth.getOrPut(key) { HashSet() } + commit.authors.forEach { if (!it.isBot) people += it.login.lowercase() } + } + val thisYear = Calendar.getInstance().get(Calendar.YEAR) + + // Only meaningful once both numbers are known, and only when the reader is actually + // behind — telling someone who is up to date that they are up to date is noise. + val commitsAhead = + if (feed.totalCommits > 0 && installedVersionCode > 0) { + (feed.totalCommits - installedVersionCode).toInt().coerceAtLeast(0) + } else 0 + // Past the head of master: place it at the top rather than looking for a commit it could + // sit above, because there is not one. + val aheadOfMaster = feed.totalCommits > 0 && installedVersionCode > feed.totalCommits + if (aheadOfMaster) { + items += + FeedItem.InstalledMarker( + versionCode = installedVersionCode, + commitsAhead = (installedVersionCode - feed.totalCommits).toInt(), + aheadOfMaster = true, + ) + } + var markerPlaced = aheadOfMaster || commitsAhead <= 0 + + var lastMonth: String? = null + + visible.forEachIndexed { index, commit -> + if (!markerPlaced && commit.globalIndex <= installedVersionCode) { + items += FeedItem.InstalledMarker(installedVersionCode, commitsAhead) + markerPlaced = true + } + + val month = months[index] + if (month != lastMonth) { + calendar.timeInMillis = commit.epochSeconds * 1000 + val year = calendar.get(Calendar.YEAR) + items += + FeedItem.MonthMarker( + key = month, + month = calendar.get(Calendar.MONTH), + year = year.takeIf { it != thisYear }, + commits = commitsPerMonth[month] ?: 0, + people = peoplePerMonth[month]?.size ?: 0, + ) + lastMonth = month + } + + val older = visible.getOrNull(index + 1) + items += + FeedItem.Commit( + commit = commit, + isFirst = index == 0, + isLast = older == null && bots.isEmpty(), + ) + + if (older != null) { + val gapDays = + ((commit.epochSeconds - older.epochSeconds) / 86_400L).toInt().coerceAtLeast(0) + items += FeedItem.Gap(gapDays, commit.sha) + } + } + + if (bots.isNotEmpty()) items += FeedItem.Bots(bots.size, bots) + return items + } + + /** + * Gap in days to rail height. + * + * Square root rather than linear. Linear is the honest chart, but this project's gaps span + * 0 to 76 days, so a literal scale spends a full screen of empty rail on one silence and + * flattens every ordinary one-to-three-day gap into the same nothing. The root keeps short + * gaps distinguishable, still shows a long one as visibly long, and the clamp stops any + * single quiet stretch from dominating the scroll. + */ + fun railHeightDp(gapDays: Int): Float = + (MIN_GAP_DP + sqrt(gapDays.toFloat()) * SCALE).coerceAtMost(MAX_GAP_DP) + + const val MIN_GAP_DP = 8f + const val MAX_GAP_DP = 120f + private const val SCALE = 13f + + /** + * The grouping key, deliberately language-independent. + * + * A displayed month name cannot be built here. `Locale.getDefault()` is the process default, + * which parasitically belongs to the host app rather than to the manager, and the app's own + * in-composition language override is not visible from the model at all. So the model groups by + * an invariant key and the screen names the month at draw time. + */ + private fun monthKey(calendar: Calendar): String = + "%d-%02d".format(Locale.ROOT, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH)) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt new file mode 100644 index 000000000..88747478c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt @@ -0,0 +1,373 @@ +package org.matrix.vector.manager.data.github + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The slice of GitHub's commit payload the Home feed needs. + * + * One request to `/commits?since=…` carries everything: the author's login and avatar for the + * contributor row, the date for the timeline, the subject line, and the SHA. There is deliberately + * no second call to `/contributors` — the people are derived from the commits, which both halves + * the rate-limit spend and makes the row mean "who worked on this *recently*" rather than an + * all-time leaderboard nobody reads twice. + */ +@Serializable +data class GhCommit( + val sha: String, + val commit: GhCommitDetail, + val author: GhUser? = null, + @SerialName("html_url") val htmlUrl: String? = null, +) + +@Serializable +data class GhCommitDetail( + val message: String, + val author: GhCommitAuthor, + /** + * When the commit landed, as opposed to when it was written. + * + * The two differ on about half of the newest hundred commits here, by as much as three weeks — + * anything rebased, cherry-picked or merged from a branch that sat for a while. It matters + * because `since` and `until` filter on *this* date, so it is the only correct cursor for + * walking history backwards. Walking on the author date would step past commits written before + * the boundary but landed after it, and lose them silently. + */ + val committer: GhCommitAuthor? = null, +) + +@Serializable +data class GhCommitAuthor( + val name: String, + val date: String, + /** Present on every commit, and the only handle on an author GitHub failed to link. */ + val email: String = "", +) + +@Serializable +data class GhUser( + val login: String, + @SerialName("avatar_url") val avatarUrl: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, + val type: String? = null, +) + +@Serializable +data class GhRepo( + @SerialName("stargazers_count") val stars: Int = 0, + @SerialName("forks_count") val forks: Int = 0, + @SerialName("open_issues_count") val openIssues: Int = 0, + val license: GhLicense? = null, +) + +@Serializable data class GhLicense(@SerialName("spdx_id") val spdxId: String? = null) + +/** + * How a commit subject is classified. + * + * Vector writes plain imperative subjects rather than conventional-commit prefixes, so the leading + * verb is what gets read. Over the newest 300 commits that verb is a fix or a dependency bump + * almost half the time, which is why those two have categories of their own. + */ +enum class CommitKind { + Fix, + Add, + Remove, + Chore, + Change; + + companion object { + fun of(subject: String): CommitKind = + when (subject.substringBefore(' ').trimStart('[').lowercase()) { + "fix", + "fixes", + "fixed" -> Fix + "add", + "adds", + "new", + "implement", + "introduce", + "allow", + "support" -> Add + "remove", + "removes", + "delete", + "drop" -> Remove + "bump", + "update", + "upgrade", + "migrate", + "translation]", + "translation" -> Chore + else -> Change + } + } +} + +/** + * One person credited on a commit — the author, or anyone named in a `Co-authored-by:` trailer. + * + * Co-authors are real contributors and are counted as such. GitHub's commits API does not return + * them as users, so they are parsed out of the message; when the trailer carries a GitHub noreply + * address the login and avatar can be recovered from it exactly, and otherwise the person is shown + * under the name they signed with and a monogram. + */ +data class CommitPerson( + val login: String, + val avatarUrl: String?, + val profileUrl: String?, + val isBot: Boolean = false, +) + +/** A commit as the timeline renders it. */ +data class TimelineCommit( + val sha: String, + val shortSha: String, + val subject: String, + val kind: CommitKind, + /** Pull-request number parsed from a trailing `(#123)`, the link to the discussion. */ + val pullRequest: Int?, + /** The author first, then any co-authors, deduplicated. */ + val authors: List, + val epochSeconds: Long, + val htmlUrl: String?, + /** + * Distance from the repository's first commit, i.e. this commit's own version number — + * `versionCode` is generated by `git rev-list --count`, so the two are the same scale and a + * build can be located on the timeline exactly. + */ + val globalIndex: Long, + /** True when anyone credited, bots aside, is not the repository owner — marked on the rail. */ + val isCommunity: Boolean, + val isBot: Boolean, +) { + val authorLogin: String + get() = authors.firstOrNull()?.login.orEmpty() + + val coAuthors: List + get() = authors.drop(1) +} + +/** A person credited inside the window, with how much and how recently. */ +data class Contributor( + val login: String, + val avatarUrl: String?, + val profileUrl: String?, + val commits: Int, + /** Their most recent commit in the window; breaks ties so the row stays a live scoreboard. */ + val lastEpochSeconds: Long, +) + +/** Everything the Home community section renders, or the reason it cannot. */ +data class CommunityFeed( + val commits: List = emptyList(), + val contributors: List = emptyList(), + val windowStartEpochSeconds: Long = 0, + val repo: GhRepo? = null, + /** Commits on the default branch, ever. Equals the newest build's versionCode. */ + val totalCommits: Long = 0, + /** True when this came off disk rather than the network, for any reason. */ + val fromCache: Boolean = false, + /** + * True only when the network was actually tried and could not be reached. + * + * Not the same as [fromCache]. Home deliberately reads the cache on most launches, so "could + * not reach GitHub" keyed off [fromCache] would report a failure on a launch that never asked. + */ + val offline: Boolean = false, + /** + * False until the first load resolves. Without it the initial empty value is indistinguishable + * from a genuinely empty result, and the page claims "no commits" before it has looked. + */ + val loaded: Boolean = false, + /** + * True while the archive has not yet been walked back as far as this window reaches. + * + * The distinction the feed's foot depends on: more to fetch means an invitation to keep + * scrolling, nothing more to fetch means the rail has genuinely reached the first commit and + * says so. + */ + val hasMoreHistory: Boolean = false, + /** + * True when a bounded window is already backed by history reaching past its start. + * + * The distinction the foot of the feed needs: "there is nothing more *here*" is a different + * sentence from "this is where the project began", and neither is "there is more to fetch". + */ + val windowCovered: Boolean = false, +) { + val commitCount: Int + get() = commits.size + + val isEmpty: Boolean + get() = commits.isEmpty() + + /** + * The same feed narrowed to the commits [logins] took part in. + * + * Co-authorship counts, which is the whole point of filtering here rather than linking out to + * GitHub's author filter: GitHub's own view is by *author*, so a contribution that landed under + * a maintainer's name with the contributor credited in a trailer does not appear under the + * contributor at all. Here it does, because the rail already knows every name on a commit. + * + * Only the commits are narrowed. The contributor row is the control — filtering it by its own + * selection would remove the people you would need to tap to change it. + */ + fun filteredBy(logins: Set): CommunityFeed = + if (logins.isEmpty()) this + else + copy( + commits = + commits.filter { commit -> + commit.authors.any { it.login.lowercase() in logins } + } + ) +} + +// --- CI builds ------------------------------------------------------------------------------ + +@Serializable +data class GhRelease( + val id: Long, + @SerialName("tag_name") val tagName: String = "", + val name: String? = null, + val prerelease: Boolean = false, + @SerialName("target_commitish") val targetCommitish: String = "", + @SerialName("published_at") val publishedAt: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, + val body: String? = null, + val assets: List = emptyList(), +) + +@Serializable +data class GhReleaseAsset( + val id: Long, + val name: String, + val size: Long = 0, + @SerialName("browser_download_url") val downloadUrl: String? = null, +) + +/** + * A closed issue, as GitHub's issue list reports it. + * + * **This is how the canary screen knows what got fixed, and it has to be.** A commit message only + * names an issue when somebody wrote `Fixes #816` into it; this repository's issues are usually + * linked through the web UI's *Development* panel instead, which closes them on merge and writes + * nothing into the history at all. The link itself is only readable through GraphQL — + * `PullRequest.closingIssuesReferences` — and GraphQL answers 403 to an anonymous caller, which + * this app is by design. So what is asked instead is the question REST will answer without an + * account: which issues closed, and when. + */ +data class ClosedIssue( + val number: Int, + val title: String, + val closedAtEpoch: Long, + val htmlUrl: String?, +) + +@Serializable +data class GhIssue( + val number: Int, + val title: String = "", + @SerialName("closed_at") val closedAt: String? = null, + /** + * Why it closed: `completed`, `not_planned` or `duplicate`. + * + * Only the first is a fix. Counting the others would tell a reader that eleven issues were + * dealt with since their build when five of them were triage. + */ + @SerialName("state_reason") val stateReason: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, +) { + /** + * Whether this is really a pull request. + * + * The issues endpoint returns both — a pull request *is* an issue to GitHub — and in one page + * of this repository's closed items more than half were pull requests. Told apart by the URL + * rather than by the presence of the `pull_request` object, which would mean decoding a nested + * payload none of whose fields are wanted. + */ + val isPullRequest: Boolean + get() = htmlUrl?.contains("/pull/") == true +} + +/** + * A published build of the framework, canary or stable, with the zip to flash. + * + * One type for both channels because the install path is identical — the difference is only which + * of them a given reader is allowed to be offered. + * + * One type for the canary list as well, which used to read the same endpoint through a shape of its + * own. Two models over one response meant the canary page fetched what the update page was already + * holding, and could say nothing about a build that this type does not carry — no notes, no commit, + * and so no way to mark the one that is running. + */ +data class FrameworkRelease( + val tag: String, + val title: String, + val versionCode: Long, + val isCanary: Boolean, + val notesMarkdown: String?, + val htmlUrl: String?, + val epochSeconds: Long, + /** + * The commit the release was cut from, when GitHub knows one. + * + * `target_commitish` is a full SHA for the canaries, because CI creates them against an exact + * commit — but it is the literal string "master" for a hand-made release, which names a branch + * and not a build. Only the first kind can be compared against, and the difference between + * "these differ" and "I cannot tell" is one the UI has to keep. + */ + val commit: String?, + /** + * Every zip the release published, in the order GitHub listed them. + * + * A list rather than one chosen zip because each release ships both a Release and a Debug + * build, and they are very different sizes. The reader has to be able to see which one they + * are about to flash and to pick the other — the app asks people for a debug build when they + * report a problem, so installing one has to be possible. + */ + val zips: List, +) { + /** The one to offer by default when nothing has been chosen. */ + val defaultZip: CanaryArtifact? + get() = zips.firstOrNull { it.variant == ZipVariant.Release } ?: zips.firstOrNull() + + /** + * The commit, abbreviated the way git abbreviates it, or null when the release names a branch. + * + * Seven characters because that is what `git rev-parse --short` gives on a repository this + * size, and what the commit rail already prints — the two are read side by side. + */ + val shortSha: String? + get() = commit?.take(7) +} + +/** + * Which build a zip is, read from its file name. + * + * [Other] is not a failure: a release may one day publish something these two names do not cover, + * and a picker that cannot represent it would either hide the file or mislabel it. Both are worse + * than showing the name it actually has. + */ +enum class ZipVariant(val key: String) { + Release("release"), + Debug("debug"), + Other("other"), +} + +data class CanaryArtifact( + val id: Long, + val name: String, + val sizeInBytes: Long, + val downloadUrl: String?, +) { + val variant: ZipVariant + get() = + when { + name.contains("release", ignoreCase = true) -> ZipVariant.Release + name.contains("debug", ignoreCase = true) -> ZipVariant.Debug + else -> ZipVariant.Other + } +} + diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt new file mode 100644 index 000000000..d9f8205dd --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -0,0 +1,1009 @@ +package org.matrix.vector.manager.data.github + +import java.io.File +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** + * Activity on the project's GitHub repository: the commits, the people behind them, the repository + * counters, and the published builds. + * + * Offline-first: [load] falls back to whatever is on disk whenever the network fails, and the + * caller renders it with a "showing cached" affordance rather than an error. A framework manager + * must never be blocked by GitHub being unreachable. + */ +class GitHubRepository( + private val client: OkHttpClient, + cacheDir: File, + /** How far back to reach, in months. User-configurable; see SettingsRepository. */ + private val windowMonthsProvider: () -> Int = { DEFAULT_WINDOW_MONTHS }, +) { + + private val snapshotFile = File(cacheDir, "github_feed.json") + private val peopleFile = File(cacheDir, "github_people.json") + + /** + * The stars, forks and licence, in a file of their own. + * + * Kept out of the feed snapshot, which is rewritten on every successful commit fetch: the repo + * comes from a *separate* request that fails independently, so one rate-limited hour would + * otherwise replace a perfectly good answer with nothing and every later launch would write the + * nothing back. Here they can only be replaced by an actual answer to the question they came + * from, because nothing else writes this file. + * + * They are also the slowest-changing thing on the screen — a star count from yesterday is not + * wrong in any way a reader cares about — so serving a stale one indefinitely is the correct + * behaviour, not a fallback. + */ + private val repoFile = File(cacheDir, "github_repo.json") + + /** + * The whole history, once it has been walked. + * + * Separate from the feed snapshot on purpose: the snapshot is one window's worth and is + * replaced wholesale, while this only ever grows. See [CommitArchive] for why it is + * append-only and keyed by SHA. + */ + private val archive by lazy { + CommitArchive( + File(cacheDir, "github_history.ndjson"), + File(cacheDir, "github_history_state.json"), + json, + ) + } + + private fun readRepo(): GhRepo? = + runCatching { json.decodeFromString(repoFile.readText()) }.getOrNull() + + private fun writeRepo(repo: GhRepo?) { + if (repo == null) return + runCatching { repoFile.writeText(json.encodeToString(repo)) } + } + + /** + * Names we have already tried to resolve to a GitHub account, and what came back. + * + * A null value is a remembered *404*: it is as worth keeping as a success, because the + * alternative is asking about the same unresolvable name on every load. Only a 404 lands here; + * see [resolvePerson] for why every other kind of failure is left out. Persisted so that it + * survives the process, which parasitically is `com.android.shell` and is killed often. + */ + private val resolvedPeople: MutableMap by lazy { + runCatching { json.decodeFromString>(peopleFile.readText()) } + .getOrDefault(emptyMap()) + .toMutableMap() + } + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * How hard to try for fresh data. + * + * Opening Home is not a reason to talk to GitHub. The window it shows moves a few times a + * week at most, so revalidating on every launch spends the user's battery and their share of + * an anonymous rate limit to redraw the same rows. + */ + enum class Freshness { + /** Disk only. Never touches the network. */ + Cached, + /** + * Serve from the HTTP cache while the answer is under [REVALIDATE_MINUTES] old, and + * revalidate after that. A 304 costs nothing against the rate limit. + */ + Revalidate, + /** Ignore caches entirely. Only for an explicit pull-to-refresh. */ + Force, + } + + suspend fun load(freshness: Freshness = Freshness.Revalidate): CommunityFeed = + withContext(Dispatchers.IO) { + // Zero means "as far back as there is", which is a different question from "how many + // months". Either way this call fetches exactly one page — the newest hundred — and the + // rest of history comes from [archive], which [backfill] fills in a few pages at a time + // across sessions. The line under the feed says how far back the answer actually + // reaches, rather than claiming the project began there. + val months = windowMonthsProvider().coerceIn(0, 60) + val windowStart = + if (months == 0) 0L + else System.currentTimeMillis() / 1000 - months * DAYS_PER_MONTH * 24L * 60 * 60 + // One read, however this call ends. The same text answers two questions — which fields + // a successful fetch could not supply, and what to render when there was no fetch at + // all — and re-reading it for the second would be a second pass over the file for an + // answer already in hand. + val snapshotText = runCatching { snapshotFile.readText() }.getOrNull() + val previous = + snapshotText?.let { + runCatching { json.decodeFromString(it) }.getOrNull() + } + val fetched = runCatching { fetch(windowStart, freshness) } + val fresh = fetched.getOrNull() + // Not on the Cached path: a cold OkHttp cache answers FORCE_CACHE with an + // unsatisfiable 504, and every scroll to the foot of the feed would log. + if (fresh == null && freshness != Freshness.Cached) { + logW( + "feed: github commit fetch failed ($freshness), falling back to disk", + fetched.exceptionOrNull(), + ) + } + if (fresh != null) { + // Merged with what is already on disk rather than replacing it. The stars, forks + // and licence come from a *second* request, and the two do not fail together — a + // rate-limited hour can deliver the commits and not the repo — so a field this + // fetch could not answer keeps the last answer that was. + writeRepo(fresh.repo) + // The head window is the mutable part of history — it can be amended or rebased — + // so it is appended every time and the duplicates resolved on read. + archive.append(fresh.rawCommits) + val repo = fresh.repo ?: readRepo() ?: previous?.repo + val total = if (fresh.totalCommits > 0) fresh.totalCommits else previous?.totalCommits ?: 0L + runCatching { + snapshotFile.writeText( + json.encodeToString(Snapshot(total, fresh.rawCommits, repo)) + ) + } + .onFailure { e -> logW("feed: snapshot write failed", e) } + return@withContext build( + timeline(fresh.rawCommits, windowStart), + repo, + windowStart, + fromCache = false, + offline = false, + totalCommits = total, + freshness = freshness, + ) + } + val cached = + previous + // A file written before the total was stored still parses as a bare list, and + // is worth reading — it only costs the "you are here" line until the next fetch. + ?: runCatching { + Snapshot( + commits = + json.decodeFromString>(snapshotText.orEmpty()) + ) + } + .getOrNull() + // An empty snapshot rather than an early return with an empty *feed*. The + // snapshot is one window's worth of commits; the archive is every commit ever + // walked, and it lives in a different file — so giving up here because this + // file is missing or unreadable would throw away thousands of commits that are + // still on disk. Falling through costs nothing when there really is nothing: + // the merge below simply has no fallback to merge. + ?: Snapshot() + build( + timeline(cached.commits, windowStart), + // Its own file first; the feed snapshot's copy is the fallback behind it. + readRepo() ?: cached.repo, + windowStart, + fromCache = true, + offline = freshness != Freshness.Cached, + // Read back rather than recomputed: it comes from the `Link` header of a request + // this path did not make, and without it a cached read loses the commit numbering + // that the "you are here" marker is built on. + totalCommits = cached.totalCommits, + ) + } + + /** + * The commits the feed should render, from the archive where it reaches and [fallback] where it + * does not. + * + * Both sources are used rather than one: the archive is authoritative — it is the only thing + * that reaches past the newest hundred — but it lives in the cache directory and can be cleared + * out from under us at any moment, in which case the page must still show what it just fetched + * rather than nothing. Merging by SHA makes the overlap between them free. + * + * The window is applied here, at the end, so it is a view of the archive rather than a limit on + * what is kept. Narrowing the window is then instant and costs no request, and widening it + * later finds the history already on disk. + */ + private fun timeline(fallback: List, windowStart: Long): List { + val merged = LinkedHashMap() + fallback.forEach { merged[it.sha] = it } + archive.read().forEach { merged[it.sha] = it } + // The author date — when the commit was written — because that is the date printed beside + // every row, and a list ordered by one date and labelled with another reads as broken. That + // it is not the committer date [backfill] walks on is not an inconsistency: the cursor is a + // minimum over a whole page, taken commit by commit through [cursorDateOf], and never the + // first or last entry of a sorted list. Ordering here and the cursor there are answers to + // different questions and neither is derived from the other. + val all = merged.values.sortedByDescending { it.commit.author.date } + // Learned from the whole archive, not from the window, so a co-author trailer in a recent + // commit can be resolved by an attribution GitHub made three years ago. + identities = identityIndex(all) + return if (windowStart <= 0) all + else all.filter { parseIso8601(it.commit.author.date) >= windowStart } + } + + /** + * Addresses and names that GitHub has already told us belong to an account. + * + * The problem this solves is co-author trailers. `Co-authored-by: Someone ` + * carries no account, and there is no API that turns an address into one — the users search + * endpoint deliberately refuses to index email addresses, and answers `total_count: 0` for a + * `@users.noreply.github.com` one no matter how it is phrased. So the address either resolves + * from something already in hand or it does not resolve at all. + * + * Something already in hand is exactly what the archive is. Every commit carries both the git + * identity that wrote it — name and email — and, when GitHub could match that identity to an + * account, the account itself. Every such commit is therefore a verified email-to-login pair, + * published by the only party in a position to know. Someone who co-authored one commit has + * very often authored another; indexing the pairs makes the second commit answer the first. + * + * The cost is one pass over a list already in memory, and no request at all. Names are indexed + * too, one tier weaker: a display name is not unique and is claimed first-wins, which is the + * right trade for a credit line but would not be for anything that mattered more. + */ + private fun identityIndex(commits: List): Map { + val index = HashMap() + commits.forEach { c -> + val user = c.author ?: return@forEach + val person = + CommitPerson( + login = user.login, + avatarUrl = user.avatarUrl, + profileUrl = user.htmlUrl, + isBot = user.type == "Bot" || user.login.endsWith("[bot]"), + ) + c.commit.author.email.lowercase().takeIf { it.isNotEmpty() }?.let { + index.putIfAbsent(it, person) + } + c.commit.author.name.lowercase().takeIf { it.isNotEmpty() }?.let { + index.putIfAbsent(it, person) + } + } + return index + } + + /** Rebuilt on every load from the archive; empty until the first one. */ + @Volatile private var identities: Map = emptyMap() + + /** + * Every commit on the default branch, ever, as GitHub last reported it. + * + * From the `Link: rel="last"` header of a one-per-page request — a number the walk can be + * checked against. Holding that many unique commits *is* holding the history, which is a + * better answer than "a page came back empty": it is known before the request that would have + * proved it, so a finished archive stops asking rather than asking once more to be told no. + */ + @Volatile private var knownTotalCommits: Long = 0 + + /** + * Walks the history backwards, a page at a time, and stops. + * + * ## The algorithm + * + * The cursor is the commit date of the oldest commit held, and each request asks for commits at + * or before it — `until=`, not `page=`. Page numbers are the obvious choice and the wrong one: + * they are relative to the tip, so a single new commit landing mid-walk shifts every boundary + * and silently skips or repeats a page. A date is absolute. The cost of that choice is that + * the boundary commit comes back again on the next request, which is harmless because the + * archive is keyed by SHA. + * + * The *commit* date, not the author date, because that is what `until` filters on and the two + * are not the same — about half of the newest hundred commits here differ, by as much as three + * weeks. A cursor on the author date would ask for commits before a moment that had already + * passed for some of them, and they would never be seen again. + * + * A date cursor has one failure mode, and this repository has it. Squashes and imports stamp + * many commits with the same second — 100+ of these share `2023-02-26T08:48:49Z` — and a plateau + * wider than one page is a wall the cursor cannot climb: every request returns the same hundred, + * and the walk stops a fifth of the way through history believing it is done. So when a page + * fails to move the cursor, the walk pages by number *within that timestamp* until it does. + * Numbered paging is safe in exactly this position and nowhere else: the window is anchored by + * an `until` in the past, so commits landing now fall outside it and cannot shift it. + * + * Completion is an *empty* page, and nothing weaker. "Nothing new in this page" is what the + * plateau produces on every request, and "fewer than a hundred" is what a shared boundary + * second produces legitimately; neither means the history has run out. + * + * ## Why it stops early + * + * An anonymous client gets sixty requests an hour and a few thousand commits is thirty of them. + * So this fetches a handful of pages and returns, leaving the cursor on disk for the next call + * — from the next launch, or from the reader scrolling towards the end of the list. A history + * that assembles over a few sessions is fine; one that spends someone's entire rate limit in a + * single launch, and then leaves the store empty for an hour, is not. + * + * Returns the number of commits genuinely new to the archive. + */ + suspend fun backfill(maxPages: Int = 3): Int = + withContext(Dispatchers.IO) { + var state = archive.state() + if (state.complete) return@withContext 0 + + val known = archive.read() + // The cheapest completion test there is, and the only one that does not cost a + // request: the project has a known number of commits and this holds that many. It also + // covers the case the page-walk cannot — an archive assembled across several sessions + // whose last page happened to end exactly on the first commit, which otherwise stays + // "incomplete" forever and re-asks on every visit to the foot of the feed. + if (knownTotalCommits > 0 && known.size >= knownTotalCommits) { + archive.writeState(state.copy(complete = true)) + return@withContext 0 + } + val seen = known.mapTo(mutableSetOf()) { it.sha } + var cursor = + state.oldestSeenEpochSeconds.takeIf { it > 0 } + ?: known.minOfOrNull { cursorDateOf(it) } + ?: return@withContext 0 + var page = state.pageWithinCursor.coerceAtLeast(1) + + var added = 0 + repeat(maxPages) { + val url = "$API/$REPO/commits?until=${iso8601(cursor)}&per_page=100&page=$page" + val result = + runCatching { + get(url, Freshness.Revalidate)?.let { + json.decodeFromString>(it) + } + } + val batch = result.getOrNull() + // A refused or failed request is not the end of history. Leaving `complete` false + // means the next call tries again rather than declaring the archive finished + // because GitHub was rate limiting at the time. + if (batch == null) { + logW("feed: history backfill $url unavailable", result.exceptionOrNull()) + return@repeat + } + + if (batch.isEmpty()) { + state = state.copy(complete = true) + archive.writeState(state) + archive.compactIfWasteful() + return@withContext added + } + + val fresh = batch.filterNot { it.sha in seen } + if (fresh.isNotEmpty()) { + archive.append(fresh) + fresh.forEach { seen += it.sha } + added += fresh.size + } + + // Whether the cursor can move is decided by the whole page, not by the new part of + // it: inside a plateau every commit is already known and the oldest date is + // unchanged, which is exactly the case the page number exists to get past. + val oldest = batch.minOf { cursorDateOf(it) } + if (oldest < cursor) { + cursor = oldest + page = 1 + } else { + page++ + } + state = + state.copy( + oldestSeenEpochSeconds = cursor, + pageWithinCursor = page, + pagesFetched = state.pagesFetched + 1, + ) + archive.writeState(state) + } + archive.compactIfWasteful() + added + } + + /** The date `until` understands: when the commit landed, falling back to when it was written. */ + private fun cursorDateOf(commit: GhCommit): Long = + parseIso8601(commit.commit.committer?.date ?: commit.commit.author.date) + + /** + * What the feed file holds. + * + * The total is stored beside the commits because it cannot be derived from them: it comes from + * the `Link: rel="last"` header of the commit request, and a cached read makes no request. + */ + @Serializable + private data class Snapshot( + val totalCommits: Long = 0L, + val commits: List = emptyList(), + /** + * The stars, forks and licence line. + * + * Stored for the same reason as the total: it comes from a second request, and a cached + * read makes none — so without it the "take part" numbers would be missing on every launch + * that deliberately does not fetch, which is most of them. `github_repo.json` owns the copy + * that is preferred on read; this one stays as the fallback behind it. + */ + val repo: GhRepo? = null, + ) + + private class Fetched( + val rawCommits: List, + val repo: GhRepo?, + val totalCommits: Long, + ) + + private fun fetch(windowStartEpochSeconds: Long, freshness: Freshness): Fetched { + val since = iso8601(windowStartEpochSeconds) + val commits = + get("$API/$REPO/commits?since=$since&per_page=100", freshness)?.let { + json.decodeFromString>(it) + } ?: throw IllegalStateException("commits unavailable") + + // The repo stats are a nice-to-have; a failure here must not lose the commits. + val repo = + runCatching { get("$API/$REPO", freshness)?.let { json.decodeFromString(it) } } + .getOrNull() + + val total = runCatching { fetchTotalCommits() }.getOrDefault(0L) + return Fetched(commits, repo, total) + } + + /** + * How many commits the default branch has, ever. + * + * There is no field for this, but asking for one commit per page makes GitHub report the last + * page number in its `Link` header, and that number is the count. One cheap request, and it + * is what makes "you are N commits behind" exact rather than a guess. + */ + private fun fetchTotalCommits(): Long { + val request = + Request.Builder() + .url("$API/$REPO/commits?per_page=1") + .header("Accept", "application/vnd.github+json") + .build() + client.newCall(request).execute().use { response -> + val link = response.header("Link") ?: return 0L + return LAST_PAGE.find(link)?.groupValues?.getOrNull(1)?.toLongOrNull() ?: 0L + } + } + + /** + * A body, and the status that came with it. + * + * Almost every caller wants the payload and nothing else, which is what [get] is for. + * [resolvePerson] is the exception: it has to tell "GitHub says there is no such account" apart + * from "GitHub would not answer just now", and those two differ only in the code. + */ + private class Answer(val code: Int, val body: String?) + + private fun get(url: String, freshness: Freshness): String? = getWithStatus(url, freshness).body + + private fun getWithStatus(url: String, freshness: Freshness): Answer { + val request = + Request.Builder() + .url(url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .apply { + // OkHttp replays the stored ETag as If-None-Match on its own. A 304 costs + // nothing against GitHub's 60/hour budget, so revalidation stays cheap. + cacheControl( + when (freshness) { + Freshness.Force -> CacheControl.FORCE_NETWORK + Freshness.Cached -> CacheControl.FORCE_CACHE + Freshness.Revalidate -> + CacheControl.Builder() + .maxAge(REVALIDATE_MINUTES.toInt(), TimeUnit.MINUTES) + .build() + } + ) + } + .build() + + client.newCall(request).execute().use { response -> + return Answer( + response.code, + if (response.isSuccessful) response.body.string() else null, + ) + } + } + + private fun build( + raw: List, + repo: GhRepo?, + windowStart: Long, + fromCache: Boolean, + offline: Boolean, + totalCommits: Long, + freshness: Freshness = Freshness.Cached, + ): CommunityFeed { + if (totalCommits > 0) knownTotalCommits = totalCommits + // Read once: three of the answers below are about what is *held*, not what is shown. + val archived = archive.read() + val archiveOldest = + archived.minOfOrNull { parseIso8601(it.commit.author.date) } ?: Long.MAX_VALUE + val commits = + raw.map { c -> + val subject = c.commit.message.lineSequence().first().trim() + val primary = + c.author?.let { + CommitPerson( + login = it.login, + avatarUrl = it.avatarUrl, + profileUrl = it.htmlUrl, + isBot = it.type == "Bot" || it.login.endsWith("[bot]"), + ) + } + // GitHub links a commit to an account by email, and does not always + // manage it — a commit written under an address the account never + // verified arrives with no author at all. The trailer path already knows + // how to make something of a name and an address, so it is reused here + // rather than dropping the person to a bare string. + ?: person(c.commit.author.name, c.commit.author.email, freshness) + + // Everyone credited, author first, deduplicated case-insensitively — the + // maintainer often appears in a trailer on their own commits. + val authors = + (listOf(primary) + coAuthors(c.commit.message, freshness)).distinctBy { + it.login.lowercase() + } + + TimelineCommit( + sha = c.sha, + shortSha = c.sha.take(7), + subject = subject.removeSuffix(" (#${prNumber(subject) ?: ""})").trim(), + kind = CommitKind.of(subject), + pullRequest = prNumber(subject), + authors = authors, + epochSeconds = parseIso8601(c.commit.author.date), + htmlUrl = c.htmlUrl, + globalIndex = 0, // assigned after sorting, below + + // Collaboration counts: a commit the maintainer landed with an outside + // co-author is a community contribution and is highlighted as one. + isCommunity = + authors.any { + !it.isBot && !it.login.equals(OWNER, ignoreCase = true) + }, + isBot = primary.isBot, + ) + } + .sortedByDescending { it.epochSeconds } + // Newest-first, so the head of the list is the newest commit and its distance + // from the repository root is the total count. + // + // Counting down by position is only right while the list is one unbroken run from + // HEAD, and what keeps it unbroken is overlap: every fetch is anchored at HEAD and + // brings back the newest hundred, while the backfill only ever extends the oldest + // end, so the two meet unless a hundred commits land between two fetches. Nothing + // stronger is available — GitHub publishes no per-commit number, and the payload + // carries no ancestry to check a run against — so a page lost after it was written + // leaves the numbers below the seam reading high. That slides the "you are here" + // marker further down the feed; it cannot place a commit where none was. + .mapIndexed { index, commit -> commit.copy(globalIndex = totalCommits - index) } + + // Credit follows people, not commits: a co-author is a contributor. Bots are excluded — + // automation is not a contributor. + val contributors = + commits + .flatMap { commit -> commit.authors.map { person -> person to commit.epochSeconds } } + .filterNot { (person, _) -> person.isBot } + .groupBy { (person, _) -> person.login.lowercase() } + .map { (_, entries) -> + val people = entries.map { it.first } + Contributor( + login = people.first().login, + avatarUrl = people.firstNotNullOfOrNull { it.avatarUrl }, + profileUrl = people.firstNotNullOfOrNull { it.profileUrl }, + commits = entries.size, + lastEpochSeconds = entries.maxOf { it.second }, + ) + } + // Ties break on recency, so among equals the person who contributed most + // recently is shown first — the row is meant to move. + .sortedWith( + compareByDescending { it.commits } + .thenByDescending { it.lastEpochSeconds } + .thenBy { it.login } + ) + + return CommunityFeed( + commits = commits, + contributors = contributors, + // The oldest commit actually in hand, not the window that was asked for. They differ + // whenever the window reaches further back than the history does — always, for an + // unbounded window — and "since 1 January 1970" would be a strange thing to tell + // someone. Saying how far the data really goes is both honest and more useful. + windowStartEpochSeconds = + commits.minOfOrNull { it.epochSeconds }?.coerceAtLeast(windowStart) ?: windowStart, + repo = repo, + totalCommits = totalCommits, + fromCache = fromCache, + offline = offline, + loaded = true, + // Whether *fetching* could add anything, which is not the same question as whether + // the window is full. + // + // Judged against the oldest commit in the **archive**, never against the oldest one on + // screen. The rendered list is already cut to the window, so its oldest entry is at or + // after the window's start by construction; a test against that can never fail, and a + // bounded window would offer "load earlier commits" forever, on a fetch that could only + // return commits the window would throw away again. + hasMoreHistory = + !archive.state().complete && + !(totalCommits > 0 && archived.size >= totalCommits) && + (windowStart <= 0 || archiveOldest > windowStart), + // The archive reaches past the start of a bounded window: everything the window can + // ever show is already held, so the foot says so instead of inviting a fetch. + windowCovered = windowStart > 0 && archiveOldest <= windowStart, + ) + } + + /** + * Parses `Co-authored-by: Name ` trailers. + * + * When the address is a GitHub noreply one the login is exactly the local part, and the + * numeric prefix — `44231502+byemaxx@users.noreply.github.com` — is the user id, which yields + * the real avatar. Otherwise [identityIndex] is asked whether this project has seen the address + * attributed before, which resolves most of the rest for free. Only when all of that fails is + * the person shown under the name they signed with, with a monogram rather than being dropped. + */ + private fun coAuthors(message: String, freshness: Freshness): List = + CO_AUTHOR.findAll(message) + .map { match -> person(match.groupValues[1].trim(), match.groupValues[2].trim(), freshness) } + .toList() + + /** + * The best account we can make of a name and an email address. + * + * Four tiers, cheapest and most certain first: an address GitHub has already attributed + * somewhere in the archive is simply that account; a `@users.noreply.github.com` address *is* + * the account and needs nothing but parsing; a name seen attributed before costs nothing + * either; a handle-shaped name is worth one lookup. Failing all four, the person is shown under + * the name they signed with, uncredited but not dropped. + */ + private fun person(name: String, email: String, freshness: Freshness): CommitPerson { + // Before anything else, and before any request: an address this project has seen attributed + // is settled, and no amount of guessing improves on GitHub's own answer. + identities[email.lowercase()]?.let { + return it + } + val noreply = GITHUB_NOREPLY.find(email) + if (noreply != null) { + val id = noreply.groupValues[1].takeIf { it.isNotEmpty() } + val login = noreply.groupValues[2] + return CommitPerson( + login = login, + avatarUrl = + if (id != null) "https://avatars.githubusercontent.com/u/$id?v=4" + else "https://github.com/$login.png", + profileUrl = "https://github.com/$login", + isBot = login.endsWith("[bot]"), + ) + } + identities[name.lowercase()]?.let { + return it + } + return resolvePerson(name, freshness)?.let { + CommitPerson( + login = it.login, + avatarUrl = it.avatarUrl, + profileUrl = it.profileUrl, + isBot = it.isBot, + ) + } ?: CommitPerson(login = name, avatarUrl = null, profileUrl = null) + } + + /** + * The releases page, or null when GitHub could not be reached. + * + * Both readers of this endpoint are launched from a `viewModelScope` or a `LaunchedEffect`, + * neither of which has a `CoroutineExceptionHandler` anywhere in this app — so a throw here is + * a fatal on the main thread rather than a card that stays empty. Every other request in this + * file already guards itself; these two did not, which is how a resolver failure took the whole + * manager down instead of leaving the update card blank. + * + * One helper for both because it is the same request: fetching it twice under two URLs built + * from the same three constants is how the two lists drift apart. + */ + private fun releaseListJson(freshness: Freshness): String? = + runCatching { get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) } + .onFailure { e -> logW("update: github release list unavailable", e) } + .getOrNull() + + /** + * The issues that have been closed as done, newest first. + * + * **Asked of the issue tracker rather than derived from the commits, and that is not a + * shortcut.** An issue linked through GitHub's *Development* panel — the usual way here — is + * closed by the merge itself and leaves no trace in any commit message, so reading the history + * would report only the minority that happened to be written up as `Fixes #816`. The link that + * did the closing lives in `PullRequest.closingIssuesReferences`, which exists only in GraphQL, + * and GraphQL answers 403 without an account. This endpoint answers the neighbouring question + * anonymously, and the answer is the one worth showing: not which commit closed what, but what + * has been fixed since the reader's build. + * + * Closed is not fixed: `not_planned` and `duplicate` are also closures, and were a fifth of one + * page here. Only `completed` is counted. + * + * One page, unpaginated. A hundred items reaches back several weeks on this repository, which + * covers the span between any two builds a reader could be choosing between; older than that + * and the number stops mattering because the reader is being told to update, not to test. + */ + suspend fun closedIssues(freshness: Freshness = Freshness.Revalidate): List = + withContext(Dispatchers.IO) { + val url = "$API/$REPO/issues?state=closed&per_page=100&sort=updated&direction=desc" + val body = + runCatching { get(url, freshness) } + .onFailure { e -> logW("canary: closed issue list unavailable", e) } + .getOrNull() ?: return@withContext emptyList() + + runCatching { json.decodeFromString>(body) } + .onFailure { e -> logE("canary: closed issue list unreadable", e) } + .getOrDefault(emptyList()) + .filter { !it.isPullRequest && it.stateReason == "completed" } + .mapNotNull { issue -> + val closed = parseIso8601(issue.closedAt ?: return@mapNotNull null) + ClosedIssue( + number = issue.number, + title = issue.title, + closedAtEpoch = closed.takeIf { it > 0 } ?: return@mapNotNull null, + htmlUrl = issue.htmlUrl, + ) + } + .sortedByDescending { it.closedAtEpoch } + } + + /** + * Every published build, both channels, newest first. + * + * The canaries here are **prereleases**, not Actions artifacts, and that is the whole point. + * GitHub gates an artifact download behind an account even for a public repository — + * `actions/artifacts//zip` answers 401 to an anonymous caller, while a release asset answers + * 206 — so sourcing canaries from artifacts would mean asking every would-be tester for an OAuth + * grant to work around a storage decision. CI attaches the same zips to a rolling + * `canary-` prerelease, and this reads those, so nobody signs in to anything. + * + * A canary is recognised by its tag rather than by being a prerelease: a hand-cut release + * candidate is also a prerelease, and it is not a nightly. + * + * One fetch for both channels because they come from the same endpoint, because deciding which + * channel a reader is on needs to see both — a canary that has aged out of the rolling five is + * still recognisable by being *newer than the newest stable release*, and that comparison is + * impossible with only one of the two lists in hand — and because the canary list is this same + * answer filtered, not a second question. + */ + suspend fun frameworkReleases(freshness: Freshness = Freshness.Revalidate): + List = + withContext(Dispatchers.IO) { + val body = releaseListJson(freshness) ?: return@withContext emptyList() + + runCatching { json.decodeFromString>(body) } + .onFailure { e -> logE("update: release list unreadable", e) } + .getOrDefault(emptyList()) + .mapNotNull { release -> + val canary = release.prerelease && release.tagName.startsWith(CANARY_TAG_PREFIX) + FrameworkRelease( + tag = release.tagName, + title = release.name ?: release.tagName, + versionCode = release.versionCode() ?: return@mapNotNull null, + isCanary = canary, + notesMarkdown = release.body, + htmlUrl = release.htmlUrl, + epochSeconds = parseIso8601(release.publishedAt.orEmpty()), + // A branch name is not a build. Only a SHA identifies one. + commit = + release.targetCommitish.takeIf { c -> + c.length >= 7 && c.all { it.isDigit() || it in 'a'..'f' } + }, + zips = + release.assets + .filter { it.name.endsWith(".zip", ignoreCase = true) } + .map { + CanaryArtifact( + id = it.id, + name = it.name, + sizeInBytes = it.size, + downloadUrl = it.downloadUrl, + ) + }, + ) + } + .sortedByDescending { it.versionCode } + } + + /** + * The build number a release represents, or null when it is not comparable with ours. + * + * `canary-3049` states it outright. A stable tag does not, so it is read out of the zip's file + * name — the CI names them with the same version code — and a release whose number cannot be + * established at all is dropped rather than compared as zero, which would have made every + * stable release look older than every canary. + * + * **Only releases of this product count, and that is not pedantry.** The version code restarted + * when LSPosed became Vector: this repository's own release list holds `LSPosed-v1.11.0-7209` + * beside `Vector-v2.0-3021`, so a plain numeric comparison makes the *older* project look four + * thousand builds newer, and the manager would offer LSPosed 1.11.0 to a Vector device as an + * update — a cross-product downgrade, flashed with root. Matching the [ZIP_PREFIX] asset prefix + * is what keeps the comparison inside one numbering scheme. + */ + private fun GhRelease.versionCode(): Long? { + val ours = assets.filter { it.name.startsWith(ZIP_PREFIX, ignoreCase = true) } + if (ours.isEmpty()) return null + if (tagName.startsWith(CANARY_TAG_PREFIX)) { + return tagName.removePrefix(CANARY_TAG_PREFIX).toLongOrNull() + } + return ours.firstNotNullOfOrNull { asset -> + Regex("(\\d{3,})").findAll(asset.name).map { it.value }.lastOrNull()?.toLongOrNull() + } + } + + @Serializable + private data class ResolvedPerson( + val login: String, + val avatarUrl: String?, + val profileUrl: String?, + val isBot: Boolean, + ) + + /** + * Turns a name signed in a trailer into a GitHub account, when it can be done safely. + * + * GitHub's own web UI resolves these by matching the commit *email* to an account, which it can + * do because it holds every address a user has ever verified. We cannot: the address is usually + * private, and `search/users?q=…+in:email` finds nothing for it — checked against + * `krc440002@gmail.com`, which the web UI resolves and the search API returns zero results for. + * + * What is left is asking whether an account exists under that name, and that is only safe for + * names that are plainly *handles*. `GET /users/Qing` answers 200 with a real account — id + * 158244, an unrelated person — so probing every display name would eventually attach a + * stranger's face and profile to someone else's contribution. [HANDLE_SHAPED] keeps the shape + * of a handle (`frknkrc44`) and rejects the shape of a name (`Qing`, `Furkan Karcıoğlu`). + * + * The cost of the guard is that a handle made only of letters stays unresolved. That is the + * right way to be wrong: an unlinked contributor is merely uncredited, a mislinked one is + * credited to the wrong person. + */ + private fun resolvePerson(name: String, freshness: Freshness): ResolvedPerson? { + val key = name.lowercase() + if (resolvedPeople.containsKey(key)) return resolvedPeople[key] + if (!HANDLE_SHAPED.matches(name)) return null + // A cache-only load must not decide that a name is unresolvable: the request would be + // served FORCE_CACHE, miss, and the miss would be written down permanently — so the first + // launch after install, which reads the feed from disk, would poison every name before the + // network was ever asked. + if (freshness == Freshness.Cached) return null + + val answer = runCatching { getWithStatus("$API_ROOT/users/$name", freshness) }.getOrNull() + val found = + runCatching { + answer?.body?.let { + val user = json.decodeFromString(it) + ResolvedPerson( + login = user.login, + avatarUrl = user.avatarUrl, + profileUrl = user.htmlUrl, + isBot = user.type == "Bot" || user.login.endsWith("[bot]"), + ) + } + } + .getOrNull() + + // Only a 404 says anything about the person. A rate-limited 403, a 5xx or a dropped + // connection says something about the moment, and this map is persisted — writing one of + // those down as "no such account" would leave a contributor uncredited on every later + // launch. Anything that is not a plain "not found" leaves the key absent, and the next load + // asks again. + if (found == null && answer?.code != HTTP_NOT_FOUND) return null + + resolvedPeople[key] = found + runCatching { peopleFile.writeText(json.encodeToString(resolvedPeople.toMap())) } + return found + } + + private fun prNumber(subject: String): Int? = + PR_SUFFIX.find(subject)?.groupValues?.getOrNull(1)?.toIntOrNull() + + private fun iso8601(epochSeconds: Long): String { + val cal = + java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")).apply { + timeInMillis = epochSeconds * 1000 + } + return String.format( + java.util.Locale.ROOT, + "%04d-%02d-%02dT%02d:%02d:%02dZ", + cal.get(java.util.Calendar.YEAR), + cal.get(java.util.Calendar.MONTH) + 1, + cal.get(java.util.Calendar.DAY_OF_MONTH), + cal.get(java.util.Calendar.HOUR_OF_DAY), + cal.get(java.util.Calendar.MINUTE), + cal.get(java.util.Calendar.SECOND), + ) + } + + private fun parseIso8601(value: String): Long = + runCatching { + val f = + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.ROOT) + .apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } + (f.parse(value)?.time ?: 0L) / 1000 + } + .getOrDefault(0L) + + companion object { + const val OWNER = "JingMatrix" + const val REPO = "$OWNER/Vector" + const val REPO_URL = "https://github.com/$REPO" + const val ISSUES_URL = "$REPO_URL/issues" + const val PULLS_URL = "$REPO_URL/pulls" + const val DISCUSSIONS_URL = "$REPO_URL/discussions" + + /** + * The Actions page, filtered the way the project README's build badge filters it. + * + * `event:push` and `is:completed` as well as the branch: a run started by hand, or one + * still going, is not a build anyone should be told to fetch, and the badge is the link + * that already draws that line. + */ + const val CANARY_URL = + "$REPO_URL/actions/workflows/core.yml" + + "?query=event%3Apush+branch%3Amaster+is%3Acompleted" + private const val CANARY_TAG_PREFIX = "canary-" + + /** + * What this product's own release zips are called. + * + * The release list still carries the pre-rename LSPosed builds, whose version codes are + * from a different and higher numbering; see `versionCode()`. + */ + private const val ZIP_PREFIX = "Vector-" + + /** CI keeps five; a few extra are fetched so a stable release among them costs nothing. */ + private const val CANARY_FETCH = 12 + + /** + * How many canaries CI keeps, which the canary screen states as reassurance. + * + * Read from here rather than written into the sentence, so the promise the screen makes + * and the number the workflow prunes to cannot drift apart silently. + */ + const val CANARY_KEEP = 5 + + private const val API = "https://api.github.com/repos" + private const val API_ROOT = "https://api.github.com" + + /** The only status that is an answer about a person rather than about the hour. */ + private const val HTTP_NOT_FOUND = 404 + + /** + * A name shaped like a handle rather than a display name. + * + * A digit or a hyphen somewhere in it, no spaces, no accented letters, and within GitHub's + * 39-character limit. See [resolvePerson] for why the bar is deliberately this high. + * + * An underscore is not a handle character — a login is alphanumerics and single hyphens, + * nothing else — so `foo_bar` is a display name that no account can be under, and asking + * about it spends a request to be told what the shape already said. + */ + private val HANDLE_SHAPED = + Regex("^(?=.*[0-9-])[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$") + + /** + * Six months: long enough that a quiet stretch does not read as a dead project, short + * enough that the people row is still a scoreboard rather than a monument. At this + * project's rate that is ~44 commits, which is one unpaginated request. Overridable in + * settings. + */ + const val DEFAULT_WINDOW_MONTHS = 6 + + /** The window is a rough reach backwards, not a calendar, so a flat month will do. */ + private const val DAYS_PER_MONTH = 30L + + /** How long a fetched answer is served without asking GitHub anything; see [Freshness]. */ + private const val REVALIDATE_MINUTES = 30L + + private val PR_SUFFIX = Regex("""\(#(\d+)\)\s*$""") + + + private val LAST_PAGE = Regex("""[?&]page=(\d+)>;\s*rel="last"""") + + private val CO_AUTHOR = + Regex("""(?im)^\s*Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$""") + + private val GITHUB_NOREPLY = + Regex("""^(?:(\d+)\+)?([^@]+)@users\.noreply\.github\.com$""", RegexOption.IGNORE_CASE) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt new file mode 100644 index 000000000..28c7a1de7 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt @@ -0,0 +1,188 @@ +package org.matrix.vector.manager.data.log + +import android.content.Context +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.stackTraceOf + +/** + * Keeps the manager's own crashes where the log export already looks for them. + * + * The daemon captures the manager's logcat output — `logcat.cpp` routes any tag beginning `Vector` + * to the verbose stream, so `VectorManager` lines land in the Verbose tab beside the daemon's own. + * It captures the manager's *crashes* too, since the same filter admits `LOG_ID_CRASH`. Both, + * however, only while verbose logging is switched on and only while a daemon is alive to do the + * capturing — and neither is true in the case that matters most, which is a manager crashing on a + * device whose framework is not activated. + * + * So the trace is written to disk first, and the platform's own handler runs afterwards. The + * system dialog still appears, the tombstone is still written, `logcat -b crash` still has it. + * + * The trace is formatted by [stackTraceOf] rather than `Log.getStackTraceString`, which returns + * `""` for any [java.net.UnknownHostException] cause chain and so wrote header-only files for every + * crash on the network path — the class of crash this exists to catch. + * + * **The directory is not a choice.** `FileSystem.getLogs`, which builds the zip the log screen + * exports, already collects two of them: + * ``` + * addDir("crash_shell", File("/data/data/${'$'}{MANAGER_INJECTED_PKG_NAME}/cache/crash")) + * addDir("crash_manager", File("/data/data/${'$'}{DEFAULT_MANAGER_PACKAGE_NAME}/cache/crash")) + * ``` + * Those are this process's `cacheDir/crash` in each of the two ways the manager can run — + * parasitically inside `com.android.shell`, or standalone. Writing here means a crash travels in + * the export with no new binder call, no daemon change, and no second place for anyone to look. + * One file per crash, named for the epoch millisecond it happened at: a crash loop produces several + * within the same second, and a name coarser than that would have each one overwrite the last. + * + * Two properties matter more than anything this class does: + * + * - **It never throws.** It runs inside a process that is already dying, on a thread whose stack + * just unwound. An exception here would replace a diagnosable crash with an undiagnosable one, + * so every step is wrapped and failure is silent by design. + * - **It always delegates.** The previous handler is captured and called even if the write fails. + * Parasitically this is not our process — it is `com.android.shell`, and quietly swallowing that + * process's crashes because the manager happened to be open would be far worse than losing a + * trace. For the same reason it records whatever crashes, ours or not, rather than trying to + * guess whose stack frame it is looking at. + */ +object CrashRecorder { + + private const val DIR_NAME = "crash" + + /** How many crashes are kept. Older ones are the least likely to still be true. */ + private const val MAX_FILES = 5 + + @Volatile private var installed = false + + /** + * Takes over the default handler, once per process, and discards what another build left. + * + * Called from [org.matrix.vector.manager.di.ServiceLocator.attach], early in the activity's + * `onCreate` and before anything that could fail, so the handler is in place before any screen + * exists. + */ + @Synchronized + fun install(context: Context) { + if (installed) return + installed = true + val application = context.applicationContext ?: context + runCatching { discardOtherBuilds(application) } + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { record(application, thread, throwable) } + previous?.uncaughtException(thread, throwable) + } + } + + /** The recorded crashes, newest first, or null when there have been none. */ + fun read(context: Context): String? = + runCatching { + files(context) + .joinToString("\n") { it.readText().trimEnd() } + .ifBlank { null } + } + .getOrNull() + + /** + * The newest crash, parsed — what the card summarises and the trace screen lists. + * + * Only the newest: the question the status screen answers is "what just happened", and the + * older records are still on file, still in [read]'s output, and still in the log export. + */ + fun newest(context: Context): CrashReport? = + runCatching { files(context).firstOrNull()?.readText()?.let(::parseCrashReport) } + .getOrNull() + + fun clear(context: Context) { + runCatching { files(context).forEach { it.delete() } } + } + + /** + * Drops the records that some other build of the manager wrote. + * + * A record outlives the build that made it — the directory is this process's cache, which an + * update does not clear — so the status card goes on showing a crash that the running build + * has already fixed, until somebody thinks to press clear. That is not a stale line on a + * screen. It is what a reporter copies into the issue to show the fix did not work: #799 was + * answered with five traces from the build before the one that fixed them. + * + * Only at [install], never at [record]: this is about what a *new* build inherits, and a crash + * loop within one build must keep every record it makes. + * + * A file whose second line cannot be read is kept. Losing a trace is the worse of the two + * failures, and a record this class cannot parse is exactly the one worth still having. + */ + private fun discardOtherBuilds(context: Context) { + val current = BUILD + files(context).forEach { file -> + val theirs = runCatching { file.useLines { it.drop(1).firstOrNull() } }.getOrNull() + if (theirs != null && !theirs.startsWith(current)) runCatching { file.delete() } + } + } + + /** Where the log export collects them from, as named in this class's own documentation. */ + private fun directory(context: Context): File = File(context.cacheDir, DIR_NAME) + + /** Newest first, which is the order both the card and the clipboard want. */ + private fun files(context: Context): List = + directory(context) + .listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } + ?.sortedByDescending { it.name.removeSuffix(SUFFIX).toLongOrNull() ?: 0L } + .orEmpty() + + private fun record(context: Context, thread: Thread, throwable: Throwable) { + val now = System.currentTimeMillis() + val dir = directory(context) + dir.mkdirs() + val text = buildString { + append(header(context, thread, now)) + append('\n') + append(stackTraceOf(throwable)) + append('\n') + } + runCatching { File(dir, "$now$SUFFIX").writeText(text) } + // After writing, so a failure to prune never costs us the record we just made. + runCatching { files(context).drop(MAX_FILES).forEach { it.delete() } } + } + + /** + * The context a stack trace alone does not carry. + * + * Which build, which of the two ways the manager can be running, which thread, and which + * platform — the four things that are always asked first and are never in the trace. Written + * in [Locale.ROOT] on purpose: this text exists to be pasted into an issue, and a crash report + * whose date is formatted for the reporter's locale is a crash report the reader has to parse. + * + * Split across two lines by what a reader can find elsewhere rather than by subject. When and + * on which thread is the first line because nothing else on the status screen answers it; the + * build and the platform are the second because everything there is repeated a few rows down. + * [parseCrashReport] reads the split back, so the two lines are a format, not a layout. + */ + private fun header(context: Context, thread: Thread, at: Long): String { + val parasitic = context.packageName == BuildConfig.INJECTED_PACKAGE_NAME + val host = if (parasitic) "parasitic in ${context.packageName}" else "standalone" + return "${TIMESTAMP.format(Date(at))} · thread ${thread.name}\n" + + "$BUILD · $host · " + + "android ${android.os.Build.VERSION.RELEASE} (sdk ${android.os.Build.VERSION.SDK_INT})" + } + + /** + * How a record says which build wrote it, and so what [discardOtherBuilds] matches on. + * + * The whole line rather than the hash alone: a build from a dirty tree carries the hash of the + * commit it was built from, so two of them can share it while differing by everything that was + * uncommitted at the time. + */ + private val BUILD: String + get() = + "manager ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) " + + BuildConfig.VERSION_HASH + + private const val SUFFIX = ".log" + + private val TIMESTAMP + get() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.ROOT) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt new file mode 100644 index 000000000..983864831 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashReport.kt @@ -0,0 +1,226 @@ +package org.matrix.vector.manager.data.log + +/** + * A recorded crash, in the shape the screens ask questions of. + * + * The file [CrashRecorder] writes is the record; this is that record read back. Parsing it here + * rather than rendering the text means the UI can answer "what threw", "where", and "is this frame + * ours" without a reader having to find those things in a wall of monospace — and it means the one + * frame that names our own code can be pulled to the front of a summary, which is the single fact a + * bug report is usually missing. + * + * Parsing never decides what is kept. A line the parser does not recognise contributes no frame and + * nothing more; the file on disk is untouched, [CrashRecorder.read] still returns every byte of it, + * and the copy action on the trace screen reads from there rather than from anything here. A trace + * is evidence, and failing to understand it is not a reason to be unable to hand it over. + */ +data class CrashReport( + /** The recorded timestamp, in the fixed format the file was written with. */ + val at: String, + /** + * The thread that threw, or empty for a record written before the header carried one — the + * cache outlives an update, so the first run after one reads the old shape. + */ + val thread: String, + /** Build, host and platform, as one line. Restated from "What is running" on that screen. */ + val build: String, + /** The throwable, then what caused it, in the order `printStackTrace` prints them. */ + val sections: List, +) { + /** + * The innermost cause, which is the thing that actually went wrong. + * + * `RuntimeException: Unable to start activity` is the platform restating where it noticed; the + * end of the chain is the sentence worth putting in a summary. + */ + val root: CrashSection? + get() = sections.lastOrNull() + + /** + * The first frame in code we ship, anywhere in the chain. + * + * A crash inside `ActivityThread` is not a report anyone can act on until it says which of our + * frames led there, and that frame is rarely near the top — the platform's own frames sit above + * it. Null when nothing in the trace is ours, which happens and is itself worth seeing. + */ + val ours: CrashFrame? + get() = sections.firstNotNullOfOrNull { section -> section.frames.firstOrNull { it.ours } } +} + +/** One throwable in the chain: what it was, what it said, and where it had been. */ +data class CrashSection( + /** The fully qualified type, e.g. `java.net.UnknownHostException`. */ + val type: String, + val message: String?, + val frames: List, + /** The `... N more` count, which stands for frames identical to the ones already printed. */ + val elided: Int, + /** False for the throwable that reached the handler, true for everything under `Caused by:`. */ + val isCause: Boolean, +) { + /** The type without its package, which is what a heading has room for. */ + val simpleType: String + get() = type.substringAfterLast('.') +} + +/** One `at ...` line, split at the point where it stops being a name and starts being a place. */ +data class CrashFrame( + /** `org.matrix.vector.manager.ui.MainActivity.onCreate` */ + val method: String, + /** `MainActivity.kt:39`, or null for a native frame, which prints no source. */ + val location: String?, + /** Whether the class belongs to something in this repository rather than to the platform. */ + val ours: Boolean, +) { + /** `MainActivity.onCreate` — the part a reader recognises, without the package. */ + val shortMethod: String + get() { + val method = this.method.substringAfterLast('.', "") + val type = this.method.substringBeforeLast('.').substringAfterLast('.') + return if (method.isEmpty() || type.isEmpty()) this.method else "$type.$method" + } + + /** The line as it was written, for copying a single frame. */ + val line: String + get() = if (location == null) "at $method" else "at $method($location)" +} + +/** + * The packages this project ships, by prefix. + * + * Used only to decide emphasis, so being wrong costs a frame its highlight and nothing else. The + * legacy Xposed prefixes are here because a module's crash goes through them and a reader chasing + * one wants those frames to stand out for the same reason they want ours to. + */ +private val OUR_PACKAGES = + listOf( + "org.matrix.vector", + // Where this project's own code used to live. A trace is read long after it was captured — + // out of a saved report, or out of the crash cache an update did not clear — so the frames + // an older build wrote still arrive under the old name and are still ours. + "org.lsposed.lspd", + "de.robv.android.xposed", + "io.github.libxposed", + ) + +private val FRAME = Regex("""^\s*at (.+?)(?:\(([^)]*)\))?$""") +private val ELIDED = Regex("""^\s*\.\.\. (\d+) more$""") +private const val CAUSED_BY = "Caused by: " +private const val SUPPRESSED = "Suppressed: " + +/** + * Reads back a record written by [CrashRecorder]. + * + * The two header lines are ours; everything after them is a stack trace, handed to + * [parseStackTrace]. + * + * Returns null only when there is no header to read, never on a trace it cannot make sense of. + */ +fun parseCrashReport(record: String): CrashReport? { + val lines = record.trimEnd().lines() + if (lines.size < 2) return null + val (at, thread) = + lines[0].split(" · thread ", limit = 2).let { it[0] to it.getOrElse(1) { "" } } + return CrashReport( + at = at, + thread = thread, + build = lines[1], + sections = parseStackTrace(lines.drop(2)), + ) +} + +/** + * `Throwable.printStackTrace` output, as the chain of throwables it describes. + * + * The shape is fixed by the JDK: a header line naming the throwable, tab-indented `at` lines, an + * optional `... N more`, and the same again after `Caused by:`. Suppressed exceptions print under + * `Suppressed:` and are treated as another link, since for reading purposes they are one. + * + * Total by construction. A line it does not recognise contributes nothing and ends nothing; text + * that is not a trace at all yields an empty list, which is how a caller asks "is there a trace + * here" without a second parser to decide it first. Written against the *printed* form rather than + * against our own writer, because the traces it is given come from the daemon, from modules, and + * from the platform's crash handler as readily as from us. + */ +fun parseStackTrace(trace: String): List = parseStackTrace(trace.trimEnd().lines()) + +/** + * The same, for a caller that already holds the lines. + * + * The log panel does: an entry's continuation lines *are* the trace, so joining them into a string + * for this to split again would be work done twice on every visible row. + */ +fun parseStackTrace(lines: List): List { + val sections = mutableListOf() + + var type: String? = null + var message: String? = null + var isCause = false + var open = false + var frames = mutableListOf() + var elided = 0 + + fun flush() { + if (!open) return + sections += CrashSection(type.orEmpty(), message, frames.toList(), elided, isCause) + frames = mutableListOf() + elided = 0 + open = false + type = null + message = null + isCause = false + } + + for (line in lines) { + val frame = FRAME.matchEntire(line) + val skipped = ELIDED.matchEntire(line) + when { + frame != null -> { + // A frame may arrive before any header, and does whenever the text handed here is + // only the *continuation* of a log entry: `XposedBridge.log(Throwable)` writes the + // whole trace as one message, so the header lands on the entry's own line and the + // frames land under it. Such a trace opens an untyped section. Reading the frame as + // a header instead — which a stricter rule did — spent it on a heading that said + // "java:248)", the tail of the frame it had just eaten. + open = true + val method = frame.groupValues[1] + val location = frame.groupValues[2].takeIf { it.isNotEmpty() } + frames += CrashFrame(method, location, OUR_PACKAGES.any(method::startsWith)) + } + skipped != null -> { + open = true + elided = skipped.groupValues[1].toIntOrNull() ?: 0 + } + line.isBlank() -> Unit + else -> { + // A header: the throwable itself, or one introduced by Caused by:/Suppressed:. + flush() + open = true + isCause = line.startsWith(CAUSED_BY) || line.startsWith(SUPPRESSED) + val header = line.removePrefix(CAUSED_BY).removePrefix(SUPPRESSED).trim() + // "type: message", where the type never contains a space and the message may. + val split = header.indexOf(": ") + type = if (split < 0) header else header.substring(0, split) + message = if (split < 0) null else header.substring(split + 2) + } + } + } + flush() + return sections +} + +/** + * A line that introduces a throwable, written flush left by `printStackTrace`. + * + * Either a labelled link in the chain, or a bare header: a dotted type name with no spaces in it, + * ending in something that reads as a throwable, optionally followed by `: ` and a message. + * Deliberately narrow, because callers use it to decide whether a line belongs to a trace at all — + * "store: refreshing failed" is rejected on the first test, having no dot in its type. + */ +fun isThrowableHeader(text: String): Boolean { + if (text.startsWith(CAUSED_BY) || text.startsWith(SUPPRESSED)) return true + val type = text.substringBefore(": ") + return type.contains('.') && + type.none { it.isWhitespace() } && + (type.endsWith("Exception") || type.endsWith("Error") || type.endsWith("Throwable")) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogArchiveName.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogArchiveName.kt new file mode 100644 index 000000000..b6895ed03 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogArchiveName.kt @@ -0,0 +1,42 @@ +package org.matrix.vector.manager.data.log + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import org.matrix.vector.manager.BuildConfig + +/** + * What a saved bug report is called, wherever it is saved from. + * + * Three places produce one — the log panel, the troubleshooting page, and the root export that + * tars the daemon's folder — and they used to name it three ways. The name is the first thing + * anyone attaching one to an issue sees, and it has to say which build it came from: a report from + * a debug build explains behaviour that a release build does not have, and asking after the fact + * is a round trip that the file name can save. + * + * Not a string resource. It was one, translated into nineteen locales, but a file name is not + * language — a report named in Persian and one named in German are the same file, and the + * translations only made it possible for them to disagree. + * + * [extension] rather than a fixed `zip`: the manager builds a zip through SAF, while the root + * export shells out to `tar`, which is what Android actually ships. Only the extension differs. + */ +fun logArchiveName(extension: String): String = + "Vector-logs-${BuildConfig.BUILD_TYPE}-${LocalDateTime.now().format(ARCHIVE_STAMP)}.$extension" + +/** + * Which build wrote an archive, for the archive itself to carry. + * + * The name says the build type and no more, because a name has to stay short enough to read. What + * identifies a *binary* is the commit: the version code is the commit count on master, so every + * branch build at the same depth wears the number of an official build it was never made from. + * + * Where this goes depends on what the format offers. A zip has a comment field and gets this + * verbatim; a backup is our own document and carries it as a field. `tar` has no such slot at all, + * so the root export can only say what its name says. + */ +fun archiveBuildStamp(): String = + "Vector ${BuildConfig.BUILD_TYPE} ${BuildConfig.VERSION_NAME} " + + "(${BuildConfig.VERSION_CODE}) ${BuildConfig.VERSION_HASH}" + +/** Sortable, no separators a file manager or a shell would have to be told about. */ +private val ARCHIVE_STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt new file mode 100644 index 000000000..bb898ea95 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt @@ -0,0 +1,455 @@ +package org.matrix.vector.manager.data.log + +import android.os.ParcelFileDescriptor +import java.io.Closeable +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.yield + +/** + * A random-access window onto one of the daemon's log files. + * + * A single log file is capped today: `logcat.cpp` rotates at 4 MB per part. That cap belongs to + * the daemon build that happens to be running, though, and the manager cannot inspect the + * provenance of the descriptor it was handed before it reads from it. **A reader whose peak memory + * scales with file size is wrong regardless of today's number** — reading a part into `String`s + * would be megabytes of churn per refresh, inside a process whose heap belongs to + * `com.android.shell`. + * + * So nothing here scales with the file: + * - [index] never allocates a `String`. It scans bytes for `'\n'` and records line offsets into a + * `LongArray` — 8 bytes per line, ~240 KB for a full part, and capped at [MAX_INDEXED_LINES]. + * - [readRows] materialises at most a window's worth of lines, reading them in one seek per + * 256 KB block. Peak heap is a function of the window size alone. + * - [scan] streams the whole file to build a filter, but only ever holds one block plus the + * matching *offsets*. + * + * The descriptor is real and seekable: `ManagerService.getVerboseLog()` opens + * `/proc/self/fd/N`, which resolves the procfs symlink back to the log inode, so positional reads + * work and this class exploits them rather than streaming forward. + * + * Ownership is exactly one object. [ParcelFileDescriptor.AutoCloseInputStream] adopts the + * descriptor and closes it once, in [close]. Wrapping the raw `pfd.fileDescriptor` in a + * `FileInputStream` *and* closing the `ParcelFileDescriptor` separately closes the same fd number + * twice, and between the two closes the runtime is free to hand that number to an OkHttp socket or + * a Coil bitmap, which the second close then silently detaches. + */ +class LogFile(pfd: ParcelFileDescriptor) : Closeable { + + private val stream = ParcelFileDescriptor.AutoCloseInputStream(pfd) + private val channel: FileChannel = stream.channel + private val block = ByteArray(READ_BLOCK) + private val oneByte = ByteBuffer.allocate(1) + + /** + * Pass one: where every line starts. + * + * One sequential read of the page cache with a tight byte loop and no decoding at all. The + * size is captured once and everything past it is ignored, so a line the daemon is appending + * while this runs is never half-decoded — it simply appears on the next refresh. + */ + suspend fun index(): LogIndex { + val size = channel.size() + val starts = LongVec() + starts.add(0L) + var dropped = 0 + var pos = 0L + + while (pos < size) { + val want = min(READ_BLOCK.toLong(), size - pos).toInt() + val read = readAt(pos, want) + if (read <= 0) break + for (i in 0 until read) { + if (block[i] == NEWLINE) starts.add(pos + i + 1) + } + pos += read + + // A file long enough to blow the offset table is a file nobody is going to read from + // the top anyway, so the leading offsets are dropped and the header says so. Silently + // showing a truncated file as if it were whole is the one thing not allowed. + if (starts.size > MAX_INDEXED_LINES + DROP_BLOCK) { + starts.dropFirst(DROP_BLOCK) + dropped += DROP_BLOCK + } + yield() + } + + // The array doubles as its own end sentinel: line k spans [bounds[k], bounds[k + 1]). + if (starts.last() != size) starts.add(size) + return LogIndex(starts.toArray(), dropped) + } + + /** + * Pass two: turn a selection of lines into rows. + * + * [lines] is ascending and absolute. The unfiltered case passes a contiguous run, so the read + * covers exactly the bytes needed; a filtered case passes a sparse selection, and the block + * loop below reads through the gaps and discards them rather than issuing one seek per line. + * Either way the bytes held at once are bounded by [READ_BLOCK]. + */ + suspend fun readRows(index: LogIndex, lines: IntArray): List { + val rows = ArrayList(lines.size + 8) + var lastDate: String? = null + var traceOwner = -1 + var continuation: ArrayList? = null + // Set when a line folded into the entry above had been cut. The flag belongs on the row a + // reader can see, and after a join the cut line may no longer be one of them. + var continuationCut = false + + fun flushContinuation() { + val lines = continuation + if (traceOwner >= 0 && lines != null) { + val owner = rows[traceOwner] as LogRow.Entry + rows[traceOwner] = + owner.copy(continuation = lines, truncated = owner.truncated || continuationCut) + } + continuation = null + continuationCut = false + traceOwner = -1 + } + + fun addContinuation(text: String, cut: Boolean) { + (continuation ?: ArrayList(8).also { continuation = it }).add(text) + if (cut) continuationCut = true + } + + forEachLine(index, lines, null) { lineIndex, text, truncated -> + // Parsed first, always. A multi-line message reaches the file as one writev, so its + // continuation lines carry no prefix — but *carrying* one is what makes a line an entry + // of its own, and asking only whether a line could be a continuation swallows the whole + // log into whichever entry happens to be first. + val row = parseLogLine(lineIndex, text, truncated) + val owner = if (traceOwner >= 0) rows[traceOwner] as LogRow.Entry else null + if (owner != null && row !is LogRow.Entry && isContinuationLine(text)) { + addContinuation(text, truncated) + } else if (owner != null && row is LogRow.Entry && isSplitChunk(owner, row)) { + // A tail the writer was forced to cut off. Its prefix is dropped and its message + // rejoins the entry above, which is where it was written; the lines after it need + // no special case, because [traceOwner] never moved. + addContinuation(row.message, truncated) + } else { + flushContinuation() + when (row) { + is LogRow.Entry -> { + if (row.date != lastDate) { + lastDate = row.date + rows.add(LogRow.DayBreak(lineIndex, row.date)) + } + rows.add(row) + traceOwner = rows.size - 1 + } + else -> rows.add(row) + } + } + } + flushContinuation() + return rows + } + + /** + * Walks back from [line] to the entry that owns it. + * + * Without this a window boundary landing inside a multi-line message opens the page on its tail + * with nothing to attach it to. It stops on the first line that is an entry — the owner — or on + * one of the daemon's raw banners, which own nothing. + * + * Indented lines, which are most of them, still cost one byte: nothing else in the format + * begins with a space or a tab, so they are continuations without being read. Anything else + * costs a capped read of the line's head, which is what the general rule needs — a continuation + * is no longer recognisable from its first character, and pretending otherwise is what left the + * `Caused by:` and the `--- Parsed Mount Argument ---` lines stranded. + * + * A line that is *neither* is also where the walk has to stop: it is a marker the scanner could + * not read, and stepping over it would hand the window a start belonging to some earlier + * entry. + * + * An entry that [looksLikeSplitChunk] is stepped over too, since [readRows] is about to fold it + * into the entry above and stopping on it would open the page on half a trace again. Only the + * message is examined, not [isSplitChunk]'s full test: the entry this one would be compared + * against is further back than the line above, and walking one line too far only widens the + * window, which is free — whereas stopping one line too early is the bug. + */ + fun entryStart(index: LogIndex, line: Int): Int { + if (line >= index.lineCount) return line + var at = line + var steps = 0 + while (at > 0 && steps < TRACE_LOOKBACK) { + val first = firstByte(index, at) + if (first != SPACE && first != TAB) { + val text = lineText(index, at) + val row = parseLogLine(at, text) + if (row is LogRow.Entry) { + if (!looksLikeSplitChunk(row.message)) break + } else if (!isContinuationLine(text)) break + } + at-- + steps++ + } + return at + } + + /** + * Builds the filter, and the facets, in one streaming pass. + * + * Only a *matching* line is ever kept, and only as its offset, so filtering a 40 MB file costs + * one sequential read and an `IntArray` of hits. Progress is a real fraction of bytes scanned + * rather than a spinner, because on a large file this is long enough to be worth reporting + * honestly. + */ + suspend fun scan( + index: LogIndex, + query: LogQuery, + onProgress: (Float) -> Unit, + ): LogScanResult { + val matches = if (query.isActive) IntVec() else null + val tags = HashMap() + val levels = HashMap() + var previousMatched = false + // The last row that was an entry, which is what lets a line be read as part of its message + // here exactly as [readRows] reads it — both the unprefixed kind and the split-off tail. + // Without it the two passes disagree, and a filtered view drops the half of a trace the + // unfiltered one keeps. It is the row rather than a flag because [isSplitChunk] compares + // against the entry itself. + var lastEntry: LogRow.Entry? = null + + forEachLine(index, null, onProgress) { lineIndex, text, truncated -> + // Parsed before the continuation test, for the reason given in [readRows]: a line that + // carries a prefix is an entry whatever precedes it. + val row = parseLogLine(lineIndex, text, truncated) + val owner = lastEntry + val joins = + owner != null && + if (row is LogRow.Entry) isSplitChunk(owner, row) else isContinuationLine(text) + if (joins) { + // Frames follow their entry into the filtered view; a stack trace whose header + // matched and whose body vanished is a filter actively hiding the answer. A joined + // tail counts for neither facet, because the row it belongs to was counted once. + if (previousMatched) matches?.add(lineIndex) + return@forEachLine + } + if (row is LogRow.Entry) { + tags[row.tag] = (tags[row.tag] ?: 0) + 1 + levels[row.level] = (levels[row.level] ?: 0) + 1 + } + lastEntry = row as? LogRow.Entry + previousMatched = query.matches(row) + if (previousMatched) matches?.add(lineIndex) + } + + return LogScanResult( + matches = matches?.toArray(), + facets = + LogFacets( + tags = tags.entries.sortedByDescending { it.value }.map { it.key to it.value }, + levels = levels, + ), + ) + } + + override fun close() { + runCatching { stream.close() } + } + + // --- Block iteration --------------------------------------------------------------------- + + /** + * Feeds lines to [action] a block at a time. + * + * Blocks end on a line boundary, so no line ever straddles two reads and the caller never has + * to stitch. A single line longer than [READ_BLOCK] is the one exception and is cut short — + * [MAX_LINE_BYTES] cuts it far sooner in any case. + */ + private suspend fun forEachLine( + index: LogIndex, + selection: IntArray?, + onProgress: ((Float) -> Unit)?, + action: (lineIndex: Int, text: String, truncated: Boolean) -> Unit, + ) { + val bounds = index.bounds + val count = selection?.size ?: index.lineCount + if (count == 0) return + val span = (bounds[index.lineCount] - bounds[0]).coerceAtLeast(1L) + + var k = 0 + while (k < count) { + val startLine = selection?.get(k) ?: k + val startOffset = bounds[startLine] + + // Take as many whole lines as fit in one block, always at least one. + var endLine = startLine + 1 + while (endLine < index.lineCount && bounds[endLine + 1] - startOffset <= READ_BLOCK) { + endLine++ + } + val want = min(bounds[endLine] - startOffset, READ_BLOCK.toLong()).toInt() + val read = readAt(startOffset, want) + + while (k < count) { + val line = selection?.get(k) ?: k + if (line >= endLine) break + val from = (bounds[line] - startOffset).toInt() + val to = min((bounds[line + 1] - startOffset).toInt(), read) + var length = max(0, to - from) + // The stored bound includes the newline that ended the line. + if (length > 0 && block[from + length - 1] == NEWLINE) length-- + if (length > 0 && block[from + length - 1] == RETURN) length-- + val cut = length > MAX_LINE_BYTES + if (cut) { + // The limit is a byte count, so it can land in the middle of a UTF-8 sequence. + // Backing up over the continuation bytes cuts between characters instead of + // handing the decoder half of one, which it would show as a replacement mark. + length = MAX_LINE_BYTES + while (length > 0 && (block[from + length].toInt() and 0xC0) == 0x80) length-- + } + action(line, String(block, from, length, Charsets.UTF_8), cut) + k++ + } + + onProgress?.invoke(((bounds[endLine] - bounds[0]).toFloat() / span).coerceIn(0f, 1f)) + yield() + } + } + + /** Fills [block] from [offset]; returns how many bytes actually landed. */ + private fun readAt(offset: Long, length: Int): Int { + val buffer = ByteBuffer.wrap(block, 0, length) + var total = 0 + while (buffer.hasRemaining()) { + val n = channel.read(buffer, offset + total) + if (n <= 0) break + total += n + } + return total + } + + /** + * The head of a line, decoded — enough of it to tell an entry and a banner from a continuation. + * + * Only the head is needed: [parseLogLine] decides on the prefix, which is fixed-width and far + * shorter than this, and [isContinuationLine] on a banner, which is shorter still. So the read + * is capped rather than following a line of unbounded length. It borrows [block], which is safe + * only because the one caller, [entryStart], runs between block iterations and never during + * one. + */ + private fun lineText(index: LogIndex, line: Int): String { + val from = index.bounds[line] + val length = min(index.bounds[line + 1] - from, HEADER_PROBE.toLong()).toInt() + if (length <= 0) return "" + val read = readAt(from, length) + return if (read <= 0) "" else String(block, 0, read, Charsets.UTF_8).trimEnd('\n', '\r') + } + + private fun firstByte(index: LogIndex, line: Int): Int { + if (index.bounds[line + 1] <= index.bounds[line]) return -1 + oneByte.clear() + if (channel.read(oneByte, index.bounds[line]) <= 0) return -1 + return oneByte.get(0).toInt() + } + + companion object { + /** One page-cache-friendly read. Also the largest amount of raw log held at any moment. */ + private const val READ_BLOCK = 256 * 1024 + + /** + * 400,000 lines is ~3.2 MB of offsets, and more than ten times the lines in a full 4 MB + * part. Past it the *oldest* lines are dropped, because a log is read from the end. + */ + private const val MAX_INDEXED_LINES = 400_000 + + private const val DROP_BLOCK = 50_000 + + /** How far back a window start may walk to find the entry that owns a stack frame. */ + private const val TRACE_LOOKBACK = 64 + + /** Comfortably past the longest throwable type name anyone has written. */ + private const val HEADER_PROBE = 512 + + private const val NEWLINE = '\n'.code.toByte() + private const val RETURN = '\r'.code.toByte() + private const val SPACE = ' '.code + private const val TAB = '\t'.code + } +} + +/** + * Where every line of the file starts, plus the end sentinel. + * + * `bounds` has `lineCount + 1` entries; line `k` is the bytes in `[bounds[k], bounds[k + 1])`. + * [droppedLeading] is how many lines fell off the front of an over-long file, and exists so the + * header can say so rather than quietly misreport the file's length. + */ +class LogIndex(val bounds: LongArray, val droppedLeading: Int) { + val lineCount: Int + get() = bounds.size - 1 +} + +/** What [LogFile.scan] found: the filtered line numbers, and what the file contains. */ +class LogScanResult(val matches: IntArray?, val facets: LogFacets) + +/** The tags and levels actually present, with counts, so the filter sheet cannot go stale. */ +data class LogFacets( + val tags: List> = emptyList(), + val levels: Map = emptyMap(), +) + +/** Everything that narrows the view. All of it is applied in one pass over the file. */ +data class LogQuery( + val levels: Set = emptySet(), + val tag: String? = null, + val text: String = "", +) { + val isActive: Boolean + get() = levels.isNotEmpty() || tag != null || text.isNotBlank() + + fun matches(row: LogRow): Boolean = + when (row) { + is LogRow.Entry -> + (levels.isEmpty() || row.level in levels) && + (tag == null || row.tag == tag) && + (text.isBlank() || + row.message.contains(text, ignoreCase = true) || + row.tag.contains(text, ignoreCase = true)) + // A rotation banner has neither level nor tag, so it survives only a plain text + // search. It marks where the daemon restarted, which is worth keeping when it can be. + is LogRow.Marker -> + levels.isEmpty() && + tag == null && + (text.isBlank() || row.text.contains(text, ignoreCase = true)) + is LogRow.DayBreak -> false + } +} + +/** Growable `long` storage. `ArrayList` would box every offset. */ +private class LongVec(initial: Int = 1 shl 12) { + private var data = LongArray(initial) + var size = 0 + private set + + fun add(value: Long) { + if (size == data.size) data = data.copyOf(size * 2) + data[size++] = value + } + + fun last(): Long = if (size == 0) -1L else data[size - 1] + + fun dropFirst(n: Int) { + System.arraycopy(data, n, data, 0, size - n) + size -= n + } + + fun toArray(): LongArray = data.copyOf(size) +} + +/** The same, for line numbers, which are half the width. */ +private class IntVec(initial: Int = 1 shl 10) { + private var data = IntArray(initial) + private var size = 0 + + fun add(value: Int) { + if (size == data.size) data = data.copyOf(size * 2) + data[size++] = value + } + + fun toArray(): IntArray = data.copyOf(size) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt new file mode 100644 index 000000000..bb6ee3469 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt @@ -0,0 +1,325 @@ +package org.matrix.vector.manager.data.log + +/** + * The shape of a line in the daemon's log, and the scanner that recovers it. + * + * The authority for this format is not a sample of the output — it is the writer, + * `daemon/src/main/jni/logcat.cpp`, which emits every entry as a single `writev` of + * + * ``` + * "[ " %Y-%m-%dT%H:%M:%S ".%03ld %8d:%6d:%6d %c/%-15.*s ] " "\n" + * ``` + * + * Three properties of that format decide how this parser is written: + * + * 1. **The widths are `printf` minimums, not columns.** A uid of `1010324` is seven digits and a + * future pid can exceed six, so the prefix has to be *scanned*. Slicing at constant offsets + * works right up until the day it silently does not. + * 2. **A message containing newlines is still one `writev`.** Its continuation lines therefore + * carry no prefix at all, and *nothing* marks them as continuations — a stack trace's frames + * happen to be indented, but a `Caused by:` and `zygisk-core64`'s mount-argument report are + * flush left. They belong to the entry above them, not to nothing. See [isContinuationLine]. + * The exception is a message past the logger's payload, which the *writer* splits into several + * entries before liblog ever sees it; [isSplitChunk] puts those back together. + * 3. **Not every line is an entry.** `----part N start----` / `-----part N end----` mark the + * daemon rotating to a fresh file, and the watchdog writes its own banners. Those are real + * information about the log rather than noise, so they survive as [LogRow.Marker] instead of + * being dropped. They are also the *only* unprefixed lines that are not continuations, which + * is what makes rule 2 decidable. + * + * Anything the scanner cannot make sense of degrades to a marker carrying the raw text. A log + * viewer that hides a line it failed to understand is worse than useless during a diagnosis. + */ +enum class LogLevel(val char: Char) { + VERBOSE('V'), + DEBUG('D'), + INFO('I'), + WARN('W'), + ERROR('E'), + FATAL('F'), + SILENT('S'), + UNKNOWN('?'); + + companion object { + /** The characters `kLogChar` in logcat.cpp emits, indexed by Android's log priority. */ + fun of(c: Char): LogLevel = + when (c) { + 'V' -> VERBOSE + 'D' -> DEBUG + 'I' -> INFO + 'W' -> WARN + 'E' -> ERROR + 'F' -> FATAL + 'S' -> SILENT + else -> UNKNOWN + } + + /** + * The levels worth offering as a filter. Nothing writes at `SILENT`, and `UNKNOWN` is what + * an unrecognised level character degrades to. + */ + val selectable = listOf(VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL) + } +} + +/** One row of the rendered log. [index] is the line's absolute position in the file. */ +sealed interface LogRow { + val index: Int + + /** + * Stable identity for the lazy list. + * + * This is what lets the window be extended upwards without the viewport lurching: the list + * re-resolves its first visible item by key after rows are inserted above it, so a prepend + * re-anchors instead of shifting. A day break shares its line's index with the entry it + * introduces, so its key is negated to keep the two distinct. + */ + val key: Long + get() = index.toLong() + + data class Entry( + override val index: Int, + /** `yyyy-MM-dd`, kept as written; only the day separator ever needs it. */ + val date: String, + /** `HH:mm:ss.SSS`. The date is redundant on every row and moves to the separator. */ + val time: String, + val uid: Int, + val pid: Int, + val tid: Int, + val level: LogLevel, + val tag: String, + val message: String, + /** + * The rest of a multi-line message: every line of it after the first, which the writer + * emitted in the same `writev` and so left without a prefix. Often a stack trace, often + * not — `zygisk-core64` reports a parsed mount argument this way. + */ + val continuation: List = emptyList(), + /** Set when the line exceeded [MAX_LINE_BYTES] and was cut. */ + val truncated: Boolean = false, + ) : LogRow + + /** A rotation banner, a watchdog line, or anything the scanner could not read. */ + data class Marker(override val index: Int, val text: String) : LogRow + + /** Synthetic: introduces the first entry of a calendar day. */ + data class DayBreak(override val index: Int, val date: String) : LogRow { + override val key: Long + get() = -(index.toLong() + 1) + } +} + +/** + * Cut point for a single line, counted in bytes because that is what the reader has: the line is + * cut before it is decoded, from the byte offsets the index recorded. + * + * The longest line observed in either log on a real device is 816 characters (an attestation + * dump), so this only ever bites on pathological output — but without it one runaway line sets + * the horizontal extent for the entire list and makes panning useless. + */ +const val MAX_LINE_BYTES = 4096 + +/** The three-character delimiter that ends the prefix. See [parseLogLine]. */ +private const val DELIMITER = " ] " + +/** `"[ "` plus the 23-character timestamp; below this the fixed-position checks run off the end. */ +private const val MIN_PREFIX = 26 + +/** + * Whether a line belongs to the entry above it rather than standing on its own. + * + * The answer follows from the writer, not from the look of the line. `logcat.cpp` emits an entry as + * a single `writev` whose first `iovec` is the prefix, so **every** line of a multi-line message + * after the first arrives without one. An unprefixed line under an entry is therefore a + * continuation by construction, whatever it looks like. + * + * Testing the look instead is what this used to do, and it was wrong in both directions of the same + * failure. Indented lines passed; anything else was cut loose into [LogRow.Marker], one row per + * line. `Throwable.printStackTrace` writes its header and every `Caused by:` flush left, so a cause + * chain shredded. `zygisk-core64` prints a mount-argument block flush left, so a twenty-line report + * of what it had parsed shredded too — a divider drawn between every line of it, and the whole + * thing visually detached from the entry that produced it. + * + * So the rule is inverted: a line is a continuation unless it is one of the four things the daemon + * writes raw, which [isRawBanner] names. + * + * **Two things the caller must have established first**, neither of which this can see: + * 1. That [text] is not itself an entry. This answers only "does this *unprefixed* line belong to + * the entry above", and a prefixed line is not unprefixed — asked without that check it says yes + * to every line in the file, and the log collapses into its first entry with a hundred + * continuations hanging off it. [parseLogLine] is the check. + * 2. That there is an entry above at all. Before the first one of a file, and after a banner, there + * is nothing to continue. + * + * Both guards are the caller's because both are facts about position in the file rather than about + * the line, and a reader that got either wrong would disagree with the other pass over the same + * bytes — which is how a filtered view comes to drop the half of a trace the unfiltered one keeps. + */ +fun isContinuationLine(text: String): Boolean = !isRawBanner(text) + +/** + * Whether [row] is the tail of a message the writer had to cut in two, rather than an entry. + * + * Rule 2 holds only up to a point. `android.util.Log.e(tag, msg, tr)` does not hand liblog a + * message longer than the logger's payload — `printlns` in AOSP's `Log.java` walks the string and + * emits it as *several* entries, breaking at the last newline that fits. Each of those is a real + * entry with its own prefix, written by its own `writev`, and logd stores them separately: a 5.7 KB + * crash arrives as one entry ending mid-trace and another beginning `\tat …`, both stamped the same + * millisecond. The manager's own [org.matrix.vector.manager.logE] splits the same way and for the + * same reason, since liblog would otherwise truncate the tail. + * + * So this is not a guess at structure — it is the *undo* of a split the writer did not choose, and + * it is the one place the "a prefixed line is an entry" rule is knowingly overruled. It is kept + * narrow to earn that: same tag, same process, same thread, same level, immediately adjacent, and a + * message that reads as a tail rather than a beginning. Four writers would have to collide inside + * one thread, at one level, on one tag, with the second opening on whitespace, before this joined + * two things that were never one. + * + * Timestamps are deliberately **not** compared. The chunks leave `printlns` microseconds apart and + * the format keeps milliseconds, so they usually match — usually is not a rule, and a boundary that + * happened to straddle a tick would strand exactly the long trace this exists for. + * + * Being wrong costs a divider between two rows that stay legible either way; nothing is discarded. + */ +fun isSplitChunk(previous: LogRow.Entry, row: LogRow.Entry): Boolean = + previous.tag == row.tag && + previous.pid == row.pid && + previous.tid == row.tid && + previous.level == row.level && + looksLikeSplitChunk(row.message) + +/** + * Whether a message opens the way a continuation does rather than the way a message does. + * + * `printlns` breaks on a newline, so a tail begins with whatever line followed the break: inside a + * stack trace that is an indented frame, or a `Caused by:`/`Suppressed:` written flush left. A + * writer starting its *own* message with whitespace is close enough to unheard of to be worth + * trading against joining the traces this exists for. + * + * The other case — `printlns` hard-splitting a single line too long to break — is deliberately not + * recognised. Its tail begins mid-token, which is indistinguishable from a message, and stack + * frames never reach that length anyway. + */ +fun looksLikeSplitChunk(message: String): Boolean = + message.isNotEmpty() && + (message[0].isWhitespace() || + message.startsWith("Caused by: ") || + message.startsWith("Suppressed: ")) + +/** + * The lines the daemon writes to the file itself, outside any entry. + * + * `Logcat::LogRaw` has exactly two callers and the rotation code exactly two more, so this list is + * closed and can be enumerated rather than guessed at: + * ``` + * "----part %zu start----\n" // OpenFd + * "-----part %zu end----\n" // CloseFd + * "\nLogd crashed too many times, trying manually start...\n" // OnCrash + * "\nLogd maybe crashed (err=%s), retrying in 1s...\n" // OnCrash + * ``` + * Both crash banners are written with a leading newline, so a blank line is one of their parts and + * not a line of its own. + * + * Anything added to that file has to be added here too, or it will be swallowed into whichever + * entry precedes it. The alternative — guessing from shape — is what shredded the traces. + */ +private fun isRawBanner(text: String): Boolean = + text.isEmpty() || + PART_BANNER.matches(text) || + text.startsWith("Logd crashed too many times") || + text.startsWith("Logd maybe crashed (err=") + +private val PART_BANNER = Regex("""-{4,}part \d+ (start|end)-{4,}""") + +/** Parses one raw line, degrading to [LogRow.Marker] rather than failing. */ +fun parseLogLine(index: Int, text: String, truncated: Boolean = false): LogRow = + parseEntry(index, text, truncated) ?: LogRow.Marker(index, text) + +private fun parseEntry(index: Int, line: String, truncated: Boolean): LogRow.Entry? { + val n = line.length + if (n < MIN_PREFIX || line[0] != '[' || line[1] != ' ') return null + + // The timestamp is fixed-width, so it is the one part worth checking by position: cheap + // separators to reject in six comparisons before any digit scanning happens. + if ( + line[6] != '-' || + line[9] != '-' || + line[12] != 'T' || + line[15] != ':' || + line[18] != ':' || + line[21] != '.' + ) + return null + + var i = 25 // "[ " + 23 characters of timestamp + + val uidField = readInt(line, skipSpaces(line, i)) + if (uidField == NO_INT) return null + i = endOf(uidField) + if (i >= n || line[i] != ':') return null + + val pidField = readInt(line, skipSpaces(line, i + 1)) + if (pidField == NO_INT) return null + i = endOf(pidField) + if (i >= n || line[i] != ':') return null + + val tidField = readInt(line, skipSpaces(line, i + 1)) + if (tidField == NO_INT) return null + i = endOf(tidField) + + if (i + 2 >= n || line[i] != ' ' || line[i + 2] != '/') return null + val level = LogLevel.of(line[i + 1]) + val tagStart = i + 3 + + // The delimiter is the three-character sequence, not a bare ']'. A message that contains a + // bracket — "[TX_ID: 773] Intercept…" — has no space before its ']', and the tag is padded + // with spaces to fifteen columns, so the first " ] " is always the real end of the prefix. + val delimiter = line.indexOf(DELIMITER, tagStart) + if (delimiter < 0) return null + + return LogRow.Entry( + index = index, + date = line.substring(2, 12), + time = line.substring(13, 25), + uid = valueOf(uidField), + pid = valueOf(pidField), + tid = valueOf(tidField), + level = level, + tag = line.substring(tagStart, delimiter).trimEnd(), + message = line.substring(delimiter + DELIMITER.length), + truncated = truncated, + ) +} + +private fun skipSpaces(s: String, from: Int): Int { + var i = from + while (i < s.length && s[i] == ' ') i++ + return i +} + +/** + * [readInt] has to return both the value and where it stopped. + * + * The two are packed into one `Long` rather than returned as a `Pair`, because a `Pair` would + * allocate three times per parsed line — and a window of a full 4 MB log part is thirty thousand + * lines, re-parsed every time the window moves. A top-level scratch variable would be shorter + * still, but both log panes parse concurrently on the IO pool and would corrupt each other. + */ +private const val NO_INT = -1L + +private fun valueOf(field: Long): Int = (field ushr 32).toInt() + +private fun endOf(field: Long): Int = (field and 0xFFFFFFFFL).toInt() + +/** Reads an unsigned decimal, refusing anything long enough to overflow. */ +private fun readInt(s: String, from: Int): Long { + var i = from + var value = 0L + while (i < s.length && s[i] in '0'..'9') { + value = value * 10 + (s[i] - '0') + if (value > Int.MAX_VALUE) return NO_INT + i++ + } + if (i == from) return NO_INT + return (value shl 32) or i.toLong() +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt new file mode 100644 index 000000000..5ccb3e50b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt @@ -0,0 +1,34 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.ApplicationInfo + +/** Represents an installed application for the Scope configuration screen. */ +data class AppInfo( + val packageName: String, + val userId: Int, + val appName: String, + val isSystemApp: Boolean, + val isGame: Boolean, + val isSelectedInScope: Boolean, + /** + * In the scope without anyone having put it there, and not removable. + * + * Nothing in the scope table says so — the daemon derives this target while it rebuilds its + * configuration — so it is stamped on the row by the screen that knows the rule, exactly as + * [isSelectedInScope] and [isRecommended] are. + */ + val isImplicitInScope: Boolean = false, + val isRecommended: Boolean, + /** When the package was last installed or updated, for the "recently updated" sort. */ + val lastUpdateTime: Long, + /** When it was first installed — a different question, and the list sorts on both. */ + val firstInstallTime: Long, + /** + * The installed version, which with [lastUpdateTime] is the module detection cache's key. + * + * Defaulted because not every [AppInfo] comes from a real package; the stand-in row for the + * system server has no version to report and is never inspected. + */ + val versionCode: Long = 0, + val applicationInfo: ApplicationInfo, +) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/BuildStamp.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/BuildStamp.kt new file mode 100644 index 000000000..5cad24fee --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/BuildStamp.kt @@ -0,0 +1,74 @@ +package org.matrix.vector.manager.data.model + +/** + * A build stamp, taken apart: which commit a build came from, and where it was built. + * + * The framework and the manager both report one — `BuildConfig.VERSION_HASH`, and + * `getBuildStamp()` across the binder — because the version code cannot tell two builds apart: + * it is the commit count on origin/master, so every branch build at the same depth wears the same + * number as the official build it was never made from. + * + * Only the commit is taken out. Where a build was made is for a person to read, and it is exactly + * the rest of the string — a caller that wants it takes what follows the commit, separator and all. + * + * @property commit the commit the build was made from, abbreviated as git abbreviated it, or null + * when the stamp names none — "unknown" from a build made outside a git checkout, and anything + * else this does not recognise. That is a third answer, distinct from "these differ". + * @property modified whether the tree had uncommitted changes. Such a build was made from no commit + * at all: [commit] names the one it departed from, not one the binary corresponds to. + */ +data class BuildStamp(val commit: String?, val modified: Boolean) { + + /** + * Whether this build and [other] were made from the same commit. + * + * A modified tree matches nothing, including itself. Otherwise it is a prefix test in either + * direction, because the two sides need not be abbreviated to the same length: a stamp carries + * git's short form and a GitHub release carries the full SHA. + * + * **Where the build was made is deliberately not compared.** A fork building the same commit + * builds the same code; the repository is in the stamp to identify a *binary* to someone + * reading a bug report, which is a different question from "is this the build that release + * published". + */ + fun isCommit(other: String?): Boolean { + if (modified || other == null) return false + val ours = commit ?: return false + return other.startsWith(ours) || ours.startsWith(other) + } +} + +/** + * Reads a build stamp. + * + * The commit leads, always, and the separator says what follows it — `-` a repository that holds + * this exact commit, `+` a machine that holds changes no repository does. So the commit is the head + * and there is nothing to guess: neither character can occur inside a host name or an `owner/repo`, + * and a hyphen inside either is harmless because everything after the first one is the origin. + * + * - `93d66473` — a local build of a clean tree. + * - `93d66473-JingMatrix-Vector` — CI, from `JingMatrix/Vector`. + * - `93d66473+thinkpad` — a local build with uncommitted changes, named by the machine that made it. + * + * A stamp that does not start with a commit yields a null one, which everywhere it is asked means + * "I cannot tell" rather than "these differ". That covers the shape shipped between #809 and this + * change, `JingMatrix-Vector-93d66473`, which is what the canaries already on people's devices + * carry: they are shown as they were recorded and claimed to be neither installed nor divergent, + * which is the truthful answer for a build whose stamp this cannot read. + */ +fun buildStamp(reported: String): BuildStamp { + val stamp = reported.trim() + val head = stamp.takeWhile { it != '-' && it != '+' } + if (!head.isAbbreviatedSha()) return BuildStamp(null, modified = false) + return BuildStamp(commit = head, modified = stamp.getOrNull(head.length) == '+') +} + +/** + * Whether this could be a commit as git abbreviates one. + * + * Seven is git's own floor for an abbreviation and forty is a full SHA. Lower case only, which git + * emits and which is what keeps the word "unknown" — a build made where git could not be asked — + * from being mistaken for one. + */ +private fun String.isAbbreviatedSha(): Boolean = + length in 7..40 && all { it.isDigit() || it in 'a'..'f' } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/InstalledModule.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/InstalledModule.kt new file mode 100644 index 000000000..64290111f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/InstalledModule.kt @@ -0,0 +1,43 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.ApplicationInfo + +/** An installed Xposed module, as the Modules screen sees it. */ +data class InstalledModule( + val packageName: String, + val userId: Int, + val appName: String, + val versionName: String, + val versionCode: Long, + val description: String, + val minVersion: Int, + val targetVersion: Int, + val isLegacy: Boolean, + /** False when the module declares no Xposed API version at all, on either scale. */ + val declaresApiVersion: Boolean, + /** When the module was last installed or updated — how you find the one you just added. */ + val lastUpdateTime: Long, + val isEnabled: Boolean, + val applicationInfo: ApplicationInfo, // The row's icon is rasterised from this. +) { + /** + * The API version this module *is*, as opposed to the one it asks for. + * + * `module.prop` carries two different numbers: `minApiVersion`, the author's stated floor, and + * `targetApiVersion`, what the module was built against. Of the two, only the second decides + * anything. The daemon's `FileSystem.loadModule` tries `targetApiVersion` first — 101 or above + * loads as modern — then falls back to an `assets/xposed_init`, which makes it legacy, and + * refuses a module that declares exactly 100 with no such entry. `minApiVersion` is read + * nowhere in the daemon or the framework, so showing it as "API n" would report a number + * nothing acts on. + * + * Legacy modules keep their own number, because there is no target on that scale — it comes + * from the `xposedminversion` manifest entry and is all they have. + */ + val apiVersion: Int = + when { + isLegacy -> minVersion + targetVersion > 0 -> targetVersion + else -> minVersion + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ManagerCopy.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ManagerCopy.kt new file mode 100644 index 000000000..7a90a4d52 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ManagerCopy.kt @@ -0,0 +1,47 @@ +package org.matrix.vector.manager.data.model + +/** + * Which copy of the manager, if any, is installed beside the one that is running. + * + * Parasitically the manager is not a package at all — it is loaded into the host from the daemon's + * own APK — so installing it as an app puts a second copy of that code on the device. The two are + * meant to be the same build and nothing on the device keeps them so: an install is a moment, and + * the module behind it is reflashed whenever the framework is updated. + * + * The version code cannot tell them apart when they drift. It is `git rev-list --count + * origin/master`, so a branch build and the official build at the same depth carry the same number, + * and a copy installed from either reports the number the other would. Only the bytes settle it, + * which is why the check that produces this reaches for a digest whenever the numbers agree. + */ +enum class ManagerCopy { + + /** No such package on this device. Installing it is the whole of the offer. */ + Absent, + + /** + * Installed, and nothing has shown it to be a different build. + * + * This is also where "could not tell" lands — a daemon that will not hand over its APK, an + * install whose file could not be read — because a check that did not complete has found no + * difference, and a failed check reported as a wrong build would send the reader to replace a + * copy that is very probably fine. The card then reads exactly as it did before there was + * anything to compare, which is the state this state is deliberately indistinguishable from. + */ + Present, + + /** + * Installed, and it is a different build of Vector: another version code, or the same code over + * different bytes. + */ + Diverged; + + /** + * Whether the launcher has a Vector icon to open, whichever build is behind it. + * + * A diverged copy is still a way back in — an older or a branch build of the same manager opens + * and talks to the same daemon — so anything asking "can this device reach the manager" must + * count it, and only the offer to install reads the difference. + */ + val installed: Boolean + get() = this != Absent +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt new file mode 100644 index 000000000..8974ef1c9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt @@ -0,0 +1,276 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import java.util.Properties +import java.util.zip.ZipFile +import org.matrix.vector.manager.logW + +/** + * Everything the manager can learn about a package by looking at it. + * + * There are two generations of Xposed module and both have to be recognised. A legacy module + * announces itself with `xposedminversion` manifest meta-data; a module written against API 100 or + * later carries no such meta-data, only marker files inside its APK, so a manifest test alone + * leaves the modern half of the module list empty. + * + * The whole inspection is one pass over the APK. Opening the zip is the expensive part, so the API + * versions, the scope and the description are all read while it is open rather than in a pass each. + */ +data class ModuleManifest( + val isModule: Boolean = false, + val isLegacy: Boolean = false, + /** The Xposed API the module needs, or 0 when it does not say. */ + val minApiVersion: Int = 0, + val targetApiVersion: Int = 0, + /** Packages the module asks to hook. */ + val scope: List = emptyList(), + /** + * True when [scope] is the outer limit of what may be hooked, not merely what is asked for. + * + * It fixes the set the scope is drawn from and nothing more: which of those packages end up + * in the scope is still the user's answer, and the daemon's `ModuleDatabase.setModuleScope` + * refuses only targets beyond the claimed set, so any subset of it is stored. + * + * Never true while [scope] is empty, whatever module.prop says: see [ModuleDetection.inspect]. + */ + val staticScope: Boolean = false, + /** The module's own description, which the two generations store in different places. */ + val description: String = "", +) { + /** + * Either number counts as declaring one. + * + * A module may state only `targetApiVersion` — the one the framework loads by — and nothing + * about it is then undeclared, so testing `minApiVersion` alone would mark it unknown. + */ + val declaresApiVersion: Boolean + get() = minApiVersion > 0 || targetApiVersion > 0 +} + +object ModuleDetection { + + private const val MODERN_ENTRY = "META-INF/xposed/java_init.list" + private const val SCOPE_ENTRY = "META-INF/xposed/scope.list" + private const val MODULE_PROP = "META-INF/xposed/module.prop" + private const val LEGACY_MIN_VERSION = "xposedminversion" + private const val LEGACY_SCOPE = "xposedscope" + private const val LEGACY_DESCRIPTION = "xposeddescription" + + /** + * Reads a package's module metadata, opening each APK at most once. + * + * Returns [ModuleManifest] with `isModule = false` for anything that is not a module, so a + * caller can filter and inspect in a single step. + */ + fun inspect(info: ApplicationInfo, packageManager: PackageManager): ModuleManifest { + val legacy = info.metaData?.containsKey(LEGACY_MIN_VERSION) == true + + val apks = buildList { + info.splitSourceDirs?.let { addAll(it) } + info.sourceDir?.let { add(it) } + } + + for (apk in apks) { + val modern = + runCatching { + ZipFile(apk).use { zip -> + if (zip.getEntry(MODERN_ENTRY) == null) return@use null + + var minApi = 0 + var targetApi = 0 + var static = false + zip.getEntry(MODULE_PROP)?.let { entry -> + // A malformed module.prop must cost these fields, not the whole + // module — Properties.load throws on a bad unicode escape. + runCatching { + val props = + Properties().apply { load(zip.getInputStream(entry)) } + minApi = props.getProperty("minApiVersion").toIntOrZero() + targetApi = + props.getProperty("targetApiVersion").toIntOrZero() + static = props.getProperty("staticScope") == "true" + } + .onFailure { e -> + logW( + "modules: ${info.packageName} module.prop unparsable, " + + "api version and static scope unknown", + e, + ) + } + } + + val scope = + zip.getEntry(SCOPE_ENTRY)?.let { entry -> + zip.getInputStream(entry) + .bufferedReader() + .readLines() + .map { it.trim() } + .filter { it.isNotEmpty() } + } ?: emptyList() + + // A module that fixes its scope and then names nothing has fixed it at + // "no apps at all": the picker narrows its list to the declared set, so + // the reader would be left with the empty-list state blaming a search + // they never typed, and + // the daemon would refuse every write and prune away the rows the user + // already has. It is a packaging mistake — staticScope=true with a + // scope.list that was never generated — so the flag is dropped and the + // scope stays the user's. FileSystem.readStaticScope ignores the same + // declaration, so the two sides agree on what such a module is allowed + // to hook. + if (static && scope.isEmpty()) { + logW( + "modules: ${info.packageName} fixes its scope but names " + + "nothing; ignoring staticScope and leaving the scope open" + ) + static = false + } + + ModuleManifest( + isModule = true, + isLegacy = false, + minApiVersion = minApi, + targetApiVersion = targetApi, + scope = scope, + staticScope = static, + // A modern module uses the ordinary manifest description. + description = info.loadDescription(packageManager)?.toString()?.trim().orEmpty(), + ) + } + } + .onFailure { e -> + logW( + "modules: reading ${info.packageName} " + + "${apk.substringAfterLast('/')} failed", + e, + ) + } + .getOrNull() + if (modern != null) return modern + } + + if (!legacy) return ModuleManifest(isModule = false) + + return ModuleManifest( + isModule = true, + isLegacy = true, + minApiVersion = legacyMinApiVersion(info), + scope = legacyScope(info, packageManager), + staticScope = false, + description = legacyDescription(info, packageManager), + ) + } + + /** + * A legacy module's description lives in `xposeddescription`, not `android:description`. + * + * Legacy modules do not set the manifest attribute at all, so reading it for both generations + * leaves every legacy row blank. The meta-data value is either a literal string or a + * string-resource id. + */ + private fun legacyDescription(info: ApplicationInfo, packageManager: PackageManager): String { + // `Bundle.get` is deprecated in favour of the type-specific getters, which is exactly what + // this read cannot use: the value's type is the module author's choice and is not known + // until it has been read. Asking for the wrong one costs a logged warning and a stack trace + // out of Bundle for every module that picked the other kind, so the untyped read stays. + @Suppress("DEPRECATION") val raw = info.metaData?.get(LEGACY_DESCRIPTION) ?: return "" + return when (raw) { + is String -> raw.trim() + is Int -> + runCatching { + if (raw == 0) "" + else packageManager.getResourcesForApplication(info).getString(raw).trim() + } + .getOrDefault("") + else -> "" + } + } + + /** The `xposedminversion` a legacy module asks for, or 0 when it does not say. */ + private fun legacyMinApiVersion(info: ApplicationInfo): Int { + val meta = info.metaData ?: return 0 + // Sometimes an int, sometimes a string like "93 (for Android 9)", so leading digits win. + meta.getInt(LEGACY_MIN_VERSION, -1).let { if (it >= 0) return it } + val text = meta.getString(LEGACY_MIN_VERSION) ?: return 0 + return text.trim().takeWhile { it.isDigit() }.toIntOrNull() ?: 0 + } + + /** + * A legacy module's `xposedscope`: either a string-array resource id or a `;`-separated list. + */ + private fun legacyScope(info: ApplicationInfo, packageManager: PackageManager): List { + val meta = info.metaData ?: return emptyList() + val raw = + runCatching { + val resourceId = meta.getInt(LEGACY_SCOPE, 0) + if (resourceId != 0) { + packageManager + .getResourcesForApplication(info) + .getStringArray(resourceId) + .toList() + } else { + meta.getString(LEGACY_SCOPE)?.split(';')?.map { it.trim() } + } + } + .onFailure { e -> + logW("modules: ${info.packageName} legacy xposedscope unreadable", e) + } + .getOrNull() + ?.filter { it.isNotEmpty() } ?: return emptyList() + + return swapLegacyFrameworkNames(raw) + } + + /** + * A legacy module's declared scope, spelled the way everything else here spells it. + * + * Legacy modules name the system server the other way round: their "android" is the daemon's + * "system", and their "system" is the ordinary "android" package. XposedBridge reported + * `packageName` as "system" for the system dialogues so that a module testing for "android" + * found system_server alone, and the scope vocabulary grew up around that; LSPosed later made + * "system" the system server and left "android" as the real package, which is what every + * modern module and the whole of the daemon mean by the two words today. The convention is + * universal among legacy modules, so the swap is unconditional for them. + * + * Not private, and not applied at the point of reading alone: the store shows a module's + * declared scope from the catalogue rather than from the APK, and that list is written in the + * module's own vocabulary too — so it has to pass through here before it is put on screen + * beside a list that already has. + */ + fun swapLegacyFrameworkNames(scope: List): List = + scope.map { + when (it) { + "android" -> "system" + "system" -> "android" + else -> it + } + } + + private fun String?.toIntOrZero(): Int = + this?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 +} + +/** + * What a module says it wants to hook. + * + * [staticScope] means the module fixes *which apps may be listed*, not which of them are hooked: + * [packages] is the outer limit and the user cannot widen it, but which of those packages are in + * the scope remains theirs to choose. The editor narrows its list to [packages] and leaves the + * checkboxes live for exactly that reason, and the daemon agrees — `setModuleScope` refuses only + * targets beyond the claimed set, so any subset of it is accepted. + */ +data class RecommendedScope(val packages: List, val staticScope: Boolean) { + val isEmpty: Boolean + get() = packages.isEmpty() + + companion object { + val NONE = RecommendedScope(emptyList(), staticScope = false) + } +} + +/** User ids are encoded into the uid; this is AOSP's `UserHandle.PER_USER_RANGE`. */ +const val PER_USER_RANGE = 100_000 + +/** `PackageManager.MATCH_ANY_USER`, which is a hidden constant on the public SDK. */ +const val MATCH_ANY_USER = 0x00400000 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt new file mode 100644 index 000000000..31ec0b766 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt @@ -0,0 +1,149 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.util.Base64 +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import org.matrix.vector.manager.logW + +/** + * Remembers which installed packages are modules, so the answer is computed once per APK. + * + * Deciding whether a package is a module means opening its APK — and its splits — as a zip and + * looking for a marker entry. On the device this was written against that is 363 packages and 193 + * split APKs, roughly 550 zip opens, and uncached the Modules panel pays it in full on every visit + * rather than only the first. + * + * The answer only changes when the APK does, so it is keyed by the package's version code and + * install time. A package that has been updated re-inspects itself; everything else is a map + * lookup. That makes the expensive scan a one-off after an install or an update rather than a + * per-visit cost, and it is why the cache is persistent: a cold start would otherwise pay the full + * 550 again. + * + * It lives in the cache directory on purpose. Losing it costs one slow scan and nothing else, so it + * is never a source of truth and never needs migrating. + */ +/** + * What separates one scope entry from the next inside a field. + * + * NUL, because a package name cannot contain one and the scope list is Base64'd into a + * tab-separated record where a space or a comma would be ambiguous. Written as an escape rather + * than as the byte itself: a raw NUL in source is invisible in an editor, invisible to grep, and + * one whitespace-normalising tool away from silently changing the file format. + */ +private const val SCOPE_SEPARATOR = "\u0000" + +class ModuleDetectionCache(private val file: File) { + + private data class Key(val packageName: String, val versionCode: Long, val updatedAt: Long) + + private val entries = ConcurrentHashMap() + + /** The keys this process has asked about, whether or not the answer had to be computed. */ + private val touched = ConcurrentHashMap.newKeySet() + + @Volatile private var loaded = false + + @Volatile private var dirty = false + + /** How many packages this run actually had to open, for the scan's own log line. */ + @Volatile var inspectedThisRun = 0 + private set + + /** + * The manifest for [info], from the cache when the APK has not changed. + * + * [versionCode] and [updatedAt] come from the PackageInfo the caller already holds — asking the + * package manager again here would reintroduce a per-package cost to avoid a per-package cost. + */ + fun inspect( + info: ApplicationInfo, + packageManager: PackageManager, + versionCode: Long, + updatedAt: Long, + ): ModuleManifest { + load() + val key = Key(info.packageName, versionCode, updatedAt) + // Recorded before the early return: a package answered from the cache must still count as + // touched, or [flush] would throw it away for having been cheap. + touched += key + entries[key]?.let { + return it + } + val manifest = ModuleDetection.inspect(info, packageManager) + inspectedThisRun++ + entries[key] = manifest + dirty = true + return manifest + } + + /** + * Writes the cache out, dropping every key this run did not look at. + * + * Called once at the end of a scan rather than per entry: the scan is the only writer, and + * rewriting the file 363 times would cost more than the zip opens it is saving. + */ + @Synchronized + fun flush(seen: Set) { + if (!dirty) return + // Retained by key rather than by package name, which is what drops the stale entry an + // update leaves behind: both keys name the same package and only the newer one was + // touched. [seen] is still needed, because [touched] accumulates for the life of the + // process — after an uninstall the caller's set is the only one that knows the package is + // gone. + entries.keys.retainAll { it in touched && it.packageName in seen } + runCatching { + file.parentFile?.mkdirs() + file.writeText( + entries.entries.joinToString("\n") { (key, manifest) -> + listOf( + key.packageName, + key.versionCode.toString(), + key.updatedAt.toString(), + if (manifest.isModule) "1" else "0", + if (manifest.isLegacy) "1" else "0", + manifest.minApiVersion.toString(), + manifest.targetApiVersion.toString(), + if (manifest.staticScope) "1" else "0", + encode(manifest.scope.joinToString(SCOPE_SEPARATOR)), + encode(manifest.description), + ) + .joinToString("\t") + } + ) + } + .onFailure { e -> logW("modules: detection cache write failed", e) } + dirty = false + } + + @Synchronized + private fun load() { + if (loaded) return + loaded = true + runCatching { + if (!file.isFile) return@runCatching + file.forEachLine { line -> + val f = line.split('\t') + if (f.size != 10) return@forEachLine + entries[Key(f[0], f[1].toLong(), f[2].toLong())] = + ModuleManifest( + isModule = f[3] == "1", + isLegacy = f[4] == "1", + minApiVersion = f[5].toInt(), + targetApiVersion = f[6].toInt(), + staticScope = f[7] == "1", + scope = decode(f[8]).split(SCOPE_SEPARATOR).filter { it.isNotEmpty() }, + description = decode(f[9]), + ) + } + } + } + + // Descriptions carry newlines and tabs of their own, which would otherwise end the record. + private fun encode(value: String): String = + Base64.encodeToString(value.toByteArray(), Base64.NO_WRAP) + + private fun decode(value: String): String = + runCatching { String(Base64.decode(value, Base64.NO_WRAP)) }.getOrDefault("") +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/PackageVersion.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/PackageVersion.kt new file mode 100644 index 000000000..d4da762c5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/PackageVersion.kt @@ -0,0 +1,22 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.PackageInfo +import android.os.Build + +/** + * A package's version code, on every release this app runs on. + * + * `PackageInfo.getLongVersionCode` arrived in API 28 and the `int` field it replaces was deprecated + * in the same release, so on a supported device one of the two is always the wrong one to call. The + * minimum here is API 27: reading the long unguarded is a `NoSuchMethodError` on Android 8.1, not a + * lint opinion, and it would take out the app list, the module list and the store's installed-check + * — every screen that names a version. + * + * Reading the deprecated field is not a loss below 28. The high half it drops, `versionCodeMajor`, + * cannot be set by a package installed on a release that has no concept of it. + */ +val PackageInfo.versionCodeCompat: Long + @Suppress("DEPRECATION") + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) longVersionCode + else versionCode.toLong() diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt new file mode 100644 index 000000000..bcac22d03 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -0,0 +1,274 @@ +package org.matrix.vector.manager.data.model + +import com.google.gson.annotations.SerializedName + +/** + * The module repository's JSON, as types. + * + * These mirror what the server actually sends, measured against a live `modules.json` of 809 + * entries. Two things shape the file: + * + * **Nullability is not decoration here.** `scope` is null on 506 of the 809 entries, `sourceUrl` on + * 369 and `summary` on 121. Gson constructs through `Unsafe` and runs neither Kotlin's + * default-argument logic nor its null checks, so a non-null type on a field the server omits yields + * a `null` that only explodes at the first dereference, far from the parse. Every field the payload + * does not guarantee is therefore declared optional. + * + * **The list payload is nearly a detail payload.** Each list entry already carries exactly one + * release — the newest — with its `.apk` asset and download URL. Installing the current version of + * a module needs no second request, which is what lets the Store work on a bad connection. + */ +data class OnlineModule( + @SerializedName("name") val name: String, + @SerializedName("description") val description: String?, + @SerializedName("summary") val summary: String?, + @SerializedName("url") val url: String?, + @SerializedName("homepageUrl") val homepageUrl: String?, + @SerializedName("sourceUrl") val sourceUrl: String?, + @SerializedName("hide") val hide: Boolean? = false, + @SerializedName("readmeHTML") val readmeHTML: String?, + @SerializedName("scope") val scope: List? = null, + @SerializedName("stargazerCount") val stargazerCount: Int? = null, + @SerializedName("createdAt") val createdAt: String? = null, + @SerializedName("updatedAt") val updatedAt: String? = null, + @SerializedName("pushedAt") val pushedAt: String? = null, + @SerializedName("latestRelease") val latestRelease: String? = null, + @SerializedName("latestReleaseTime") val latestReleaseTime: String? = null, + @SerializedName("latestBetaRelease") val latestBetaRelease: String? = null, + @SerializedName("latestBetaReleaseTime") val latestBetaReleaseTime: String? = null, + @SerializedName("collaborators") val collaborators: List? = null, + @SerializedName("additionalAuthors") val additionalAuthors: List? = null, + @SerializedName("releases") val releases: List? = null, + @SerializedName("betaReleases") val betaReleases: List? = null, +) { + /** The display name, falling back to the package name so a row is never blank. */ + val title: String + get() = description?.takeIf { it.isNotBlank() } ?: name + + /** The module's own page, synthesised when the payload carries no explicit url. */ + val repoUrl: String + get() = url ?: "https://github.com/Xposed-Modules-Repo/$name" +} + +data class Collaborator( + @SerializedName("login") val login: String?, + @SerializedName("name") val name: String?, +) + +/** + * Someone credited beyond the repository's collaborators. + * + * An object, not a bare name, though the field reads like a list of names: the 49 entries that + * carry one hold `{type, name, link}`. Typed as a list of strings it makes Gson throw `Expected a + * string but was BEGIN_OBJECT`, which takes the whole catalogue parse — and with it the entire + * Store — rather than the one module. + */ +data class AdditionalAuthor( + @SerializedName("name") val name: String?, + @SerializedName("link") val link: String?, + @SerializedName("type") val type: String?, +) + +data class Release( + /** GitHub's node id — the only field on a release that is actually unique. See [key]. */ + @SerializedName("id") val id: String?, + @SerializedName("databaseId") val databaseId: Long? = null, + @SerializedName("name") val name: String?, + @SerializedName("tagName") val tagName: String? = null, + @SerializedName("url") val url: String?, + @SerializedName("descriptionHTML") val descriptionHTML: String?, + @SerializedName("createdAt") val createdAt: String? = null, + @SerializedName("publishedAt") val publishedAt: String?, + @SerializedName("isDraft") val isDraft: Boolean? = null, + @SerializedName("isPrerelease") val isPrerelease: Boolean? = null, + @SerializedName("isLatest") val isLatest: Boolean? = null, + @SerializedName("isLatestBeta") val isLatestBeta: Boolean? = null, + @SerializedName("releaseAssets") val releaseAssets: List? = null, +) { + /** + * A stable identity for a lazy list. + * + * Release *names* are not unique in real data: `com.rww.wetypeswipe` currently publishes two + * releases both named `1.11.4`, under tags `43-` and `42-`. `LazyColumn` throws + * `IllegalArgumentException` on a duplicate key, so identity comes from `id`, which is unique + * by construction, with the tag as fallback and the index as last resort — a malformed payload + * then degrades into an odd-looking list rather than a crash. + */ + fun key(index: Int): String = id ?: tagName ?: "release:$index" + + /** The version this release publishes, read from its `-` tag. */ + val version: RepoVersion? + get() = RepoVersion.parse(tagName) + + /** The assets a package installer could actually accept. */ + val apks: List + get() = releaseAssets.orEmpty().filter { it.isApk } +} + +data class ReleaseAsset( + @SerializedName("name") val name: String?, + @SerializedName("contentType") val contentType: String? = null, + @SerializedName("downloadUrl") val downloadUrl: String?, + @SerializedName("downloadCount") val downloadCount: Int? = null, + /** A byte count. `Int` runs out at 2 GB, and this is a file size, so it is a `Long`. */ + @SerializedName("size") val size: Long = 0, +) { + /** + * Whether this asset is an APK. + * + * Judged on the declared content type *and* the filename: 923 of the 946 assets in the + * catalogue declare `application/vnd.android.package-archive`, but a few authors upload theirs + * as `application/octet-stream`, and trusting the type alone would hide the only download those + * modules have. + */ + val isApk: Boolean + get() = + downloadUrl != null && + (contentType == "application/vnd.android.package-archive" || + name?.endsWith(".apk", ignoreCase = true) == true) +} + +/** + * A module version as the repository states it: `"44-1.11.5"` is code 44, name `1.11.5`. + * + * The second clause of the comparison is load-bearing rather than defensive: a release whose *code* + * equals what is installed but whose *name* differs is a rebuild of that version, and the user does + * want it. + */ +data class RepoVersion(val versionCode: Long, val versionName: String) { + + /** The tag this was read from, which is also how [StoreInstall] writes one back down. */ + val tag: String + get() = "$versionCode-$versionName" + + fun upgradableOver(installedCode: Long, installedName: String): Boolean = + versionCode > installedCode || + (versionCode == installedCode && installedName.replace(' ', '_') != versionName) + + /** + * Whether installing this would leave the reader on the version they already have, by name. + * + * Which is all the offer can be worded as when it is true. Two different things reach here — a + * rebuild of the same version under a higher code, and a tag whose code is simply not the APK's + * — and nothing in either number tells them apart, so the wording has to be true of both. What + * is certain in both is where the reader ends up: on this version name again. + * + * The underscores are the same normalisation [upgradableOver] applies, and for the same reason: + * a git tag cannot carry a space, so an author whose versionName has one writes it with an + * underscore. + */ + fun sameVersionAs(installed: RepoVersion?): Boolean = + installed != null && installed.versionName.replace(' ', '_') == versionName + + companion object { + fun parse(raw: String?): RepoVersion? { + val text = raw?.takeIf { it.isNotBlank() } ?: return null + val split = text.split('-', limit = 2) + if (split.size < 2) return null + val code = split[0].toLongOrNull() ?: return null + return RepoVersion(code, split[1]) + } + } +} + +/** + * A release this manager installed, and what the device said the module was afterwards. + * + * Two versions, because they are not the same kind of fact and need not be the same number: + * [release] is what a tag claimed, [installed] is what the APK inside it turned out to be. + * + * That difference is the whole reason this is recorded. The comparison above believes the tag, and + * nothing obliges an author to tag a release with the version their manifest actually states. Where + * the two disagree the offer cannot be satisfied by taking it: installing leaves the device on a + * version the tag still claims to beat, so the row asks again, and again, for ever. + * + * Nor can it be settled by reading the two numbers harder, because both halves of the comparison + * are load-bearing for someone: a module that never changes its tag code is only ever seen to + * update through the name clause, and one that reuses a versionName across several codes only + * through the code clause. Any rule over `(code, name)` is wrong for one of them. + * + * So the Store stops inferring and records instead. An offer it has already installed, on a device + * still reporting what that install produced, is one the reader has taken. + * + * [installed] is what makes the record expire on its own: it is checked against what the device + * reports now, so a module replaced from anywhere else stops matching and the offer comes back. + */ +data class StoreInstall(val release: RepoVersion, val installed: RepoVersion) { + + /** Whether this note says [latest] is already here, as [current]. */ + fun satisfies(latest: RepoVersion?, current: RepoVersion?): Boolean = + release == latest && installed == current +} + +/** + * One row of the Store: a catalogue entry, plus what this device has to say about it. + * + * The join lives in the ViewModel rather than in either repository, so the network layer stays + * ignorant of the daemon and neither has to know the other exists. + */ +data class StoreEntry( + val module: OnlineModule, + val latest: RepoVersion?, + val installed: RepoVersion?, + /** The reader asked not to be told about this one again. */ + val updatesMuted: Boolean = false, + /** What this manager last installed here, if this manager is what installed it. */ + val storeInstall: StoreInstall? = null, +) { + + /** The newest release is one we installed, and the device still reports what it left behind. */ + private val alreadyInstalled: Boolean + get() = storeInstall?.satisfies(latest, installed) == true + + /** + * The offer would not change which version this device says it has. See [sameVersionAs]. + * + * Read by everything that *words* an offer, because `1.1.1 → 1.1.1` is a sentence the app cannot + * mean. [upgradable] deliberately does not consult it: whether to offer at all is a different + * question from what to call it, and a rebuild is worth offering. + */ + val sameVersion: Boolean + get() = latest?.sameVersionAs(installed) == true + + /** + * There is a newer version *and* the reader wants to hear about it. + * + * A release this manager itself installed is not a newer version, whatever the two numbers say; + * see [StoreInstall]. + * + * Muting is folded in here rather than at each place that reads this, because every list and + * count that mentions updates reads it — the Store's header count, its updates filter, its row + * badge, and the set the Modules screen badges from — and a mute that only some of them + * honoured would be worse than none at all. + * + * The two screens that show a module *by itself* deliberately sidestep the mute: the store's + * detail page computes its own answer, and the module's own sheet asks this with `updatesMuted` + * cleared and puts the switch right beside the result. Muting means "stop counting this and + * stop mentioning it in lists", not "refuse to let me update it" — someone who has opened the + * page for one module is not being nagged, they are asking. + */ + val upgradable: Boolean + get() = + !updatesMuted && + installed != null && + latest != null && + !alreadyInstalled && + latest.upgradableOver(installed.versionCode, installed.versionName) +} + +/** + * The catalogue as one value, so "these are saved results" is a property of the data. + * + * The shape `CommunityFeed` uses on Home, for the same reason: the manager routinely runs with no + * network, and a screen that cannot tell a stale list from a fresh one has to choose between lying + * and showing an error where a perfectly usable list was available. + */ +data class StoreCatalog( + val modules: List = emptyList(), + val loaded: Boolean = false, + val fromCache: Boolean = false, + val loadedAtMillis: Long = 0L, +) { + val isEmpty: Boolean + get() = modules.isEmpty() +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/XposedApi.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/XposedApi.kt new file mode 100644 index 000000000..79e372a55 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/XposedApi.kt @@ -0,0 +1,52 @@ +package org.matrix.vector.manager.data.model + +/** + * Which API a module was built against, and whether this framework still honours it. + * + * Two scales share one number, which is the source of most of the confusion here. Anything below + * [LIBXPOSED_FLOOR] is a *legacy Xposed* API version — 54, 82, 93 — and anything at or above it is + * a *libxposed* one. They are not comparable: a module declaring 93 is not "eight behind" a + * framework declaring 101, it is on the other scale entirely. The app therefore names the scale + * wherever it states a number, rather than saying "API 101" and leaving the reader to know which + * API is meant. + */ +object XposedApi { + + /** Where the modern scale begins. Below this is legacy Xposed, and always supported. */ + const val LIBXPOSED_FLOOR = 100 + + /** + * libxposed versions that changed the interface incompatibly. + * + * A version listed here means: everything built against a version *below* it may not work on a + * framework at or above it. So `101` in this list is a statement about **100** — modules built + * against libxposed API 100 are not reliably supported once the framework implements 101. + * + * Legacy Xposed is deliberately absent. That interface stopped changing long ago and the + * framework still implements all of it, so a legacy module's declared version says how old it + * is and nothing about whether it works. + * + * Add a version here when a release breaks what came before it. [brokenSince] is the only + * reader, and the warning the module list shows derives from what it returns. + */ + val BREAKING = listOf(101) + + /** True for the modern scale, where a version number is a libxposed version. */ + fun isLibxposed(api: Int): Boolean = api >= LIBXPOSED_FLOOR + + /** + * The break that a module has fallen behind, or null if it has not. + * + * Returns the *breaking version* rather than a boolean, because that number is the whole + * explanation: "built for 100, and 101 changed it" says what went wrong and when, where "may + * be incompatible" says only that someone is worried. + * + * Only applies within the modern scale, and only when the framework is actually past the + * break — a module built for 100 running on a framework that also implements 100 is fine, and + * saying otherwise would warn every user of every module about a future they are not in. + */ + fun brokenSince(moduleApi: Int, frameworkApi: Int): Int? { + if (!isLibxposed(moduleApi) || !isLibxposed(frameworkApi)) return null + return BREAKING.firstOrNull { breaking -> moduleApi < breaking && breaking <= frameworkApi } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt new file mode 100644 index 000000000..134db4a13 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt @@ -0,0 +1,122 @@ +package org.matrix.vector.manager.data.repository +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.matrix.vector.manager.data.model.AppInfo +import org.matrix.vector.manager.data.model.ModuleDetectionCache +import org.matrix.vector.manager.data.model.versionCodeCompat +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logW + +/** Fetches and caches the list of installed applications from the daemon. */ +class AppRepository( + private val daemonClient: DaemonClient, + private val packageManager: PackageManager, + private val moduleDetection: ModuleDetectionCache, +) { + @Volatile private var cachedApps: List? = null + @Volatile private var cachedModulePackages: Set? = null + + /** + * Drops the cache so the next read goes back to the daemon. + * + * Called from the package added, replaced and removed broadcasts: without it a module installed + * while the manager is open would not appear for the life of the process. + */ + fun invalidate() { + cachedApps = null + cachedModulePackages = null + } + + suspend fun getInstalledApps(forceRefresh: Boolean = false): List = + withContext(Dispatchers.IO) { + if (!forceRefresh && cachedApps != null) { + return@withContext cachedApps!! + } + + val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_META_DATA + + val result = + daemonClient.getInstalledPackagesFromAllUsers(flags, filterNoProcess = true) + val failure = result.exceptionOrNull() + if (failure != null) { + if (failure !is CancellationException) { + logW("apps: installed package list unavailable from daemon", failure) + } + return@withContext emptyList() + } + + val packages = result.getOrNull() ?: emptyList() + val PER_USER_RANGE = 100000 + + val appList = + packages.mapNotNull { pkg -> + val appInfo = pkg.applicationInfo ?: return@mapNotNull null + val isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + // FLAG_IS_GAME was replaced by the category in API 26 and is deprecated, but an + // app built before that still ships it and sets no category, so both are read. + @Suppress("DEPRECATION") + val isGame = + appInfo.category == ApplicationInfo.CATEGORY_GAME || + (appInfo.flags and ApplicationInfo.FLAG_IS_GAME) != 0 + + val userId = appInfo.uid / PER_USER_RANGE + + AppInfo( + packageName = pkg.packageName, + userId = userId, + appName = appInfo.loadLabel(packageManager).toString(), + isSystemApp = isSystem, + isGame = isGame, + isSelectedInScope = false, // To be merged later in the ViewModel + isRecommended = false, + lastUpdateTime = pkg.lastUpdateTime, + firstInstallTime = pkg.firstInstallTime, + versionCode = pkg.versionCodeCompat, + applicationInfo = appInfo, + ) + } + + cachedApps = appList + return@withContext appList + } + + /** + * Which installed packages are themselves Xposed modules. + * + * Answering this means putting every installed package through module detection, which opens + * the APK of any it has not seen before, so it is computed once per process and held until the + * app list is invalidated. The scope screen needs it on open — modules are hidden from the + * hookable-app list by default — and paying that cost on every scope screen would be a visible + * stall each time. + * + * Through the shared [ModuleDetectionCache] rather than straight to `ModuleDetection`, so a + * package the Modules panel has already inspected is a map lookup here, and stays one across a + * cold start. + */ + suspend fun modulePackages(): Set = + withContext(Dispatchers.IO) { + cachedModulePackages?.let { + return@withContext it + } + val packages = + getInstalledApps() + .asSequence() + .filter { + moduleDetection + .inspect( + it.applicationInfo, + packageManager, + it.versionCode, + it.lastUpdateTime, + ) + .isModule + } + .map { it.packageName } + .toSet() + cachedModulePackages = packages + packages + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt new file mode 100644 index 000000000..0c497a2f9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt @@ -0,0 +1,169 @@ +package org.matrix.vector.manager.data.repository +import android.content.Context +import android.net.Uri +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.matrix.vector.ipc.ScopeEntry +import org.matrix.vector.manager.data.log.archiveBuildStamp +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** + * Backup and restore of which modules are on and what each may hook. + * + * This is the configuration a user would most hate to rebuild by hand — a dozen modules each with + * a hand-picked scope — and it is exactly what a bad flash destroys. + * + * Deliberately *not* backed up: anything the manager can rediscover. The module APKs themselves + * belong to the package manager, and a restore proceeds module by module, so one the daemon + * refuses costs that module alone rather than the whole operation. + */ +class BackupRepository(private val context: Context, private val daemon: DaemonClient) { + + @Serializable + private data class BackupFile( + val version: Int = FORMAT_VERSION, + // Which build wrote this. gzip's own comment field is not reachable through + // GZIPOutputStream, and this document is ours, so it says so itself -- `zcat file | head` + // answers "where did this come from" without a restore. + val build: String = archiveBuildStamp(), + val createdAt: Long, + val modules: List, + ) + + @Serializable + private data class BackupModule( + val packageName: String, + val enabled: Boolean, + val scope: List, + ) + + @Serializable private data class BackupTarget(val packageName: String, val userId: Int) + + private val json = Json { ignoreUnknownKeys = true } + + /** Result of a restore, so the UI can say what actually happened rather than "done". */ + data class RestoreOutcome(val restored: Int, val skipped: Int) + + /** + * Writes a backup. + * + * [only] narrows it to a chosen set of packages, which is what the module list's selection mode + * hands in; empty means everything currently enabled. The file format is identical either way, + * so a partial backup restores through exactly the same path as a whole one. + */ + suspend fun backupTo(uri: Uri, only: Set = emptySet()): Result = + withContext(Dispatchers.IO) { + runCatching { + val enabled = + daemon.getEnabledModules().getOrThrow().let { + if (only.isEmpty()) it else it.filter { pkg -> pkg in only } + } + val modules = + enabled.map { packageName -> + val scope = + daemon + .getModuleScope(packageName) + .onFailure { e -> + logW( + "backup: scope of $packageName unreadable, saved as empty", + e, + ) + } + .getOrDefault(emptyList()) + .map { BackupTarget(it.packageName, it.userId) } + BackupModule(packageName, enabled = true, scope = scope) + } + + val payload = + BackupFile(createdAt = System.currentTimeMillis() / 1000, modules = modules) + + // Gzipped: a scope list for a device with hundreds of apps is mostly repeated + // package prefixes and compresses to a fraction of its size. + context.contentResolver.openOutputStream(uri)?.use { out -> + GZIPOutputStream(out).use { gzip -> + gzip.write(json.encodeToString(payload).toByteArray()) + } + } ?: error("could not open the chosen file for writing") + + modules.size + } + .onFailure { e -> + if (e is CancellationException) throw e + logE( + "backup: writing a backup of " + + (if (only.isEmpty()) "all enabled" else "${only.size} selected") + + " modules failed", + e, + ) + } + } + + suspend fun restoreFrom(uri: Uri): Result = + withContext(Dispatchers.IO) { + runCatching { + val text = + context.contentResolver.openInputStream(uri)?.use { input -> + GZIPInputStream(input).use { it.readBytes().decodeToString() } + } ?: error("could not open the chosen file for reading") + + val payload = json.decodeFromString(text) + + var restored = 0 + var skipped = 0 + payload.modules.forEach { module -> + // A refusal is counted and stepped over rather than failing the restore — a + // backup is routinely carried between devices. It is not the missing-module + // case: the daemon accepts an enable for a package this device does not have, + // and drops the row itself on its next cache update. + val enabledOk = + daemon + .setModuleEnabled(module.packageName, module.enabled) + .onFailure { e -> + logW("restore: enabling ${module.packageName} failed, skipping", e) + } + .getOrDefault(false) + if (!enabledOk) { + skipped++ + return@forEach + } + if (module.scope.isNotEmpty()) { + val scope = + module.scope.map { target -> + ScopeEntry().apply { + packageName = target.packageName + userId = target.userId + } + } + val scopeResult = daemon.setModuleScope(module.packageName, scope) + if (!scopeResult.getOrDefault(false)) { + logE( + "restore: scope of ${module.packageName} not applied " + + "(${scope.size} targets)", + scopeResult.exceptionOrNull(), + ) + } + } + restored++ + } + RestoreOutcome(restored, skipped) + } + .onFailure { e -> + if (e is CancellationException) throw e + logE( + "restore: reading or parsing the backup file from ${uri.authority} failed", + e, + ) + } + } + + private companion object { + const val FORMAT_VERSION = 1 + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/CanaryLayout.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/CanaryLayout.kt new file mode 100644 index 000000000..69e925e43 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/CanaryLayout.kt @@ -0,0 +1,216 @@ +package org.matrix.vector.manager.data.repository + +import org.matrix.vector.manager.data.github.ClosedIssue +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.FrameworkRelease +import org.matrix.vector.manager.data.github.TimelineCommit + +/** + * What the canary list draws, in order. + * + * **A version code is a commit count.** `versionCode` is generated by `git rev-list --count`, and + * `TimelineCommit.globalIndex` counts the same way on the same history, so the commits a build + * carries over the one before it are not an estimate — they are the half-open range between two + * version codes, and the feed the home screen already holds can name them. + * + * That is the whole reason this file exists. Without it a canary row can only say what it is + * called and how big its zip is, which tells a reader nothing about whether it is worth their + * evening. With it the row says what landed, and the page above it says what has been fixed since + * the build they are running. + */ +sealed interface CanaryItem { + + /** One published canary, with the work it brought over the canary below it. */ + data class Build(val span: CanarySpan) : CanaryItem + + /** + * Where the reader's own build sits among the canaries. + * + * The same marker the commit rail draws, for the same reason and from the same numbers: + * everything above it is what installing would actually bring. + */ + data class Installed( + val versionCode: Long, + val commitsAhead: Int, + /** Running something newer than every published canary — a local or branch build. */ + val ahead: Boolean, + ) : CanaryItem +} + +/** + * One canary: the build, and the commit it was cut from. + * + * **The head commit, and nothing else about the history.** A build is one commit — the tip CI + * happened to build — and that commit's subject is what tells a reader what this build is. Counting + * the commits between it and the build below says only how much time passed, which the dates + * already say, and it competed for room with the thing worth reading. + */ +data class CanarySpan( + val release: FrameworkRelease, + /** + * The commit this build was made from, when the feed reaches it. + * + * Matched by SHA rather than by version code. Both would usually work, but `globalIndex` is + * assigned by counting down from a total across a paged fetch, and its own documentation warns + * that a page lost after it was written leaves the numbers below the seam reading high. A + * release names its commit exactly, so there is no reason to rely on the fragile one. + */ + val head: TimelineCommit?, + /** + * The subject to show, from the head commit or, failing that, from the release notes. + * + * CI writes the commit subject as the first bold line of every canary's notes, so a build whose + * commit the feed no longer reaches — older than the window, or a cold cache — still has a + * title rather than a bare number. + */ + val subject: String?, + /** True when this is the build that is running. */ + val installed: Boolean, + /** True when the running build wears this number but was not built from this release. */ + val diverged: Boolean, +) + +/** + * The sentence at the top of the canary screen: what taking one would get this reader. + * + * [fixed] is the recruiting number, and it is a claim about other people's reports rather than + * about our own commit subjects — which is what makes it worth stating. It is scoped by time, not + * by authorship: these are the issues closed as done *since the reader's build was cut*, which is + * the only attribution obtainable without an account. That the sentence says exactly that, and + * claims no more, is deliberate. + */ +data class CanaryOverview( + val installedVersionCode: Long, + val commitsAhead: Int, + /** Issues closed as completed since the running build, newest first. */ + val fixed: List = emptyList(), + /** True when the running build is itself a canary: this reader is already testing. */ + val onCanary: Boolean = false, + /** True when the running build is newer than every published canary. */ + val ahead: Boolean = false, + /** + * True when the running build carries a listed canary's number but was not built from it. + * + * A version code is a commit count, not an identity: a build from another branch, or from a + * working tree with changes in it, reaches the same count and wears the same number. Without + * this the page would tell such a reader they were running the newest canary while the card for + * that canary, two lines below, marked itself "same number, other build". + */ + val diverged: Boolean = false, +) { + val behind: Boolean + get() = commitsAhead > 0 +} + +data class CanaryBoard( + val overview: CanaryOverview = CanaryOverview(0, 0), + val items: List = emptyList(), + /** False until the release list has answered; the screen spins rather than saying "none". */ + val loaded: Boolean = false, +) + +object CanaryLayout { + + /** + * @param attempted whether the release list has been asked for and answered, however it + * answered. A refusal — no network, a rate limit, a daemon that cannot say what is installed + * — leaves the catalogue empty, and without this the screen could not tell that from a fetch + * still in flight, so it span forever on exactly the devices least able to reach GitHub. + */ + fun build( + feed: CommunityFeed, + state: FrameworkUpdateState, + closed: List, + attempted: Boolean, + ): CanaryBoard { + val canaries = state.catalog.filter { it.isCanary }.sortedByDescending { it.versionCode } + val installed = state.installedVersionCode + val loaded = attempted || state.catalog.isNotEmpty() + if (canaries.isEmpty()) return CanaryBoard(loaded = loaded, items = emptyList()) + + val newest = canaries.first().versionCode + val since = state.builtAt(feed) + val overview = + CanaryOverview( + installedVersionCode = installed, + commitsAhead = (newest - installed).coerceAtLeast(0).toInt(), + fixed = + if (since == null) emptyList() + else closed.filter { it.closedAtEpoch > since }, + onCanary = state.onCanary, + ahead = installed > newest, + diverged = + canaries + .firstOrNull { it.versionCode == installed } + ?.let { state.divergesFrom(it) } == true, + ) + + val items = ArrayList(canaries.size + 1) + // Above every canary, because there is no canary it could sit under. A build past the head + // of master is not a position in this list; it is a statement that the list does not + // describe what is running. + if (overview.ahead) { + items += CanaryItem.Installed(installed, overview.commitsAhead, ahead = true) + } + // Nothing to mark when the reader is already on the newest canary: the card for it is + // badged as installed, and a marker under it saying "0 commits newer than yours" is a + // sentence about nothing. + var markerPlaced = overview.ahead || installed <= 0 || !overview.behind + + canaries.forEachIndexed { index, release -> + val head = release.commit?.let { sha -> feed.commits.firstOrNull { it.sha == sha } } + + items += + CanaryItem.Build( + CanarySpan( + release = release, + head = head, + subject = head?.subject ?: release.notesSubject(), + installed = release.versionCode == installed, + diverged = state.divergesFrom(release), + ) + ) + + // Placed under the last canary that is newer than the reader's build, which is where + // the line between "what I could have" and "what I already have" actually falls. + val next = canaries.getOrNull(index + 1) + if (!markerPlaced && (next == null || next.versionCode <= installed)) { + items += CanaryItem.Installed(installed, overview.commitsAhead, ahead = false) + markerPlaced = true + } + } + + return CanaryBoard(overview = overview, items = items, loaded = true) + } +} + +/** + * When the running build was cut, or null when that cannot be established. + * + * The commit at the reader's version code is the exact answer and needs no extra request — the + * version code *is* that commit's position. A published build the feed no longer reaches falls + * back to when its release went out, which is within hours of the same thing. A build that is + * neither in the window nor in the catalogue — someone's own — has no date here, and the screen + * says nothing about what has been fixed rather than guessing a span. + */ +private fun FrameworkUpdateState.builtAt(feed: CommunityFeed): Long? = + feed.commits.firstOrNull { it.globalIndex == installedVersionCode }?.epochSeconds + ?: catalog.firstOrNull { it.versionCode == installedVersionCode }?.epochSeconds + +/** + * The commit subject CI wrote into the release notes, or null. + * + * The workflow opens every canary's body with the subject in bold — `**Keep a module inside the + * users that installed it**` — so this is a copy of the same string the commit carries, published + * alongside the zips. It is the fallback for a build the commit feed cannot reach, and it is why + * such a build still reads as a build rather than as a number. + */ +private fun FrameworkRelease.notesSubject(): String? = + notesMarkdown + ?.lineSequence() + ?.firstOrNull { it.isNotBlank() } + ?.trim() + ?.let { NOTES_SUBJECT.find(it)?.groupValues?.getOrNull(1) } + ?.takeIf { it.isNotBlank() } + +private val NOTES_SUBJECT = Regex("""^\*\*(.+?)\*\*$""") diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt new file mode 100644 index 000000000..705fc3234 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt @@ -0,0 +1,344 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import org.matrix.vector.ipc.IFrameworkInstallReceiver +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** Where a framework flash has got to. */ +sealed interface FlashStep { + + data object Idle : FlashStep + + data class Downloading(val bytes: Long, val total: Long) : FlashStep + + /** The daemon is running the installer; [FrameworkInstaller.lines] grows as it speaks. */ + data object Flashing : FlashStep + + /** The installer exited zero. A reboot is what makes it take effect. */ + data object Done : FlashStep + + /** [code] is the installer's exit status, or one of IFrameworkInstallReceiver.INSTALL_*. */ + data class Failed(val code: Int) : FlashStep +} + +/** + * Downloads a framework zip and hands it to the daemon to flash. + * + * **Via a file, unlike the module installer.** That one streams an APK straight into a + * `PackageInstaller` session with no temporary file, and the reasoning does not carry over: a root + * implementation's installer is a program that takes a *path*, so there has to be a file for it to + * open. It goes in the manager's own cache directory, which the daemon can read as root, and it is + * deleted once the installer has exited. + * + * **The download is separate from the flash, and reported separately**, because they fail for + * unrelated reasons and the reader needs to know which happened. A download that dies on a flaky + * connection has changed nothing on the device; an installer that dies halfway has. + * + * **The flash belongs to this object, not to the screen that asked for it.** It is the daemon that + * is doing the work, and the daemon does not stop for anything the manager does, so the only thing + * a caller taken away mid-flash can achieve is losing the answer. The download in front of it is + * the exception — it has changed nothing on the device yet, and [cancelDownload] is where the + * difference is argued. + */ +class FrameworkInstaller( + private val context: Context, + private val client: OkHttpClient, + private val daemon: DaemonClient, +) { + + /** + * The flash's own scope, alive for as long as the process is. + * + * A flash takes minutes, and the screen that starts one is a single back gesture away from + * being destroyed together with its view model scope. When the work ran there, that gesture + * killed the one line that reads the installer's exit code and moves off [FlashStep.Flashing]: + * the daemon finished the install regardless, and the manager went on reporting a flash that + * was already over, with no button anywhere on the bar to say otherwise, until it was force + * stopped. Supervised, so a run that ends in a throw does not take the scope down with it and + * leave the next flash nowhere to run. + */ + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _state = MutableStateFlow(FlashStep.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _lines = MutableStateFlow>(emptyList()) + + /** + * Everything the installer has said, in order. + * + * Cleared when a new flash starts, and when a finished one is put away with [acknowledge]. + */ + val lines: StateFlow> = _lines.asStateFlow() + + private var job: Job? = null + + /** + * The transfer in flight, and the only part of a flash that can be called off. + * + * The HTTP call rather than the coroutine holding it. Cancelling the coroutine would also + * cancel the wait for the installer's exit code if the press landed in the instant between the + * last byte and the daemon starting, and losing that wait is the failure this class exists to + * prevent; cancelling the call can only ever end a transfer. Written from whichever thread + * pressed the button and read on the download's own, hence volatile. + */ + @Volatile private var transfer: Call? = null + + /** + * Starts fetching [url] and flashing it, and returns straight away. + * + * There is nothing to wait for: [state] and [lines] are where the answer arrives, and they + * outlive whatever screen is watching them. + * + * One at a time. A second call while a flash is in flight is refused rather than queued behind + * it — the only way to reach one is a button on a screen that is reporting a flash in progress, + * so honouring it would mean acting on a decision made against a screen that had moved on. And + * refused means untouched: clearing [lines] on a press that starts nothing would empty the log + * of the flash that is actually running. + */ + fun start(url: String, declaredSize: Long, fileName: String) { + if (job?.isActive == true) return + _lines.value = emptyList() + // Built here rather than on the download's own thread so that [cancelDownload] has + // something to cancel from the first frame the progress row is on screen: a press that + // arrived before the coroutine had been dispatched would otherwise find nothing to stop + // and be dropped in silence. A call cancelled before it is executed refuses to run at all, + // which is the same outcome by a shorter route. + // + // Guarded because building it parses the url, and `HttpUrl` throws on one it cannot read. + // That used to happen inside the download coroutine, where the surrounding catch turned it + // into a failed step; here it would be an uncaught exception on the caller's thread, which + // is the main one. A release with an unusable asset url would take the app down on a press + // of Install rather than saying so on the bar. + val call = + runCatching { client.newCall(Request.Builder().url(url).build()) } + .getOrElse { e -> + logW("update: unusable download url $url", e) + append("Download failed: ${e.message}") + _state.value = FlashStep.Failed(IFrameworkInstallReceiver.INSTALL_NO_SUCH_FILE) + return + } + transfer = call + // Here rather than when the first byte lands: opening the connection can take seconds, and + // a press that leaves the Install button sitting where it was reads as a press that missed. + _state.value = FlashStep.Downloading(0, declaredSize) + job = scope.launch { flash(call, declaredSize, fileName) } + } + + /** + * Calls off a download in progress, leaving nothing of it behind. + * + * A download and nothing else. [FlashStep.Flashing] has no equivalent and must not grow one: + * an installer half way through writing a module tree cannot be recalled, so stopping the wait + * would throw away the exit code and change nothing on the device. A transfer is the opposite + * case — it has changed nothing yet, it can be tens of megabytes over mobile data, and letting + * one the reader has abandoned run to the end would flash a build they had decided against. + * + * Nothing here touches [state]. The transfer's own unwinding puts the bar back to + * [FlashStep.Idle], after it has deleted the part of the zip it had written and at the moment + * it has actually stopped, which is the only moment at which saying so is true. + * + * A press from anywhere else is a no-op: [transfer] is cleared the moment the transfer returns, + * and a press that beats it there by a hair finds a call that has already delivered every byte + * it was asked for. Either way the flash goes ahead, which is what "too late" has to mean. + */ + fun cancelDownload() { + // OkHttp's cancel closes the socket from under the read, so the transfer returns at once + // rather than when the connection's read timeout expires — the difference between a + // button that works and a button that looks broken on a stalled download. + transfer?.cancel() + } + + /** + * Puts a finished flash away, so the bar goes back to offering one. + * + * Only a finished one. A flash still running owns what [state] says about it, and clearing it + * would leave the download and the installer going with nothing on screen admitting it — which + * is the reset this class used to do to itself. A transfer the reader genuinely wants rid of + * is ended by [cancelDownload], which stops it rather than hiding it. + * + * [FlashStep.Flashing] is the state this refusal can strand, and the residual risk is worth + * stating rather than wishing away. Two things end that wait. The daemon dying with the + * install started is one, and it needs nothing from here: `Constants.setBinder` links to that + * death and exits the manager process, taking this state with it. The other is the exit code + * arriving — over a `oneway` binder call that `ManagerService.installFrameworkZip` wraps in + * `runCatching`, logging "Could not report install result" and carrying on when the transaction + * fails. A `oneway` call fails when the *receiving* process's transaction buffer is full, which + * a chatty installer's output can do to this one, and the daemon that then gives up is very + * much alive — so no death recipient fires, [state] stays here, and the bar reports a flash + * that is over for the life of the process. The install itself is unharmed and the device has + * it; what was lost is only the report, and this object holds nothing across a restart, so + * ending the manager process is the way out. That is a poor escape but a better one than + * dismissing the row would be: from here a lost report and an installer still working look + * exactly alike, so a dismissal offered for the first is a dismissal offered during every + * flash — and this class was given a life of its own precisely to stop that press existing. + */ + fun acknowledge() { + val step = _state.value + if (step !is FlashStep.Done && step !is FlashStep.Failed) return + _state.value = FlashStep.Idle + _lines.value = emptyList() + } + + /** + * Fetches what [call] asks for and flashes it, reporting where it has got to through [state]. + * + * Runs to the end on [scope] whatever the screen that asked for it does. Only the download can + * be called off, and only through the call it is made on: from [FlashStep.Flashing] onwards + * there is nothing to stop, because an installer half way through writing a module tree cannot + * be recalled and abandoning the wait would throw away the exit code and change nothing else. + */ + private suspend fun flash(call: Call, declaredSize: Long, fileName: String) { + val zip = + try { + download(call, declaredSize, fileName) + } catch (_: DownloadAbandoned) { + // Called off, not broken. Nothing reached the device and nothing is left in the + // cache, so there is no failure to report and nothing for the reader to put away: + // the bar goes back to offering the flash it was offering before the press. This + // is the one place that can say so, because it is the moment the transfer has + // actually stopped. + _state.value = FlashStep.Idle + return + } catch (e: Exception) { + // Only the process going away can cancel this coroutine — a download is called off + // through its call, which arrives above — but a cancellation is a coroutine ending, + // not a file that could not be fetched, and it must never be recorded as one. + if (e is CancellationException) throw e + logW("update: download failed", e) + append("Download failed: ${e.message}") + _state.value = FlashStep.Failed(IFrameworkInstallReceiver.INSTALL_NO_SUCH_FILE) + return + } + + _state.value = FlashStep.Flashing + try { + awaitInstall(zip.absolutePath) + } finally { + // Deleted once the installer has exited: a release zip left in the cache costs tens of + // megabytes that nothing else will ever clean up. + runCatching { zip.delete() } + } + } + + private suspend fun download(call: Call, declaredSize: Long, fileName: String): File = + withContext(Dispatchers.IO) { + val target = File(context.cacheDir, fileName) + + try { + call.execute().use { response -> + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code} for ${call.request().url}") + } + val body = response.body + val total = body.contentLength().takeIf { it > 0 } ?: declaredSize + + target.outputStream().use { out -> + body.byteStream().use { input -> + val buffer = ByteArray(DOWNLOAD_BUFFER) + var written = 0L + while (true) { + currentCoroutineContext().ensureActive() + val read = input.read(buffer) + if (read == -1) break + out.write(buffer, 0, read) + written += read + _state.value = FlashStep.Downloading(written, total) + } + } + } + } + target + } catch (e: Throwable) { + // Nothing will ever finish what is on disk. A transfer that stopped in the middle + // leaves a truncated zip — tens of megabytes of it for a release build — that only + // another attempt at the same file name would overwrite, and nothing else in the + // app would ever reclaim; a connection that never opened leaves no file at all, + // and deleting that costs nothing. + runCatching { target.delete() } + // A cancelled call reaches this as the socket read failing, which is true of the + // socket and false about what happened: the reader stopped it. Said in the type, + // because the caller has two entirely different things to do about the two cases. + if (call.isCanceled()) throw DownloadAbandoned() + throw e + } finally { + // Cleared here rather than by the caller so that it is cleared on every way out, + // including the successful one: from this point the flash is the daemon's and + // there must be nothing left for a cancel to reach. + transfer = null + } + } + + /** + * Runs the daemon-side install and suspends until it reports an exit code. + * + * The installer's output arrives on the receiver as it is produced rather than with the result, + * so the screen fills in during a flash that takes minutes. The exit code comes separately, on + * a deferred nobody here abandons: it is the one moment the flash can be called finished, and a + * wait that ended early left the bar spinning over an install that had long since succeeded. + */ + private suspend fun awaitInstall(path: String) { + val done = kotlinx.coroutines.CompletableDeferred() + val receiver = + object : IFrameworkInstallReceiver.Stub() { + override fun onLine(line: String?) { + line?.let(::append) + } + + override fun onFinished(exitCode: Int) { + done.complete(exitCode) + } + } + + val started = daemon.installFrameworkZip(path, receiver) + if (started.isFailure) { + val cause = started.exceptionOrNull() + logE("update: daemon did not start the install of $path", cause) + append("The daemon refused the install: ${cause?.message}") + _state.value = FlashStep.Failed(IFrameworkInstallReceiver.INSTALL_NOT_EXECUTED) + return + } + + val exit = done.await() + _state.value = if (exit == 0) FlashStep.Done else FlashStep.Failed(exit) + } + + private fun append(line: String) { + // Bounded: an installer that loops would otherwise grow this without limit, and the screen + // follows the tail. + _lines.value = (_lines.value + line).takeLast(MAX_LINES) + } + + /** + * The transfer was called off by [cancelDownload], rather than failing. + * + * An `IOException` because that is what it is thrown in place of, and its own type because the + * two are the opposite kind of news: one is something to tell the reader about, and the other + * is the reader telling this class something. + */ + private class DownloadAbandoned : IOException("Download abandoned") + + private companion object { + const val DOWNLOAD_BUFFER = 64 * 1024 + const val MAX_LINES = 500 + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt new file mode 100644 index 000000000..7a3c0c006 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt @@ -0,0 +1,133 @@ +package org.matrix.vector.manager.data.repository + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.matrix.vector.manager.data.github.FrameworkRelease +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.model.buildStamp + +/** + * Whether a newer build of the framework exists, and which one this reader may be offered. + * + * **The channel is derived, not configured.** A preference would be a second source of truth about + * something the device can simply be asked: someone who flashed a canary once and never changed a + * setting would keep being offered canaries, and someone on a release build who toggled the setting + * out of curiosity would be offered nightlies. Neither is what they are running. + * + * A build is a canary if either is true: + * + * - a `canary-` release exists for exactly this version code, which is the direct + * evidence; or + * - this version code is *higher than the newest stable release*, which catches the canary that has + * aged out of the rolling five prereleases and would otherwise look like a release build. It also + * correctly classifies a locally built development copy, which is ahead of everything published. + * + * A reader on a release build is never *offered* a canary. That is the whole point of the + * distinction: a nightly is not something to be nudged towards. It is not a ban on installing one — + * the canary list exists to be acted on, and [FrameworkUpdateState.catalog] keeps both channels so + * a build asked for by name can still be found. Only the unasked-for offer is filtered. + */ +class FrameworkUpdateRepository(private val github: GitHubRepository) { + + private val _state = MutableStateFlow(FrameworkUpdateState()) + val state: StateFlow = _state.asStateFlow() + + suspend fun refresh( + installedVersionCode: Long, + installedCommit: String? = null, + freshness: GitHubRepository.Freshness = GitHubRepository.Freshness.Revalidate, + ) { + if (installedVersionCode <= 0) return + val releases = github.frameworkReleases(freshness) + if (releases.isEmpty()) return + + val newestStable = releases.firstOrNull { !it.isCanary } + val onCanary = + releases.any { it.isCanary && it.versionCode == installedVersionCode } || + (newestStable != null && installedVersionCode > newestStable.versionCode) + + // A canary reader is offered whichever is newer; a release reader is offered no canary. + val newest = releases.filter { onCanary || !it.isCanary }.maxByOrNull { it.versionCode } + + _state.value = + FrameworkUpdateState( + installedVersionCode = installedVersionCode, + installedCommit = installedCommit, + available = newest?.takeIf { it.versionCode > installedVersionCode }, + // Every published build, not only the newest and not only this channel's: the same + // list that answers "is there anything newer" also answers "what could I go back + // to" — a question people ask after a build breaks something for them — and "which + // build was that row on the canary page", which is a question only the other + // channel can answer. + catalog = releases.sortedByDescending { it.versionCode }, + onCanary = onCanary, + ) + } +} + +/** + * What is known about framework updates right now. + * + * [available] is null both when nothing newer exists and before anything has been fetched. Nothing + * asks the two apart: the screens that read this show an update when there is one and say nothing + * when there is not, which is the same answer either way. + */ +data class FrameworkUpdateState( + val installedVersionCode: Long = 0, + /** + * The build stamp the running daemon reports, when it recorded one. + * + * Not a bare hash: it names where the build came from as well as what commit it was made from, + * in one of the shapes [buildStamp] reads. Nothing here compares it as a string. + */ + val installedCommit: String? = null, + val available: FrameworkRelease? = null, + /** + * Every published build, both channels, newest first — including ones older than the installed + * one, and, for a reader on a release build, the canaries they are not being offered. + * + * Kept whole because a canary the reader picked off the canary page has to be resolvable by + * version code. Filtering it out here is what made that tap land on the newest *release* + * instead: the number named a build the screen had thrown away, so the selection fell through + * to the channel's default and a reader who asked for a nightly was shown the stable release + * they were already running. + */ + val catalog: List = emptyList(), + /** Whether the running build is itself a canary, by the rule the repository documents. */ + val onCanary: Boolean = false, +) { + /** What this reader is offered unasked: their own channel, newest first. */ + val history: List + get() = catalog.filter { onCanary || !it.isCanary } + + val hasUpdate: Boolean + get() = available != null +} + +/** Where a release sits relative to what is installed. */ +enum class ReleaseDirection { + Newer, + Installed, + Older, +} + +/** + * Whether the running build is something other than the one this release published. + * + * Only askable when both sides recorded a commit: the canaries carry a SHA, a hand-made release + * carries a branch name, and a build made before this existed carries nothing. "I cannot tell" is a + * third answer and is reported as false rather than as divergence. + * + * What the framework reports is a *build stamp*, not a bare hash, so the commit is read out of it + * before anything is compared. Comparing the whole stamp is what #809 left behind: a CI stamp + * carries the repository as well, no release SHA matches that, and so every canary reader was told + * they were running "same number, other build" against the very release they had flashed, with no + * row anywhere marked as installed. + */ +fun FrameworkUpdateState.divergesFrom(release: FrameworkRelease?): Boolean { + if (release == null || release.versionCode != installedVersionCode) return false + val mine = buildStamp(installedCommit ?: return false) + if (mine.commit == null || release.commit == null) return false + return !mine.isCommit(release.commit) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt new file mode 100644 index 000000000..5382e2876 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/LaunchShortcut.kt @@ -0,0 +1,324 @@ +package org.matrix.vector.manager.data.repository + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.IntentSender +import android.content.pm.PackageManager +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.graphics.drawable.LayerDrawable +import android.os.Build +import androidx.core.content.ContextCompat +import java.util.UUID +import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW +import org.matrix.vector.manager.R + +/** + * The launcher entry for a manager that is not installed. + * + * Vector normally runs *parasitically*: the manager APK is injected into `com.android.shell`, so + * nothing about it is installed and the launcher has nothing to show. There is no icon to tap, and + * short of dialling the secret code or going through the root manager's action button, no way to + * open it at all — which is what #815 reported, and it is not a bug so much as a feature that was + * never carried over when the manager was rewritten in Compose. + * + * A pinned shortcut is the answer the platform gives for this: the host publishes it, the launcher + * keeps it, and it survives the manager not existing as a package. What it points at is an ordinary + * activity of the host, marked with the [category] below — [ParasiticManagerSystemHooker] watches + * `resolveActivity` for exactly that category and rewrites the resolution to run the manager's code + * in its own process. Drop the category and the shortcut opens whatever the host would have opened. + * + * None of this applies once the manager is installed as an app, where the launcher has a real icon + * of its own; every entry point here is guarded on [isParasitic]. + */ +object LaunchShortcut { + + /** + * Stable across versions, because the launcher keys the pinned copy on it. + * + * Changing this string does not move an existing shortcut, it orphans it: the old one stays on + * the home screen pointing at whatever it was built with, and [update] can no longer find it. + */ + private const val ID = "org.matrix.vector.manager.shortcut" + + /** What [ParasiticManagerSystemHooker] matches on to redirect the activity. */ + private val category = "${BuildConfig.MANAGER_PACKAGE_NAME}.LAUNCH_MANAGER" + + /** True when this manager is injected into the host rather than installed. */ + fun isParasitic(context: Context): Boolean = + context.packageName == BuildConfig.INJECTED_PACKAGE_NAME + + /** + * Whether the launcher accepts pin requests at all. + * + * Most do. Some third-party ones, and some very cut-down OEM ones, do not — and a button that + * silently does nothing is worse than one that is not offered, so the caller asks first. + */ + fun isSupported(context: Context): Boolean = + runCatching { manager(context)?.isRequestPinShortcutSupported == true } + .onFailure { logW("actions: pin support query failed", it) } + .getOrDefault(false) + + /** + * Whether *some* launcher on this device holds the shortcut. + * + * Not the same question as whether the reader can see it, which is [isPinnedHere]. The pin flag + * is a property of the shortcut and not of the launcher that asked for it, so this stays true + * for a launcher that has since been replaced. + */ + fun isPinned(context: Context): Boolean = + runCatching { manager(context)?.pinnedShortcuts.orEmpty().any { it.id == ID } } + .onFailure { logW("actions: pinned shortcut query failed", it) } + .getOrDefault(false) + + /** + * Whether the shortcut is on the home screen the reader is actually looking at. + * + * Installing a different launcher does not carry pinned shortcuts across — the new one starts + * with an empty home screen — but [isPinned] keeps saying yes, because the platform records the + * pin on the shortcut rather than on the pair and only lets the active launcher read the + * per-launcher sets. So the row offering to create one showed a tick over a home screen with no + * Vector on it, and there was no way to ask for another: #883. + * + * The launchers that have pinned it are therefore remembered on this side, in + * SettingsRepository. A device with nothing recorded is one that pinned the shortcut before this + * was written, or by some route that never came back through [request]; rather than tell that + * reader their shortcut is missing, the launcher they are on now is adopted as its owner, which + * is almost certainly true and makes the *next* launcher change detectable. + */ + fun isPinnedHere(context: Context): Boolean { + if (!isPinned(context)) return false + // No answer is not a mismatch. A device whose default home cannot be resolved is not one we + // may tell that its shortcut has gone. + val launcher = currentLauncher(context) ?: return true + val settings = ServiceLocator.settings + val known = settings.shortcutLaunchers() + if (known.isEmpty()) { + settings.noteShortcutLauncher(launcher) + return true + } + return launcher in known + } + + /** + * The package drawing the home screen, or null when the device will not say which. + * + * Null covers two cases that must both be read as "do not know": the query failing, and it + * resolving to the platform's own chooser, which is what a device with several launchers and no + * default answers. Neither is evidence that the shortcut is somewhere the reader cannot see. + */ + fun currentLauncher(context: Context): String? = + runCatching { + context.packageManager + .resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), + PackageManager.MATCH_DEFAULT_ONLY, + ) + ?.activityInfo + ?.packageName + ?.takeIf { it != RESOLVER_PACKAGE } + } + .onFailure { logW("actions: current launcher query failed", it) } + .getOrNull() + + /** + * Asks the launcher to pin the shortcut, calling [onPinned] if and when it does. + * + * Returns whether the request was *accepted*, which is not whether the shortcut was pinned: the + * launcher puts its own dialog in front of the user and may take a while, or never come back at + * all if they dismiss it. [onPinned] is the only report that it landed, and it is delivered + * through a broadcast the platform sends — guarded by a permission no ordinary app holds, so a + * third party cannot forge the confirmation. + */ + fun request(context: Context, onPinned: () -> Unit): Boolean { + if (!isParasitic(context)) return false + val shortcut = build(context) ?: return false + return runCatching { + val confirmed = + callback(context) { + // Recorded here rather than when the request is made, because the launcher + // may refuse or the reader may dismiss its dialog, and a launcher noted as + // holding a shortcut it never took would suppress the offer for good. Read + // again rather than captured: this runs after the launcher's own dialog, + // which is long enough for the default home to have changed. + currentLauncher(context)?.let { + ServiceLocator.settings.noteShortcutLauncher(it) + } + onPinned() + } + manager(context)?.requestPinShortcut(shortcut, confirmed) == true + } + .onFailure { logE("actions: pin shortcut request failed", it) } + .getOrDefault(false) + } + + /** + * Refreshes a shortcut that is already on the home screen. + * + * The label and the icon are copied into the launcher when the shortcut is pinned, so a manager + * that changes either would otherwise be represented by the previous build's for as long as the + * shortcut lives. A no-op when nothing is pinned. + */ + fun update(context: Context) { + if (!isParasitic(context) || !isPinned(context)) return + val shortcut = build(context) ?: return + runCatching { manager(context)?.updateShortcuts(listOf(shortcut)) } + .onFailure { logW("actions: pinned shortcut update failed", it) } + } + + private fun manager(context: Context): ShortcutManager? = + context.getSystemService(ShortcutManager::class.java) + + private fun build(context: Context): ShortcutInfo? { + val intent = launchIntent(context) ?: return null + val builder = + ShortcutInfo.Builder(context, ID) + .setShortLabel(context.getString(R.string.app_name)) + .setIntent(intent) + icon(context)?.let { builder.setIcon(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Q and later want the publishing activity named, and the host has no launcher entry to + // name. AppDetailsActivity is the one the platform synthesises for precisely that case + // — every package has it, and it is what the system itself uses to stand for a package + // with nothing to launch. + builder.setActivity(ComponentName(context.packageName, APP_DETAILS_ACTIVITY)) + } + return builder.build() + } + + /** + * An activity of the host, tagged so the framework turns it into the manager. + * + * The host is `com.android.shell`, which has no launcher entry, so the usual + * `getLaunchIntentForPackage` answers null and the fallback picks any activity that runs in the + * package's own process. Which one hardly matters: the resolution is intercepted before it is + * used. What matters is that the intent resolves to *something* in the host package, because + * the hook only rewrites a result that came back pointing there. + */ + private fun launchIntent(context: Context): Intent? { + val pm = context.packageManager + val pkg = context.packageName + val intent = + pm.getLaunchIntentForPackage(pkg) + ?: runCatching { + pm.getPackageInfo(pkg, PackageManager.GET_ACTIVITIES) + .activities + ?.firstOrNull { it.processName == it.packageName } + ?.let { + Intent(Intent.ACTION_MAIN) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setComponent(ComponentName(pkg, it.name)) + } + } + .onFailure { logE("actions: no host activity to point at", it) } + .getOrNull() + ?: return null + + // CATEGORY_LAUNCHER and the rest belong to whatever the host activity was for. Left on, + // they are matched against the manager's own filter after redirection and can fail it. + intent.categories?.clear() + intent.addCategory(category) + intent.setPackage(pkg) + return intent + } + + /** + * The app icon, flattened for the launcher. + * + * `Icon.createWithResource` would name a resource in the *host's* package, where it does not + * exist — parasitically this app's resources are loaded from an APK the system knows nothing + * about. The bitmap has to be rendered here and shipped by value. + */ + private fun icon(context: Context): Icon? = + runCatching { + val drawable = + ContextCompat.getDrawable(context, R.mipmap.ic_launcher) + ?: return@runCatching null + if (drawable is BitmapDrawable) { + return@runCatching Icon.createWithAdaptiveBitmap(drawable.bitmap) + } + + // An adaptive icon draws nothing through `Drawable.draw` until it is given bounds, + // and reports its layers separately; stacking them keeps the mask off, which is + // what `createWithAdaptiveBitmap` wants — the launcher applies its own. + val flat: Drawable = + if (drawable is AdaptiveIconDrawable) + LayerDrawable( + listOfNotNull(drawable.background, drawable.foreground).toTypedArray() + ) + else drawable + val width = flat.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX + val height = flat.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + flat.setBounds(0, 0, canvas.width, canvas.height) + flat.draw(canvas) + Icon.createWithAdaptiveBitmap(bitmap) + } + .onFailure { logW("actions: shortcut icon could not be rendered", it) } + .getOrNull() + + /** + * A one-shot receiver the platform pings once the launcher has pinned the shortcut. + * + * The action is a fresh UUID rather than a constant, so two requests cannot answer each other, + * and the receiver requires `CREATE_USERS` of the sender: that permission is held by the system + * and by nothing a user can install, which makes the broadcast unforgeable by a third party + * that has guessed the action. It unregisters itself on the first matching delivery. + */ + @SuppressLint("InlinedApi") // RECEIVER_EXPORTED is a constant; the flags overload is API 26. + private fun callback(context: Context, onPinned: () -> Unit): IntentSender? = + runCatching { + val action = UUID.randomUUID().toString() + val receiver = + object : BroadcastReceiver() { + override fun onReceive(received: Context, intent: Intent) { + if (intent.action != action) return + context.unregisterReceiver(this) + onPinned() + } + } + context.registerReceiver( + receiver, + IntentFilter(action), + CONFIRMATION_PERMISSION, + null, // the main thread + Context.RECEIVER_EXPORTED, + ) + PendingIntent.getBroadcast( + context, + 0, + Intent(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + .intentSender + } + .onFailure { logW("actions: pin confirmation receiver failed", it) } + .getOrNull() +} + +/** The synthesised entry every package has, used as the shortcut's publishing activity on Q+. */ +private const val APP_DETAILS_ACTIVITY = "android.app.AppDetailsActivity" + +/** What CATEGORY_HOME resolves to when the device has several launchers and no default. */ +private const val RESOLVER_PACKAGE = "android" + +/** Held by the system and by nothing installable, so only the platform can confirm a pin. */ +private const val CONFIRMATION_PERMISSION = "android.permission.CREATE_USERS" + +/** Only reached by a drawable that reports no intrinsic size; an adaptive icon always does. */ +private const val ICON_FALLBACK_PX = 108 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt new file mode 100644 index 000000000..b2b9a489c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ManagerInstaller.kt @@ -0,0 +1,385 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import java.io.FileInputStream +import java.io.InputStream +import java.security.MessageDigest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.data.model.ManagerCopy +import org.matrix.vector.manager.data.model.versionCodeCompat +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.ipc.commitForResult +import org.matrix.vector.manager.ipc.requestReplaceExisting + +/** Where installing the manager as an app has got to. */ +sealed interface ManagerInstallStep { + + data object Idle : ManagerInstallStep + + data object Installing : ManagerInstallStep + + /** Installed. The launcher now has a real Vector icon, and this process is still the host. */ + data object Done : ManagerInstallStep + + /** + * [signatureConflict] is the one failure the reader can act on. + * + * A copy of the manager signed with a different key is already on the device, and the platform + * will not replace it — which happens to anyone who flashed a build from CI over one they built + * themselves, since the two are signed with different debug keys. Every other failure gets a + * flat "could not be installed", because naming a cause we cannot act on only invites the + * reader to try the same thing again. + */ + data class Failed(val reason: String?, val signatureConflict: Boolean = false) : + ManagerInstallStep +} + +/** + * Installs Vector's own manager as an ordinary app. + * + * The framework does not need this — the manager runs perfectly well injected into + * `com.android.shell`, which is the default and what most people should stay on. It is offered + * because the parasitic arrangement costs the manager a few things a normal app has: a launcher + * icon, a place in the app list, per-app settings, notification permission that survives a reboot. + * Some launchers also refuse to pin the shortcut [LaunchShortcut] would otherwise create, and on + * those this is the only way to get an icon at all. + * + * What it costs the other way is worth knowing and is said on the screen that offers it: installed, + * the manager is an ordinary app with ordinary permissions, so installing a module goes through the + * system's `REQUEST_INSTALL_PACKAGES` prompt instead of happening silently under the host's + * `INSTALL_PACKAGES`. + * + * The daemon already expects this: `ConfigCache` resolves `org.matrix.vector.manager`, verifies its + * signature, and remembers its UID, so an installed manager is granted the same binder as the + * injected one and needs no further arrangement. + */ +class ManagerInstaller(private val context: Context, private val daemon: DaemonClient) { + + private val _state = MutableStateFlow(ManagerInstallStep.Idle) + val state: StateFlow = _state.asStateFlow() + + /** + * The last digest comparison, and the copy it was made against. + * + * Hashing two APKs of twenty-odd megabytes is not something to repeat every time somebody opens + * the status page — which is every arrival at Home as well, since both screens refresh presence + * and each holds its own ViewModel. This installer is the process-wide singleton both reach + * through, so remembering the verdict here answers all of them. + * + * It is keyed on when the installed copy was last written rather than cleared by hand. That + * expires the verdict on exactly the events that could change it — an install, an update, a + * reinstall of the very same bytes — including the ones that happen while this app is not + * running, and it needs no invalidation call at the far end of a code path that might forget. + */ + @Volatile private var comparison: Comparison? = null + + /** + * Holds the comparison to one at a time. + * + * Home and the status page each hold their own ViewModel and the first composition refreshes + * presence from both within milliseconds of each other, which is early enough that neither has + * written [comparison] by the time the other looks. Without this they would both go and hash + * eighty megabytes between them for the one answer. + */ + private val comparing = Mutex() + + /** Clears a finished result so the button returns to its resting state. */ + fun acknowledge() { + _state.value = ManagerInstallStep.Idle + } + + /** + * Removes the copy that is refusing the install, from every user. + * + * Through the daemon rather than through this app: the manager is not the installer of record + * for that package, and parasitically it is the host, which has no business uninstalling apps + * on its own account. Every user, because a copy left behind in another profile refuses the + * install exactly as loudly as one in this profile — which is how this device got here. + */ + suspend fun removeConflicting(): Boolean { + val removed = + daemon + .uninstallPackage(BuildConfig.MANAGER_PACKAGE_NAME, IManagerService.ALL_USERS) + .getOrDefault(false) + if (removed) _state.value = ManagerInstallStep.Idle + else logW("actions: could not remove the conflicting manager") + return removed + } + + /** + * What can be said about the installed copy without hashing anything. + * + * Whether the package exists is one `getPackageInfo`, and its version code comes back on the + * same object, so both are cheap enough to answer on the main thread, which is where the + * presence refresh asks. Whether two copies wearing the same number hold the same bytes is not, + * so the verdict [refreshInstalledManager] last reached for this very copy is repeated until it + * is asked again, and a copy nothing is yet known about reads as [ManagerCopy.Present] — the + * same "installed, with nothing said against it" a failed comparison gives. + * + * Repeating the verdict is what keeps the row still. Without it every arrival at the screen + * would show a plain check for as long as the digest takes and then flip to a reinstall button. + */ + fun installedManager(): ManagerCopy { + val installed = installedPackage() ?: return ManagerCopy.Absent + // The cheap half of the comparison is redone rather than remembered. It costs one field of + // a `PackageInfo` already in hand, and a divergence the numbers alone can see is the one + // this screen meets most often — an install left behind by an older framework — so it would + // be a shame to show it a check for as long as a digest takes and then take the check away. + if (installed.versionCodeCompat != BuildConfig.VERSION_CODE.toLong()) { + return ManagerCopy.Diverged + } + val known = comparison + return if (known != null && known.installedAt == installed.lastUpdateTime) known.verdict + else ManagerCopy.Present + } + + /** + * Compares the installed copy of the manager with the build this one is running. + * + * The version code goes first because it is free, and a disagreement there is already the + * answer. Agreement is not: the code is `git rev-list --count origin/master`, so a build made + * on a branch and the official build at that same depth wear the same number while being + * different binaries entirely. That is the case worth spending a digest on, and the only one — + * the whole point of taking one is to separate two copies the numbers call identical. + * + * What this manager is running is the canonical side. Parasitically its dex comes out of the + * daemon's own module APK, which is the same file `getManagerApk` hands over, so `BuildConfig` + * here describes the copy the daemon would install and the fetch is only needed for its bytes. + * + * **A comparison that could not be made is not a mismatch.** A dead daemon, a refused APK, an + * unreadable install: each leaves [ManagerCopy.Present], because the reader would be sent to + * replace a perfectly good copy on the strength of a check that never ran. Only a completed + * comparison that came out different says so, and only that is remembered — a failure is left + * unremembered on purpose, so that a daemon which comes back is asked again. + */ + suspend fun refreshInstalledManager(): ManagerCopy { + // Installed rather than parasitic, this manager *is* the copy in question: the package it + // would compare itself against is itself, and the daemon's module APK is then a third file + // that is allowed to differ without anything being wrong. Nothing renders the answer in + // that mode either — the card that asks is drawn only parasitically — so the cheap answer + // is the whole answer. + if (!LaunchShortcut.isParasitic(context)) return installedManager() + return comparing.withLock { compare() } + } + + /** The comparison itself, off the drawing thread and one at a time; see [comparing]. */ + private suspend fun compare(): ManagerCopy = + withContext(Dispatchers.IO) { + val installed = installedPackage() ?: return@withContext ManagerCopy.Absent + if (installed.versionCodeCompat != BuildConfig.VERSION_CODE.toLong()) { + return@withContext ManagerCopy.Diverged + } + + val known = comparison + if (known != null && known.installedAt == installed.lastUpdateTime) { + return@withContext known.verdict + } + + // The daemon's copy first, dearer though the round trip is. A daemon that is gone + // refuses it at once, and that is much the likeliest reason this comparison cannot be + // made: presence is refreshed whether or not there is a binder, and the row showing the + // answer is drawn disabled without one. Hashing the local copy first would spend twenty + // megabytes of reads, on every arrival at the screen, to arrive at the same nothing. + val ours = canonicalDigest() + if (ours == null) { + logW("actions: the daemon served no manager APK to compare against") + return@withContext ManagerCopy.Present + } + // `sourceDir` is the whole of the installed copy — this installer stages one APK and + // never any splits, so there is nothing else of it to fold in. + val source = installed.applicationInfo?.sourceDir + val theirs = source?.let { path -> sha256 { FileInputStream(path) } } + if (theirs == null) { + logW("actions: the installed manager could not be read, so it cannot be compared") + return@withContext ManagerCopy.Present + } + + val verdict = + if (theirs.contentEquals(ours)) ManagerCopy.Present else ManagerCopy.Diverged + comparison = Comparison(installed.lastUpdateTime, verdict) + verdict + } + + /** The installed manager as the package manager sees it, or null when there is none. */ + private fun installedPackage(): PackageInfo? = + runCatching { context.packageManager.getPackageInfo(BuildConfig.MANAGER_PACKAGE_NAME, 0) } + .getOrNull() + + /** + * Digests the APK the daemon would install, and closes the descriptor it came on. + * + * A fresh `getManagerApk` rather than anything [install] holds: that descriptor is read to its + * end and closed by the install itself, and there is no rewinding it. + */ + private suspend fun canonicalDigest(): ByteArray? { + val apk = + withTimeoutOrNull(APK_TIMEOUT_MS) { daemon.getManagerApk().getOrNull() } ?: return null + return try { + sha256 { FileInputStream(apk.fileDescriptor) } + } finally { + runCatching { apk.close() } + } + } + + /** + * The SHA-256 of a stream, or null if it could not be read to its end. + * + * A chunk at a time, because the manager APK runs to tens of megabytes and neither side of this + * comparison has any business sitting in this process's heap — parasitically that heap belongs + * to `com.android.shell`, which is not sized for it. The stream is opened inside so that a file + * that cannot be opened is the same null as one that cannot be read, and closed on every path. + */ + private fun sha256(open: () -> InputStream): ByteArray? = + runCatching { + open().use { stream -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DIGEST_CHUNK) + while (true) { + val read = stream.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.digest() + } + } + .getOrNull() + + /** + * Fetches the flashed manager APK from the daemon and installs it. + * + * The APK is streamed straight from the daemon's descriptor into the install session, with no + * copy in between: the manager has nowhere to put a copy that the package installer could read + * anyway, and parasitically it has no `FileProvider` to serve one from. + */ + suspend fun install(): Boolean = + withContext(Dispatchers.IO) { + _state.value = ManagerInstallStep.Installing + + val apk = + withTimeoutOrNull(APK_TIMEOUT_MS) { daemon.getManagerApk().getOrNull() } + if (apk == null) { + // Either the daemon is gone, or it refused: the APK is missing from the module + // directory or its signature is not the one this framework was built to accept. + logE("actions: the daemon served no manager APK to install") + _state.value = ManagerInstallStep.Failed(null) + return@withContext false + } + + val packageInstaller = context.packageManager.packageInstaller + var sessionId = -1 + var succeeded = false + try { + val size = apk.statSize.takeIf { it > 0 } ?: -1L + val params = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + .apply { + // Pinned, and the platform fails an install whose staged APK disagrees + // with it. A daemon serving something else cannot install it as Vector. + setAppPackageName(BuildConfig.MANAGER_PACKAGE_NAME) + if (size > 0) setSize(size) + // Updating an installed manager from the host is a replace, and + // parasitically the platform does not make it one for us. + requestReplaceExisting() + } + sessionId = packageInstaller.createSession(params) + + packageInstaller.openSession(sessionId).use { session -> + session.openWrite(WRITE_NAME, 0, size).use { out -> + FileInputStream(apk.fileDescriptor).use { input -> input.copyTo(out) } + out.flush() + session.fsync(out) + } + val (status, message) = commit(session, sessionId) + succeeded = status == PackageInstaller.STATUS_SUCCESS + if (!succeeded) { + logW("actions: manager install failed, status $status: $message") + } + _state.value = + if (succeeded) ManagerInstallStep.Done + else + ManagerInstallStep.Failed( + message, + // STATUS_FAILURE_CONFLICT covers more than a signature clash, so + // the platform's own reason decides. It is not localised and is + // never shown; it is only matched on here and logged above. + signatureConflict = + status == PackageInstaller.STATUS_FAILURE_CONFLICT && + message?.contains(SIGNATURE_CONFLICT) == true, + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + logE("actions: manager install failed", e) + _state.value = ManagerInstallStep.Failed(e.message) + } finally { + runCatching { apk.close() } + // A session left staged holds the bytes written so far, and they accumulate. + if (!succeeded && sessionId != -1) { + runCatching { packageInstaller.abandonSession(sessionId) } + } + } + succeeded + } + + /** + * Commits the session and waits for the platform's verdict. + * + * `STATUS_PENDING_USER_ACTION` should not arise here — the host holds `INSTALL_PACKAGES`, so + * the commit is silent — but it is handled anyway, because the same code runs from a manager + * that is already installed and updating itself, where the prompt is exactly what the platform + * will do. + * + * @see commitForResult + */ + private suspend fun commit( + session: PackageInstaller.Session, + sessionId: Int, + ): Pair = + context.commitForResult( + session, + sessionId, + promptFailure = "actions: manager install prompt could not be started", + ) + + /** + * A completed comparison, and the copy it was made against. + * + * @property installedAt `PackageInfo.lastUpdateTime` of the copy that was compared, which is + * what makes this verdict expire when that copy is replaced rather than outlive it. + */ + private data class Comparison(val installedAt: Long, val verdict: ManagerCopy) + + private companion object { + const val WRITE_NAME = "manager.apk" + + /** How much of an APK is held at once while it is being hashed. */ + const val DIGEST_CHUNK = 64 * 1024 + + /** What the platform calls it in `EXTRA_STATUS_MESSAGE`; see PackageManagerException. */ + const val SIGNATURE_CONFLICT = "INSTALL_FAILED_UPDATE_INCOMPATIBLE" + + /** + * How long the daemon gets to hand over the APK. + * + * The binder call is synchronous and the daemon verifies a 20-odd megabyte signature before + * answering, so it is not instant — but it is also the one step here with no failure of its + * own to report. Without a bound, a daemon that never answers leaves the row spinning for + * the life of the process, which is exactly what it did. + */ + const val APK_TIMEOUT_MS = 30_000L + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt new file mode 100644 index 000000000..5d408fd73 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -0,0 +1,203 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import android.content.pm.PackageInstaller +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.ipc.commitForResult +import org.matrix.vector.manager.ipc.requestReplaceExisting +import org.matrix.vector.manager.logW + +/** Where an install has got to. One at a time, because a user installs one module at a time. */ +sealed interface InstallStep { + + data object Idle : InstallStep + + data class Downloading(val packageName: String, val bytes: Long, val total: Long) : InstallStep + + /** Handed to the package installer; nothing more to report until it answers. */ + data class Installing(val packageName: String) : InstallStep + + /** Standalone only: the system's own install prompt is up and waiting on the user. */ + data class Confirming(val packageName: String) : InstallStep + + data class Done(val packageName: String) : InstallStep + + data class Failed(val packageName: String, val reason: String?) : InstallStep +} + +/** + * Downloads a release asset straight into a `PackageInstaller` session. + * + * **Straight into**, with no temporary file, and not as an optimisation. Parasitically the + * manager's manifest is never installed, so it has no `ContentProvider` and therefore no + * `FileProvider`; `ACTION_INSTALL_PACKAGE` with a `content://` URI is not available at all. + * `PackageInstaller.Session.openWrite` is the one path that works identically in both modes, and it + * needs no storage permission. + * + * **The consent story differs sharply between the two modes, and that is why the caller's own + * dialog matters.** Parasitically the manager runs inside `com.android.shell`, which holds + * `android.permission.INSTALL_PACKAGES` — so the commit below installs a third-party APK with no + * system confirmation whatsoever. Standalone, the same code produces the usual + * `REQUEST_INSTALL_PACKAGES` prompt. In the mode most people run, Vector's own confirmation is the + * *only* consent gate there is, so it must name what is about to happen before anything is + * downloaded. See ConfirmInstall, which asks the platform which of the two modes it is in. + * + * The session's package name is pinned to the catalogue entry's, and the platform fails an install + * whose staged APKs are inconsistent with it. A module page therefore cannot install a package + * other than the one it advertises. + */ +class ModuleInstaller(private val context: Context, private val client: OkHttpClient) { + + private val _state = MutableStateFlow(InstallStep.Idle) + val state: StateFlow = _state.asStateFlow() + + /** Clears a finished result so the button returns to its resting state. */ + fun acknowledge() { + _state.value = InstallStep.Idle + } + + /** + * Fetches [asset] and installs it as [packageName]. + * + * Returns true only when the platform reports the package installed. There is no resume: a + * dropped connection costs the whole transfer, which is an acceptable trade for module APKs + * (tens to a few hundred kilobytes) in exchange for never touching the filesystem. + * + * What became of it is recorded by the caller rather than here — see RepoRepository.readInstalled + * and SettingsRepository.noteStoreInstall — because the version to record has to be read the way + * the Store reads it, across every user, and this class talks to the platform rather than to the + * daemon. + */ + suspend fun install(packageName: String, asset: ReleaseAsset): Boolean = + withContext(Dispatchers.IO) { + val url = asset.downloadUrl + if (url == null || !asset.isApk) { + _state.value = InstallStep.Failed(packageName, null) + return@withContext false + } + + val packageInstaller = context.packageManager.packageInstaller + var sessionId = -1 + var succeeded = false + try { + _state.value = InstallStep.Downloading(packageName, 0, asset.size) + + val params = + PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ) + .apply { + setAppPackageName(packageName) + if (asset.size > 0) setSize(asset.size) + requestReplaceExisting() + } + sessionId = packageInstaller.createSession(params) + + packageInstaller.openSession(sessionId).use { session -> + stream(session, packageName, url, asset.size) + _state.value = InstallStep.Installing(packageName) + val result = commit(session, sessionId, packageName) + succeeded = result.first == PackageInstaller.STATUS_SUCCESS + if (!succeeded) { + logW( + "store: install of $packageName failed, status ${result.first}: " + + "${result.second}" + ) + } + _state.value = + if (succeeded) InstallStep.Done(packageName) + else InstallStep.Failed(packageName, result.second) + } + } catch (e: Exception) { + // The check in stream() cancels by throwing, and a cancelled transfer is not a + // failed install: reporting it as one would put an error on a screen the reader + // has already left, and would race the acknowledge() that cancelled it. + if (e is CancellationException) throw e + logW("store: install of $packageName failed", e) + _state.value = InstallStep.Failed(packageName, e.message) + } finally { + // Without this, a cancelled download leaves a staged session behind — and staged + // sessions accumulate, each holding the bytes written so far. + if (!succeeded && sessionId != -1) { + runCatching { packageInstaller.abandonSession(sessionId) } + } + } + succeeded + } + + private suspend fun stream( + session: PackageInstaller.Session, + packageName: String, + url: String, + declaredSize: Long, + ) { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code} for $url") + val body = response.body + val total = body.contentLength().takeIf { it > 0 } ?: declaredSize + + session.openWrite(WRITE_NAME, 0, total).use { out -> + body.byteStream().use { input -> + val buffer = ByteArray(CHUNK_BYTES) + var written = 0L + var reported = 0L + while (true) { + // The read below is blocking, so cancellation is only observed between + // chunks. Checking here is what lets leaving the screen stop the transfer. + currentCoroutineContext().ensureActive() + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + + // Progress is published per 256 KB, not per chunk: at 64 KB a small module + // would spend more time on binder calls to setStagingProgress and on + // recompositions than on the download itself. + if (written - reported >= PROGRESS_STEP_BYTES || read < buffer.size) { + reported = written + _state.value = InstallStep.Downloading(packageName, written, total) + if (total > 0) session.setStagingProgress(written.toFloat() / total) + } + } + out.flush() + session.fsync(out) + } + } + } + } + + /** + * Commits the session and waits for the platform's verdict. + * + * @see commitForResult + */ + private suspend fun commit( + session: PackageInstaller.Session, + sessionId: Int, + packageName: String, + ): Pair = + context.commitForResult( + session, + sessionId, + promptFailure = "store: install prompt for $packageName could not be started", + ) { + _state.value = InstallStep.Confirming(packageName) + } + + private companion object { + const val WRITE_NAME = "module.apk" + const val CHUNK_BYTES = 64 * 1024 + const val PROGRESS_STEP_BYTES = 256L * 1024 + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt new file mode 100644 index 000000000..94d6679d1 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt @@ -0,0 +1,109 @@ +package org.matrix.vector.manager.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** + * The single source of truth for which modules are enabled. + * + * It observes the binder rather than fetching once at construction. This is built before any daemon + * connection exists, so a single fetch from `init` would run too early, fail, and have nothing to + * retry it — the enabled set would stay empty for the life of the process. + */ +class ModuleRepository( + private val daemonClient: DaemonClient, + private val scope: CoroutineScope, +) { + + private val _enabledModulesState = MutableStateFlow>(emptySet()) + val enabledModulesState: StateFlow> = _enabledModulesState.asStateFlow() + + private val _scopeRevision = MutableStateFlow(0) + + /** + * Bumped whenever a module's scope has been written. + * + * The scope editor is a separate screen with its own view model, so the list behind it has no + * other way to learn that the thing it depicts has just been edited: without this, applying a + * scope and pressing back leaves the row showing the old set of app icons until a manual pull + * to refresh. + */ + val scopeRevision: StateFlow = _scopeRevision.asStateFlow() + + /** Called once a scope write has gone through, not when one is started. */ + fun noteScopeChanged() { + _scopeRevision.update { it + 1 } + } + + private val _packageRevision = MutableStateFlow(0) + + /** + * Bumped when a package is installed, updated or removed. + * + * The module list is cached across visits, which is what makes it fast — and what would make it + * wrong, because a list that is never recomputed never notices a module being installed. The + * platform's own package broadcasts feed this, so the rescan happens when something has changed + * rather than on every visit. The scan it triggers is cheap: the detection cache is keyed by + * version code and install time, so only the package that actually changed is opened again. + */ + val packageRevision: StateFlow = _packageRevision.asStateFlow() + + fun notePackagesChanged() { + _packageRevision.update { it + 1 } + } + + init { + scope.launch { + // Re-reads whenever a binder arrives, including a reconnect. + ServiceLocator.service.collect { service -> + if (service == null) _enabledModulesState.update { emptySet() } else refresh() + } + } + } + + fun refresh() { + scope.launch { + daemonClient + .getEnabledModules() + .onSuccess { enabled -> _enabledModulesState.update { enabled.toSet() } } + .onFailure { e -> + logW("modules: enabled list unavailable, showing none enabled", e) + } + } + } + + /** + * Asks the daemon to enable or disable a module, and reports whether it agreed. + * + * The local set is only updated when the daemon confirms, so the switch can never show a state + * the framework does not actually hold. Callers surface the `false` — silently snapping the + * control back leaves the user with no idea what happened. + */ + suspend fun toggleModule(packageName: String, enable: Boolean): Boolean { + val verb = if (enable) "enable" else "disable" + val result = daemonClient.setModuleEnabled(packageName, enable) + val accepted = result.getOrDefault(false) + if (!accepted) { + val cause = result.exceptionOrNull() + if (cause != null) { + logE("modules: $verb of $packageName failed", cause) + } else { + logE("modules: daemon refused to $verb $packageName") + } + return false + } + + _enabledModulesState.update { current -> + if (enable) current + packageName else current - packageName + } + return true + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt new file mode 100644 index 000000000..c8fd1e3a4 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt @@ -0,0 +1,138 @@ +package org.matrix.vector.manager.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreInstall + +/** + * Several module updates, installed one after another. + * + * One at a time is not a simplification. `PackageInstaller` sessions are independent, but a phone + * asked to install four APKs at once spends the whole time contending for the same disk and, in + * standalone mode, stacks four system confirmation dialogs on top of each other in an order nobody + * chose. Sequential is also what makes the progress line truthful: there is exactly one download to + * report on at any moment, which is what [ModuleInstaller] already models. + * + * It lives outside the sheet that starts it, on the application scope, because updating four + * modules takes longer than anyone will keep a bottom sheet open. Closing the sheet is not a + * cancellation, and reopening it finds the run where it left off. + */ +class ModuleUpdateQueue( + private val installer: ModuleInstaller, + private val store: RepoRepository, + private val modules: ModuleRepository, + private val settings: SettingsRepository, + private val scope: CoroutineScope, +) { + + /** + * One module to update, resolved before the run starts so nothing is looked up mid-flight. + * + * [release] is the version of the release [asset] came from, carried so that the installer can + * record what it put on the device. See ModuleInstaller.install. + */ + data class Item( + val packageName: String, + val title: String, + val asset: ReleaseAsset, + val release: RepoVersion?, + ) + + data class State( + val queued: List = emptyList(), + /** What is being installed right now; null between items and when nothing is running. */ + val current: Item? = null, + val done: Set = emptySet(), + val failed: Set = emptySet(), + val running: Boolean = false, + ) { + val total: Int + get() = queued.size + + val finished: Int + get() = done.size + failed.size + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private var job: Job? = null + + /** + * Starts a run, unless one is already going. + * + * A second call during a run is ignored rather than queued behind it. The only way to reach one + * is to press a button that reports a run in progress, so honouring it would mean acting on a + * decision made against a screen that had already moved on. + */ + fun start(items: List) { + if (items.isEmpty() || _state.value.running) return + _state.value = State(queued = items, running = true) + job = + scope.launch { + for (item in items) { + _state.update { it.copy(current = item) } + val ok = runCatching { installer.install(item.packageName, item.asset) } + // runCatching swallows everything, and everything includes the cancellation + // acknowledge() raises in here. Without this check a dismissed run carries + // on behind the cleared state, recording every remaining item as failed and + // putting the finished-with-failures line back on a screen just cleared of it. + ensureActive() + _state.update { + if (ok.getOrDefault(false)) it.copy(done = it.done + item.packageName) + else it.copy(failed = it.failed + item.packageName) + } + } + _state.update { it.copy(current = null, running = false) } + // Once, at the end, rather than after each install: every version read comes from + // one daemon call over every installed package, and paying that four times to + // watch four badges settle a second earlier each is not a trade worth making. + // Awaited, because the notes below are written from that same read. + note(items, store.readInstalled()) + // Told rather than overheard. A replaced package does broadcast, and the manager + // does listen, but this is the one install path the app performed itself: there is + // no reason for the list to wait on a delivery the system owns. + modules.notePackagesChanged() + } + } + + /** + * Records what landed, so the Store stops offering a release it has already installed. + * + * Only the items that succeeded, and only against what the device reports now — which is why + * [installed] is passed in rather than read here. See [StoreInstall]. + */ + private fun note(items: List, installed: Map) { + val landed = _state.value.done + for (item in items) { + if (item.packageName !in landed) continue + val release = item.release ?: continue + val version = installed[item.packageName] ?: continue + settings.noteStoreInstall(item.packageName, StoreInstall(release, version)) + } + } + + /** + * Clears the run, finished or not. + * + * This is also the cancel, deliberately. An install that has reached the platform cannot be + * recalled, but a download stalled on a connection that never times out, or a system + * confirmation dialog that was dismissed, would otherwise leave `running` set for the life of + * the process, with a progress line reporting it that nothing could get rid of. What the + * platform already accepted stays installed; what stops is the queue. + */ + fun acknowledge() { + job?.cancel() + job = null + _state.value = State() + installer.acknowledge() + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt new file mode 100644 index 000000000..b02d00781 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -0,0 +1,310 @@ +package org.matrix.vector.manager.data.repository + +import com.google.gson.Gson +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.matrix.vector.manager.data.model.OnlineModule +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreCatalog +import org.matrix.vector.manager.data.model.versionCodeCompat +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logI +import org.matrix.vector.manager.logW + +/** + * The Store's data: the online catalogue, and what this device already has of it. + * + * **The mirror list is two lists, and that is not an oversight.** The full `modules.json` is served + * by exactly one host today: `modules.lsposed.org` answers that path with a 403. Per-module + * `module/.json` *is* served by both hosts, so the public site is a real fallback there + * and only there. Merging these two lists back into one would quietly take the Store offline. + * + * **Caching is declared, not hoped for.** Every request states its own freshness, so the 16 MB disk + * cache in `HttpClientFactory` is actually used: the catalogue revalidates against the server's own + * ten-minute `max-age` and its ETag, pull-to-refresh forces the network, and when every mirror + * fails the same request is replayed against the cache alone. That last step is why a cold start + * with no network renders the last known catalogue rather than an error, and it is also what gives + * the DNS-over-HTTPS setting an effect here, since the shared client is the one carrying the DoH + * resolver. + * + * There is deliberately **no snapshot file** of our own, unlike `GitHubRepository`. The OkHttp + * cache already holds these exact bytes; a 1.2 MB duplicate in the same cache directory would buy + * nothing but a second thing to keep in sync. + */ +class RepoRepository( + private val client: OkHttpClient, + private val daemon: DaemonClient, + private val scope: CoroutineScope, + private val gson: Gson = Gson(), +) { + + private val _catalog = MutableStateFlow(StoreCatalog()) + val catalog: StateFlow = _catalog.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _installed = MutableStateFlow>(emptyMap()) + + /** + * What each package on this device is at, keyed by package name. + * + * One `getInstalledPackagesFromAllUsers` call and no `ModuleDetection`: the Store already knows + * that every name it asks about is a module, so it does not need the much more expensive + * discovery the Modules screen runs, which opens every APK to find out. + */ + val installedVersions: StateFlow> = _installed.asStateFlow() + + + /** Held for the length of a refresh; `tryLock` leaves no window between checking and taking. */ + private val refreshing = Mutex() + + init { + scope.launch { + // Re-read whenever a binder arrives, including a reconnect. The map is deliberately + // *not* cleared when the daemon goes away: which packages are installed is a fact + // about the device, not about the framework, and dropping every "Installed" badge + // because the daemon died would state something untrue. + ServiceLocator.service.collect { service -> if (service != null) loadInstalled() } + } + } + + /** + * Reloads the catalogue. + * + * [force] is pull-to-refresh: it bypasses the cache rather than revalidating against it, + * because a user who pulls is telling us they think what they are looking at is stale. + */ + suspend fun refresh(force: Boolean = false) { + // A second caller during a refresh is a no-op rather than a queued duplicate of a 1.2 MB + // download. + if (!refreshing.tryLock()) return + try { + _isRefreshing.value = true + withContext(Dispatchers.IO) { + val freshness = + if (force) CacheControl.FORCE_NETWORK + else + CacheControl.Builder() + .maxAge(CATALOG_MAX_AGE_MINUTES, TimeUnit.MINUTES) + .build() + + val fetched = LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, freshness) } + if (fetched != null) { + _catalog.value = fetched + return@withContext + } + + // Every mirror failed. Before reporting nothing, ask the cache — the bytes from + // the last successful visit are usually still on disk, and a stale catalogue is + // far more use than an empty screen. + val cached = + LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, CacheControl.FORCE_CACHE) } + when { + cached != null -> _catalog.value = cached.copy(fromCache = true) + // Nothing on the network and nothing on disk. `loaded` still flips, so the + // screen can say the repository is unreachable instead of sitting forever on + // a spinner that means nothing. + else -> _catalog.value = _catalog.value.copy(loaded = true) + } + } + } finally { + // In a `finally` rather than after the block: a cancelled `viewModelScope` — a + // rotation mid-refresh — would otherwise strand the flag at true, and with + // pull-to-refresh reading it that is a spinner that never stops. + _isRefreshing.value = false + refreshing.unlock() + } + } + + /** + * The full record for one module: its README, and every release rather than only the newest. + * + * Returns null when no mirror answers. Callers are expected to fall back to the catalogue entry + * they already hold, which carries the description, the scope, the collaborators and the newest + * release with its APK — a usable page, and much better than an error screen. + */ + suspend fun details(packageName: String): OnlineModule? = + withContext(Dispatchers.IO) { + val freshness = + CacheControl.Builder().maxAge(DETAIL_MAX_AGE_MINUTES, TimeUnit.MINUTES).build() + DETAIL_MIRRORS.firstNotNullOfOrNull { fetchDetails(it, packageName, freshness) } + ?: DETAIL_MIRRORS.firstNotNullOfOrNull { + fetchDetails(it, packageName, CacheControl.FORCE_CACHE) + } + } + + /** Re-reads installed versions; called on opening the Store and after an install lands. */ + fun refreshInstalled() { + scope.launch { loadInstalled() } + } + + /** + * The same read, awaited and handed back, for a caller that has to act on what it finds. + * + * Which is how an install records what it produced: the note that suppresses a satisfied offer + * is compared against [installedVersions], so it has to be written from that same reading. A + * local `getPackageInfo` would answer for user 0 while this map answers with the highest version + * across every user, and on a device with a work profile the two differ — leaving a note that can + * never match and a row that nags for ever. + * + * Returns the last known map when the daemon cannot be reached, which is the safe direction: a + * note written from a stale version simply fails to match, and the offer stays. + */ + suspend fun readInstalled(): Map { + loadInstalled() + return _installed.value + } + + private suspend fun loadInstalled() { + val packages = + daemon + .getInstalledPackagesFromAllUsers(0, false) + .onFailure { e -> + logW("store: installed versions unavailable", e) + } + .getOrNull() ?: return + val versions = HashMap(packages.size) + for (info in packages) { + val version = RepoVersion(info.versionCodeCompat, info.versionName.orEmpty()) + // The daemon reports every user, so the same package arrives more than once. The + // highest version wins, because that is the one an update would have to beat. + val known = versions[info.packageName] + if (known == null || version.versionCode > known.versionCode) { + versions[info.packageName] = version + } + } + _installed.value = versions + } + + private fun fetchCatalog(baseUrl: String, cacheControl: CacheControl): StoreCatalog? { + val url = baseUrl + "modules.json" + return try { + // `use` covers the failure branch as well as the success one, and the failure branch is + // the one that runs whenever a mirror is down. + client.newCall(request(url, cacheControl)).execute().use { response -> + if (!response.isSuccessful) { + // The FORCE_CACHE replay synthesises 504 without contacting the mirror, so + // only report a status the network actually produced. + if (response.networkResponse != null) { + logW("store: $url returned HTTP ${response.code}") + } + return null + } + val parsed = parseCatalog(response) + if (parsed.isEmpty()) return null + logI("store: ${parsed.size} modules from $url") + // `fromCache` is deliberately *not* derived from `response.networkResponse`. A hit + // inside the ten-minute freshness window is served from disk without touching the + // network, and calling that "the saved catalogue" would put an offline notice on + // a perfectly current list. Staleness is a property of which branch produced this, + // so the caller sets the flag on the fallback path and only there. + StoreCatalog( + modules = usable(parsed), + loaded = true, + loadedAtMillis = response.receivedResponseAtMillis, + ) + } + } catch (e: Exception) { + logW("store: $url unavailable", e) + null + } + } + + private fun fetchDetails( + baseUrl: String, + packageName: String, + cacheControl: CacheControl, + ): OnlineModule? { + val url = "${baseUrl}module/$packageName.json" + return try { + client.newCall(request(url, cacheControl)).execute().use { response -> + if (!response.isSuccessful) return null + gson.fromJson(response.body.string(), OnlineModule::class.java) + } + } catch (e: Exception) { + logW("store: $url unavailable", e) + null + } + } + + private fun request(url: String, cacheControl: CacheControl): Request = + Request.Builder().url(url).cacheControl(cacheControl).build() + + /** + * Reads the catalogue one entry at a time, and survives a bad one. + * + * Binding the whole array in a single `fromJson` call fails the entire Store on one unexpected + * field — `additionalAuthors` holds objects rather than the strings its name suggests, and + * `AdditionalAuthor` exists because of it. This is third-party data written by hundreds of + * authors, so an entry the model does not expect must cost that entry and nothing else. + * + * Streamed off the response rather than through a `String`, which also keeps the 1.2 MB body + * from being materialised twice. + */ + private fun parseCatalog(response: Response): List { + val modules = ArrayList(1024) + var rejected = 0 + JsonReader(response.body.charStream()).use { reader -> + reader.beginArray() + while (reader.hasNext()) { + // Parsing to a JsonElement first cannot fail on well-formed JSON, so a binding + // failure below leaves the reader cleanly positioned on the next entry. + val element = JsonParser.parseReader(reader) + val module = runCatching { gson.fromJson(element, OnlineModule::class.java) } + if (module.isSuccess) module.getOrNull()?.let(modules::add) else rejected++ + } + reader.endArray() + } + if (rejected > 0) logW("store: skipped $rejected unreadable entries") + return modules + } + + /** + * What is worth showing of a parsed catalogue. + * + * `distinctBy` is not superstition about today's data — it is what stops a mirror serving the + * same package twice from crashing the Store's `LazyColumn`, which is keyed by package name. + * Entries with no release at all are dropped because there is nothing to install and nothing to + * say about them. + */ + private fun usable(parsed: List): List = + parsed + .asSequence() + .filter { it.hide != true } + .filter { !it.releases.isNullOrEmpty() } + .distinctBy { it.name } + .toList() + + private companion object { + /** + * The only host serving the full list. Probed rather than assumed: roughly 1.2 MB and 800 + * entries here, against a 403 from `modules.lsposed.org`. + */ + val LIST_MIRRORS = listOf("https://backup.modules.lsposed.org/") + + /** Detail is served by both hosts, so here the public site is a genuine fallback. */ + val DETAIL_MIRRORS = + listOf("https://backup.modules.lsposed.org/", "https://modules.lsposed.org/") + + /** Matches the server's own `cache-control: max-age=600`, so revalidation stays free. */ + const val CATALOG_MAX_AGE_MINUTES = 10 + + /** Longer: a module's release history changes far less often than the index does. */ + const val DETAIL_MAX_AGE_MINUTES = 60 + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt new file mode 100644 index 000000000..927f2944f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt @@ -0,0 +1,530 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import android.content.SharedPreferences +import java.time.LocalDate +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreInstall + +/** + * The manager's own preferences: how it looks, what it shows, and what it has been told to stop + * mentioning. + * + * Nothing here belongs to the framework — which modules are on and what they may hook lives in the + * daemon's database. This is the reader's opinion of the app, and it survives a process death, + * which parasitically happens far more often than a user would expect since the host is + * `com.android.shell`. + */ +class SettingsRepository(context: Context) { + private val prefs: SharedPreferences = + context.getSharedPreferences("vector_settings", Context.MODE_PRIVATE) + + // Theme Settings + private val _themeMode = MutableStateFlow(prefs.getString("theme_mode", "system") ?: "system") + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _dynamicColor = MutableStateFlow(prefs.getBoolean("dynamic_color", true)) + val dynamicColor: StateFlow = _dynamicColor.asStateFlow() + + private val _amoledBlack = MutableStateFlow(prefs.getBoolean("amoled_black", false)) + val amoledBlack: StateFlow = _amoledBlack.asStateFlow() + + /** + * The colour every other colour is derived from, when dynamic colour is off. + * + * Stored as an ARGB int rather than a preset index so that a colour picked from the wheel + * survives a reinstall and does not depend on the preset list staying the same order. + */ + private val _seedColor = MutableStateFlow(prefs.getInt("seed_color", DEFAULT_SEED_COLOR)) + val seedColor: StateFlow = _seedColor.asStateFlow() + + fun setSeedColor(argb: Int) { + prefs.edit().putInt("seed_color", argb).apply() + _seedColor.value = argb + } + + // Updates & Network + + /** + * Which releases of a *module* the Store offers, "stable" or "beta". See StoreChannel. + * + * The framework's own channel is not here and is not a setting: it is derived from the build + * that is actually running. See FrameworkUpdateRepository. + */ + private val _updateChannel = + MutableStateFlow(prefs.getString("update_channel", "stable") ?: "stable") + val updateChannel: StateFlow = _updateChannel.asStateFlow() + + /** + * Resolve through Cloudflare rather than the network's own resolver. + * + * On by default. The mirrors this app depends on are the ones a network is most likely to + * resolve wrongly or not at all, and someone whose Store is empty because of it has no reason + * to suspect DNS. VectorDns only uses it when nothing is proxying the connection, and falls + * back to the system resolver for the rest of the session the first time a lookup fails, so + * the default costs nothing on a network where ordinary DNS already works. + */ + private val _dohEnabled = MutableStateFlow(prefs.getBoolean("doh_enabled", true)) + val dohEnabled: StateFlow = _dohEnabled.asStateFlow() + + // --- Home activity feed --- + + /** + * How far back the Home activity feed reaches, in months. + * + * Six is the default: long enough that a quiet stretch does not read as a dead project, short + * enough that the contributor row still moves. A busy fork may want less, someone tracking a + * slow-moving release may want more, so it is theirs to set. + */ + private val _activityWindowMonths = MutableStateFlow(prefs.getInt("activity_window_months", 6)) + val activityWindowMonths: StateFlow = _activityWindowMonths.asStateFlow() + + /** + * Whether GitHub links leave the app. + * + * Off by default: the built-in viewer keeps the user in Vector, which matters most in + * parasitic mode where "the app" is really the shell process and handing off to a browser is a + * jarring context switch out of something that does not look like an app to the system. + */ + private val _openLinksExternally = + MutableStateFlow(prefs.getBoolean("open_links_externally", false)) + val openLinksExternally: StateFlow = _openLinksExternally.asStateFlow() + + /** + * How the scope list is filtered and ordered, remembered across visits. + * + * A scope is edited one module at a time, so these are settled a dozen times over in a single + * sitting otherwise. They are ways of *reading* a list of several hundred apps rather than + * anything about a particular module, which is the test this app applies everywhere else — + * word wrap, header surface, activity window — and the reason it applies it is that the host + * process is killed constantly, so anything held in a ViewModel is gone by the next visit. + * + * "Recommended only" is deliberately absent. It narrows the list to what one module asked for, + * and a module that asks for nothing would then open to an empty screen — a filter that reads + * as breakage. It stays per visit. + */ + private val _scopeShowSystemApps = MutableStateFlow(prefs.getBoolean("scope_system_apps", false)) + val scopeShowSystemApps: StateFlow = _scopeShowSystemApps.asStateFlow() + + fun setScopeShowSystemApps(show: Boolean) { + prefs.edit().putBoolean("scope_system_apps", show).apply() + _scopeShowSystemApps.value = show + } + + private val _scopeShowGames = MutableStateFlow(prefs.getBoolean("scope_games", true)) + val scopeShowGames: StateFlow = _scopeShowGames.asStateFlow() + + fun setScopeShowGames(show: Boolean) { + prefs.edit().putBoolean("scope_games", show).apply() + _scopeShowGames.value = show + } + + private val _scopeShowModules = MutableStateFlow(prefs.getBoolean("scope_modules", false)) + val scopeShowModules: StateFlow = _scopeShowModules.asStateFlow() + + fun setScopeShowModules(show: Boolean) { + prefs.edit().putBoolean("scope_modules", show).apply() + _scopeShowModules.value = show + } + + private val _scopeSort = MutableStateFlow(prefs.getString("scope_sort", "relevance") ?: "relevance") + val scopeSort: StateFlow = _scopeSort.asStateFlow() + + fun setScopeSort(key: String) { + prefs.edit().putString("scope_sort", key).apply() + _scopeSort.value = key + } + + private val _scopeSortReversed = MutableStateFlow(prefs.getBoolean("scope_sort_reversed", false)) + val scopeSortReversed: StateFlow = _scopeSortReversed.asStateFlow() + + fun setScopeSortReversed(reversed: Boolean) { + prefs.edit().putBoolean("scope_sort_reversed", reversed).apply() + _scopeSortReversed.value = reversed + } + + /** + * How the contributor row is ordered: by how much someone has done, or by how recently. + * + * Both are honest and they honour different people. Volume puts the maintainer first forever, + * which is accurate and unchanging; recency puts whoever last landed something at the front, + * which is what makes a first contribution visible the day it happens. + */ + private val _contributorOrder = + MutableStateFlow(prefs.getString("contributor_order", "commits") ?: "commits") + val contributorOrder: StateFlow = _contributorOrder.asStateFlow() + + fun setContributorOrder(key: String) { + prefs.edit().putString("contributor_order", key).apply() + _contributorOrder.value = key + } + + /** + * The language the app is shown in, as a BCP-47 tag, or empty for whatever the system says. + * + * Not `setApplicationLocales`: that API is keyed on an installed package, and parasitically + * this one is never installed. Asking the framework would change the host's language or + * nothing at all. See LocalizedContent for how the override is applied instead. + */ + private val _appLocale = MutableStateFlow(prefs.getString("app_locale", "") ?: "") + val appLocale: StateFlow = _appLocale.asStateFlow() + + fun setAppLocale(tag: String) { + prefs.edit().putString("app_locale", tag).apply() + _appLocale.value = tag + } + + /** + * Modules the reader has told us to stop nagging about. + * + * In the manager's own preferences rather than in the daemon's module database, because this is + * a fact about *this reader's opinion of the catalogue*, not about the module: the daemon has + * never heard of the catalogue, does not know a remote version exists, and would have to be + * taught the whole notion to store one boolean. Muting also has to survive a module being + * uninstalled and reinstalled, which a daemon-side per-module row would not. + */ + private val _mutedUpdates = + MutableStateFlow(prefs.getStringSet("muted_updates", emptySet())?.toSet() ?: emptySet()) + val mutedUpdates: StateFlow> = _mutedUpdates.asStateFlow() + + fun setUpdatesMuted(packageName: String, muted: Boolean) { + val next = + if (muted) _mutedUpdates.value + packageName else _mutedUpdates.value - packageName + // A set of our own on the way in, and `toSet()` on the way out above: `getStringSet` hands + // back the instance the preferences hold, which the platform documents as not ours to + // modify. + prefs.edit().putStringSet("muted_updates", HashSet(next)).apply() + _mutedUpdates.value = next + } + + /** + * Which catalogue release the Store put on this device, per package. See [StoreInstall]. + * + * Here rather than in the daemon for the reason the mute above is: the daemon has never heard + * of the catalogue, and this is a fact about what *this* app did rather than about the module. + * It has to survive a process death for the same reason too — parasitically the process is the + * shell's, and it is killed constantly, so an in-memory note would forget by the next visit and + * the offer it silenced would be back. + * + * A string set, like the mute, rather than a serialised map: three fields per row, joined by + * newlines, which no package name or tag contains. A row that no longer parses is dropped, + * which is the right answer for a note whose only job is to suppress an offer — the worst a + * lost row can do is offer an update again. Rows are never pruned either, for the same reason: + * one is a few dozen bytes, a device carries tens of modules, and a note left behind by a + * module that has since been uninstalled says nothing until that module is back at that exact + * version. + */ + private val _storeInstalls = MutableStateFlow(readStoreInstalls()) + val storeInstalls: StateFlow> = _storeInstalls.asStateFlow() + + /** Records what the Store installed for [packageName], replacing any earlier note of it. */ + fun noteStoreInstall(packageName: String, install: StoreInstall) { + val next = _storeInstalls.value + (packageName to install) + val rows = next.mapTo(HashSet()) { (name, noted) -> encode(name, noted) } + prefs.edit().putStringSet("store_installs", rows).apply() + _storeInstalls.value = next + } + + private fun encode(packageName: String, install: StoreInstall): String = + "$packageName\n${install.release.tag}\n${install.installed.tag}" + + private fun readStoreInstalls(): Map = + prefs + .getStringSet("store_installs", emptySet()) + .orEmpty() + .mapNotNull { row -> + val parts = row.split('\n') + if (parts.size != 3) return@mapNotNull null + val release = RepoVersion.parse(parts[1]) ?: return@mapNotNull null + val installed = RepoVersion.parse(parts[2]) ?: return@mapNotNull null + parts[0] to StoreInstall(release, installed) + } + .toMap() + + /** Which living surface the status header draws. See AmbienceKind. */ + private val _headerAmbience = + MutableStateFlow(prefs.getString("header_ambience", DEFAULT_AMBIENCE) ?: DEFAULT_AMBIENCE) + val headerAmbience: StateFlow = _headerAmbience.asStateFlow() + + private val _updateVariant = + MutableStateFlow(prefs.getString("update_variant", "release") ?: "release") + + /** + * Which build of the framework to install, "release" or "debug". + * + * Remembered because someone who wants debug builds wants them every time — a maintainer + * chasing a bug report is not making a fresh decision on each update — and because the choice + * is otherwise invisible until the download size appears. + */ + val updateVariant: StateFlow = _updateVariant.asStateFlow() + + fun setUpdateVariant(key: String) { + prefs.edit().putString("update_variant", key).apply() + _updateVariant.value = key + } + + /** + * How big, how varied and how fast each ambience draws itself. + * + * Per kind rather than global: a comfortable glyph size for the code rain says nothing about + * how large a maze cell should be, and someone who has tuned one and switches away should find + * it as they left it. Written straight through on every gesture — these are a handful of bytes, + * and the alternative is losing the adjustment to the next process death. + */ + fun ambienceScale(kind: String): Float = prefs.getFloat("ambience_scale_$kind", 1f) + + fun setAmbienceScale(kind: String, value: Float) { + prefs.edit().putFloat("ambience_scale_$kind", value).apply() + } + + fun ambienceVariant(kind: String): Int = prefs.getInt("ambience_variant_$kind", 0) + + fun setAmbienceVariant(kind: String, value: Int) { + prefs.edit().putInt("ambience_variant_$kind", value).apply() + } + + fun ambienceSpeed(kind: String): Float = prefs.getFloat("ambience_speed_$kind", 1f) + + fun setAmbienceSpeed(kind: String, value: Float) { + prefs.edit().putFloat("ambience_speed_$kind", value).apply() + } + + fun setHeaderAmbience(key: String) { + prefs.edit().putString("header_ambience", key).apply() + _headerAmbience.value = key + } + + fun setActivityWindowMonths(months: Int) { + prefs.edit().putInt("activity_window_months", months).apply() + _activityWindowMonths.value = months + } + + fun setOpenLinksExternally(enabled: Boolean) { + prefs.edit().putBoolean("open_links_externally", enabled).apply() + _openLinksExternally.value = enabled + } + + /** + * Whether Home has been told to stop offering a launcher icon. + * + * Set by the "don't ask again" on the prompt that appears on first launch. Kept separate from + * "a shortcut is pinned", which is the launcher's fact and is asked of the launcher: someone + * who dismisses the prompt and later pins the shortcut by hand should not be asked again, and + * someone who removes the shortcut should not be nagged about it once they have said no. + */ + private val _launcherPromptDismissed = + MutableStateFlow(prefs.getBoolean("launcher_prompt_dismissed", false)) + val launcherPromptDismissed: StateFlow = _launcherPromptDismissed.asStateFlow() + + fun dismissLauncherPrompt() { + prefs.edit().putBoolean("launcher_prompt_dismissed", true).apply() + _launcherPromptDismissed.value = true + } + + /** + * Which launchers are known to be holding a pinned Vector shortcut. + * + * The platform will not say. `ShortcutManager.getPinnedShortcuts` answers for *any* launcher at + * once — the pin flag lives on the shortcut, not on the pair — and the per-launcher sets are + * only readable by a caller that is itself the active launcher. So a device that pinned the + * shortcut, then installed a different launcher, is told it already has one while its home + * screen has nothing on it, which is what #883 reported. + * + * A set rather than a single package because pinning on a second launcher does not unpin the + * first, and someone who keeps two and switches between them should not be offered a shortcut + * they already have on both. What the set cannot represent is a shortcut *removed* from one of + * several launchers holding it: nothing tells us which one lost it, and the platform still + * reports the shortcut pinned. That row will read as done until the last copy is gone. + */ + fun shortcutLaunchers(): Set = + prefs.getStringSet("shortcut_launchers", emptySet()).orEmpty().toSet() + + fun noteShortcutLauncher(packageName: String) { + val known = shortcutLaunchers() + if (packageName in known) return + // A set of our own: `getStringSet` hands back the instance the preferences hold, which the + // platform documents as not ours to modify. + prefs.edit().putStringSet("shortcut_launchers", HashSet(known + packageName)).apply() + } + + // --- the status badge's own hint ---------------------------------------------------------- + + /** + * How many times *today* the status badge was used to open System status. + * + * The badge is the only way to those settings, and nothing about a tick says so — #856. The + * header answers that by having the tick turn into a gear now and then, and this is what stops + * it: a reader who has opened the page several times today plainly knows where it is, and a gear + * that keeps appearing after that is noise on the one part of the header whose job is to report + * a state. How many is several is HomeViewModel's to say — this only counts. + * + * Counted per day rather than for good because the hint costs nothing to offer again and the + * knowledge does fade — and because a count that only ever grows would retire the hint on the + * strength of an afternoon spent on that page months ago. The day is stored beside the count and + * a stale one reads as zero, so no reset has to run at midnight. + */ + private val _statusBadgeOpens = MutableStateFlow(statusBadgeOpensToday()) + val statusBadgeOpens: StateFlow = _statusBadgeOpens.asStateFlow() + + fun noteStatusBadgeOpened() { + val today = LocalDate.now().toEpochDay() + // Against the stored day, not against the flow: a session left open across midnight holds + // yesterday's count in memory, and adding to it would carry it into today. + val next = + if (prefs.getLong("status_badge_day", 0L) == today) _statusBadgeOpens.value + 1 else 1 + prefs.edit().putLong("status_badge_day", today).putInt("status_badge_opens", next).apply() + _statusBadgeOpens.value = next + } + + /** + * Re-reads the count against today's date. + * + * Called when Home is opened, which is the only moment the hint can start running again, and is + * what lets a session that has crossed midnight — parasitically rare, since the host process is + * killed constantly, but free to handle — offer it afresh. + */ + fun refreshStatusBadgeOpens() { + _statusBadgeOpens.value = statusBadgeOpensToday() + } + + private fun statusBadgeOpensToday(): Int = + if (prefs.getLong("status_badge_day", 0L) == LocalDate.now().toEpochDay()) + prefs.getInt("status_badge_opens", 0) + else 0 + + // --- Logs --- + + /** + * Whether log lines wrap rather than pan sideways. + * + * Persisted because it is a reading preference, not a transient view state: parasitically the + * manager lives inside `com.android.shell`, whose process is killed routinely, so anything held + * only in a ViewModel resets far more often than a user would expect. + */ + private val _logWordWrap = MutableStateFlow(prefs.getBoolean("log_word_wrap", true)) + val logWordWrap: StateFlow = _logWordWrap.asStateFlow() + + fun setLogWordWrap(enabled: Boolean) { + prefs.edit().putBoolean("log_word_wrap", enabled).apply() + _logWordWrap.value = enabled + } + + /** + * Whether a stack trace in the log opens where it sits, or on a screen of its own. + * + * Inline by default. The log is read with a filter applied and a scroll position worth keeping, + * and pushing a route for one entry costs both — which matters most when the reason you are + * reading the log is to compare one trace against another. The screen is the better answer for + * a trace long enough that having it inside the list is the thing in the way, so which one is + * right depends on the reader, and that is what makes it a setting rather than a decision. + */ + private val _logTracesInline = MutableStateFlow(prefs.getBoolean("log_traces_inline", true)) + val logTracesInline: StateFlow = _logTracesInline.asStateFlow() + + fun setLogTracesInline(inline: Boolean) { + prefs.edit().putBoolean("log_traces_inline", inline).apply() + _logTracesInline.value = inline + } + + // --- Navigation panels --- + + /** + * Which panels the navigation container shows, in which order, and which are hidden. + * + * One delimited string rather than a set: `putStringSet` does not preserve order — the same + * fact `muted_updates` above relies on being harmless — and here the order is the whole point. + * Route keys rather than ordinals or class names, because R8 rewrites class names in a release + * build and an ordinal would silently mean a different panel the day a fifth one is added. + * Empty means "the catalogue as declared", which is what a fresh install has and what anyone + * who has never opened edit mode keeps. See NavPanels for the format. + */ + private val _navPanels = MutableStateFlow(prefs.getString("nav_panels", "") ?: "") + val navPanels: StateFlow = _navPanels.asStateFlow() + + fun setNavPanels(encoded: String) { + prefs.edit().putString("nav_panels", encoded).apply() + _navPanels.value = encoded + } + + /** + * Whether the panels live on a draggable ball over the content instead of in a bar or a rail. + * + * Off by default: the bar is what every other app on the device puts there, and a reader who + * has not asked for anything else should not have to work out where their panels went. It is + * offered at all because the bar costs a strip of every screen for four items that are rarely + * touched, and on a small phone reading a log that strip is the expensive part. + */ + private val _floatingNav = MutableStateFlow(prefs.getBoolean("floating_nav", false)) + val floatingNav: StateFlow = _floatingNav.asStateFlow() + + fun setFloatingNav(enabled: Boolean) { + prefs.edit().putBoolean("floating_nav", enabled).apply() + _floatingNav.value = enabled + } + + /** + * Where the floating ball was left: which side it snapped to, and how far down it sits as a + * fraction of the window height. + * + * No flow, for the same reason the ambience adjustments have none: written straight through + * from a gesture and read once when the ball is composed, so a StateFlow would recompose the + * very thing being dragged on every frame of the drag. Persisted rather than remembered because + * somebody who moved the ball out of the way of what they were reading has made a decision + * about their thumb, and the host process is killed often enough that anything held in memory + * would put the ball back over the content within the hour. + * + * The side is stored, not the x position: the ball always snaps to an edge, so a coordinate + * would be a lie the moment the window is a different width — which, unfoldable and in + * landscape, it routinely is. + */ + fun floatingNavAtEnd(): Boolean = prefs.getBoolean("floating_nav_at_end", true) + + fun setFloatingNavAtEnd(atEnd: Boolean) { + prefs.edit().putBoolean("floating_nav_at_end", atEnd).apply() + } + + fun floatingNavY(): Float = prefs.getFloat("floating_nav_y", 0.72f) + + fun setFloatingNavY(fraction: Float) { + prefs.edit().putFloat("floating_nav_y", fraction).apply() + } + + fun setThemeMode(mode: String) { + prefs.edit().putString("theme_mode", mode).apply() + _themeMode.value = mode + } + + fun setDynamicColor(enabled: Boolean) { + prefs.edit().putBoolean("dynamic_color", enabled).apply() + _dynamicColor.value = enabled + } + + fun setAmoledBlack(enabled: Boolean) { + prefs.edit().putBoolean("amoled_black", enabled).apply() + _amoledBlack.value = enabled + } + + fun setUpdateChannel(channel: String) { + prefs.edit().putString("update_channel", channel).apply() + _updateChannel.value = channel + } + + fun setDohEnabled(enabled: Boolean) { + prefs.edit().putBoolean("doh_enabled", enabled).apply() + _dohEnabled.value = enabled + } + + private companion object { + /** The Winged Victory's patina. Kept as a literal so this file needs no UI imports. */ + const val DEFAULT_SEED_COLOR = 0xFF6ABFCF.toInt() + + /** + * Must match an `AmbienceKind` key. An unknown one falls back harmlessly, but a stored + * default that names no surface misleads whoever reads the preferences next. + */ + const val DEFAULT_AMBIENCE = "maze" + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt new file mode 100644 index 000000000..b67f65b27 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt @@ -0,0 +1,317 @@ +package org.matrix.vector.manager.di + +import org.matrix.vector.manager.data.model.ModuleDetectionCache +import java.io.File +import org.matrix.vector.manager.ui.screens.repo.latestOn +import org.matrix.vector.manager.ui.screens.repo.StoreChannel +import org.matrix.vector.manager.data.model.StoreEntry +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.SharingStarted +import android.annotation.SuppressLint +import android.content.Context +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.data.log.CrashRecorder +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.repository.AppRepository +import org.matrix.vector.manager.data.repository.BackupRepository +import org.matrix.vector.manager.data.repository.FrameworkInstaller +import org.matrix.vector.manager.data.repository.FrameworkUpdateRepository +import org.matrix.vector.manager.data.repository.ManagerInstaller +import org.matrix.vector.manager.data.repository.ModuleInstaller +import org.matrix.vector.manager.data.repository.ModuleUpdateQueue +import org.matrix.vector.manager.data.repository.ModuleRepository +import org.matrix.vector.manager.data.repository.RepoRepository +import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.ipc.daemonPackageEventsFlow +import org.matrix.vector.manager.ipc.packageEventsFlow +import org.matrix.vector.manager.net.HttpClientFactory +import org.matrix.vector.manager.net.VectorDns + +/** + * Hand-rolled service location, deliberately not a DI framework. + * + * The manager normally runs *parasitically*: `ParasiticManagerHooker` injects `manager.apk` into + * the `com.android.shell` process, so this app's `AndroidManifest.xml` is never installed. Nothing + * declared in it exists at runtime — no `ContentProvider`, therefore no `androidx.startup`, and no + * guaranteed custom `Application`. Anything that self-initialises through `InitializationProvider` + * silently never runs. Everything here is therefore initialised explicitly and lazily. + * + * Initialisation order is not fixed either. The daemon may call `Constants.setBinder()` before the + * activity exists, or the activity may start before any binder arrives. [attach] and [bind] are + * both idempotent and safe in either order; nothing here throws because the other half has not + * happened yet. + */ +@SuppressLint("StaticFieldLeak") // Application context; it outlives everything here by design. +object ServiceLocator { + + /** Survives configuration changes, unlike anything scoped to the activity. */ + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @Volatile private var appContext: Context? = null + + private val _service = MutableStateFlow(null) + + /** + * The daemon binder, as observable state. + * + * Repositories collect this rather than being poked by a setter, so a binder that arrives after + * they were constructed — or arrives again after a reconnect — makes them re-read instead of + * leaving them with whatever they managed to fetch before there was a daemon at all. + */ + val service: StateFlow = _service.asStateFlow() + + private val _peerMismatch = MutableStateFlow(null) + + /** + * What the daemon turned out to be, when it is not something this build can talk to. + * + * Null in the ordinary case, including "no daemon at all" — this is only ever set when a binder + * did arrive and was then refused. That is a distinct situation from having no daemon and has to + * be rendered as one: the binder is alive and the framework is plainly running, so every screen + * would otherwise draw it as one that answers nothing. + * + * Two things set it, and the string says which: a descriptor that is not this interface, or a + * protocol version this build does not speak. It is text for a log and a marker for the header + * rather than something to branch on — the answer to both is the same, and a reader cannot act + * on the difference. + */ + val peerMismatch: StateFlow = _peerMismatch.asStateFlow() + + val context: Context + get() = + appContext + ?: error("ServiceLocator.attach() must run before the UI touches the context") + + val daemon: DaemonClient by lazy { DaemonClient(service) } + + private val net: HttpClientFactory.NetStack by lazy { + HttpClientFactory.create(context, settings) + } + + val http: OkHttpClient + get() = net.client + + /** The resolver inside [http], so the settings sheet can report what DoH is actually doing. */ + val dns: VectorDns + get() = net.dns + + val settings: SettingsRepository by lazy { SettingsRepository(context) } + + val modules: ModuleRepository by lazy { ModuleRepository(daemon, appScope) } + + val apps: AppRepository by lazy { + AppRepository(daemon, context.packageManager, moduleDetection) + } + + /** + * Which packages are modules, remembered across launches. + * + * Shared rather than per view model: the answer is a property of the installed APKs, and a + * second copy would mean a second pass of opening every APK and split on the device as a zip. + */ + val moduleDetection: ModuleDetectionCache by lazy { + ModuleDetectionCache(File(context.cacheDir, "module-detection.tsv")) + } + + val store: RepoRepository by lazy { RepoRepository(http, daemon, appScope) } + + val installer: ModuleInstaller by lazy { ModuleInstaller(context, http) } + + val frameworkUpdates: FrameworkUpdateRepository by lazy { FrameworkUpdateRepository(github) } + + /** + * Every installed module the catalogue knows about, joined to what this device has. + * + * Here rather than in either view model because three screens need it and they must agree: the + * Modules list marks a version as out of date, the module's own sheet offers to update it, and + * the Store counts the same modules in its header. Three independent answers to "is this out of + * date" is precisely how those numbers end up contradicting each other on one device. + * + * Keyed by package and limited to what is installed, because every reader of this asks about a + * module in front of them. The Store's own list joins the other direction, catalogue first. + */ + val storeEntries: StateFlow> by lazy { + combine( + store.catalog, + store.installedVersions, + settings.updateChannel, + settings.mutedUpdates, + settings.storeInstalls, + ) { catalog, installed, channelPreference, muted, storeInstalls -> + val channel = StoreChannel.of(channelPreference) + catalog.modules + .filter { it.name in installed } + .associate { module -> + module.name to + StoreEntry( + module = module, + latest = module.latestOn(channel), + installed = installed[module.name], + updatesMuted = module.name in muted, + storeInstall = storeInstalls[module.name], + ) + } + } + .flowOn(Dispatchers.Default) + .stateIn(appScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + } + + /** + * Installed modules the catalogue has something newer for, muting already applied. + * + * Expressed through `StoreEntry.upgradable`, the same property the Store's own list and count + * use, so there is one definition of the word and not a second one that merely agrees today. + */ + val upgradablePackages: StateFlow> by lazy { + storeEntries + .map { entries -> entries.values.filter { it.upgradable }.map { it.module.name }.toSet() } + .stateIn(appScope, SharingStarted.WhileSubscribed(5_000), emptySet()) + } + + /** + * Installed modules that *are* out of date but were asked to keep quiet. + * + * Counted separately rather than folded into [upgradablePackages], because the two answer + * different questions: one is "what should this device tell you about", the other is "what did + * you tell it to stop mentioning". Only the update sheet asks the second, and it asks so that + * an ignored module is visible when someone goes looking, rather than being unreachable from + * the panel that hid it. + */ + val mutedUpgradablePackages: StateFlow> by lazy { + storeEntries + .map { entries -> + entries.values + .filter { it.updatesMuted && it.copy(updatesMuted = false).upgradable } + .map { it.module.name } + .toSet() + } + .stateIn(appScope, SharingStarted.WhileSubscribed(5_000), emptySet()) + } + + /** Sequential module updates, outliving the sheet that started them. */ + val moduleUpdates: ModuleUpdateQueue by lazy { ModuleUpdateQueue(installer, store, modules, settings, appScope) } + + val frameworkInstaller: FrameworkInstaller by lazy { FrameworkInstaller(context, http, daemon) } + + /** Installs the manager itself as an ordinary app, for anyone who would rather have an icon. */ + val managerInstaller: ManagerInstaller by lazy { ManagerInstaller(context, daemon) } + + val backup: BackupRepository by lazy { BackupRepository(context, daemon) } + + val github: GitHubRepository by lazy { + GitHubRepository( + client = http, + cacheDir = context.cacheDir, + windowMonthsProvider = { settings.activityWindowMonths.value }, + ) + } + + /** Called from the activity. Safe to call repeatedly; later calls are ignored. */ + fun attach(context: Context) { + if (appContext != null) return + appContext = context.applicationContext ?: context + // Before anything else that could fail. Nothing below is load-bearing for it, and a crash + // during startup is exactly the one that is hardest to catch on a cable. + CrashRecorder.install(appContext!!) + + // Coil is configured by hand rather than through its manifest hooks, for the same reason + // OkHttp is: parasitically this app's manifest is never installed, so nothing that + // self-registers there ever runs. Here rather than in the activity because every entry + // point comes through `attach` — including the debug demo host, which never opens + // MainActivity and so had no image loader at all while this lived there. + // + // The factory is not called until the first image, so this costs nothing at startup and + // does not build the OkHttp client before something asks for it. + SingletonImageLoader.setSafe { platformContext: PlatformContext -> + ImageLoader.Builder(platformContext) + .components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) } + .build() + } + + observePackageChanges() + } + + /** + * Invalidates the caches when a package is installed, updated or removed. + * + * The only collector of either flow, and on [appScope] so it lasts as long as the process: + * without it a module installed while the manager is open would never appear. + * + * Two sources, because neither sees the whole device. The platform's own broadcasts arrive only + * for the user this process runs in, so an app installed into a work profile or a secondary + * user went unnoticed here and the scope editor for a module in that profile never listed it. + * The daemon watches every user and re-broadcasts what it saw, but only while it is alive, so + * it cannot replace the platform source either. + * + * A package event on the primary user therefore arrives twice and this body runs twice, which + * is left alone on purpose: it drops two cached fields, reads the enabled list once and bumps a + * revision, and the platform alone already delivers ADDED and REPLACED for a single update — a + * repeat is the case this collector was written for, not a new one. + */ + private fun observePackageChanges() { + appScope.launch { + merge(context.packageEventsFlow(), context.daemonPackageEventsFlow()).collect { + apps.invalidate() + modules.refresh() + modules.notePackagesChanged() + } + } + } + + /** + * Starts the expensive reads while the splash is still on screen. + * + * The three panels a user actually opens first each begin with work that has nothing to do + * with drawing: enumerating every installed package, reading the module catalogue, fetching + * the activity feed. Doing that on first visit means the panel appears and then fills in; + * doing it here means it is usually already there. + * + * Every one of these is idempotent and cached, so the view models that ask again on arrival get + * the finished answer rather than starting a second copy. Failures are ignored on purpose: this + * is a head start, not a load-bearing step, and a screen that could not be reached because its + * prefetch failed would be worse than one that is merely slow. + */ + fun prefetch() { + appScope.launch { runCatching { apps.getInstalledApps() } } + appScope.launch { runCatching { store.refresh() } } + // From disk only. Opening the manager is not by itself a reason to talk to GitHub, and + // asking for a revalidation here would override Home's own gate — the one that decides how + // rarely a launch is allowed to go and check. + appScope.launch { runCatching { github.load(GitHubRepository.Freshness.Cached) } } + } + + /** Called from `Constants.setBinder`, possibly before [attach]. */ + fun bind(service: IManagerService?) { + _service.value = service + _peerMismatch.value = null + } + + /** + * Called instead of [bind] when the binder that arrived is not one this build can use. + * + * Deliberately leaves [service] null. A refused peer is not a degraded daemon that answers some + * calls; it is one whose every answer would be thrown or wrong, so handing it out would only + * spread failures across every screen. + */ + fun bindMismatch(what: String) { + _service.value = null + _peerMismatch.value = what + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt new file mode 100644 index 000000000..b4c0705f8 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -0,0 +1,378 @@ +package org.matrix.vector.manager.ipc + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.matrix.vector.ipc.IFrameworkInstallReceiver +import android.content.Intent +import android.content.pm.ActivityInfo +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** + * Every call the manager makes to the daemon, as coroutines. + * + * A Binder transaction is synchronous and the daemon on the other end can be slow, busy or gone, so + * none of this is allowed to happen on the thread that draws. + */ +class DaemonClient(private val serviceState: StateFlow) { + + val service: IManagerService? + get() = serviceState.value + + val isAlive: Boolean + get() = service?.asBinder()?.isBinderAlive == true + + /** + * Runs one daemon transaction on the IO dispatcher and reports its outcome as a [Result], so an + * unreachable or refusing daemon is a value the caller can render rather than a thrown + * exception. + */ + private suspend fun runIpc(block: (IManagerService) -> T): Result = + withContext(Dispatchers.IO) { + // Read the binder once: it comes from a StateFlow the daemon can change underneath us, + // so checking one value for liveness and calling another is a race with the daemon + // dying. + val binder = service + if (binder == null || binder.asBinder()?.isBinderAlive != true) { + return@withContext Result.failure(IllegalStateException("Daemon is not active")) + } + try { + Result.success(block(binder)) + } catch (e: Exception) { + // Deliberately broad. A SecurityException, an IllegalArgumentException, or a + // RuntimeException thrown while unparcelling a large ParcelableListSlice are all + // reachable here, and any of them escaping fails the calling coroutine — which for + // an unhandled failure in a viewModelScope means the process goes down. + logW("ipc: daemon transaction failed", e) + Result.failure(e) + } + } + + suspend fun getLibxposedApiVersion(): Result = runIpc { it.libxposedApiVersion } + + suspend fun getEnabledModules(): Result> = runIpc { it.enabledModules + } + + /** + * The activity that would open for this package, or null when it has none. + * + * Three categories, in order. A module that declares the Xposed settings category is naming its + * companion — the screen its author wrote to configure it — and for a module that wins. + * `CATEGORY_INFO` comes next: it is what an app declares when it has a screen worth opening but + * deliberately keeps out of the launcher, which is the common shape for a module. + * `CATEGORY_LAUNCHER` last. + * + * Resolved as the package's own user throughout, and through the daemon: the manager's own + * package manager cannot see another profile's activities. + */ + suspend fun findAppUi( + packageName: String, + userId: Int, + /** + * True for a module, where the companion screen is the point. + * + * No default: the caller that asks whether a companion exists and the caller that opens it + * have to be asking the same question, or the manager offers a button that resolves to + * nothing. + */ + companionFirst: Boolean, + ): Result = runIpc { service -> + val categories = buildList { + if (companionFirst) add(XPOSED_MODULE_SETTINGS_CATEGORY) + add(Intent.CATEGORY_INFO) + add(Intent.CATEGORY_LAUNCHER) + } + categories + .asSequence() + .mapNotNull { category -> + val intent = + Intent(Intent.ACTION_MAIN).addCategory(category).setPackage(packageName) + service.queryIntentActivitiesAsUser(intent, 0, userId)?.list?.firstOrNull() + } + .firstOrNull() + ?.activityInfo + } + + /** + * Opens that screen. + * + * Whether to suppress the daemon's user switch is decided here, from the resolved activity's + * `FLAG_SHOW_FOR_ALL_USERS` — see [startActivityAsUser] for what the switch does and why an + * activity that shows for every user must not trigger one. + * + * Returns false when the package has no such screen, which is an answer rather than a failure. + */ + suspend fun openAppUi( + packageName: String, + userId: Int, + /** As in [findAppUi], and for the same reason it has no default there. */ + companionFirst: Boolean, + ): Result { + val resolved = findAppUi(packageName, userId, companionFirst) + val target = resolved.getOrNull() + if (target == null) { + logE( + "ipc: open resolved no activity for $packageName in user $userId " + + "(companionFirst=$companionFirst)", + resolved.exceptionOrNull(), + ) + return Result.success(false) + } + return runIpc { service -> + val code = + service.startActivityAsUser( + Intent(Intent.ACTION_MAIN) + .setClassName(target.packageName, target.name) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + userId, + (target.flags and FLAG_SHOW_FOR_ALL_USERS) != 0, + ) + // The daemon hands back the activity manager's own start code, so a refusal reaches the + // caller rather than a flat `true`: a refused user switch (-1), a disabled or + // unexported activity, an activity that has gone since it was resolved. Reporting any + // of those as "opened" leaves the screen silent with nothing in front of it. A start + // succeeded when the code is 0 to 99, which is what `ActivityManager` tests itself in + // `isStartResultSuccessful`; those codes are @hide, so the band is written out. Fatal + // refusals occupy -100 to -1, and 100 to 199 is the non-fatal error band — + // START_SWITCHES_CANCELED (100), START_RETURN_LOCK_TASK_MODE_VIOLATION (101), + // START_ABORTED (102) — where nothing came up either. + val started = code in 0..99 + if (!started) { + logE( + "ipc: ${target.packageName}/${target.name} refused by the activity manager " + + "in user $userId (code $code)", + ) + } + started + } + } + + /** + * Modules the daemon could not load, though they are installed and enabled, keyed by package + * with one of `IManagerService.MODULE_LOAD_*` as the value. + * + * The daemon keeps what the user asked for separately from what it can actually load, and the + * two can disagree — an APK whose path will not resolve, a DEX the loader refuses. A module + * listed here is still switched on; it is the loading that failed, and saying so is the only + * way the screen can tell that apart from "switched off". + * + * A map because that is what the daemon holds and what the caller wants. It used to be a list + * of names plus one transaction per name to ask why, which forced the caller to seed each + * entry with a placeholder reason before the second round could overwrite it — so a dropped + * transaction reported a module as missing its APK, which nothing had established. + */ + suspend fun getModuleLoadFailures(): Result> = runIpc { service -> + service.moduleLoadFailures.associate { it.packageName to it.reason } + } + + suspend fun setModuleEnabled(packageName: String, enable: Boolean): Result = runIpc { + it.setModuleEnabled(packageName, enable) + } + + suspend fun getBuildStamp(): Result = runIpc { it.buildStamp } + + suspend fun getFrameworkVersionName(): Result = runIpc { it.frameworkVersionName } + + suspend fun getFrameworkVersionCode(): Result = runIpc { it.frameworkVersionCode } + + suspend fun getInstalledPackagesFromAllUsers( + flags: Int, + filterNoProcess: Boolean, + ): Result> = runIpc { it.getInstalledPackagesFromAllUsers(flags, filterNoProcess).list + } + + suspend fun setModuleScope( + packageName: String, + applications: List, + ): Result = runIpc { it.setModuleScope(packageName, applications) } + + /** + * A module's configured scope. + * + * The AIDL answers null for the framework's own pseudo-module row, which is not a module and has + * no scope — and null is emphatically not an empty scope: reading a refusal as "no rows" and + * writing that back is how a scope gets erased. AIDL's Java backend emits no nullability + * annotations, so that null arrives as an unchecked platform type; it is turned into a failure + * here so the caller's existing error path takes it rather than a `List` that is null at + * runtime. + */ + suspend fun getModuleScope( + packageName: String + ): Result> = runIpc { + it.getModuleScope(packageName) + ?: throw IllegalArgumentException("$packageName has no scope to read") + } + + suspend fun isStatusNotificationEnabled(): Result = runIpc { + it.isStatusNotificationEnabled + } + + suspend fun setStatusNotificationEnabled(enabled: Boolean): Result = runIpc { + it.setStatusNotificationEnabled(enabled) + } + + suspend fun isVerboseLogEnabled(): Result = runIpc { it.isVerboseLogEnabled } + + suspend fun setVerboseLogEnabled(enabled: Boolean): Result = runIpc { it.setVerboseLogEnabled(enabled) + } + + /** + * The rotated parts the daemon still holds for one of the two logs, oldest first. + * + * Empty against a daemon too old to answer the call, in which case the manager shows the live + * part alone. + */ + suspend fun getLogParts(verbose: Boolean): Result> = runIpc { + it.getLogParts(verbose).orEmpty() + } + + suspend fun getLogPart( + verbose: Boolean, + name: String, + ): Result = runIpc { it.getLogPart(verbose, name) } + + /** + * The part currently being written, or `null` when the daemon has not opened one yet. + * + * The AIDL returns a platform type, so the nullability is spelled out in the type parameter: + * "the daemon is unreachable" and "there is no log file yet" are different situations, the Logs + * screen renders them differently, and a `Result` would collapse them. + */ + suspend fun getLiveLogPart(verbose: Boolean): Result = runIpc { + it.getLiveLogPart(verbose) + } + + /** + * Closes the part being written and opens a fresh one. Nothing is deleted. + * + * `Result` because there is nothing truthful to answer: the daemon asks its log reader to + * rotate by writing a sentinel and never learns whether it acted. The call used to answer a + * constant `true`, which the Logs screen read as a success signal — so a rotation that never + * happened was reported as one that had. Success here means the daemon took the request. + */ + suspend fun startNewLogPart(verbose: Boolean): Result = runIpc { + it.startNewLogPart(verbose) + } + + suspend fun forceStopPackage(packageName: String, userId: Int): Result = runIpc { it.forceStopPackage(packageName, userId) + } + + suspend fun reboot(): Result = runIpc { it.reboot() } + + suspend fun uninstallPackage(packageName: String, userId: Int): Result = runIpc { it.uninstallPackage(packageName, userId) + } + + suspend fun isSepolicyLoaded(): Result = runIpc { it.isSepolicyLoaded } + + suspend fun getUsers(): Result> = runIpc { it.users + } + + suspend fun isSystemServerAttached(): Result = runIpc { it.isSystemServerAttached } + + suspend fun isDex2OatInliningDisabled(): Result = runIpc { + it.isDex2OatInliningDisabled + } + + suspend fun getDex2OatWrapperState(): Result = runIpc { it.dex2OatWrapperState } + + suspend fun optimizePackage(packageName: String): Result = runIpc { + it.optimizePackage(packageName) + } + + /** + * Writes the daemon's bug report into [zipFd]. + * + * More than the logs: the daemon adds tombstones, ANR traces, both crash directories, a full + * logcat and dmesg, the module database and the resolved scopes. + */ + suspend fun writeBugReportTo(zipFd: android.os.ParcelFileDescriptor): Result = runIpc { + it.writeBugReport(zipFd) + } + + /** + * Starts an activity as another user. + * + * [noUserSwitch] is not decoration. Without it, and whenever the current user is not already the + * target's profile parent, the daemon switches the device to that parent and locks the screen + * before starting the activity — right for an activity that exists in one profile only, and a + * startling thing to do to someone who pressed "open" on a module whose window shows for every + * user anyway. The resolved activity's `FLAG_SHOW_FOR_ALL_USERS` says which case this is. + */ + suspend fun startActivityAsUser( + intent: android.content.Intent, + userId: Int, + noUserSwitch: Boolean, + ): Result = runIpc { it.startActivityAsUser(intent, userId, noUserSwitch) } + + /** Restarts the framework without rebooting the device. Everything on screen goes with it. */ + suspend fun softReboot(): Result = runIpc { it.softReboot() } + + /** + * The flashed manager APK, for installing the manager as an ordinary app. + * + * Null against a daemon too old to answer the call, and null when the daemon has the call but + * refused — the APK is missing from the module directory, or its signature is not the one this + * framework accepts. Both leave the offer to install unusable, and neither is worth telling + * apart on screen. + */ + suspend fun getManagerApk(): Result = runIpc { + it.managerApk + } + + /** + * Whether apps that declare no launcher entry are given one anyway. + * + * True is the platform default, and is what the daemon answers on a device where nothing has + * ever set it. + */ + suspend fun isForcedLauncherIcons(): Result = runIpc { it.isForcedLauncherIcons } + + suspend fun setForcedLauncherIcons(force: Boolean): Result = runIpc { + it.setForcedLauncherIcons(force) + } + + suspend fun getIncludeNewApps(packageName: String): Result = runIpc { it.getIncludeNewApps(packageName) + } + + /** + * Returns whether the daemon stored it, which is not the same as whether the call arrived. + * + * `ModuleDatabase.setIncludeNewApps` answers false when no row was updated — the package is + * not a known module, or it is the framework itself — and the switch has to follow that answer + * rather than assume the write landed. + */ + suspend fun setIncludeNewApps(packageName: String, enable: Boolean): Result = runIpc { + it.setIncludeNewApps(packageName, enable) + } + + suspend fun getRootImplementation(): Result = runIpc { it.rootImplementation } + + /** + * Starts a flash and returns as soon as the daemon has accepted it. + * + * Deliberately not wrapped into a suspend-until-finished call: the result arrives on [receiver] + * over minutes, and a coroutine suspended across a reboot-inducing operation is a coroutine + * that never resumes. The caller keeps the receiver alive for as long as it wants the output. + */ + suspend fun installFrameworkZip( + zipPath: String, + receiver: IFrameworkInstallReceiver, + ): Result = runIpc { it.installFrameworkZip(zipPath, receiver) } +} + +/** + * `ActivityInfo.FLAG_SHOW_FOR_ALL_USERS`, which is hidden. + * + * An activity carrying it displays whichever user is current, so opening it needs no user switch. + */ +private const val FLAG_SHOW_FOR_ALL_USERS = 0x0400 + +/** + * How an Xposed module has advertised its settings screen since the original framework. + * + * A module that hides its launcher icon still needs somewhere to be configured from, and this is + * where it says so. + */ +private const val XPOSED_MODULE_SETTINGS_CATEGORY = "de.robv.android.xposed.category.MODULE_SETTINGS" diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt new file mode 100644 index 000000000..fe1bc4d75 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/InstallResult.kt @@ -0,0 +1,128 @@ +package org.matrix.vector.manager.ipc + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.os.Build +import androidx.core.content.IntentCompat +import java.util.UUID +import kotlinx.coroutines.suspendCancellableCoroutine +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** + * Asks for an install that replaces whatever copy of the package is already on the device. + * + * `MODE_FULL_INSTALL` does not say that, and parasitically nothing else does either. + * `PackageInstallerService.createSessionInternal` sets `INSTALL_REPLACE_EXISTING` itself for every + * ordinary caller, and takes a separate branch for `SHELL_UID` and `ROOT_UID` which adds + * `INSTALL_FROM_ADB` and leaves the rest of the flags as they came. `pm install` sets the flag in + * its own argument parsing, which is why an adb install still replaces and why `-r` is accepted and + * ignored; a caller of the framework API gets no such help. Under the host the manager *is* that + * uid, so `PackageManagerService` treats a module the device already has as a first install and + * fails it with `INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install without first + * uninstalling`. That branch reads the same from API 27, this app's minimum, to AOSP main, so + * updating a module through the store has never worked parasitically on any release, and neither + * has updating an installed manager from the host. + * + * Standalone this changes nothing, because the platform has already set the flag by the time a + * session exists — which is why failing to set it is worth no more than a warning. The field is + * `@hide` but greylisted (`@UnsupportedAppUsage` carrying no `maxTargetSdk`), so the reflection is + * permitted in both modes rather than only under the platform-signed host. + */ +fun PackageInstaller.SessionParams.requestReplaceExisting() { + runCatching { + val flags = PackageInstaller.SessionParams::class.java.getDeclaredField("installFlags") + flags.setInt(this, flags.getInt(this) or INSTALL_REPLACE_EXISTING) + } + .onFailure { logW("ipc: install session could not request a replace", it) } +} + +/** + * Commits [session] and suspends until the platform says what became of it. + * + * The verdict arrives as a broadcast, and the receiver is registered here rather than declared: + * parasitically the manager's manifest is never installed, so a declared receiver would never fire. + * `STATUS_PENDING_USER_ACTION` is not terminal — it means the system is asking the user, and the + * real status follows their answer. [onPrompt] is the caller's chance to say so on screen, and + * [promptFailure] is what to log if the prompt cannot be started. + * + * **The UUID in the action is what keeps the verdict ours, and below API 33 nothing else can.** A + * registered receiver has no exported flag before then, so anything installed can broadcast to one + * whose action it knows. `ContextCompat.registerReceiver` only appears to answer that: below 33 it + * stands in for the missing flag by demanding `.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` + * of this process — a signature permission declared by a manifest that parasitically was never + * installed, looked up under the host's package name — so it threw instead of registering, and + * every install on API 27..32 failed before it began. Requiring a permission of the *sender* is no + * better: a `PendingIntent` broadcast is sent as whoever created it, so that is this process, and + * no permission is held both under the host and standalone. + * + * A forged verdict is worth ruling out rather than merely tidy. A fake `STATUS_SUCCESS` reports an + * install that never happened and skips the caller's `abandonSession`; a fake + * `STATUS_PENDING_USER_ACTION` hands us an arbitrary intent to start, and parasitically we would + * start it as `com.android.shell`. The session id is not a secret to lean on either — the platform + * announces every new session to every app in the user, and asks no permission to listen. + */ +suspend fun Context.commitForResult( + session: PackageInstaller.Session, + sessionId: Int, + promptFailure: String, + onPrompt: () -> Unit = {}, +): Pair = suspendCancellableCoroutine { continuation -> + val action = "$RESULT_ACTION.$sessionId.${UUID.randomUUID()}" + val receiver = + object : BroadcastReceiver() { + override fun onReceive(received: Context, intent: Intent) { + if (intent.action != action) return + val status = + intent.getIntExtra( + PackageInstaller.EXTRA_STATUS, + PackageInstaller.STATUS_FAILURE, + ) + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + onPrompt() + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java) + ?.let { confirm -> + runCatching { + startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + .onFailure { logE(promptFailure, it) } + } + return + } + runCatching { unregisterReceiver(this) } + if (continuation.isActive) { + continuation.resumeWith( + Result.success( + status to intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + ) + ) + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, IntentFilter(action), Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(receiver, IntentFilter(action)) + } + continuation.invokeOnCancellation { runCatching { unregisterReceiver(receiver) } } + + val flags = + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + // The package restriction names the host parasitically, and has to: the receiver belongs to + // this process, so a broadcast confined to the manager's own package would reach nobody. + val pending = + PendingIntent.getBroadcast(this, sessionId, Intent(action).setPackage(packageName), flags) + session.commit(pending.intentSender) +} + +/** Only ever a prefix; the session id and a UUID follow. */ +private const val RESULT_ACTION = "org.matrix.vector.manager.INSTALL_RESULT" + +/** `PackageManager.INSTALL_REPLACE_EXISTING`, `@hide` like the field it belongs in. */ +private const val INSTALL_REPLACE_EXISTING = 0x00000002 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt new file mode 100644 index 000000000..2ed5ab280 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt @@ -0,0 +1,199 @@ +package org.matrix.vector.manager.ipc + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.logW +import org.matrix.vector.manager.data.model.PER_USER_RANGE + +sealed class PackageEvent { + data class Added(val packageName: String, val userId: Int) : PackageEvent() + + data class Removed(val packageName: String, val userId: Int, val fullyRemoved: Boolean) : + PackageEvent() + + data class Changed(val packageName: String, val userId: Int) : PackageEvent() +} + +/** + * Package installs, removals and updates, as a flow. + * + * The receiver exists only while the flow is collected. `ServiceLocator` collects it on a scope that + * lasts as long as the process, which is what keeps the manager's lists from going stale. + * + * This process's own user and nobody else's — an install in a work profile or a secondary user is + * never delivered here. Those arrive through [daemonPackageEventsFlow], which is collected + * alongside this one. + */ +fun Context.packageEventsFlow(): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val packageName = intent.data?.schemeSpecificPart ?: return + // The uid these broadcasts carry names the same user, so it stands in for a + // sender that leaves the id out. + val userId = + intent.getIntExtra( + EXTRA_USER_HANDLE, + intent.getIntExtra(Intent.EXTRA_UID, 0) / PER_USER_RANGE, + ) + + when (intent.action) { + // An update to an existing package produces a REMOVED for the old copy, an + // ADDED carrying EXTRA_REPLACING, and a REPLACED of its own. The last two say + // the same thing — the package is installed now — so both map to Added, and + // the duplicate costs a collector nothing beyond a repeated invalidation. + Intent.ACTION_PACKAGE_REPLACED, + Intent.ACTION_PACKAGE_ADDED -> { + trySend(PackageEvent.Added(packageName, userId)) + } + Intent.ACTION_PACKAGE_REMOVED -> { + val fullyRemoved = intent.getBooleanExtra(Intent.EXTRA_DATA_REMOVED, false) + trySend(PackageEvent.Removed(packageName, userId, fullyRemoved)) + } + Intent.ACTION_PACKAGE_CHANGED -> { + trySend(PackageEvent.Changed(packageName, userId)) + } + } + } + } + + val filter = + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_CHANGED) + addDataScheme("package") + } + + registerReceiver(receiver, filter) + + awaitClose { unregisterReceiver(receiver) } +} + +/** + * The same events as [packageEventsFlow], but for every user on the device. + * + * A dynamic receiver hears its own user's package broadcasts and nobody else's; the rest take + * `registerReceiverForAllUsers` and `INTERACT_ACROSS_USERS`. The parasitic host holds those, being + * `com.android.shell`; a standalone install holds neither, so a receiver built on them would work + * in one of the two shapes this app runs in and not the other. An app installed into a work profile + * or a secondary user therefore reached the manager not at all — nothing dropped the all-users app + * list, so the scope editor for a module in that profile showed every app except the one that had + * just been installed, and no refresh anywhere would ever have brought it in. + * + * The daemon registers its own package receiver for `USER_ALL` and re-broadcasts what it saw to + * both packages the manager can be running as. This is that re-broadcast, and it is the only way + * another user's installs are heard about here. + */ +fun Context.daemonPackageEventsFlow(): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Extras from a stranger are not guaranteed to unparcel in this process — one + // naming a class the manager does not have throws right here, on the main thread, + // and kills the manager rather than whoever sent it. Since the registration below + // has to accept strangers, reading them is guarded. + val event = + runCatching { intent.daemonPackageEvent() } + .onFailure { + logW("ipc: unreadable package notification", it) + } + .getOrNull() + if (event != null) trySend(event) + } + } + + // Exported, and it has to be: the sender is the daemon, running as another uid, and since + // SDK 34 a dynamic receiver registered with neither export flag throws at registration. The + // action is guessable and no permission is demanded of the sender, so any app on the device + // can forge one of these. That is acceptable for exactly one reason: a delivery makes the + // manager drop a cache and ask the daemon for it again, and nothing else. Nothing is believed + // on the strength of the payload — the collector does not even read which package an event + // names, and the "isXposedModule" flag the daemon also sends is deliberately never read here, + // because whether a package is a module is settled by inspecting its APK. + val registered = + runCatching { + ContextCompat.registerReceiver( + this@daemonPackageEventsFlow, + receiver, + IntentFilter(ACTION_DAEMON_NOTIFICATION), + ContextCompat.RECEIVER_EXPORTED, + ) + } + // Throwing here would fail this flow, and the merge it is collected in would take the + // platform source down with it — losing every user's package events, on a scope whose + // failure ends the process, to save the one this flow adds. + .onFailure { logW("ipc: daemon package notifications unavailable", it) } + .isSuccess + + awaitClose { if (registered) unregisterReceiver(receiver) } +} + +/** + * The daemon's notification, read back with the types it was actually written with. + * + * Not one of these extras can be read with the accessor its name implies, which is worth knowing + * before someone corrects it: the package arrives as a single `String` under + * [EXTRA_PACKAGES], whose documented type is `String[]`, so `getStringArrayExtra` answers null, and + * the user arrives as a plain `Int` under [Intent.EXTRA_USER], whose documented type is + * `UserHandle`, so `getParcelableExtra` answers null and the id quietly becomes 0 — the one user + * this whole flow exists to see past. What the platform sent is wrapped whole under + * [Intent.EXTRA_INTENT], and only its action says what happened. + */ +private fun Intent.daemonPackageEvent(): PackageEvent? { + val packageName = getStringExtra(EXTRA_PACKAGES) ?: return null + val userId = getIntExtra(Intent.EXTRA_USER, 0) + val wrapped = IntentCompat.getParcelableExtra(this, Intent.EXTRA_INTENT, Intent::class.java) + + return when (wrapped?.action) { + Intent.ACTION_PACKAGE_ADDED -> PackageEvent.Added(packageName, userId) + Intent.ACTION_PACKAGE_CHANGED -> PackageEvent.Changed(packageName, userId) + // Both removals the daemon forwards mean the package is really gone: the transient + // PACKAGE_REMOVED an update produces is not among the actions it listens for, and + // UID_REMOVED arrives once the uid itself has been reclaimed. Neither is the removal half + // of an update, which is the only thing `fullyRemoved` is there to tell apart. + Intent.ACTION_PACKAGE_FULLY_REMOVED, + Intent.ACTION_UID_REMOVED -> PackageEvent.Removed(packageName, userId, fullyRemoved = true) + else -> null + } +} + +/** + * The action the daemon sends its package notification under. + * + * Built from the standalone manager's package name rather than from whatever this process happens + * to be called, because the manager is usually not running as itself: parasitically it lives inside + * `com.android.shell`, and an action derived from that would be one nobody ever sends. The daemon + * builds this string from its own `DEFAULT_MANAGER_PACKAGE_NAME` and sends it to both hosts, so the + * two sides stay in step only as long as both keep deriving it from that same Gradle value. + */ +private const val ACTION_DAEMON_NOTIFICATION = "${BuildConfig.MANAGER_PACKAGE_NAME}.NOTIFICATION" + +/** + * `Intent.EXTRA_USER_HANDLE`, which is hidden. + * + * The public `EXTRA_USER` is a `UserHandle` parcelable, so reading it as an int always answers the + * default. The id these broadcasts actually carry is under this name. + * + * Only the platform's broadcasts, mind: the daemon's notification is the other way round and puts a + * plain int under `EXTRA_USER`, so [daemonPackageEvent] reads that key and not this one. + */ +private const val EXTRA_USER_HANDLE = "android.intent.extra.user_handle" + +/** + * `Intent.EXTRA_PACKAGES`, which is only public API from 34. + * + * This app runs from 27, where the constant does not exist to be referenced — and there is nothing + * to reference it for: the daemon writes this key as a literal of its own, so the two sides agree + * on the string and never on the symbol. + */ +private const val EXTRA_PACKAGES = "android.intent.extra.PACKAGES" diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt b/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt new file mode 100644 index 000000000..87e4df483 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt @@ -0,0 +1,70 @@ +package org.matrix.vector.manager.net + +import android.content.Context +import java.io.File +import java.util.concurrent.TimeUnit +import okhttp3.Cache +import okhttp3.OkHttp +import okhttp3.OkHttpClient +import org.matrix.vector.manager.data.repository.SettingsRepository + +/** + * The one HTTP client the manager uses, for the module store, the GitHub feed and avatars alike. + * + * Two things it must get right: + * - **A disk cache.** Every remote surface renders from cache first and treats the network as an + * upgrade, because the manager is routinely opened with no connectivity. The cache also makes + * GitHub's conditional requests cheap: a `304 Not Modified` does not count against the 60 + * requests/hour an unauthenticated client gets, so revalidation is effectively free. + * - **DNS over HTTPS, as a fallback rather than a replacement.** Users on censored networks cannot + * resolve the module repository or GitHub over plain DNS. See [VectorDns] for why it must never + * be the only path: a network that blocks Cloudflare as well would then leave the Store + * permanently empty. + */ +object HttpClientFactory { + + private const val CACHE_DIR = "http_cache" + private const val CACHE_SIZE_BYTES = 16L * 1024 * 1024 + + /** + * The client and the resolver inside it. + * + * The resolver comes back alongside rather than being fished out of `client.dns` later: it is + * the only thing that knows whether DoH is actually working, the settings sheet reports that, + * and a cast back from the `Dns` interface would be a promise that nothing checks. + */ + class NetStack(val client: OkHttpClient, val dns: VectorDns) + + fun create(context: Context, settings: SettingsRepository): NetStack { + // OkHttp's Android artifact ships the public suffix list as an *asset* and reaches it + // through a process-static Context that `PlatformInitializer` sets from `androidx.startup`. + // Parasitically this app's manifest is never installed, so that provider never runs and the + // first DoH lookup — which asks the list whether a host is private before opening any + // socket — dies with "Unable to load PublicSuffixDatabase.list". OkHttp latches that + // failure for the life of the process, so it has to be prevented rather than recovered + // from. + // + // Here rather than in the activity because this is the only place a client is built, which + // puts it on the path to every request in both parasitic and standalone runs and in the + // debug demo host, which never opens MainActivity. Idempotent, so it is a no-op in the + // standalone install, where the Startup initializer did run. + OkHttp.initialize(context) + + val cache = Cache(File(context.cacheDir, CACHE_DIR), CACHE_SIZE_BYTES) + + val base = + OkHttpClient.Builder() + .cache(cache) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + // The resolver reads the setting on every lookup, so the switch takes effect immediately + // and the shared client — with its connection pool and its disk cache — is never rebuilt. + // `base` is passed in as the bootstrap client because a DoH client must not itself resolve + // through DoH. + val dns = VectorDns(settings, base) + return NetStack(base.newBuilder().dns(dns).build(), dns) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt new file mode 100644 index 000000000..acc2fb50e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt @@ -0,0 +1,185 @@ +package org.matrix.vector.manager.net + +import java.net.InetAddress +import java.net.Proxy +import java.net.ProxySelector +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.logW + +/** + * What the last name lookup of this session actually did. + * + * Deliberately a record of the real resolver rather than something a probe could produce. A "test + * DoH" button would be a second code path — another host, another moment — and it can pass while + * the client that fetches the module list is failing. Only the shared resolver knows the truth, so + * only the shared resolver reports it. + */ +sealed interface DohStatus { + /** Nothing has been resolved yet, so there is nothing to report. */ + data object Untested : DohStatus + + /** The setting is off; names went to the system resolver. */ + data object Disabled : DohStatus + + /** A proxy is configured, so resolving is its job and DoH was skipped. */ + data object Bypassed : DohStatus + + /** The last lookup went through the DoH endpoint. */ + data class Working(val host: String) : DohStatus + + /** + * DoH failed and the session has fallen back to the system resolver. + * + * No hostname here on purpose. What failed is reaching the DoH endpoint; whichever name was + * being looked up at that moment is incidental — it is simply whatever the app asked for first + * — and naming it reads as though that host were the subject of a test. The log line keeps it + * for anyone diagnosing; the sheet does not need it. + */ + data class FellBack(val reason: String) : DohStatus +} + +/** + * Name resolution: DNS over HTTPS when it helps, the system resolver when it does not. + * + * DoH exists here for users whose network will not resolve the module repository or GitHub over + * plain DNS. It is deliberately **best-effort** rather than all-or-nothing, because the networks + * that make the setting worth having are also the ones that may block `cloudflare-dns.com` itself, + * and a lookup path with no fallback would then take the module list, the activity feed and every + * avatar down together — leaving the switch that caused it as the only way out. So: + * - a failed DoH lookup falls through to the system resolver rather than failing the request; + * - the first failure latches for the session, so the timeout is paid once and not per lookup; + * - the DoH client's own timeouts are short, so that one payment is a few seconds, not fifteen; + * - a configured HTTP proxy disables DoH entirely, because the proxy is doing the resolving and + * bootstrap IPs are meaningless to it. + * + * Every one of those branches used to be invisible: off, bypassed and latched all looked like the + * same working switch, and the fallback existed only as a log line. [status] is what each lookup + * did, so the sheet that owns the switch can say which of them is happening. + * + * The setting and the proxy are both read **per lookup** rather than baked into the client at + * construction. OkHttp cannot have its DNS swapped on a live client, and rebuilding the shared + * client would drop the connection pool and orphan the disk cache, so reading them here is what + * lets a switch — or joining a VPN — take effect before the next process start. + */ +class VectorDns(private val settings: SettingsRepository, bootstrapClient: OkHttpClient) : Dns { + + private val endpoint = "https://cloudflare-dns.com/dns-query".toHttpUrl() + + private val _status = MutableStateFlow(DohStatus.Untested) + + /** What the last lookup did. See [DohStatus] for why this is observed and never probed. */ + val status: StateFlow = _status.asStateFlow() + + /** + * Latched once the DoH endpoint proves unreachable. + * + * Volatile rather than synchronised: two threads racing to set it to true is harmless, and + * lookups happen on every OkHttp dispatcher thread. + */ + @Volatile private var dohUnavailable = false + + private val doh: DnsOverHttps by lazy { + DnsOverHttps.Builder() + .client( + bootstrapClient + .newBuilder() + // Fail fast. The default connect timeout is long enough that a blocked + // endpoint reads as a hung app rather than as a fallback about to happen. + .connectTimeout(3, TimeUnit.SECONDS) + .callTimeout(5, TimeUnit.SECONDS) + .build() + ) + .url(endpoint) + .bootstrapDnsHosts( + InetAddress.getByName("1.1.1.1"), + InetAddress.getByName("1.0.0.1"), + InetAddress.getByName("2606:4700:4700::1111"), + InetAddress.getByName("2606:4700:4700::1001"), + ) + .includeIPv6(true) + .build() + } + + /** + * True when nothing is proxying our traffic, which is the only case where DoH is ours to do. + * + * Asked on every lookup rather than cached. A proxy can appear mid-session — joining a VPN or a + * work profile does exactly that — and a value read once at startup would keep sending queries + * to Cloudflare long after the answer changed, while [status] claimed a state that was no + * longer true. The call is local and a lookup is about to do network I/O anyway. + */ + private fun direct(): Boolean = + runCatching { + ProxySelector.getDefault().select(endpoint.toUri()).firstOrNull() == Proxy.NO_PROXY + } + .getOrDefault(true) + + /** + * Clears the session latch so the next lookup tries DoH again. + * + * The latch is what keeps a blocked endpoint from costing five seconds per name, but "the + * session" here is `com.android.shell`, a process nobody can restart on purpose — so without + * this a single bad lookup on a captive portal disables DoH until something else happens to + * kill the host. This is the way back, and it is offered only once the fallback has happened. + */ + fun retry() { + dohUnavailable = false + _status.value = DohStatus.Untested + } + + override fun lookup(hostname: String): List { + if (!settings.dohEnabled.value) { + _status.value = DohStatus.Disabled + } else if (!direct()) { + _status.value = DohStatus.Bypassed + } else if (!dohUnavailable) { + try { + val resolved = doh.lookup(hostname) + _status.value = DohStatus.Working(hostname) + return resolved + } catch (e: Exception) { + // Every way DoH can fail, not only "no such host". A blocked endpoint raises + // UnknownHostException, a slow one raises InterruptedIOException from the timeouts + // above, and a resolver that cannot read its own public suffix list raises + // IllegalStateException before a socket is ever opened — which used to escape this + // method entirely, so the latch never closed and the fallback this class is built + // around never engaged. Anything arriving here means DoH is not usable, which is + // the condition documented above. + // + // Exception and not Throwable: an OutOfMemoryError is not a DNS outcome. There is + // no CancellationException to preserve either — this is a plain blocking call on an + // OkHttp dispatcher thread, with no coroutine in the stack. + dohUnavailable = true + _status.value = DohStatus.FellBack(e.describe(hostname)) + logW( + "dns: DoH lookup of $hostname failed, using the system resolver for this session", + e, + ) + } + } + // Latched: the status already says why, and repeating it on every name would only replace + // the host that actually failed with whichever one asked next. + return Dns.SYSTEM.lookup(hostname) + } + + /** + * A line short enough to sit under a switch, and worth the room it takes. + * + * The class name whenever the message would not add anything. `UnknownHostException` carries + * the hostname as its entire message, so using it verbatim printed the name twice — "could not + * resolve example.org (example.org)" — while the one thing a reader wants, *which way* it + * failed, went missing. The class name is jargon, but it is jargon that distinguishes a blocked + * endpoint from a timeout, and it is what a bug report needs to carry anyway. + */ + private fun Throwable.describe(host: String): String = + message?.takeIf { it.isNotBlank() && !it.equals(host, ignoreCase = true) } + ?: javaClass.simpleName +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt new file mode 100644 index 000000000..5c4cfd38e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt @@ -0,0 +1,113 @@ +package org.matrix.vector.manager.ui + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import org.matrix.vector.manager.data.repository.LaunchShortcut +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.navigation.DeepLink +import org.matrix.vector.manager.ui.screens.splash.SplashGate +import org.matrix.vector.manager.ui.theme.LocalizedContent +import org.matrix.vector.manager.ui.theme.VectorTheme + +/** + * The only activity. + * + * Parasitically, every activity has to be tracked by hand by the zygisk hooker: it captures and + * restores their saved state itself and rewrites every launch intent to this class, because + * `system_server` does not know these spoofed activities exist. A single activity is what the + * injection model wants, not a style preference. + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + // Must precede super.onCreate. Handing off from the platform splash is what keeps an + // unthemed frame from appearing between the system splash and the Compose one. + val splash = installSplashScreen() + + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + // Idempotent, and safe whether or not the daemon already called Constants.setBinder. + // Configures Coil, among the rest: it used to be done here, which left the debug demo host + // without it. + ServiceLocator.attach(this) + + // Started here rather than from the panels that need it: the splash is dead time the app + // is spending anyway, and these reads are what makes a panel's first visit slower than its + // second. By the time the splash has played, most of them have already answered. + ServiceLocator.prefetch() + + // The launcher copies the label and icon when the shortcut is pinned and keeps its copy, so + // a pinned shortcut otherwise represents Vector with whatever build pinned it for as long as + // it lives. A no-op unless one is pinned, and unless this manager is the parasitic one. + LaunchShortcut.update(this) + + // Keep the platform splash up only until the first frame is ready to draw; the Compose + // splash then plays and decides for itself when the daemon has been given long enough. + splash.setKeepOnScreenCondition { false } + + // The launch intent can name where to open — the module a notification was about. + // + // Offered on every creation, including a restored one. Parasitically the zygisk hooker + // saves and restores this activity's state itself, so an activity started by a notification + // arrives *with* a bundle and cannot tell itself apart from a rotation by looking at one: + // guarding on `savedInstanceState == null`, which is the obvious reading, skipped the offer + // on exactly the launch that mattered and left the previous tap's destination to be applied + // instead — one module's notification opened another module's scope editor. + // + // What separates the two is not the bundle but the destination, so that judgement belongs + // to DeepLink, which remembers the one it last applied; `intent` here is a field the + // platform keeps answering with the intent this activity was *created* with, so a rotation + // offers a destination the reader may have left behind hours ago. + DeepLink.offerFromCreate(intent) + + setContent { LocalizedContent { VectorTheme { SplashGate { VectorApp() } } } } + } + + /** + * A second launch while the manager is already up. + * + * Installed normally, `launchMode` is `singleTop`, so tapping a notification reuses this + * activity instead of starting another one and the new intent arrives here rather than at + * [onCreate]. Without this the app would stay on whatever it was already showing and the + * notification would look broken. + * + * Parasitically that is not guaranteed and this may never run. `ParasiticManagerSystemHooker` + * answers the resolution with a copy of the com.android.shell host activity's `ActivityInfo`, + * overriding only its process name, theme and flags, so the launch mode the system works from + * is the host's rather than this manifest's, and the daemon's `openManager` adds only + * `FLAG_ACTIVITY_NEW_TASK`. That is why nothing about the deep link is decided by which of the + * two callbacks ran: [DeepLink] judges the destination instead, and both paths lead there. + * + * [setIntent] is not bookkeeping. The platform leaves `getIntent()` answering the intent this + * activity was created with — `Activity.onNewIntent`'s own documentation says so and points at + * this call — so without it every later recreation would re-offer the *first* notification's + * module, long after a second one moved the reader somewhere else. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + DeepLink.offerFromNewIntent(intent) + } + + /** + * Ends the deep link's memory along with the screen it was applied to. + * + * [DeepLink] is an object, so what it last applied outlives this activity and would go on + * suppressing a repeat of that destination in a process the platform merely kept cached — so + * backing out of the manager and then tapping a second notice about the same module would open + * Home. Once the activity is finishing there is no stack left to protect, which is the only + * thing that memory is for. + * + * Gated on [isFinishing] because a configuration change destroys this activity too, and + * forgetting there would let the recreation's replayed intent straight through. + */ + override fun onDestroy() { + super.onDestroy() + if (isFinishing) DeepLink.forget() + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt new file mode 100644 index 000000000..8d146c05e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -0,0 +1,253 @@ +package org.matrix.vector.manager.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.navigation.FrameworkUpdate +import org.matrix.vector.manager.ui.screens.update.FrameworkUpdateScreen +import org.matrix.vector.manager.ui.navigation.Canary +import org.matrix.vector.manager.ui.screens.canary.CanaryScreen +import org.matrix.vector.manager.ui.navigation.Troubleshoot +import org.matrix.vector.manager.ui.screens.report.TroubleshootScreen +import org.matrix.vector.manager.ui.navigation.DeepLink +import org.matrix.vector.manager.ui.navigation.FloatingPanelNav +import org.matrix.vector.manager.ui.navigation.LocalNavigator +import org.matrix.vector.manager.ui.navigation.Navigator +import org.matrix.vector.manager.ui.navigation.PanelBar +import org.matrix.vector.manager.ui.navigation.PanelEditDone +import org.matrix.vector.manager.ui.navigation.Scope +import org.matrix.vector.manager.ui.navigation.StoreDetail +import org.matrix.vector.manager.ui.navigation.CrashTrace +import org.matrix.vector.manager.ui.navigation.LogTrace +import org.matrix.vector.manager.ui.navigation.SystemStatus +import org.matrix.vector.manager.ui.navigation.Web +import org.matrix.vector.manager.ui.navigation.TopLevelRoute +import org.matrix.vector.manager.ui.navigation.rememberNavigator +import org.matrix.vector.manager.ui.screens.home.HomeScreen +import org.matrix.vector.manager.ui.screens.home.CrashTraceScreen +import org.matrix.vector.manager.ui.screens.home.SystemStatusScreen +import org.matrix.vector.manager.ui.screens.logs.LogTraceScreen +import org.matrix.vector.manager.ui.screens.logs.LogsScreen +import org.matrix.vector.manager.ui.screens.modules.ModulesScreen +import org.matrix.vector.manager.ui.screens.modules.ScopeScreen +import org.matrix.vector.manager.ui.screens.repo.RepoDetailsScreen +import org.matrix.vector.manager.ui.screens.repo.RepoScreen +import org.matrix.vector.manager.ui.screens.web.WebScreen + +/** + * The app shell. + * + * [NavigationSuiteScaffold] picks the navigation container from the window size — a bottom bar on a + * phone, a rail when there is width to spare. That is not decoration: from targetSdk 37 an app may + * no longer lock itself to portrait or declare itself non-resizable on large screens, so the shell + * has to work unfolded and in landscape regardless. The scaffold also owns where that container + * sits, so the destinations below it are laid out beside or above it rather than under it. + * + * Which panels that container holds, in which order, is the reader's — see NavPanels — and there is + * a third arrangement it can take, a ball floating over the content with no container at all. The + * two are not independent: rearranging the panels needs something to rearrange, so edit mode always + * puts the container back for as long as it lasts. + */ +@Composable +fun VectorApp() { + val navigator = rememberNavigator() + + // Where the launch intent asked to open. The activity has no back stack to act on, so it leaves + // the destination here and this is the first place there is one — on a cold start the splash is + // still playing when the intent arrives. + val pending by DeepLink.pending.collectAsStateWithLifecycle() + LaunchedEffect(pending) { + val destination = DeepLink.consume() ?: return@LaunchedEffect + // Already there, so nothing to do — and doing it anyway would not be nothing: switching + // tabs empties the back stack and builds it again, and the scope editor's draft lives in a + // ViewModel scoped to the entry that would be thrown away with it. The reader who taps the + // notification of the module already open in front of them is the case this covers. + // + // It is not what keeps a rotation harmless. Whether an offer is a launch or a recreation + // replaying the intent it was created with is decided in DeepLink, which knows what it last + // applied; here there is only where the reader is standing. + if (navigator.current == (destination.detail ?: destination.tab)) return@LaunchedEffect + // The tab goes down first and the screen on top of it: a notification about a module opens + // that module's scope editor, and back from there should be the module list rather than the + // door out of the app it just opened. Switching also discards whatever detail screen was + // already up, so the reader is not left with a stale one buried underneath. + navigator.switchTo(destination.tab) + destination.detail?.let { navigator.go(it) } + } + + CompositionLocalProvider(LocalNavigator provides navigator) { + val settings = ServiceLocator.settings + val floating by settings.floatingNav.collectAsStateWithLifecycle() + val editing = navigator.editingPanels + // The container shows only at the root of a panel. On a detail screen none of the items is + // the current destination, and a navigation bar highlighting nothing is worse than none. + val atRoot = !navigator.canGoBack + + // Driving the scaffold's own state rather than dropping the items: hiding the items alone + // leaves the container laid out, so a detail screen — the in-app browser especially — + // keeps a dead strip of navigation-bar-sized space at the bottom. + val suiteState = rememberNavigationSuiteScaffoldState() + LaunchedEffect(atRoot) { if (atRoot) suiteState.show() else suiteState.hide() } + + // Computed rather than left to the scaffold's default, for two reasons: the floating style + // forces None, which is what actually removes the container instead of hiding it, and + // PanelBar has to be told which axis it is laying items along. Entering edit mode overrules + // the floating setting for as long as it lasts — there is nothing to rearrange otherwise. + val suiteType = + if (floating && !editing) NavigationSuiteType.None + else NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) + + NavigationSuiteScaffold( + navigationItems = { + // NavigationSuite's `when` over the type has no None branch and no else, so under + // None this slot is silently dropped along with the container. Skipping it here + // says so out loud rather than leaving a composable that never runs. + if (suiteType != NavigationSuiteType.None) { + PanelBar( + panels = navigator.panels, + current = navigator.currentTopLevel, + editing = editing, + suiteType = suiteType, + onSelect = { route -> navigator.switchTo(route) }, + onEdit = { navigator.editingPanels = true }, + onToggleHidden = { key, hidden -> navigator.setPanelHidden(key, hidden) }, + onMove = { from, to -> navigator.movePanel(from, to) }, + ) + } + }, + navigationSuiteType = suiteType, + state = suiteState, + primaryActionContent = { + if (editing) PanelEditDone(onDone = { navigator.editingPanels = false }) + }, + ) { + Box(Modifier.fillMaxSize()) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.back() }, + // Naming any decorator replaces NavDisplay's default, which is the + // saveable-state one alone, so it is repeated here; the scene-setup decorator + // NavDisplay applies internally is untouched. The ViewModel one is what this + // list is for: it scopes a ViewModelStore per entry, so opening the scope + // editor for a second module builds a second ViewModel instead of reusing the + // first (they would otherwise share one default key under the activity's + // store). + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { registerRoutes(navigator) }, + ) + // Last child of the Box so it draws over the destination, and inside the app window + // rather than in one of its own: parasitically this app is com.android.shell, which + // must never ask for SYSTEM_ALERT_WINDOW. It follows the same rule the container + // does — present at the root of a panel, gone on a detail screen that has its own + // back affordance. + if (floating && !editing && atRoot) { + FloatingPanelNav( + panels = navigator.panels, + current = navigator.currentTopLevel, + onSelect = { route -> navigator.switchTo(route) }, + ) + } + } + } + + // After the scaffold on purpose. Back callbacks are dispatched last-registered-first and + // BackHandler registers from an effect, which run in composition order, so this one + // outranks the handler NavDisplay installs and edit mode ends before the stack is touched. + BackHandler(enabled = editing) { navigator.editingPanels = false } + } +} + +/** + * Every destination, registered. + * + * All four panels keep their entry whether or not the reader has hidden them. A saved stack names + * its keys by class, and entryProvider throws for one it was never given, so dropping the + * registration of a hidden panel would turn a stale saved stack into a crash. + */ +private fun EntryProviderScope.registerRoutes(navigator: Navigator) { + entry { + HomeScreen( + onOpenStatus = { navigator.go(SystemStatus) }, + onOpenUrl = { url -> navigator.go(Web(url)) }, + onOpenCanary = { navigator.go(Canary) }, + onOpenReport = { navigator.go(Troubleshoot) }, + onOpenUpdate = { navigator.go(FrameworkUpdate()) }, + ) + } + entry { + ModulesScreen( + onModuleClick = { packageName, userId -> navigator.go(Scope(packageName, userId)) }, + onOpenStore = { packageName -> navigator.go(StoreDetail(packageName)) }, + ) + } + entry { + RepoScreen(onModuleClick = { packageName -> navigator.go(StoreDetail(packageName)) }) + } + entry { LogsScreen(onOpenTrace = { text -> navigator.go(LogTrace(text)) }) } + + entry { route -> + ScopeScreen( + packageName = route.packageName, + userId = route.userId, + onNavigateBack = { navigator.back() }, + ) + } + entry { route -> + RepoDetailsScreen(packageName = route.packageName, onNavigateBack = { navigator.back() }) + } + entry { + SystemStatusScreen( + onNavigateBack = { navigator.back() }, + onOpenCrash = { navigator.go(CrashTrace) }, + ) + } + entry { CrashTraceScreen(onNavigateBack = { navigator.back() }) } + entry { route -> + LogTraceScreen(text = route.text, onNavigateBack = { navigator.back() }) + } + entry { + TroubleshootScreen( + onNavigateBack = { navigator.back() }, + onOpenUrl = { url -> navigator.go(Web(url)) }, + onOpenCanary = { navigator.go(Canary) }, + ) + } + entry { + CanaryScreen( + onNavigateBack = { navigator.back() }, + onOpenUrl = { url -> navigator.go(Web(url)) }, + onInstall = { versionCode -> navigator.go(FrameworkUpdate(versionCode)) }, + onOpenReport = { navigator.go(Troubleshoot) }, + ) + } + entry { route -> + FrameworkUpdateScreen( + openOnVersionCode = route.versionCode.takeIf { it > 0 }, + onNavigateBack = { navigator.back() }, + onOpenUrl = { url -> navigator.go(Web(url)) }, + ) + } + entry { route -> WebScreen(url = route.url, onNavigateBack = { navigator.back() }) } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/AppIcon.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/AppIcon.kt new file mode 100644 index 000000000..6858babe2 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/AppIcon.kt @@ -0,0 +1,101 @@ +package org.matrix.vector.manager.ui.components + +import android.content.pm.ApplicationInfo +import android.graphics.Bitmap +import android.graphics.Canvas +import android.util.LruCache +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * App icons, straight from `PackageManager`. + * + * Deliberately not routed through an image-loading library. These are not network images: they come + * from a local drawable that has to be rasterised anyway, and the scope editor renders hundreds of + * them in a scrolling list, so what actually matters is a bounded cache and doing the rasterisation + * off the main thread. That is this file, and it costs no dependency. + */ +object AppIconCache { + + // Twelve megabytes: roughly 150 icons at the 48 dp of a module row on an xxhdpi screen, or 270 + // at the 36 dp of a scope row. Sized by bytes rather than count so a device with a large + // density does not quietly use several times more memory. + private val cache = object : LruCache(12 * 1024 * 1024) { + override fun sizeOf(key: String, value: ImageBitmap): Int = value.width * value.height * 4 + } + + fun cached(key: String): ImageBitmap? = cache.get(key) + + suspend fun load( + info: ApplicationInfo, + packageManager: android.content.pm.PackageManager, + sizePx: Int, + ): ImageBitmap? = + withContext(Dispatchers.IO) { + val key = "${info.packageName}:${info.uid}:$sizePx" + cache.get(key)?.let { + return@withContext it + } + val bitmap = + runCatching { + val drawable = info.loadIcon(packageManager) + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + drawable.setBounds(0, 0, sizePx, sizePx) + drawable.draw(canvas) + bmp.asImageBitmap() + } + .getOrNull() ?: return@withContext null + cache.put(key, bitmap) + bitmap + } +} + +@Composable +fun AppIcon( + applicationInfo: ApplicationInfo, + contentDescription: String?, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val context = LocalContext.current + val sizePx = with(LocalDensity.current) { size.roundToPx() } + val key = "${applicationInfo.packageName}:${applicationInfo.uid}:$sizePx" + + // Seeded from the cache so an already-loaded icon draws on the first frame and the list does + // not flicker while scrolling back over rows it has already shown. + var image by remember(key) { mutableStateOf(AppIconCache.cached(key)) } + + LaunchedEffect(key) { + if (image == null) { + image = AppIconCache.load(applicationInfo, context.packageManager, sizePx) + } + } + + val bitmap = image + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier.size(size), + ) + } else { + // A blank box of the right size, so rows do not resize as icons arrive. + androidx.compose.foundation.layout.Box(modifier.size(size)) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Avatar.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Avatar.kt new file mode 100644 index 000000000..2eb542943 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Avatar.kt @@ -0,0 +1,138 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import kotlin.math.cos +import kotlin.math.sin + +/** + * A contributor's GitHub avatar, with a monogram fallback while it loads or when there is no + * network — which, for this app, is a normal condition rather than an error. + * + * When [laurelled] the avatar is wreathed. The laurel is the Winged Victory's own iconography and + * the only place the brand motif appears outside the launcher icon and the splash, so it stays + * meaningful: it marks the most active contributor over the window the feed covers. + */ +@Composable +fun ContributorAvatar( + login: String, + avatarUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + laurelled: Boolean = false, + /** Ringed while this person is one of the authors the rail is filtered to. */ + selected: Boolean = false, +) { + // The wreath's space is reserved whether or not it is drawn, so one laurelled avatar does + // not make its column taller than the rest of the row. + val wreathPadding = size * 0.22f + Box( + modifier = modifier.size(size + wreathPadding * 2), + contentAlignment = Alignment.Center, + ) { + if (laurelled) { + Laurel( + modifier = Modifier.size(size + wreathPadding * 2), + color = MaterialTheme.colorScheme.primary, + ) + } + Box( + modifier = + Modifier.size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .then( + // Drawn over the image rather than around the whole column, so the ring + // reads as belonging to the face and not to the row. + if (selected) + Modifier.border(2.5.dp, MaterialTheme.colorScheme.primary, CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = login.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = login, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape), + ) + } + } + } +} + +/** + * Two symmetric arcs of leaves, open at the top, drawn rather than shipped as an asset so it takes + * the theme's colour and any size without a second drawable. + */ +@Composable +fun Laurel(modifier: Modifier = Modifier, color: Color) { + androidx.compose.foundation.Canvas(modifier = modifier) { drawLaurel(color) } +} + +private fun DrawScope.drawLaurel(color: Color) { + val radius = size.minDimension / 2f * 0.90f + val centre = Offset(size.width / 2f, size.height / 2f) + val leafLength = radius * 0.42f + val leafWidth = leafLength * 0.40f + val leaves = 5 + // Real laurel leaves splay outward from the binding rather than lying flat along the arc; + // without this the ovals read as a dotted ring instead of a wreath. + val splayDeg = 26f + + // Angles are measured clockwise from twelve o'clock, so a leaf at 30° sits upper-right. + // Each side runs 30° → 150°, which leaves the crown open at the top and the stems meeting + // at the bottom — the shape of an actual wreath rather than a full ring. + val startDeg = 32f + val endDeg = 148f + + for (side in listOf(1f, -1f)) { + for (i in 0 until leaves) { + val t = i / (leaves - 1f) + val angleDeg = startDeg + t * (endDeg - startDeg) + val angleRad = Math.toRadians(angleDeg.toDouble()) + + val x = centre.x + side * radius * sin(angleRad).toFloat() + val y = centre.y - radius * cos(angleRad).toFloat() + + // Leaves grow towards the base, the way a wreath is bound. + val scale = 0.55f + 0.45f * t + // Tangential to the arc, then splayed outward. + val rotation = side * (angleDeg + splayDeg) + + rotate(degrees = rotation, pivot = Offset(x, y)) { + drawOval( + color = color.copy(alpha = 0.35f + 0.45f * t), + topLeft = Offset(x - leafLength * scale / 2f, y - leafWidth * scale / 2f), + size = Size(leafLength * scale, leafWidth * scale), + ) + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt new file mode 100644 index 000000000..5d532d891 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Clipboard.kt @@ -0,0 +1,20 @@ +package org.matrix.vector.manager.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import org.matrix.vector.manager.BuildConfig + +/** + * Puts text on the clipboard, or does nothing. + * + * Every screen in this app copies for the same reason — the text is on its way into a bug report — + * and so every screen wants the same label on the clip and the same silence when there is no + * clipboard service to hand. Parasitically there may not be: the manager is running inside + * `com.android.shell` then, and a failure to copy is not worth a crash on the screen someone opened + * *because* something had already gone wrong. + */ +fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(BuildConfig.MANAGER_PACKAGE_NAME, text)) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ColorWheel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ColorWheel.kt new file mode 100644 index 000000000..b9d45911c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ColorWheel.kt @@ -0,0 +1,169 @@ +package org.matrix.vector.manager.ui.components + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.atan2 +import kotlin.math.hypot +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.matrix.vector.manager.ui.theme.SeedScheme + +/** The chroma the rim of the wheel represents. Past this, almost nothing is in gamut anyway. */ +private const val MAX_CHROMA = 110f + +/** How large the wheel is rendered before being scaled to fit. */ +private const val WHEEL_PIXELS = 320 + +/** + * The colour wheel, in the space the theme is actually generated in. + * + * Angle is hue and distance from the centre is chroma — which is not decoration, it is the same + * two numbers [SeedScheme] uses to build every role in the scheme. Pick a point and you have + * literally pointed at the seed, so the wheel shows what it is choosing rather than being an HSV + * picker whose output has to be translated into something else afterwards. + * + * The centre is grey and the rim is as saturated as sRGB permits, so "how colourful do I want this + * to be" is a single radial gesture. Colours the display cannot show are drawn at the closest thing + * it can, which is why the rim looks flat in the yellows and green — that is the shape of the sRGB + * gamut, not a rendering bug. + */ +@Composable +fun ColorWheel( + hue: Float, + chroma: Float, + dark: Boolean, + onChange: (hue: Float, chroma: Float) -> Unit, + modifier: Modifier = Modifier, +) { + // Drawn at the tone the accent will actually sit at, so the wheel is a preview and not just a + // generic rainbow: switching to dark mode visibly lightens it, the way the accent does. + val tone = if (dark) 80f else 45f + var wheel by remember { mutableStateOf(null) } + + LaunchedEffect(tone) { wheel = withContext(Dispatchers.Default) { renderWheel(tone) } } + + val haptics = LocalHapticFeedback.current + + fun report(position: Offset, canvas: Size) { + val radius = minOf(canvas.width, canvas.height) / 2f + if (radius <= 0f) return + val dx = position.x - canvas.width / 2f + val dy = position.y - canvas.height / 2f + val distance = hypot(dx, dy) + + var angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat() + if (angle < 0f) angle += 360f + // Past the rim the gesture still counts, pinned to full chroma — running a finger off the + // edge should not drop the selection back to grey. + onChange(angle, (distance / radius * MAX_CHROMA).coerceIn(0f, MAX_CHROMA)) + } + + Box( + modifier = + modifier + .fillMaxWidth() + .aspectRatio(1f) + .pointerInput(Unit) { + detectTapGestures { offset -> + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + report(offset, this.size.toSize()) + } + } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> report(offset, this.size.toSize()) } + ) { change, _ -> + report(change.position, this.size.toSize()) + } + } + ) { + Canvas(modifier = Modifier.fillMaxWidth().aspectRatio(1f)) { + val image = wheel ?: return@Canvas + drawImage( + image = image, + dstSize = + IntSize(this.size.width.roundToInt(), this.size.height.roundToInt()), + ) + drawThumb(hue, chroma, dark) + } + } +} + +/** The selection marker: a ring showing the chosen colour, not an arrow pointing at it. */ +private fun DrawScope.drawThumb(hue: Float, chroma: Float, dark: Boolean) { + val radius = minOf(size.width, size.height) / 2f + val angle = Math.toRadians(hue.toDouble()) + val distance = (chroma / MAX_CHROMA).coerceIn(0f, 1f) * radius + val centre = + Offset( + size.width / 2f + (kotlin.math.cos(angle) * distance).toFloat(), + size.height / 2f + (kotlin.math.sin(angle) * distance).toFloat(), + ) + + val swatch = SeedScheme.wheelColor(hue, chroma, if (dark) 80f else 45f) + // Two rings, dark under light, so the thumb stays visible over both the pale centre and the + // saturated rim without needing to know what is behind it. + drawCircle(color = Color.Black.copy(alpha = 0.35f), radius = 17.dp.toPx(), center = centre) + drawCircle(color = Color.White, radius = 15.dp.toPx(), center = centre) + drawCircle(color = swatch, radius = 12.dp.toPx(), center = centre) +} + +/** + * Paints the disc once per tone. + * + * Every pixel is an independent LCh conversion, which is why this runs off the main thread and is + * cached — at 320² that is a hundred thousand conversions, fine once and hopeless per frame. + */ +private fun renderWheel(tone: Float): ImageBitmap { + val n = WHEEL_PIXELS + val pixels = IntArray(n * n) + val centre = n / 2f + val radius = n / 2f + + for (y in 0 until n) { + val dy = y + 0.5f - centre + for (x in 0 until n) { + val dx = x + 0.5f - centre + val distance = hypot(dx, dy) + if (distance > radius) continue // stays transparent, leaving a clean circle + + var angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat() + if (angle < 0f) angle += 360f + + val colour = SeedScheme.wheelColor(angle, distance / radius * MAX_CHROMA, tone) + // Feather the last pixel of the rim, or the circle reads as jagged on a low-density + // screen once it is scaled up. + val edge = ((radius - distance) / 1.5f).coerceIn(0f, 1f) + pixels[y * n + x] = colour.copy(alpha = edge).toArgb() + } + } + + return Bitmap.createBitmap(pixels, n, n, Bitmap.Config.ARGB_8888).asImageBitmap() +} + +private fun IntSize.toSize(): Size = Size(width.toFloat(), height.toFloat()) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt new file mode 100644 index 000000000..3a7498d60 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt @@ -0,0 +1,550 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.util.Calendar +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.theme.currentLocale +import org.matrix.vector.manager.data.github.TimelineCommit +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * One row of the commit rail. + * + * The rail is a real vertical line with a node on it, not a list of cards, because the thing being + * shown is a history — continuity is the point. + * + * **Node fill marks authorship.** A commit written by someone other than the repository owner gets + * a filled node and its author's name in the emphasis colour; the maintainer's own commits get a + * hollow one. No badge and no label saying "community", which would read as a category rather than + * a thank-you — the contribution simply stands out on the rail. This is the design's answer to + * "encourage participation": the recognition is visible in the screen every user opens. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CommitRow( + commit: TimelineCommit, + isFirst: Boolean, + isLast: Boolean, + onOpenCommit: (TimelineCommit) -> Unit, + onOpenPullRequest: (Int) -> Unit, + modifier: Modifier = Modifier, + onFilterAuthor: (String) -> Unit = {}, +) { + val colors = MaterialTheme.colorScheme + val nodeColor = commit.railColor() + + Row( + modifier = + modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable { onOpenCommit(commit) } + ) { + Rail(isFirst = isFirst, isLast = isLast, nodeColor = nodeColor, filled = commit.isCommunity) + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f).padding(bottom = 18.dp)) { + // The badges flow with the title text rather than sitting in a reserved column: a + // fixed trailing column narrows every line of the subject, and the subject is the + // thing worth reading. They are one inline slot rather than two, so the hash and the + // pull-request badge can never be split across a line break — a wrap between them + // reads as though the number belongs to the next commit. + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val prLabel = commit.pullRequest?.let { "#$it" } + + val badgeSize = + remember(commit.shortSha, prLabel, density) { + val sha = measurer.measure(commit.shortSha, VectorMono).size + val pr = prLabel?.let { measurer.measure(it, VectorMono).size } + // Slack over the measured text: one chip's horizontal padding and border, + // per chip, plus the gap between them. + val pad = with(density) { CHIP_PADDING.roundToPx() } + val chip = (pad + with(density) { CHIP_BORDER.roundToPx() }) * 2 + val gap = with(density) { CHIP_GAP.roundToPx() } + val width = sha.width + chip + (pr?.let { it.width + chip + gap } ?: 0) + // The chips fill the placeholder's height, so the same padding again is what + // keeps their background off the text above and below it. + val height = maxOf(sha.height, pr?.height ?: 0) + pad * 2 + width to height + } + + val title = buildAnnotatedString { + append(commit.subject) + append(" ") + appendInlineContent(BADGE_SLOT, commit.shortSha) + } + + val inline = + mapOf( + BADGE_SLOT to + InlineTextContent( + Placeholder( + width = with(density) { badgeSize.first.toDp().toSp() }, + height = with(density) { badgeSize.second.toDp().toSp() }, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(CHIP_GAP), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = + Modifier.fillMaxHeight() + .clip(RoundedCornerShape(4.dp)) + .background(colors.surfaceContainerHigh) + .padding(horizontal = CHIP_PADDING), + contentAlignment = Alignment.Center, + ) { + Text( + commit.shortSha, + style = VectorMono, + color = colors.onSurfaceVariant, + ) + } + if (prLabel != null) { + // Opens the discussion where participation happens, not just + // a diff. + Box( + modifier = + Modifier.fillMaxHeight() + .clip(RoundedCornerShape(4.dp)) + .border( + CHIP_BORDER, + colors.primary.copy(alpha = 0.4f), + RoundedCornerShape(4.dp), + ) + .clickable { + onOpenPullRequest(commit.pullRequest) + } + .padding(horizontal = CHIP_PADDING), + contentAlignment = Alignment.Center, + ) { + Text(prLabel, style = VectorMono, color = colors.primary) + } + } + } + } + ) + + Text( + text = title, + inlineContent = inline, + style = MaterialTheme.typography.bodyLarge, + color = colors.onSurface, + ) + // The subject and its attribution are separate thoughts, and read as one dense block + // when they are crowded. + Spacer(Modifier.height(7.dp)) + val credit = + when (commit.coAuthors.size) { + 0 -> commit.authorLogin + 1 -> + stringResource( + R.string.home_with_coauthor, + commit.authorLogin, + commit.coAuthors.first().login, + ) + else -> + stringResource( + R.string.home_with_coauthors, + commit.authorLogin, + commit.coAuthors.size, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val haptics = LocalHapticFeedback.current + Text( + text = credit, + style = MaterialTheme.typography.labelMedium, + fontWeight = + if (commit.isCommunity) FontWeight.SemiBold else FontWeight.Normal, + color = if (commit.isCommunity) colors.primary else colors.onSurfaceVariant, + // Holding the name narrows the rail to that person's work. The gesture is put + // on the name itself rather than on the whole row because the row already + // means "open this commit", and a long press on a subject line has no obvious + // subject; a long press on a name plainly means *that name*. + modifier = + Modifier.combinedClickable( + onClick = { onOpenCommit(commit) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onFilterAuthor(commit.authorLogin) + }, + ) + ) + Text( + text = exactTime(commit.epochSeconds), + style = MaterialTheme.typography.labelSmall, + color = colors.onSurfaceVariant, + ) + } + } + } +} + +/** + * Consecutive bot commits collapse into one expandable row. + * + * About one in five of the last 300 commits on this repository is a dependabot `Bump …`. Left + * inline they bury the human work the section exists to celebrate. + */ +@Composable +fun BotBundleRow( + count: Int, + expanded: Boolean, + onToggle: () -> Unit, + isLast: Boolean, + modifier: Modifier = Modifier, + children: @Composable () -> Unit, +) { + val colors = MaterialTheme.colorScheme + Column(modifier = modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().clickable { onToggle() }) { + Rail( + isFirst = false, + isLast = isLast && !expanded, + nodeColor = colors.outline, + filled = false, + nodeSize = 8.dp, + ) + Spacer(Modifier.width(14.dp)) + Text( + text = stringResource(R.string.home_bumps, count), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(bottom = 18.dp), + ) + } + AnimatedVisibility(visible = expanded) { Column { children() } } + } +} + +@Composable +private fun Rail( + isFirst: Boolean, + isLast: Boolean, + nodeColor: Color, + filled: Boolean, + nodeSize: androidx.compose.ui.unit.Dp = 11.dp, +) { + val line = MaterialTheme.colorScheme.outlineVariant + // The line fills the row's whole height rather than a fixed length, so it still reaches the + // next node when a commit's subject runs to two lines. + Column( + modifier = Modifier.width(22.dp).fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.width(2.dp) + .height(7.dp) + .background(if (isFirst) Color.Transparent else line) + ) + Box( + modifier = + Modifier.size(nodeSize) + .clip(CircleShape) + .background(if (filled) nodeColor else MaterialTheme.colorScheme.surface) + .border(2.dp, nodeColor, CircleShape) + ) + if (!isLast) { + Box(Modifier.width(2.dp).weight(1f).background(line)) + } + } +} + +/** + * The elapsed time between two commits, drawn as rail. + * + * This is where the timeline stops being a list. Two commits on the same day sit almost touching; + * a fortnight apart and the line visibly stretches, so the project's rhythm — bursts of work, + * stretches of quiet — is legible without reading a single date. A long silence is named, because + * empty rail on its own is ambiguous and could read as a layout gap. + */ +@Composable +fun GapRow(days: Int, heightDp: Float, showLabel: Boolean, modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth().height(heightDp.dp)) { + Column( + modifier = Modifier.width(22.dp).fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.width(2.dp) + .weight(1f) + .background(MaterialTheme.colorScheme.outlineVariant) + ) + } + if (showLabel) { + Spacer(Modifier.width(14.dp)) + Text( + text = pluralStringResource(R.plurals.home_quiet_days, days, days), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + } +} + +/** + * The rail encodes **authorship only**, not commit type. + * + * Not by type. About one subject in five begins with "Fix", so an error-coloured node would make a + * perfectly healthy history read as a wall of alarm — and it would be redundant, because this + * project writes plain imperative subjects and the first word of the line the reader is already on + * *is* the type. + * + * [CommitKind] is still parsed and kept on the model, for filtering later. + */ +@Composable +private fun TimelineCommit.railColor(): Color = + if (isCommunity) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + + +/** + * The line between the build the reader is running and the commits they are not. + * + * This is the one thing on the page that is about *them*. `versionCode` is `git rev-list --count`, + * so a build's position in history is exact — everything above this marker is precisely what an + * update would bring, named commit by commit rather than summarised as "a new version". + */ +@Composable +fun InstalledMarkerRow( + versionCode: Long, + commitsAhead: Int, + aheadOfMaster: Boolean = false, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + // A build past the head of master is not a position on this timeline, it is a warning: it was + // built locally or from another branch, so the history below is not the history of what is + // running. Drawn in the caution colour rather than the accent for exactly that reason. + val accent = if (aheadOfMaster) colors.tertiary else colors.primary + Row( + modifier = modifier.fillMaxWidth().padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.width(22.dp).height(2.dp).background(accent), + contentAlignment = Alignment.Center, + ) {} + Spacer(Modifier.width(8.dp)) + Text( + text = + if (aheadOfMaster) stringResource(R.string.home_custom_build) + else pluralStringResource(R.plurals.home_commits_ahead, commitsAhead, commitsAhead), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = accent, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.home_your_build, versionCode), + style = VectorMono, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Box(Modifier.weight(1f).height(1.dp).background(accent.copy(alpha = 0.45f))) + } +} + +/** + * A month boundary, carrying what that month amounted to. + * + * A bare month name is only a scroll landmark. With its own totals the separator becomes the + * timeline's summary layer — the project's shape is readable by skimming the separators alone, + * without reading a single commit subject. + */ +@Composable +fun MonthMarkerRow(month: Int, year: Int?, commits: Int, people: Int, modifier: Modifier = Modifier) { + val locale = currentLocale() + val label = + remember(month, year, locale) { + val cal = Calendar.getInstance(locale).apply { set(Calendar.MONTH, month) } + val name = + cal.getDisplayName(Calendar.MONTH, Calendar.LONG_STANDALONE, locale) + ?: month.toString() + if (year == null) name else "$name $year" + } + Row( + modifier = modifier.fillMaxWidth().padding(top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Spacer(Modifier.width(28.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = + stringResource( + R.string.home_month_stats, + pluralStringResource(R.plurals.home_commit_count, commits, commits), + pluralStringResource(R.plurals.home_people_count_plain, people, people), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + Modifier.weight(1f) + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant) + ) + } +} + +private const val BADGE_SLOT = "badges" + +/** + * The chips' own metrics, which the placeholder standing in for them has to leave room for. + * + * The chips are laid out from these same values rather than from their own literals, so the + * placeholder cannot drift from the thing it is sizing. Held as dp and converted at the reader's + * density rather than written down as the pixels they came to on one screen, where the placeholder + * ends up a little tight or a little loose everywhere else — and a tight one clips the chip. + */ +private val CHIP_PADDING = 5.dp +private val CHIP_BORDER = 1.dp +private val CHIP_GAP = 4.dp + +/** + * The foot of the rail: where the history runs out, or where it is still being fetched. + * + * The timeline has to end somehow, and a list that simply stops is ambiguous — it reads equally as + * "that is everything" and as "something failed". So the rail always terminates in a statement. + * While there is more to fetch it says so and fetches it; when the project's first commit is on + * screen it says that instead, and the line stops in a ring rather than being cut off mid-stroke. + * + * It is also the trigger. Being composed means the reader has scrolled to the end of what is held, + * which is the clearest signal available that they want more — clearer than a scroll-offset + * threshold, and it costs no per-frame observation to detect. [onReachEnd] fires once per time this + * row enters composition, so the walk resumes each time the reader arrives here and not while they + * are somewhere further up. + */ +@Composable +fun HistoryFootRow( + loading: Boolean, + hasMore: Boolean, + stalled: Boolean, + beginningDate: String?, + /** True when the window is bounded and already full: nothing more can arrive inside it. */ + windowCovered: Boolean, + onReachEnd: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + /** + * Whether arriving here should fetch on its own. + * + * False while the rail is filtered to a few people. One person's commits are a short list, so + * the foot is on screen the moment the filter is applied — and firing there would spend three + * requests on every experiment with the chips, out of the sixty an hour an anonymous client + * gets. The row stays tappable, so the reader can still ask; they are simply not asked for. + */ + autoFetch: Boolean = true, +) { + val colors = MaterialTheme.colorScheme + val tappable = hasMore && !loading + + if (hasMore && !stalled && autoFetch) { + LaunchedEffect(Unit) { onReachEnd() } + } + + Row( + modifier = + modifier + .fillMaxWidth() + .then(if (tappable) Modifier.clickable(onClick = onRetry) else Modifier) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(22.dp), contentAlignment = Alignment.Center) { + when { + loading -> + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = colors.outline, + ) + // An open ring, drawn the way the oldest commit's node is drawn. The history does + // not stop here because we stopped looking; it stops because there is nothing + // before it. + !hasMore -> + Box( + Modifier.size(9.dp) + .clip(CircleShape) + .border(1.5.dp, colors.outlineVariant, CircleShape) + ) + else -> Box(Modifier.size(5.dp).clip(CircleShape).background(colors.outlineVariant)) + } + } + Spacer(Modifier.width(14.dp)) + Text( + text = + when { + loading -> stringResource(R.string.home_history_loading) + hasMore -> stringResource(R.string.home_history_more) + // The strongest statement first: this is the first commit of the project, and + // there is nothing before it for any window to reach. + beginningDate != null -> + stringResource(R.string.home_history_beginning, beginningDate) + // Weaker and more common: the window is full. Older commits exist and are + // held; this range simply does not include them, so fetching is not what the + // reader wants — a wider range is. + windowCovered -> stringResource(R.string.home_history_window_end) + else -> "" + }, + style = MaterialTheme.typography.labelSmall, + color = colors.outline, + ) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ConfirmInstall.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ConfirmInstall.kt new file mode 100644 index 000000000..46c0b803e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ConfirmInstall.kt @@ -0,0 +1,82 @@ +package org.matrix.vector.manager.ui.components + +import android.content.pm.PackageManager +import android.text.format.Formatter +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.model.OnlineModule +import org.matrix.vector.manager.data.model.ReleaseAsset + +/** + * The consent gate — and parasitically it is the *only* one. + * + * Inside `com.android.shell` the manager inherits `INSTALL_PACKAGES`, so the commit that follows + * installs a third-party APK with no system confirmation at all. Standalone, the platform asks as + * usual. The dialog therefore names the module, the version, the file and its size before anything + * is downloaded, and says which of the two is about to happen. + */ +@Composable +fun ConfirmInstall( + module: OnlineModule?, + packageName: String, + asset: ReleaseAsset, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val context = LocalContext.current + val silent = + remember(context) { + context.checkSelfPermission("android.permission.INSTALL_PACKAGES") == + PackageManager.PERMISSION_GRANTED + } + val size = Formatter.formatShortFileSize(context, asset.size) + + VectorAlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.store_confirm_title, module?.title ?: packageName)) }, + text = { + Column { + Text( + stringResource( + R.string.store_confirm_body, + asset.name.orEmpty(), + size, + packageName, + ) + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.store_confirm_trust), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (silent) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.store_confirm_silent), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + Button(onClick = onConfirm) { Text(stringResource(R.string.store_install)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.store_cancel)) } + }, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt new file mode 100644 index 000000000..b25b2ca01 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt @@ -0,0 +1,672 @@ +package org.matrix.vector.manager.ui.components + +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import android.text.format.Formatter +import androidx.compose.material.icons.rounded.ArrowCircleUp +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.repository.ModuleUpdateQueue +import org.matrix.vector.manager.ui.screens.repo.StoreChannel +import org.matrix.vector.manager.ui.screens.repo.releasesOn +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.TextButton +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.theme.VectorMono + +/** What a long press did, and how it went. */ +data class PackageActionResult( + val messageRes: Int, + val argument: String? = null, + val tone: SnackbarTone = SnackbarTone.Neutral, +) + +/** + * The long-press sheet for a package, whether it is a module or an app in a module's scope. + * + * A sheet rather than a dropdown menu, for two reasons. It can say *which* package it is about — a + * menu that floats over a list gives no way to tell whether it belongs to the row under your thumb + * or the one above it, which matters a great deal when one of the actions is "uninstall". And it + * has room to explain the action that needs explaining, instead of offering a bare verb. + * + * Every action here is a Binder call into the daemon, which is the process holding the privilege to + * carry it out, so each one reports what came back rather than assuming it worked. + * + * **Re-optimize is the one that is not obvious.** ART inlines small methods into their callers + * during ahead-of-time compilation, and an inlined method can no longer be hooked — so a module + * that works on one device silently does nothing on another that happened to compile the target + * more aggressively. Re-optimizing the app clears that, and it is the first thing to try when a + * hook "just doesn't fire". It is slow and it is per-app, which is why it belongs on a long press + * rather than in a settings screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PackageActionSheet( + packageName: String, + userId: Int, + appName: String, + applicationInfo: ApplicationInfo, + isModule: Boolean, + onDismiss: () -> Unit, + onResult: (PackageActionResult) -> Unit, + /** + * Where the module's store page is, when there is one to go to. + * + * Optional because this sheet is also opened from the Scope screen, over an app that is not a + * module and has no page. Null there rather than a row that leads nowhere. + */ + onOpenStore: ((String) -> Unit)? = null, +) { + // The framework is a scope target, not an app. It has no launcher entry, no settings page in + // Settings, and nothing ART could re-optimize, so those three rows would lead nowhere. What it + // does have is a way to be restarted, which takes every running app down with it — so that is + // what the sheet offers, and what it says. + val isSystemFramework = packageName == SYSTEM_FRAMEWORK_PACKAGE + + // Asked once, when the sheet opens. Most modules have neither a companion nor a launcher entry, + // and a row that exists only to report that it has nothing to do is worse than no row. + var openable by remember(packageName, userId) { mutableStateOf(null) } + LaunchedEffect(packageName, userId) { + openable = + ServiceLocator.daemon + .findAppUi(packageName, userId, companionFirst = isModule) + .onFailure { e -> + logW("actions: launch target lookup for $packageName u$userId failed", e) + } + .getOrNull() != null + } + var confirmSoftReboot by remember { mutableStateOf(false) } + + // Deliberately not `rememberCoroutineScope()`. Every action on this sheet dismisses it before + // it starts working, and the dismissal takes this composable out of the composition — which + // cancels the scope a composition remembered for it. The work launched into that scope then + // dies at the first `withContext` hop inside the daemon call, before the transaction is ever + // made, and dies quietly: no daemon call, no error branch, no snackbar, a button that did + // nothing. Worse, it is a race against the next frame rather than a reliable failure, so it + // reads as a flaky button. `appScope` belongs to the process and outlives the sheet. + val scope = ServiceLocator.appScope + val daemon = ServiceLocator.daemon + + if (confirmSoftReboot) { + VectorAlertDialog( + onDismissRequest = { confirmSoftReboot = false }, + icon = { Icon(Icons.Rounded.RestartAlt, contentDescription = null) }, + title = { Text(stringResource(R.string.action_soft_reboot)) }, + text = { Text(stringResource(R.string.action_soft_reboot_confirm)) }, + confirmButton = { + TextButton( + onClick = { + confirmSoftReboot = false + onDismiss() + scope.launch(Dispatchers.Main) { + daemon.softReboot().onFailure { + logE("actions: soft reboot request failed", it) + } + } + } + ) { + Text( + stringResource(R.string.action_soft_reboot), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirmSoftReboot = false }) { + Text(stringResource(R.string.store_cancel)) + } + }, + ) + } + + val colors = MaterialTheme.colorScheme + // Every value left enabled, rather than dropping PartiallyExpanded: that stop is the only + // thing a drag on a sheet can *do* other than dismiss it, so a sheet taller than half the + // screen would open at full height and could not be made smaller. Material caps the stop at + // the sheet's own height, so short sheets still open at that height and gain no useless drag. + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + + // `Dispatchers.Main` because [onResult] reaches a snackbar on the screen underneath, and + // because that is the thread the composition scope this replaces used to resume on. + fun finish(block: suspend () -> PackageActionResult) { + onDismiss() + scope.launch(Dispatchers.Main) { onResult(block()) } + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { +LocalizedOverlay { + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(applicationInfo = applicationInfo, contentDescription = null, size = 44.dp) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = appName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = ScopeViewModel.displayPackageName(packageName), + style = VectorMono, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + HorizontalDivider(Modifier.padding(horizontal = 24.dp)) + Spacer(Modifier.height(4.dp)) + + if (isModule) { + ModuleUpdateSection( + packageName = packageName, + onOpenStore = onOpenStore, + onDismiss = onDismiss, + onResult = onResult, + ) + } + + // A module is not an app you "open" — most have nothing to look at. What it may have is a + // companion: the screen its author wrote to configure it, which is what the Xposed settings + // category marks. Naming it that way is the difference between a control that looks + // pointless and one that says what it is for. + if (!isSystemFramework && openable == true) + ActionRow( + icon = Icons.AutoMirrored.Rounded.Launch, + title = + stringResource( + if (isModule) R.string.action_open_companion else R.string.action_launch + ), + subtitle = + if (isModule) stringResource(R.string.action_open_companion_summary) else null, + ) { + finish { + val result = daemon.openAppUi(packageName, userId, companionFirst = isModule) + if (result.getOrDefault(false)) { + PackageActionResult(R.string.action_launched) + } else { + // The row is only drawn once findAppUi resolved a target, so reaching this + // branch contradicts what was rendered. One line for both shapes: a failed + // transaction carries a throwable, a resolve that found nothing does not. + logE( + "actions: open of $packageName for user $userId did nothing, though the " + + "row had resolved a target", + result.exceptionOrNull(), + ) + PackageActionResult(R.string.action_no_launcher, tone = SnackbarTone.Failure) + } + } + } + + if (!isSystemFramework) + ActionRow(icon = Icons.Rounded.Info, title = stringResource(R.string.action_app_info)) { + finish { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // `noUserSwitch = false`, so the daemon switches to the target's profile parent + // and locks the screen first. That is right here: this is Settings' own details + // page for a package in that profile, which is not an activity that shows for + // whichever user is current. + val started = daemon.startActivityAsUser(intent, userId, noUserSwitch = false) + // The dominant failure is not an exception: the daemon hands back the activity + // manager's own start code, so a refused user switch or a screen that would not + // start arrives as a number rather than as a throw. Started means 0 to 99 — the + // band `ActivityManager.isStartResultSuccessful` tests, written out because those + // constants are hidden — with -100 to -1 fatal and 100 to 199 non-fatal refusals. + val code = started.getOrDefault(-1) + if (code !in 0..99) { + logE( + "actions: opening app info for $packageName as user $userId failed " + + "(code $code)", + started.exceptionOrNull(), + ) + } + PackageActionResult(R.string.action_opened_info) + } + } + + // Force-stopping the framework is a soft reboot, and calling it anything else would hide + // what the button does: the daemon restarts the primary zygote, so `system_server` and + // every app forked from it go down together. Named and explained accordingly, and + // confirmed first — this is the one action on this sheet that ends what the reader is + // doing everywhere else on the phone. + if (isSystemFramework) { + ActionRow( + icon = Icons.Rounded.RestartAlt, + title = stringResource(R.string.action_soft_reboot), + subtitle = stringResource(R.string.action_soft_reboot_summary), + tint = colors.error, + ) { + confirmSoftReboot = true + } + } else { + ActionRow( + icon = Icons.Rounded.Stop, + title = stringResource(R.string.action_force_stop), + ) { + finish { + val result = + daemon.forceStopPackage(packageName, userId).onFailure { e -> + logE("actions: force stop of $packageName (user $userId) failed", e) + } + // Unlike uninstall below there is no boolean to weigh: the call answers with + // Unit, so the Result itself is the verdict — a failure here means the + // transaction never reached a live daemon and nothing was stopped. + val ok = result.isSuccess + PackageActionResult( + if (ok) R.string.action_force_stopped + else R.string.action_force_stop_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + } + } + + // Only for a hook target. Re-optimizing recompiles an app so that ART stops inlining the + // methods a module wants to hook — which is about the app being hooked, not about the + // module doing the hooking, so on a module it would be an expensive button for nothing. + if (!isModule && !isSystemFramework) { + ActionRow( + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.action_optimize), + subtitle = stringResource(R.string.action_optimize_summary), + tint = colors.primary, + ) { + finish { + // Slow — this recompiles the app — so the caller is told it started and told + // again when it finishes. + onResult( + PackageActionResult( + R.string.action_optimizing, + appName, + tone = SnackbarTone.Working, + ) + ) + val ok = + daemon + .optimizePackage(packageName) + .onFailure { e -> + logE("actions: re-optimize of $packageName failed", e) + } + .getOrDefault(false) + PackageActionResult( + if (ok) R.string.action_optimized else R.string.action_optimize_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + } + } + + if (isModule) { + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + ActionRow( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.action_uninstall), + tint = colors.error, + ) { + finish { + val result = daemon.uninstallPackage(packageName, userId) + val ok = result.getOrDefault(false) + // On `!ok`: a device-policy refusal and a missing user come back as a plain + // `false`, which onFailure would never see. + if (!ok) { + logE( + "actions: uninstall of $packageName for user $userId failed", + result.exceptionOrNull(), + ) + } + PackageActionResult( + if (ok) R.string.action_uninstalled else R.string.action_uninstall_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + } +} +} + +/** + * The one shape every row on this sheet takes: a glyph in a tinted disc, the verb, and — when it + * needs one — the sentence under it saying what the verb costs. + * + * The disc is what lets a destructive action look destructive: an error-red glyph on a bare row is + * easy to miss, the same glyph on a red disc is not. Once one row carries it they all have to, or + * the bare one reads as a different kind of thing sitting in the same list — which is what the mute + * switch did while it was borrowing the generic [ToggleRow], a Material list item whose leading + * icon has no disc and whose text starts ten pixels to the left of every other row here. + * + * The measurements are chosen so that one column runs down the whole sheet: 24dp of margin, a 40dp + * disc and 20dp of gap put every title at 84dp, which is where the header puts the app's name over + * its 44dp icon and 16dp gap. + * + * [trailing] is for a row that carries state as well as an action, and the click behaviour comes in + * through [modifier] rather than as a callback: a switch row has to announce itself to a screen + * reader as a switch, not as a button, and only the caller knows which it is. + */ +@Composable +private fun ActionRowLayout( + modifier: Modifier, + icon: ImageVector, + title: String, + subtitle: String?, + tint: Color?, + trailing: (@Composable () -> Unit)? = null, +) { + val colors = MaterialTheme.colorScheme + val accent = tint ?: colors.onSurfaceVariant + + Row( + modifier = + Modifier.fillMaxWidth() + .then(modifier) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier.size(40.dp).clip(CircleShape).background(accent.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = accent, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.width(20.dp)) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = if (tint == colors.error) colors.error else colors.onSurface, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(12.dp)) + trailing() + } + } +} + +/** + * One action. + * + * [onClick] is nullable because one row on this sheet is a statement rather than an action — "not + * in the store" — and a row that ripples under a thumb and then does nothing is a worse answer than + * one that visibly cannot be pressed. + */ +@Composable +private fun ActionRow( + icon: ImageVector, + title: String, + subtitle: String? = null, + tint: Color? = null, + onClick: (() -> Unit)?, +) { + ActionRowLayout( + modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier, + icon = icon, + title = title, + subtitle = subtitle, + tint = tint, + ) +} + +/** One setting, in the same shape as the actions it sits among. */ +@Composable +private fun ActionToggleRow( + icon: ImageVector, + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + subtitle: String? = null, +) { + ActionRowLayout( + modifier = + Modifier.toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + icon = icon, + title = title, + subtitle = subtitle, + tint = null, + // The whole row is the target, and the switch itself takes no callback, so a tap on it + // cannot be counted twice. + trailing = { Switch(checked = checked, onCheckedChange = null) }, + ) +} + +/** + * What this module's update situation is, and the two things to do about it. + * + * It belongs on the module rather than only in the Store, because this is where the reader already + * is: the alternative to a row here is remembering the module's name, crossing to the Store tab and + * finding it again — and the same detour for the switch that silences a module you have decided not + * to follow. + * + * Three states, and the third is the one usually got wrong: + * + * * **Out of date** — the update leads, named with the version it brings, because that is the + * reason the sheet was opened. + * * **Current** — no row at all. "Up to date" is a sentence that has to be read to learn nothing. + * * **Not in the store** — said plainly. Most sideloaded modules are not in the catalogue, and a + * silent absence is indistinguishable from "up to date"; someone waiting to be told about a + * version that can never be checked is worse off than someone told to check themselves. + * + * The catalogue is only asked once it has loaded. Saying "not in the store" while the answer is + * still on its way would be a guess dressed as a fact. + */ +@Composable +private fun ModuleUpdateSection( + packageName: String, + onOpenStore: ((String) -> Unit)?, + onDismiss: () -> Unit, + onResult: (PackageActionResult) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val context = LocalContext.current + val settings = ServiceLocator.settings + + val entries by ServiceLocator.storeEntries.collectAsStateWithLifecycle() + val catalog by ServiceLocator.store.catalog.collectAsStateWithLifecycle() + val muted by settings.mutedUpdates.collectAsStateWithLifecycle() + val channelPreference by settings.updateChannel.collectAsStateWithLifecycle() + val queue by ServiceLocator.moduleUpdates.state.collectAsStateWithLifecycle() + + val entry = entries[packageName] + if (entry == null) { + if (catalog.loaded) { + ActionRow( + icon = Icons.Rounded.CloudOff, + title = stringResource(R.string.action_not_in_store), + subtitle = stringResource(R.string.action_not_in_store_summary), + onClick = null, + ) + HorizontalDivider(Modifier.padding(horizontal = 24.dp)) + Spacer(Modifier.height(4.dp)) + } + return + } + + // Asked without the mute, because the sheet still has to show the update to the person who + // muted it — the whole point of putting the switch here is that they can change their mind in + // the place where they see the consequence. + val outdated = entry.copy(updatesMuted = false).upgradable + val release = + remember(entry.module, channelPreference) { + entry.module.releasesOn(StoreChannel.of(channelPreference)).firstOrNull() + } + val apks = release?.releaseAssets.orEmpty().filter { it.isApk } + var confirming by remember { mutableStateOf(null) } + + if (outdated) { + val busy = queue.running && (queue.current?.packageName == packageName) + ActionRow( + icon = Icons.Rounded.ArrowCircleUp, + title = + stringResource( + if (entry.sameVersion) R.string.store_badge_reinstall + else R.string.action_update_to, + entry.latest?.versionName.orEmpty(), + ), + subtitle = + when { + busy -> stringResource(R.string.action_update_running) + apks.isEmpty() -> stringResource(R.string.action_update_no_apk) + // Several APKs is an architecture split or a variant, and choosing between + // them needs the names and sizes the store page already lays out. Sending the + // reader there is better than picking one on their behalf. + apks.size > 1 -> stringResource(R.string.action_update_choose) + // The title already names the version; saying "from 1.1.1" under "Reinstall + // 1.1.1" would only invite the reader to look for the difference. + entry.sameVersion -> + stringResource( + R.string.action_reinstall_same, + Formatter.formatShortFileSize(context, apks.first().size), + ) + else -> + stringResource( + R.string.action_update_from, + entry.installed?.versionName.orEmpty(), + Formatter.formatShortFileSize(context, apks.first().size), + ) + }, + tint = if (busy || apks.isEmpty()) colors.onSurfaceVariant else colors.primary, + onClick = { + when { + busy || apks.isEmpty() -> Unit + apks.size > 1 -> { + onDismiss() + onOpenStore?.invoke(packageName) + } + else -> confirming = apks.first() + } + }, + ) + } + + ActionToggleRow( + title = stringResource(R.string.store_mute_updates), + icon = Icons.Rounded.NotificationsOff, + checked = packageName in muted, + onCheckedChange = { settings.setUpdatesMuted(packageName, it) }, + subtitle = stringResource(R.string.store_mute_updates_summary), + ) + + if (onOpenStore != null) { + ActionRow( + // The same glyph the Store tab carries, because it is the same place. + icon = Icons.Rounded.CloudDownload, + title = stringResource(R.string.action_open_store), + onClick = { + onDismiss() + onOpenStore(packageName) + }, + ) + } + + HorizontalDivider(Modifier.padding(horizontal = 24.dp)) + Spacer(Modifier.height(4.dp)) + + confirming?.let { asset -> + ConfirmInstall( + module = entry.module, + packageName = packageName, + asset = asset, + onDismiss = { confirming = null }, + onConfirm = { + confirming = null + // Through the queue rather than straight to the installer, so a single update + // reports itself in the same place a batch does — the line on the Modules header, + // which outlives this sheet. Closing the sheet is not cancelling the install. + ServiceLocator.moduleUpdates.start( + listOf( + ModuleUpdateQueue.Item( + packageName = packageName, + title = entry.module.title, + asset = asset, + release = release?.version, + ) + ) + ) + onDismiss() + onResult(PackageActionResult(R.string.action_update_started)) + }, + ) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PanelHeader.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PanelHeader.kt new file mode 100644 index 000000000..515bbca25 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PanelHeader.kt @@ -0,0 +1,97 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * The top of a list panel, as three rows of fixed height. + * + * Modules, Store and Logs are the same kind of screen — a title, a few actions, one line of state, + * a search field and a long list — so they share one header rather than each growing its own. + * Headers of three different heights move the search field as tabs are switched, and that is the + * one control a thumb learns the position of: moving it is felt long before it is noticed. + * + * So the whole block is laid out here, at a **fixed height**, including the search field: a title + * row that may carry actions on the right, a line of description under it, and the field. Fixing the + * height rather than measuring it is what makes the layout predictable — a description that appears + * only once a catalogue has loaded, or a line counter that is empty until a log is read, then costs + * nothing below it and shifts nothing. + * + * The scope editor deliberately does not use this. It is a screen you arrive at and leave again, so + * it carries a back arrow and the name of what you came for, and the shape of the panels you + * navigate *between* is the wrong shape for it. + */ +@Composable +fun PanelHeader( + title: String, + modifier: Modifier = Modifier, + actions: (@Composable RowScope.() -> Unit)? = null, + description: (@Composable () -> Unit)? = null, + search: (@Composable () -> Unit)? = null, + /** + * Takes the place of the title and description rows while it is non-null. + * + * For modes that replace what the panel is *about* without replacing what it *does* — module + * selection is the one — so the search field below stays live and, more importantly, stays + * exactly where it was. The override gets the two rows' combined height and no more, which is + * what keeps a contextual bar from becoming a band of empty colour. + */ + titleOverlay: (@Composable () -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth().height(PANEL_HEADER_HEIGHT)) { + if (titleOverlay != null) { + Box(modifier = Modifier.fillMaxWidth().height(TITLE_ROW + DESCRIPTION_ROW)) { + titleOverlay() + } + } else { + Row( + modifier = + Modifier.fillMaxWidth().height(TITLE_ROW).padding(start = 20.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + actions?.invoke(this) + } + + // Always present, whether or not it has anything to say, so the field below never + // moves. + Box( + modifier = + Modifier.fillMaxWidth().height(DESCRIPTION_ROW).padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterStart, + ) { + description?.invoke() + } + } + + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + search?.invoke() + } + } +} + +private val TITLE_ROW = 56.dp +private val DESCRIPTION_ROW = 26.dp + +/** Title row, description row and search field, and the same on every panel. */ +val PANEL_HEADER_HEIGHT = TITLE_ROW + DESCRIPTION_ROW + 68.dp diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SearchField.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SearchField.kt new file mode 100644 index 000000000..5b7afa8e3 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SearchField.kt @@ -0,0 +1,96 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.matrix.vector.manager.R + +/** + * One rounded control that holds the search input *and* whatever narrows the list. + * + * Search and filtering answer the same question — *which of these am I looking at* — so splitting + * them across a text field and a separate row of chips would spend two rows and two mental steps on + * one intent. Everything that narrows the list lives in [trailing], on the same line. + */ +@Composable +fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit = {}, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(16.dp)) + Icon( + Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.weight(1f).padding(vertical = 16.dp), + decorationBox = { inner -> + if (query.isEmpty()) { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // A search field is a fixed-height control, so its hint may never + // wrap: in French "Rechercher une application" runs to a second line + // and would grow the whole bar, breaking the fixed header every panel + // shares. One line, always — the field cannot change shape by + // language. + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + inner() + }, + ) + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.modules_clear_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + trailing() + Spacer(Modifier.width(4.dp)) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt new file mode 100644 index 000000000..b5aa0f0a3 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt @@ -0,0 +1,186 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * The three pieces every settings sheet in this app is built from. + * + * Shared rather than copied into each sheet, because copies drift and two sheets end up looking + * *almost* the same — the tell is a heading indented differently, or a switch row whose subtitle + * wraps at another width. A new sheet inherits the pattern, and changing the pattern changes every + * sheet at once. + */ + +/** + * What a [ListItem] needs to be given to sit on a sheet. + * + * A list item's container defaults to `surface`; a `ModalBottomSheet` is drawn on + * `surfaceContainerLow`, which is a shade darker. On a screen those two agree, so the default looks + * right in the place a row is usually written and wrong the moment it is put in a sheet — a pale + * full-width band across the sheet, ending wherever the row ends. Transparent takes whatever it is + * placed on, so it is right in both, and it is what every list item in a sheet should be given. + */ +val sheetRowColors: ListItemColors + @Composable get() = ListItemDefaults.colors(containerColor = Color.Transparent) + +@Composable +fun SheetHeading(text: String, icon: ImageVector) { + Row( + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(16.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +/** + * A row of choices, wrapping onto as many lines as it needs. + * + * Wrapping rather than scrolling sideways. A chip that reflows moves every chip after it, so an + * option sits somewhere different in each language — but scrolling *hides* the options past the + * edge, and an option nobody knows about is worse than one that moved. In a sheet the vertical room + * costs nothing, so everything is shown at once. + */ +@Composable +fun ChoiceRow(content: @Composable () -> Unit) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + content() + } +} + +/** + * One switch, with the sentence that says what turning it on costs. + * + * The whole row is the target, not just the switch, and the switch itself takes no callback so a + * tap cannot be counted twice. + * + * Toggleable rather than merely clickable, because a plain clickable carries no state: a screen + * reader announces such a row as a button and reads the title, leaving no way to hear whether the + * setting is on or off — the one thing the row exists to say. + */ +@Composable +fun ToggleRow( + title: String, + icon: ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + subtitle: String? = null, +) { + ListItem( + modifier = + Modifier.toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + supportingContent = subtitle?.let { { Text(it) } }, + leadingContent = { Icon(icon, contentDescription = null) }, + trailingContent = { Switch(checked = checked, onCheckedChange = null) }, + colors = sheetRowColors, + ) { Text(title) } +} + +/** + * What the setting above is currently doing, and — when it is not working — the way back. + * + * Indented to the same column as a [ToggleRow]'s subtitle rather than given a row of its own, + * because it is not another setting: it belongs to the switch above it and has to read as a + * consequence of that switch, not as a sibling of it. + * + * The action is optional and deliberately quiet. A row that always carries a button trains people + * to press it, and most of the states here are the ones where there is nothing to fix. + */ +@Composable +fun StatusNote( + text: String, + modifier: Modifier = Modifier, + tone: Color? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Row( + modifier = + modifier.fillMaxWidth().padding(start = 72.dp, end = 24.dp, top = 2.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = tone ?: MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (actionLabel != null && onAction != null) { + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onAction, contentPadding = PaddingValues(horizontal = 12.dp)) { + Text(actionLabel, style = MaterialTheme.typography.labelLarge) + } + } + } +} + +/** + * One thing the sheet can do. + * + * The same shape as [ToggleRow] minus the switch, so a sheet that mixes settings and actions still + * reads as one list rather than two borrowed idioms. + */ +@Composable +fun SheetAction( + title: String, + icon: ImageVector, + onClick: () -> Unit, + subtitle: String? = null, + tint: Color? = null, +) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + supportingContent = subtitle?.let { { Text(it) } }, + leadingContent = { + Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) + }, + colors = sheetRowColors, + ) { Text(title) } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt new file mode 100644 index 000000000..c0b06e13b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StackTrace.kt @@ -0,0 +1,198 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.CrashFrame +import org.matrix.vector.manager.data.log.CrashSection +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * A stack trace, read as a list rather than as a wall of text. + * + * A trace is already a structured thing — a chain of throwables, each with a list of frames — and + * printing it as one string is a format for a terminal, not for a screen someone is scrolling on a + * phone. Rendered as rows it can do what the text cannot: mark the frames that belong to this + * project, separate the name of a method from the file it lives in, and let one frame be lifted to + * the clipboard without a text selection. + * + * Two things are deliberate about the emphasis. The frames in **our** code are the ones a reader is + * looking for and the platform's are context, so ours carry the weight and a filled marker while + * the platform's are dimmed — the opposite of the printed order, where the platform usually comes + * first. And the chain reads downwards to the *root* cause: `printStackTrace` puts the outermost + * throwable at the top, but "Caused by" is where the answer is, so each cause is introduced by a + * divider rather than buried in the run of frames. + * + * Shared by the crash card's trace screen and the log panel, which had been folding raw frames + * behind an expander. The same text arrives at both from the same parser, so it may as well be read + * the same way in both. + * + * Emits a plain column of rows and takes no scrolling of its own, so a caller can drop it into a + * `LazyColumn` item, a card, or an expanded log row without fighting a nested scroll. Long traces + * belong in [stackTraceItems] instead, which spends the caller's lazy list on them. + */ +@Composable +fun StackTrace( + sections: List, + onCopyFrame: (CrashFrame) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + sections.forEach { section -> + StackTraceSectionHeader(section) + section.frames.forEach { frame -> StackTraceFrame(frame) { onCopyFrame(frame) } } + if (section.elided > 0) StackTraceElided(section.elided) + } + } +} + +/** + * The same rows, contributed to a caller's `LazyColumn` instead of composed all at once. + * + * A trace can run to a hundred frames and a screen showing nothing else should not compose them + * all to show eight. + * + * Keys are positions, not frame text. A `StackOverflowError` prints the same frame hundreds of + * times over and a cause chain repeats the frames it shares, so keying on the line would hand + * `LazyColumn` a duplicate key — which it does not tolerate: it throws, on the screen whose whole + * job is showing someone what threw. + */ +fun LazyListScope.stackTraceItems(sections: List, onCopyFrame: (CrashFrame) -> Unit) { + sections.forEachIndexed { index, section -> + item(key = "s:$index") { StackTraceSectionHeader(section) } + items(section.frames.size, key = { "f:$index:$it" }) { at -> + val frame = section.frames[at] + StackTraceFrame(frame) { onCopyFrame(frame) } + } + if (section.elided > 0) { + item(key = "e:$index") { StackTraceElided(section.elided) } + } + } +} + +/** + * The throwable a run of frames belongs to. + * + * The type is the heading and the message is the sentence under it, which is the way round a reader + * needs them: the type says what kind of failure this is and is short enough to scan, the message + * says what was being attempted and is often a whole line long. A cause is introduced by a labelled + * divider so that the change of subject is visible while scrolling past at speed. + */ +@Composable +private fun StackTraceSectionHeader(section: CrashSection) { + val colors = MaterialTheme.colorScheme + Column(modifier = Modifier.fillMaxWidth()) { + if (section.isCause) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.crash_caused_by), + style = MaterialTheme.typography.labelMedium, + color = colors.error, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + HorizontalDivider(Modifier.weight(1f), color = colors.error.copy(alpha = 0.3f)) + } + Spacer(Modifier.height(6.dp)) + } + // Untyped when the text began at its first frame, which is what a trace looks like when its + // header was the log entry's own line. Nothing is drawn then rather than an empty heading: + // the entry above is already showing the sentence this would have repeated. + if (section.simpleType.isNotEmpty()) { + Text( + section.simpleType, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colors.error, + ) + section.message?.let { message -> + Spacer(Modifier.height(2.dp)) + Text(message, style = MaterialTheme.typography.bodyMedium, color = colors.onSurface) + } + Spacer(Modifier.height(8.dp)) + } + } +} + +/** + * One frame, as two lines: what ran, and where that is written. + * + * Only the file and line are monospaced. `MainActivity.kt:39` is an identifier a reader compares + * character by character against their editor; `MainActivity.onCreate` is a name they read, and + * reads worse in a typewriter face. The frame stays on one line and scrolls sideways rather than + * wrapping — a wrapped frame reads as two frames. + * + * Tapping copies this frame alone, which is the unit people quote to each other. + */ +@Composable +private fun StackTraceFrame(frame: CrashFrame, onCopy: () -> Unit) { + val colors = MaterialTheme.colorScheme + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onCopy).padding(vertical = 5.dp), + verticalAlignment = Alignment.Top, + ) { + // Filled for our code, hollow for the platform's: the shape carries the distinction where + // colour alone would not, and the column of markers can be scanned without reading a word. + Surface( + modifier = Modifier.padding(top = 6.dp).size(7.dp), + shape = CircleShape, + color = if (frame.ours) colors.primary else colors.onSurfaceVariant.copy(alpha = 0.25f), + content = {}, + ) + Spacer(Modifier.padding(horizontal = 6.dp)) + Column( + modifier = Modifier.horizontalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Text( + frame.shortMethod, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (frame.ours) FontWeight.Medium else FontWeight.Normal, + color = if (frame.ours) colors.onSurface else colors.onSurfaceVariant, + softWrap = false, + maxLines = 1, + ) + Text( + frame.location ?: frame.method, + style = VectorMono.copy(fontSize = 11.sp), + color = colors.onSurfaceVariant.copy(alpha = if (frame.ours) 1f else 0.7f), + softWrap = false, + maxLines = 1, + ) + } + } +} + +/** The frames `printStackTrace` replaced with `... N more`, having printed them already. */ +@Composable +private fun StackTraceElided(count: Int) { + Text( + pluralStringResource(R.plurals.crash_frames_elided, count, count), + modifier = Modifier.padding(start = 20.dp, top = 6.dp, bottom = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt new file mode 100644 index 000000000..cbcacf783 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt @@ -0,0 +1,517 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlin.random.Random +import kotlinx.coroutines.delay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.components.ambience.AmbienceKind +import org.matrix.vector.manager.ui.components.ambience.AmbientSurface + +/** The four states the framework can be in, plus the moment before we know. */ +enum class FrameworkState { + Checking, + Active, + Degraded, + Inactive, + + /** + * The framework is running, and this manager cannot talk to it. + * + * Distinct from [Inactive], which means there is no framework. Here there is one, it pushed + * us a binder, and that binder speaks a different generation of `IManagerService` — so every + * transaction would fail and the honest thing to say is that the two builds are out of step, + * not that nothing is installed. Reached only through `ServiceLocator.peerDescriptor`. + */ + Mismatched, +} + +/** + * The framework's state, as the top of the app. + * + * There is no app bar above it and no separate status row. A bar naming the app spends a whole row + * on what the launcher icon, the task switcher and the system already say; that row goes instead to + * the one thing that is genuinely unknown on opening the app. + * + * The header is full-bleed and runs under the status bar, tinted by state, with only its bottom + * corners rounded so it reads as a single pane hanging from the top edge rather than a card + * floating on a background. Under Material You the tint comes from the wallpaper, which is what + * makes it feel like part of the device rather than part of an app. + * + * Because hue is the user's wallpaper's to choose, state is *also* carried by shape, icon, label + * and motion — see [StatusIndicator]. Colour alone is never the signal. + */ +@Composable +fun StatusHeader( + state: FrameworkState, + version: String?, + apiVersion: Int?, + hasUpdate: Boolean, + onOpenUpdate: () -> Unit, + ambience: AmbienceKind, + /** Whether the badge should still be showing that it opens something. See [StatusIndicator]. */ + hintStatus: Boolean, + onOpenStatus: () -> Unit, + onOpenAppearance: () -> Unit, + onOpenLanguage: () -> Unit, + onBrandTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + + val container by + animateColorAsState( + when (state) { + FrameworkState.Active -> colors.primaryContainer + FrameworkState.Degraded -> colors.tertiaryContainer + FrameworkState.Inactive -> colors.errorContainer + FrameworkState.Mismatched -> colors.errorContainer + FrameworkState.Checking -> colors.surfaceContainer + }, + animationSpec = tween(420), + label = "headerContainer", + ) + val onContainer by + animateColorAsState( + when (state) { + FrameworkState.Active -> colors.onPrimaryContainer + FrameworkState.Degraded -> colors.onTertiaryContainer + FrameworkState.Inactive -> colors.onErrorContainer + FrameworkState.Mismatched -> colors.onErrorContainer + FrameworkState.Checking -> colors.onSurfaceVariant + }, + animationSpec = tween(420), + label = "headerOnContainer", + ) + + // The brand rides with the state, so the two read as one sentence: *Vector — Active*. The name + // is set lighter than the state, so the eye still lands on the word that changes. + val stateWord = + stringResource( + when (state) { + FrameworkState.Active -> R.string.status_active + FrameworkState.Degraded -> R.string.status_degraded + FrameworkState.Inactive -> R.string.status_inactive + FrameworkState.Mismatched -> R.string.status_mismatched + FrameworkState.Checking -> R.string.status_checking + } + ) + val brand = stringResource(R.string.app_name) + + Box( + modifier = + modifier + .fillMaxWidth() + // Square at the top so it meets the screen edge, rounded at the bottom so it + // reads as one pane hanging from it. + .clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)) + .background( + // A shallow wash rather than a flat fill: enough depth that the pane has a + // top and a bottom, far short of a decorative gradient. + Brush.verticalGradient( + listOf(container, container.copy(alpha = 0.82f).compositeOverSurface()) + ) + ) + ) { + // matchParentSize, NOT fillMaxSize: a Box child that fills its maximum constraint drags + // the Box to full height with it, and the header would swallow the whole screen. + // matchParentSize sizes to whatever the *content* settled on without influencing it. + AmbientSurface( + kind = ambience, + tint = onContainer, + modifier = Modifier.matchParentSize(), + ) + + Column( + modifier = + Modifier.windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 20.dp, end = 6.dp, top = 6.dp, bottom = 20.dp) + ) { + // The ambient surface gets the upper part of the pane to itself; the status settles at + // the bottom, where it sits on the surface rather than floating above a gap. + Spacer(Modifier.height(66.dp)) + + Row(verticalAlignment = Alignment.Top) { + // The indicator is the details button. It is the thing the user is already + // looking at when they wonder *why* it says what it says, so it should be the + // thing that answers — a separate chevron was a second control for one intent. + // + // Centred on the *headline* rather than on the whole block: against the block it + // lands level with the gap between "Vector Active" and the version line and reads + // as belonging to neither, while against the headline it sits square with the word + // it is the state of. + Box( + modifier = Modifier.height(HEADLINE_ROW), + contentAlignment = Alignment.Center, + ) { + StatusIndicator( + state = state, + tint = onContainer, + hint = hintStatus, + onClick = onOpenStatus, + contentDescription = stringResource(R.string.status_open_details), + ) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + // The buttons live *inside* the headline row rather than beside the whole + // block, so the stack and the state word share a centre line by construction + // and the eye reads one row rather than three loose objects. + Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.Bottom, + ) { + // The wordmark is its own target, because something is hidden behind + // it. + Text( + text = brand, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Normal, + color = onContainer.copy(alpha = 0.62f), + modifier = + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onBrandTap, + ), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = stateWord, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = onContainer, + ) + } + // Neither is a gear. What they open governs how the app *presents* + // itself — its colours and its language — rather than what it does, and + // the icons should say so. Stacked because they belong together. + // Deliberately tighter than a default icon button: the pair sets the + // height of the row it shares with the wordmark, and at the standard 48 dp + // each it would push the version line a finger's width from the name it + // belongs to. + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton( + onClick = onOpenAppearance, + modifier = Modifier.size(ICON_BUTTON), + ) { + Icon( + Icons.Rounded.Palette, + contentDescription = stringResource(R.string.appearance_title), + tint = onContainer, + modifier = Modifier.size(21.dp), + ) + } + IconButton( + onClick = onOpenLanguage, + modifier = Modifier.size(ICON_BUTTON), + ) { + Icon( + Icons.Rounded.Language, + contentDescription = stringResource(R.string.language_title), + tint = onContainer, + modifier = Modifier.size(21.dp), + ) + } + } + } + val detail = + buildList { + version?.let { add(it) } + apiVersion?.let { add("API $it") } + } + .joinToString(" · ") + if (detail.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + // The version line becomes the way in to the update, because it is the + // thing the mark is attached to: a reader who has noticed that their + // version is marked has already looked at exactly the right words. + UpdatableVersion( + text = detail, + hasUpdate = hasUpdate, + color = onContainer.copy(alpha = 0.75f), + markColor = onContainer, + // Tappable whether or not there is an update. Checking on demand is + // a thing people do, and a control that only exists once there is news + // cannot be found before there is any — so the answer "you are up to + // date" would have been the one answer unreachable from here. + modifier = + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onOpenUpdate, + ), + ) + } + } + } + } + } +} + +/** + * The indicator. Its corner radius animates between three values — a rounded square when active, a + * softer form when degraded, a circle when inactive — so a state change is legible as motion + * rather than as a colour swap alone. + * + * When active it breathes: a slow, low-amplitude pulse that reads as "running" at a glance, and + * stops dead in the other two states, so stillness itself carries meaning. + * + * It is also the door to the System status page — the settings for how to open Vector are behind it + * and nothing else leads there — and a tick does not look like a door. So while [hint] is set the + * tick turns into a gear for ten seconds every thirty, which is the one symbol everybody already + * reads as "there are settings here", and turns back. See #856. + * + * Only the tick. The other three states are being *reported*, urgently in two of them, and a badge + * that wanders off into a gear while it is saying the framework is not running would be trading the + * message for the hint. + */ +@Composable +private fun StatusIndicator( + state: FrameworkState, + tint: Color, + hint: Boolean, + onClick: () -> Unit, + contentDescription: String, +) { + val corner by + animateFloatAsState( + when (state) { + FrameworkState.Active -> 34f + FrameworkState.Degraded -> 42f + else -> 50f + }, + animationSpec = tween(420), + label = "indicatorCorner", + ) + + val breathing = rememberInfiniteTransition(label = "indicatorBreath") + val pulse by + breathing.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable(tween(1900), RepeatMode.Reverse), + label = "indicatorPulse", + ) + + val icon = + when (state) { + FrameworkState.Active -> Icons.Rounded.Check + FrameworkState.Degraded -> Icons.Rounded.PriorityHigh + FrameworkState.Inactive -> Icons.Rounded.Close + // Not a Close: the framework is there. It is this manager that cannot reach it. + FrameworkState.Mismatched -> Icons.Rounded.PriorityHigh + FrameworkState.Checking -> null + } + + val hinting = hint && state == FrameworkState.Active + var asGear by remember { mutableStateOf(false) } + // An Animatable rather than a target the composition sets, because a turn has to be able to + // *stop where it is*: the wheel that has just spun a full turn and drawn a still one next must + // hold that angle, and an animation driven from a remembered target would spring back to it. + val spin = remember { Animatable(0f) } + + LaunchedEffect(hinting) { + // Cancelled and restarted whenever the framework leaves or re-enters Active, which is also + // what puts the badge back to a tick mid-hint rather than leaving a gear over a red header. + if (!hinting) { + asGear = false + return@LaunchedEffect + } + while (true) { + delay(HINT_PERIOD_MS) + asGear = true + repeat(HINT_TURNS) { + // A coin per turn rather than a steady spin. A gear that simply rotates for ten + // seconds is decoration and the eye files it away as such by the second cycle; one + // that turns, stops, thinks and turns again reads as something being *operated*, + // and it is the stopping that makes the next turn worth looking at. + if (Random.nextBoolean()) { + spin.animateTo( + spin.value + FULL_TURN, + animationSpec = tween(HINT_TURN_MS, easing = LinearEasing), + ) + } else { + delay(HINT_TURN_MS.toLong()) + } + } + asGear = false + // Wound back into a single turn between hints, so an app left open for an afternoon + // does not accumulate an angle large enough to lose its own fraction. + spin.snapTo(spin.value.mod(FULL_TURN)) + } + } + + // One number for the whole tick-to-gear swap: what fades, what shrinks, what turns into what. + // Timed like the header's colour and corner transitions, since it is the same badge changing. + val morph by + animateFloatAsState(if (asGear) 1f else 0f, tween(MORPH_MS), label = "indicatorMorph") + + Box( + modifier = + Modifier.size(52.dp) + .scale(if (state == FrameworkState.Active) pulse else 1f) + .clip(RoundedCornerShape(percent = corner.toInt())) + // Lifted while the gear is out. The badge is asking to be pressed at that moment, + // and a fill a shade stronger is how every other control on the screen says so. + .background(tint.copy(alpha = lerp(RESTING_FILL, HINTING_FILL, morph))) + .clickable(onClick = onClick) + .semantics { this.contentDescription = contentDescription }, + contentAlignment = Alignment.Center, + ) { + // Both glyphs are laid out; `morph` decides which is visible. The transforms live in a + // `graphicsLayer` block, which re-runs in the draw phase when the state it reads changes, + // so the spin never invalidates the composition — which matters most for `spin`, whose + // value moves on every frame of a turn. + if (icon != null) { + // The label beside it already names the state, and the box carries the description, + // so the glyph must not be announced a third time. + Icon( + icon, + contentDescription = null, + tint = tint, + modifier = + Modifier.size(26.dp).graphicsLayer { + alpha = 1f - morph + // Away rather than out: the tick shrinks and twists as the gear arrives + // over it, so the two read as one object changing rather than two swapped. + // The twist is the departing tick's alone, deliberately — a turn of the + // *gear* means a coin came up heads, and nothing else may spend one. + val leaving = lerp(1f, 0.6f, morph) + scaleX = leaving + scaleY = leaving + rotationZ = -MORPH_TURN * morph + }, + ) + } + if (state == FrameworkState.Active) { + Icon( + Icons.Rounded.Settings, + contentDescription = null, + tint = tint, + modifier = + Modifier.size(26.dp).graphicsLayer { + alpha = morph + val arriving = lerp(0.6f, 1f, morph) + scaleX = arriving + scaleY = arriving + // `spin` and nothing else. The wheel arrives and leaves at exactly the + // angle it is resting at, so every degree it ever turns through was asked + // for by a coin — which is the whole point of tossing one. It used to pick + // up the tick's twist on the way in and give it back on the way out, and + // that made the gear turn on every hint whatever the coins said. + rotationZ = spin.value + }, + ) + } + } +} + +/** Keeps the gradient's lower stop opaque; a translucent stop would show the list scrolling under. */ +@Composable +private fun Color.compositeOverSurface(): Color { + val surface = MaterialTheme.colorScheme.surface + return Color( + red = red * alpha + surface.red * (1 - alpha), + green = green * alpha + surface.green * (1 - alpha), + blue = blue * alpha + surface.blue * (1 - alpha), + alpha = 1f, + ) +} + +/** One of the two stacked buttons beside the wordmark. */ +private val ICON_BUTTON = 38.dp + +// --- the badge's gear hint ------------------------------------------------------------------ +// Long enough apart that the header is a still object most of the time — this sits above whatever +// the reader came to Home to read — and long enough at a time to be noticed by someone who was +// looking elsewhere when it began. + +/** How long the badge rests as a tick between hints. */ +private const val HINT_PERIOD_MS = 30_000L + +/** Each hint is [HINT_TURNS] of these, so ten seconds as a gear. */ +private const val HINT_TURN_MS = 2_000 + +private const val HINT_TURNS = 5 + +private const val FULL_TURN = 360f + +/** The tick-to-gear cross-dissolve, timed like the header's colour and corner transitions. */ +private const val MORPH_MS = 420 + +/** + * How far the *tick* turns as it hands over, in degrees. Enough to read as a twist. + * + * Not applied to the gear. See the two `graphicsLayer` blocks: the gear's only source of rotation + * is the coin, so a hint whose five tosses all come up tails shows a wheel that never moves. + */ +private const val MORPH_TURN = 60f + +/** The badge's fill against the header, at rest and while it is asking to be pressed. */ +private const val RESTING_FILL = 0.15f + +private const val HINTING_FILL = 0.24f + +/** + * The height of the row the wordmark shares with those buttons. + * + * Derived rather than guessed, because the status indicator is centred against it: the stack is + * what makes that row taller than its text, so if the buttons change size the indicator has to + * follow or it stops lining up with the word it belongs to. + */ +private val HEADLINE_ROW = ICON_BUTTON * 2 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TakePart.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TakePart.kt new file mode 100644 index 000000000..31a789c9b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TakePart.kt @@ -0,0 +1,135 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.RateReview +import androidx.compose.material.icons.rounded.Science +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.github.GitHubRepository + +/** + * Where the page turns a reader into a participant. + * + * Four doors into the project, none of which needs an account to walk through: pull requests to + * review, discussions to join, a canary build to test, and an issue to report. The first two open + * GitHub in the built-in viewer; the last two open screens of their own. + * + * The canary door is the one that matters most for a project like this. Testing a CI build needs + * no account, no Git and no code, so it is the lowest-friction way for an ordinary user to help — + * and it is what actually catches regressions on the long tail of devices and ROMs before they + * reach a release. + */ +@Composable +fun TakePartSection( + modifier: Modifier = Modifier, + onOpen: (String) -> Unit, + onCanary: () -> Unit, + onReport: () -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.home_contribute), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(10.dp)) + // IntrinsicSize.Min, so the two doors in a row settle on the height of the taller one and + // each can then fill it. Without it a card is only as tall as its own label, and a language + // where one label wraps and its neighbour does not — "Relire une modification" beside + // "Discussions" — leaves a short card floating in a tall row. + // + // Deliberately not a fixed two lines: that would pay for the worst case in every language, + // and English, where all four fit on one line, would carry a blank line in each card. It + // also only postpones the problem to the first label that needs three. + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Door( + Icons.Rounded.RateReview, + stringResource(R.string.home_review_prs), + Modifier.weight(1f).fillMaxHeight(), + ) { + onOpen(GitHubRepository.PULLS_URL) + } + Door( + Icons.Rounded.Forum, + stringResource(R.string.home_discussions), + Modifier.weight(1f).fillMaxHeight(), + ) { + onOpen(GitHubRepository.DISCUSSIONS_URL) + } + } + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // A screen rather than a link to the Actions page, which shows an anonymous visitor + // that a build exists and then refuses to hand it over. The screen lists the builds + // without a sign-in. + Door( + Icons.Rounded.Science, + stringResource(R.string.home_test_canary), + Modifier.weight(1f).fillMaxHeight(), + onClick = onCanary, + ) + // Also a screen rather than a link. The maintainer's own first reply to a bug report + // is a checklist, and a screen can do most of it instead of describing it. + Door( + Icons.Rounded.BugReport, + stringResource(R.string.home_open_issue), + Modifier.weight(1f).fillMaxHeight(), + onClick = onReport, + ) + } + } +} + +@Composable +private fun Door( + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + OutlinedCard(onClick = onClick, modifier = modifier) { + // fillMaxHeight so the content is centred in whatever height the row settled on, rather + // than sitting at the top of a card that was stretched to match its neighbour. + Row( + modifier = Modifier.fillMaxHeight().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text(text = label, style = MaterialTheme.typography.labelLarge) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TimeText.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TimeText.kt new file mode 100644 index 000000000..3103230ee --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TimeText.kt @@ -0,0 +1,98 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import java.util.Locale +import org.matrix.vector.manager.ui.theme.currentLocale + +/** + * Compact counts for the project footer: 11905 becomes "11.9k". + * + * The locale is passed in rather than read from `Locale.getDefault()`, which is the *process* + * default and stays the host app's: a reader on a French phone who has set the app to English would + * otherwise be shown "11,9k". + */ +fun compactCount(value: Int, locale: Locale): String = + when { + value < 1_000 -> value.toString() + value < 1_000_000 -> String.format(locale, "%.1fk", value / 1000f) + else -> String.format(locale, "%.1fM", value / 1_000_000f) + } + +/** + * The precise moment a commit landed, in the device's locale and 12/24-hour preference. + * + * The timeline already carries *approximate* time structurally — the rail's length is the elapsed + * gap, and the month separators give the coarse position. So the text is free to be exact, which + * is what someone comparing a commit against their own build actually needs. A relative label + * would duplicate what the rail already says, less precisely. + */ +@Composable +fun exactTime(epochSeconds: Long): String { + val context = LocalContext.current + val locale = currentLocale() + // Built once per language rather than once per row. Formatting in place would cost two + // Calendars, a time format, an ICU pattern lookup and a SimpleDateFormat for *every commit on + // screen, on every recomposition* — invisible on a feed of a hundred, not on one that holds + // thousands and re-lays itself out whenever the author filter changes. + val formats = remember(context, locale) { TimeFormats(context, locale) } + return remember(formats, epochSeconds) { formats.format(epochSeconds) } +} + +/** + * The date and time formatters for one language, plus the two boundaries they are chosen by. + * + * "Today" and "this year" are captured when this is built, not read per row. The cost of that is a + * session left open across midnight showing a bare time for yesterday's newest commit until + * something rebuilds this; the benefit is that formatting a row is a lookup and a format call + * rather than two Calendar instantiations. + */ +private class TimeFormats(context: android.content.Context, private val locale: Locale) { + private val timeFormat = android.text.format.DateFormat.getTimeFormat(context) + private val thisYear = pattern("MMMd") + private val otherYear = pattern("yMMMd") + + private val startOfToday: Long + private val startOfNextDay: Long + private val startOfYear: Long + private val startOfNextYear: Long + + init { + val cal = java.util.Calendar.getInstance(locale) + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + startOfToday = cal.timeInMillis + cal.add(java.util.Calendar.DAY_OF_YEAR, 1) + startOfNextDay = cal.timeInMillis + cal.timeInMillis = startOfToday + cal.set(java.util.Calendar.DAY_OF_YEAR, 1) + startOfYear = cal.timeInMillis + cal.add(java.util.Calendar.YEAR, 1) + startOfNextYear = cal.timeInMillis + } + + fun format(epochSeconds: Long): String { + val millis = epochSeconds * 1000 + val date = java.util.Date(millis) + val time = timeFormat.format(date) + if (millis in startOfToday until startOfNextDay) return time + val day = + if (millis in startOfYear until startOfNextYear) thisYear.format(date) + else otherYear.format(date) + return "$day $time" + } + + // Not DateUtils: its formatting runs through `Locale.getDefault()` regardless of the context + // handed to it, so the month abbreviation would stay in the phone's language while everything + // around it followed the app's. Asking for the best pattern for a locale and formatting with + // it keeps the same shape — abbreviated month, year only when it is not this one — and honours + // the choice. + private fun pattern(skeleton: String) = + java.text.SimpleDateFormat( + android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton), + locale, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/UpdateMark.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/UpdateMark.kt new file mode 100644 index 000000000..ff82c3cba --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/UpdateMark.kt @@ -0,0 +1,104 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowCircleUp +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * A version number, marked when something newer exists. + * + * One treatment for the framework and for modules, applied to *the version text itself* rather than + * as a badge somewhere near it. A badge is a second object the reader has to associate with a first + * one, and each screen invents its own place to put it — which is how the same fact ends up looking + * like three different facts. Marking the number says it where it is already being read: this is + * the version you have, and it is not the newest. + * + * The mark is a shape and a colour, never colour alone: the header's tint follows the user's + * wallpaper under Material You, so a hue that reads as "attention" on one device is the resting + * colour on another. + * + * It breathes, slowly and shallowly. An update is not urgent — nothing is broken, and the reader + * may reasonably ignore it for weeks — so it must be findable at a glance without behaving like an + * alert. The motion stops dead when there is no update, which is what makes its presence mean + * something. + */ +@Composable +fun UpdatableVersion( + text: String, + hasUpdate: Boolean, + modifier: Modifier = Modifier, + /** Whether an over-long version scrolls past instead of being cut. */ + marquee: Boolean = false, + style: TextStyle = VectorMono, + color: Color = LocalContentColor.current, + markColor: Color = MaterialTheme.colorScheme.tertiary, +) { + if (text.isBlank()) return + + // Packed to the end. Callers that give this a fixed slot — the module row does, so that a long + // version cannot push the name — would otherwise leave it floating short of the edge every + // other element in the row is aligned to, which reads as a mistake rather than as a column. + // Where no width is imposed the row wraps its content and the arrangement costs nothing. + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasUpdate) { + val transition = rememberInfiniteTransition(label = "update mark") + val breath by + transition.animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = + infiniteRepeatable(tween(1_600), repeatMode = RepeatMode.Reverse), + label = "update breath", + ) + Icon( + Icons.Rounded.ArrowCircleUp, + contentDescription = null, + tint = markColor, + modifier = Modifier.size(14.dp).alpha(breath), + ) + Spacer(Modifier.width(5.dp)) + } + Text( + text = text, + style = style, + color = if (hasUpdate) markColor else color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // Marquee rather than an ellipsis when a version is longer than its column. A version + // string is not prose — `1.2.3-beta.4+a1b2c3d` truncated to `1.2.3-be…` has lost the + // part that distinguishes it from the build beside it — so the whole of it goes past + // once, on its own, and stops. + modifier = + if (marquee) Modifier.basicMarquee(iterations = 1, repeatDelayMillis = 2_000) + else Modifier, + ) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorAlertDialog.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorAlertDialog.kt new file mode 100644 index 000000000..06d59ad3d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorAlertDialog.kt @@ -0,0 +1,39 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.matrix.vector.manager.ui.theme.LocalizedOverlay + +/** + * Material's dialog, in the language the user chose. + * + * A dialog is its own window, and Compose gives every window a fresh set of Android composition + * locals taken from that window's context — which undoes the app's in-composition language + * override on the way in. A plain `AlertDialog` therefore speaks the *phone's* language while the + * screen behind it speaks the reader's. + * + * The override cannot be re-applied around the call, because the crossing happens inside it. It has + * to happen in each slot, which is exactly what this wrapper exists to not forget: every dialog in + * the app goes through here, so the fix cannot be omitted by writing a new one. + */ +@Composable +fun VectorAlertDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + modifier: Modifier = Modifier, + dismissButton: (@Composable () -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + text: (@Composable () -> Unit)? = null, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { LocalizedOverlay(confirmButton) }, + modifier = modifier, + dismissButton = dismissButton?.let { { LocalizedOverlay(it) } }, + icon = icon?.let { { LocalizedOverlay(it) } }, + title = title?.let { { LocalizedOverlay(it) } }, + text = text?.let { { LocalizedOverlay(it) } }, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorSnackbar.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorSnackbar.kt new file mode 100644 index 000000000..053d9bb4a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorSnackbar.kt @@ -0,0 +1,167 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarVisuals +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * What a message is telling you, which decides how it looks. + * + * A snackbar that says "force stopped" and a snackbar that says "could not uninstall" should not be + * the same object — the second one is a failure the user has to react to, and making both a plain + * grey bar means neither registers. + */ +enum class SnackbarTone { + /** Something happened. Nothing to react to. */ + Neutral, + /** It worked. */ + Success, + /** It is happening now and will take a while. */ + Working, + /** It did not work. */ + Failure, +} + +/** A message with a tone attached, carried through the standard [SnackbarHostState] channel. */ +class VectorSnackbarVisuals( + override val message: String, + val tone: SnackbarTone = SnackbarTone.Neutral, + override val duration: SnackbarDuration = + if (tone == SnackbarTone.Failure) SnackbarDuration.Long else SnackbarDuration.Short, +) : SnackbarVisuals { + override val actionLabel: String? = null + override val withDismissAction: Boolean = false +} + +/** Shows a toned message, replacing whatever is on screen. */ +suspend fun SnackbarHostState.show(message: String, tone: SnackbarTone = SnackbarTone.Neutral) { + currentSnackbarData?.dismiss() + showSnackbar(VectorSnackbarVisuals(message, tone)) +} + +/** + * The app's snackbar. + * + * Material's default is a dark slab with a hard 4dp corner: inverse-surface, so it is dark on a + * light theme and light on a dark one. That inversion is deliberate in the spec and wrong here — a + * message that is the opposite colour to everything around it reads as belonging to the system + * rather than to the app. + * + * So it sits on the app's own raised surface and earns its prominence from elevation and shape + * instead of from inversion, and leads with an icon so the outcome is legible before the sentence + * is read. + */ +@Composable +fun VectorSnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) { + SnackbarHost(hostState = hostState, modifier = modifier) { data -> + val visuals = data.visuals as? VectorSnackbarVisuals + val tone = visuals?.tone ?: SnackbarTone.Neutral + val colors = MaterialTheme.colorScheme + + val container = + when (tone) { + SnackbarTone.Failure -> colors.errorContainer + else -> colors.surfaceContainerHighest + } + val content = + when (tone) { + SnackbarTone.Failure -> colors.onErrorContainer + else -> colors.onSurface + } + val accent = + when (tone) { + SnackbarTone.Success -> colors.primary + SnackbarTone.Failure -> colors.error + else -> colors.primary + } + + // Lifted rather than inverted: it still reads as laid over the screen without being the + // opposite colour to it. + Snackbar( + modifier = Modifier.padding(horizontal = 12.dp).shadow(6.dp, RoundedCornerShape(20.dp)), + shape = RoundedCornerShape(20.dp), + containerColor = container, + contentColor = content, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier.size(28.dp) + .clip(CircleShape) + .background(accent.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + ToneIcon(tone = tone, tint = accent) + } + Spacer(Modifier.width(12.dp)) + Text(text = data.visuals.message, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun ToneIcon(tone: SnackbarTone, tint: Color) { + val icon: ImageVector = + when (tone) { + SnackbarTone.Success -> Icons.Rounded.CheckCircle + SnackbarTone.Failure -> Icons.Rounded.ErrorOutline + SnackbarTone.Working -> Icons.Rounded.Bolt + SnackbarTone.Neutral -> Icons.Rounded.Info + } + + if (tone == SnackbarTone.Working) { + // Work that takes ten seconds needs to look like it is still happening; a static icon on a + // message that says "optimizing" is indistinguishable from one that has stalled. + val spin = rememberInfiniteTransition(label = "working") + val angle by + spin.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(1600, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "workingAngle", + ) + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(17.dp).rotate(angle)) + } else { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(17.dp)) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt new file mode 100644 index 000000000..838e2435f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt @@ -0,0 +1,143 @@ +package org.matrix.vector.manager.ui.components.ambience + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import org.matrix.vector.manager.R + +/** + * What the status header's open space is doing. + * + * The header needs breathing room above the status line, and empty space that exists only because + * a layout needed it reads as a mistake. Giving it something to *be* — a surface that answers when + * touched — turns the same pixels from an accident into the most characterful part of the app. + * + * Every option must stay in the background's role: it draws in the header's own on-container + * colour at low alpha, never competes with the text over it, and never moves fast enough to pull + * the eye while someone is reading. [None] exists for people who want none of it, and skips the + * frame loop entirely rather than animating something invisible. + */ +enum class AmbienceKind(val key: String, val labelRes: Int) { + /** Snowfall. Tap a flake to burst it; tap empty space and one grows there. */ + Snow("snow", R.string.ambience_snow), + /** A carved maze with one wanderer in it. Tap to move it, swipe for a new maze. */ + Maze("maze", R.string.ambience_maze), + /** + * Signal traces carrying several pulses at once. Tap to fire one, swipe sideways to re-route + * the board, up and down to change how fast the signals run. + * + * Kept beside [Maze] rather than replaced by it because they are opposites and both are worth + * having: a circuit is a designed path many signals share, a maze is an undesigned one a single + * wanderer has to solve. + */ + Circuit("circuit", R.string.ambience_circuit), + /** Falling code. Hold to stop the rain and pick a glyph out of it; pinch to go deeper. */ + Matrix("matrix", R.string.ambience_matrix), + None("none", R.string.ambience_none); + + companion object { + fun from(key: String?): AmbienceKind = entries.firstOrNull { it.key == key } ?: Maze + } +} + +/** + * A self-contained little simulation. + * + * Deliberately mutable and frame-driven rather than built from Compose animations: these have + * dozens of independent particles with their own lifetimes, which `animate*AsState` models badly. + * The renderer owns its state, the header owns the clock. + */ +interface AmbienceRenderer { + /** [dt] is milliseconds since the previous frame. */ + fun update(dt: Float, size: Size) + + fun DrawScope.render(tint: Color) + + fun onTap(position: Offset, size: Size) + + /** A press held down; the surface may freeze, grab something, or both. */ + fun onLongPress(position: Offset, size: Size) {} + + /** The held press ended. */ + fun onRelease() {} + + /** + * A drag, reported as the movement since the previous event. + * + * The increment rather than the distance from where the finger went down, because the surface + * has no reliable notion of where that was: the transform detector reports no gesture boundary, + * so a remembered origin leaks from one drag into the next — the maze would rebuild on a nudge + * and the rain would hit its speed ceiling on the first flick. A renderer that wants a total + * accumulates one and decides for itself when it has seen enough. + */ + fun onDrag(pan: Offset, at: Offset, size: Size) {} + + /** + * How large this render draws itself, as a multiple of its resting size. + * + * Every ambience answers a pinch, and every one answers it the same way: a *scale*, not a + * camera. Zooming out gives more of the thing — finer drizzle, a bigger maze, more traces — + * and zooming in gives fewer and larger. None of these fields has the parallax cues that would + * sell a viewer moving through them, so a simulated camera reads as sliding rather than as + * approaching. + * + * The surface owns the number so it can be persisted; the renderer only has to honour it. + */ + var scale: Float + + /** + * How fast it moves, as a multiple of its resting speed. + * + * Only meaningful where there is continuous motion — the maze wanderer walks at the one pace + * that lets a decision be watched being made, so it ignores this. + */ + var speed: Float + get() = 1f + set(_) {} + + /** + * Which of the render's own variations it is drawing, cycled by a double tap. + * + * Only the code rain has any: its glyphs are half-width katakana, which is what makes the + * effect read as *code* rather than as prose — and is also a cultural reference not everyone + * wants on their home screen. Rather than argue about the default, the surface offers other + * alphabets and remembers which was chosen. + * + * An index rather than an enum because the surface persists it without knowing what any + * renderer's variations are, and a renderer is free to have none. + */ + var variant: Int + get() = 0 + set(_) {} + + /** A double tap. Distinct from [onTap], which seeds rather than switches. */ + fun onDoubleTap() {} + + /** + * Whether a double tap means anything here. + * + * Asked because listening for one is not free: a detector that must wait to see whether a + * second tap follows delays *every* single tap by the double-tap timeout. Only the code rain + * has variations, so only the code rain pays for them. + */ + val hasVariants: Boolean + get() = false + + /** + * False when nothing is moving, letting the header park the frame loop. + * + * A status header is on screen the whole time someone reads the activity feed, so an ambience + * with nothing to do should cost nothing. + */ + val isAnimating: Boolean +} + +fun rendererFor(kind: AmbienceKind): AmbienceRenderer? = + when (kind) { + AmbienceKind.Snow -> SnowRenderer() + AmbienceKind.Maze -> MazeRenderer() + AmbienceKind.Circuit -> CircuitRenderer() + AmbienceKind.Matrix -> MatrixRenderer() + AmbienceKind.None -> null + } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/AmbientSurface.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/AmbientSurface.kt new file mode 100644 index 000000000..f92c41bd6 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/AmbientSurface.kt @@ -0,0 +1,137 @@ +package org.matrix.vector.manager.ui.components.ambience + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.toSize +import kotlinx.coroutines.android.awaitFrame +import org.matrix.vector.manager.di.ServiceLocator + +/** + * The header's living background. + * + * Draws whichever [AmbienceRenderer] is selected and hands it taps. It sits *behind* the header's + * content, so the settings and details buttons above it keep working normally — only the open + * space responds. + * + * The frame loop parks itself on a single frame per wake-up whenever the renderer reports nothing + * moving, and [AmbienceKind.None] skips the loop entirely. A status header is on screen for as long + * as someone reads the activity feed, so an idle animation is not free. + */ +@Composable +fun AmbientSurface(kind: AmbienceKind, tint: Color, modifier: Modifier = Modifier) { + val settings = ServiceLocator.settings + // Restored before the first frame, so the header comes back the size it was left rather than + // snapping from the default once the setting loads. + val renderer = + remember(kind) { + rendererFor(kind)?.apply { + scale = settings.ambienceScale(kind.key) + speed = settings.ambienceSpeed(kind.key) + variant = settings.ambienceVariant(kind.key) + } + } ?: return + val haptics = LocalHapticFeedback.current + + // Bumped every frame purely to invalidate the Canvas; the renderer owns the real state. + var frame by remember(kind) { mutableFloatStateOf(0f) } + var canvasSize by remember { mutableStateOf(Size.Zero) } + + // Drives the simulation. Suspends while nothing is moving rather than spinning on frames that + // would draw an identical picture. + androidx.compose.runtime.LaunchedEffect(kind) { + var last = 0L + while (true) { + if (!renderer.isAnimating) { + // Cheap park: one frame per wake-up until something starts moving again. + awaitFrame() + last = 0L + continue + } + withFrameNanos { now -> + val dt = if (last == 0L) 16f else (now - last) / 1_000_000f + last = now + renderer.update(dt.coerceAtMost(64f), canvasSize) + frame += 1f + } + } + } + + val measurer = rememberTextMeasurer() + // The matrix renderer draws text, which a DrawScope cannot measure on its own. + (renderer as? MatrixRenderer)?.textMeasurer = measurer + + Canvas( + modifier = + modifier + // Purely decorative, and it sits behind labelled controls — announcing it would + // add noise to every pass over the header. + .clearAndSetSemantics {} + .pointerInput(kind) { + detectTapGestures( + // Only where it means something: passing a handler makes every single tap + // wait for the double-tap timeout before it fires. + onDoubleTap = + if (!renderer.hasVariants) null + else { + { + renderer.onDoubleTap() + settings.setAmbienceVariant(kind.key, renderer.variant) + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + } + }, + onTap = { offset -> + renderer.onTap(offset, size.toSize()) + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + }, + onLongPress = { offset -> + renderer.onLongPress(offset, size.toSize()) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + // A long press is only held while the finger is down, so the release has + // to come from the press handler rather than from onLongPress returning. + onPress = { + tryAwaitRelease() + renderer.onRelease() + }, + ) + } + .pointerInput(kind) { + // Drag and pinch, in one pass so they cannot fight each other. Taps are + // handled above; this gesture detector deliberately ignores them. + detectTransformGestures(panZoomLock = false) { centroid, pan, gestureZoom, _ -> + if (gestureZoom != 1f) { + // The renderer clamps to its own range, so what is stored is what it + // settled on rather than what the fingers asked for. + renderer.scale *= gestureZoom + settings.setAmbienceScale(kind.key, renderer.scale) + } + if (pan != Offset.Zero) { + renderer.onDrag(pan, centroid, size.toSize()) + settings.setAmbienceSpeed(kind.key, renderer.speed) + } + } + } + ) { + canvasSize = size + // Read so Compose redraws when the frame counter advances. + @Suppress("UNUSED_EXPRESSION") frame + with(renderer) { render(tint) } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt new file mode 100644 index 000000000..d12a2fe93 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt @@ -0,0 +1,604 @@ +package org.matrix.vector.manager.ui.components.ambience + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.random.Random + +/** + * Signal traces. + * + * The most on-theme of the set: this app manages a framework that injects code into running + * processes, and the header quietly draws the picture of that — faint traces, and a touch that + * sends a **pulse travelling down the nearest one**, lighting the trace ahead of it and leaving it + * dark behind. + * + * The board is *routed* rather than drawn. Traces run along the lanes of a lattice and step between + * neighbouring lanes at its columns, and every run they occupy is claimed, so no two are ever drawn + * along the same line — which is the whole difference between a board and a plate of spaghetti. + * Crossings are welcome, though: a trace that meets copper already laid steps aside and carries on + * over it, the way a board uses its layers, and every trace is routed through to the right-hand + * side rather than being allowed to peter out somewhere in the middle. Corners are mitred, because + * a right angle is the one thing a real router never leaves behind: copper turns through two 45° + * elbows. + * + * The signal itself is a swell of light travelling along the copper: widest and brightest under its + * head and drawn down to nothing behind, breathing a little as it goes — the one thing on the board + * that is not still. + * + * Swipe sideways to route a fresh board, up and down to change how fast the signals run. + */ +class CircuitRenderer : AmbienceRenderer { + + private companion object { + const val MIN_SCALE = 0.4f + const val MAX_SCALE = 3f + + const val MIN_SPEED = 0.2f + const val MAX_SPEED = 5f + + /** Lanes and columns of the routing lattice at rest. */ + const val BASE_LANES = 10 + const val BASE_COLUMNS = 12 + + /** How often a trace steps to a neighbouring lane at a column it passes. */ + const val JOG_CHANCE = 0.42f + + /** How far a trace may drift from its own lane before it is pulled back, and how firmly. */ + const val HOME_DRIFT = 2 + const val HOME_PULL = 0.75f + + /** How often a trace comes in from the left edge rather than starting on a land. */ + const val EDGE_START_CHANCE = 0.6f + + /** How often a trace stops on a land short of the right edge rather than running off it. */ + const val TERMINATE_CHANCE = 0.3f + + /** How far aside a blocked trace will look for a free lane before it gives up. */ + const val SIDESTEP_REACH = 3 + + /** How much of the width a pulse covers in a second, before [speed]. */ + const val PULSE_SPEED = 0.3f + + /** + * The body of light behind a signal: how long it is as a fraction of the width, how many + * samples the ribbon is built from, how wide it is under the head as a multiple of the + * trace's own stroke, and how much that width breathes as it travels. + */ + const val TAIL_LENGTH = 0.11f + const val TAIL_STEPS = 20 + const val TAIL_WIDTH = 5.2f + const val TAIL_SWELL = 0.12f + + /** Average gap between unprompted pulses. */ + const val PULSE_INTERVAL_MS = 5_000f + + /** How long a board lasts before it re-routes itself. */ + const val ROUTE_INTERVAL_MS = 60_000f + + /** How much of the width a swipe must cover before the board is re-routed. */ + const val REROUTE_FRACTION = 0.25f + } + + /** A via, sitting on the mitred elbow where a trace changes lane. */ + private class Pad(val center: Offset, val distance: Float) + + /** A routed polyline, plus its cumulative lengths for pulse travel. */ + private class Trace(val points: List) { + val lengths: List = points.zipWithNext { a, b -> hypot(b.x - a.x, b.y - a.y) } + val total: Float = lengths.sum().coerceAtLeast(1f) + + /** + * The elbows, found rather than recorded. + * + * Every segment of a routed trace is horizontal or vertical except the mitres, so a segment + * that is neither is an elbow, and its middle is where the via goes. + */ + val pads: List = buildList { + var travelled = 0f + for (i in lengths.indices) { + val a = points[i] + val b = points[i + 1] + if (abs(b.x - a.x) > 0.5f && abs(b.y - a.y) > 0.5f) { + add( + Pad( + Offset((a.x + b.x) / 2f, (a.y + b.y) / 2f), + travelled + lengths[i] / 2f, + ) + ) + } + travelled += lengths[i] + } + } + + /** Where a pulse sits after travelling [distance] along the trace. */ + fun pointAt(distance: Float): Offset { + var remaining = distance.coerceIn(0f, total) + for (i in lengths.indices) { + if (remaining <= lengths[i]) { + val t = if (lengths[i] == 0f) 0f else remaining / lengths[i] + val a = points[i] + val b = points[i + 1] + return Offset(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) + } + remaining -= lengths[i] + } + return points.last() + } + } + + /** [phase] is where this signal is in its breathing, so two of them never swell in step. */ + private class Pulse(val trace: Trace, var distance: Float, val phase: Float) { + /** How lit each via the pulse has crossed still is. */ + val litPads = mutableMapOf() + } + + private var traces: List = emptyList() + + /** + * How densely the board is laid out. + * + * Zooming out routes a finer lattice — more lanes, more columns, so more traces with shorter + * runs between turns, a busier board seen from further back. Zooming in gives a few wide traces + * with long straight stretches. The stroke follows it too, so a dense board does not turn into + * a grey wash. + */ + override var scale: Float = 1f + set(value) { + val next = value.coerceIn(MIN_SCALE, MAX_SCALE) + if (next == field) return + field = next + if (sized != Size.Zero) route(sized) + } + + /** + * How fast the signals run, on the same vertical drag the code rain and the snow use. + * + * The same gesture in the same place should mean the same thing whichever ambience is on. Here + * it is the difference between a board idling and a board under load — and the resting speed is + * deliberately unhurried, because this sits behind text somebody is reading. + */ + override var speed: Float = 1f + set(value) { + field = value.coerceIn(MIN_SPEED, MAX_SPEED) + } + + private val pulses = mutableListOf() + private var sized = Size.Zero + + /** + * The die the board rolls for itself, kept rather than made on the frame path. + * + * [route] seeds its own from [layoutSeed], because a board has to come out the same way twice; + * an unprompted pulse only has to be unpredictable. + */ + private val random = Random(0xC1AC17) + + /** Rises to 1 while a freshly routed board fades in after a swipe. */ + private var reveal = 1f + + /** Where the tails are in their swell. */ + private var wag = 0f + + /** + * The ribbon behind a signal, reused rather than built again every frame. + * + * A header redraws sixty times a second for as long as somebody is reading the activity feed, + * and a [Path] and a list per signal per frame is a steady drip of garbage for a background. + */ + private val tailPath = Path() + private val tailEdge = FloatArray((TAIL_STEPS + 1) * 2) + + /** Bumped on every re-route so the layout is genuinely different each time. */ + private var layoutSeed = 1 + + /** Counts down to the next unprompted pulse. */ + private var nextPulseMs = 2200f + + /** Counts down to the next unprompted re-route. */ + private var nextRouteMs = ROUTE_INTERVAL_MS + + override val isAnimating: Boolean + // The board runs itself rather than only reacting: a status header is mostly looked at + // rather than played with, and one that waits to be touched reads as dead. The wait for + // the next pulse is counted down in update(), which a parked frame loop stops calling. + get() = true + + private fun seed(size: Size) { + if (sized == size && traces.isNotEmpty()) return + sized = size + route(size) + } + + /** + * Lays a fresh board. Called on first draw and again on every swipe. + * + * One pass per lane, in a shuffled order so the long traces are not always the top ones. A + * trace walks rightwards along its lane, claiming each run as it goes and stepping to a + * neighbouring lane now and then, and it carries on until it has reached the right-hand side. + * Copper already laid is routed *around* rather than run over: a claimed run makes the trace + * step aside, which is what keeps two traces from ever being drawn along the same line. + */ + private fun route(size: Size) { + val random = Random(0xB0A2D + layoutSeed * 7919) + val lanes = (BASE_LANES / scale).roundToInt().coerceIn(5, 22) + val columns = (BASE_COLUMNS / scale).roundToInt().coerceIn(5, 30) + + val top = size.height * 0.12f + val laneGap = (size.height * 0.76f) / (lanes - 1) + val columnWidth = size.width / columns + // Both bounds matter: cutting more than half a lane apart turns the step into a pure + // diagonal ramp, and cutting more than a third of a run turns the straight stretch between + // two elbows into a chevron. + val cut = min(laneGap * 0.45f, columnWidth * 0.32f) + + // Which runs and which steps are already copper. Keyed rather than gridded because the + // lattice is small and this is read far more often than it is written. + val takenRuns = HashSet() + val takenSteps = HashSet() + + traces = buildList { + for (start in (0 until lanes).shuffled(random)) { + var lane = start + var column = + if (random.nextFloat() < EDGE_START_CHANCE) 0 + else random.nextInt(1, maxOf(2, columns / 2)) + // Every trace is routed to the right-hand side, because a trace that peters out in + // the middle of the header reads as one the router gave up on. Some run off the + // edge and some stop on a land just short of it, which is the difference between a + // trace leaving the board and one arriving somewhere. + val end = + if (random.nextFloat() < TERMINATE_CHANCE) columns - random.nextInt(1, 3) + else columns + if (end - column < 2) continue + + val corners = mutableListOf(column to lane) + while (column < end) { + if (lane * 64 + column in takenRuns) { + // Blocked by copper already laid: step aside rather than stop, nearest + // lane first. A run cannot be shared, but the step across other traces to + // reach a free one is fine — a board has layers, and a crossing reads as + // one of them. + var stepped = false + aside@ for (reach in 1..SIDESTEP_REACH) { + for (step in intArrayOf(reach, -reach)) { + val next = lane + step + if (next !in 0 until lanes) continue + if (next * 64 + column in takenRuns) continue + if (!claimStep(takenSteps, column, lane, next)) continue + corners += column to lane + lane = next + corners += column to lane + stepped = true + break@aside + } + } + if (!stepped) break + } + takenRuns.add(lane * 64 + column) + column++ + if (column >= end) break + if (random.nextFloat() >= JOG_CHANCE) continue + + // A trace that has wandered a couple of lanes from the one it started on + // prefers the step that takes it back, so the board keeps its horizontal + // grain rather than every trace cascading toward the same edge. Then the way + // the die fell, and then the other way: a trace boxed in below steps up + // rather than giving up on the turn. + val away = lane - start + val first = + if (abs(away) >= HOME_DRIFT && random.nextFloat() < HOME_PULL) { + if (away > 0) -1 else 1 + } else if (random.nextBoolean()) 1 else -1 + for (step in intArrayOf(first, -first)) { + val next = lane + step + if (next !in 0 until lanes) continue + if (next * 64 + column in takenRuns) continue + if (!claimStep(takenSteps, column, lane, next)) continue + corners += column to lane + lane = next + corners += column to lane + break + } + } + corners += column to lane + + val points = mutableListOf() + corners.forEach { (atColumn, atLane) -> + val point = Offset(atColumn * columnWidth, top + atLane * laneGap) + if (points.lastOrNull() != point) points += point + } + if (points.size > 1) add(Trace(mitre(points, cut))) + } + } + } + + /** + * Claims the vertical step between two lanes at one column, if it is free along its length. + * + * Two traces sharing a step would be drawn on top of each other, which is the one thing the + * lattice exists to prevent. Crossing another trace's *run* on the way is fine, and is where + * the board gets the crossings that stop it reading as a set of ruled lines. + */ + private fun claimStep(taken: HashSet, column: Int, from: Int, to: Int): Boolean { + val low = min(from, to) + val high = maxOf(from, to) + for (lane in low until high) if (column * 64 + lane in taken) return false + for (lane in low until high) taken.add(column * 64 + lane) + return true + } + + /** Replaces each right angle with the two 45° elbows a router would have left there. */ + private fun mitre(points: List, cut: Float): List { + if (points.size < 3) return points + val out = mutableListOf(points.first()) + for (i in 1 until points.size - 1) { + val before = points[i - 1] + val corner = points[i] + val after = points[i + 1] + val into = hypot(corner.x - before.x, corner.y - before.y) + val away = hypot(after.x - corner.x, after.y - corner.y) + if (into < 0.001f || away < 0.001f) continue + // Never past the middle of either leg, so two corners in a row cannot eat each other. + val back = min(cut, into * 0.45f) / into + val on = min(cut, away * 0.45f) / away + out += + Offset( + corner.x + (before.x - corner.x) * back, + corner.y + (before.y - corner.y) * back, + ) + out += + Offset(corner.x + (after.x - corner.x) * on, corner.y + (after.y - corner.y) * on) + } + out += points.last() + return out + } + + override fun update(dt: Float, size: Size) { + if (size.width <= 0f || size.height <= 0f) return + seed(size) + val seconds = dt / 1000f + + if (reveal < 1f) reveal = (reveal + dt / 420f).coerceAtMost(1f) + + // Carried rather than derived from a clock, so a change of speed bends the sway from where + // it is instead of jumping it to somewhere else in the swing. A signal under load flicks + // faster, but never in proportion — a tail that whipped would read as a fault. + wag += dt * (0.4f + 0.6f * speed) / 150f + + // The board works on its own. A signal every few seconds is what makes it read as a + // living circuit rather than a wallpaper that happens to respond to taps. + nextPulseMs -= dt + if (nextPulseMs <= 0f && traces.isNotEmpty()) { + nextPulseMs = PULSE_INTERVAL_MS * (0.65f + random.nextFloat() * 0.7f) + fire(traces.random(random), 0f) + } + + // And re-routes itself now and then, so the picture is never the same for long. + nextRouteMs -= dt + if (nextRouteMs <= 0f) { + nextRouteMs = ROUTE_INTERVAL_MS + reroute(size) + } + + // Read every frame rather than stored on the pulse, so a drag reaches the signals already + // in flight instead of only the next one. + val travel = size.width * PULSE_SPEED * speed * seconds + pulses.forEach { pulse -> + val before = pulse.distance + pulse.distance += travel + + // Light every via the pulse just crossed, so the board reacts to the signal passing + // rather than only showing the signal itself. + pulse.trace.pads.forEachIndexed { index, pad -> + if (pad.distance in before..pulse.distance) pulse.litPads[index] = 1f + } + pulse.litPads.keys.toList().forEach { key -> + val decayed = pulse.litPads.getValue(key) - dt / 700f + if (decayed <= 0f) pulse.litPads.remove(key) else pulse.litPads[key] = decayed + } + } + pulses.removeAll { it.distance > it.trace.total && it.litPads.isEmpty() } + } + + /** How much of the width a sideways drag has covered since the last re-route. */ + private var swipedX = 0f + + /** + * Sideways re-routes the board, up and down changes the speed. + * + * The traces are generated, not drawn by hand, so there is no reason the user should be stuck + * with the one they were given — and watching a new board lay itself out is half the appeal. + * + * Each axis reads only itself, the way the code rain does: a drag is almost never purely one or + * the other, so the vertical component is ignored while the finger is clearly travelling + * sideways, and a re-route never shoves the speed somewhere nobody asked for. + */ + override fun onDrag(pan: Offset, at: Offset, size: Size) { + if (size.width <= 0f || size.height <= 0f) return + + if (abs(pan.x) > abs(pan.y) * 1.5f) { + swipedX += pan.x / size.width + if (abs(swipedX) >= REROUTE_FRACTION) { + swipedX = 0f + reroute(size) + nextRouteMs = ROUTE_INTERVAL_MS + } + return + } + + val delta = pan.y / size.height + if (abs(delta) < 0.0005f) return + speed *= 1f + delta * 1.6f + } + + private fun reroute(size: Size) { + layoutSeed++ + pulses.clear() + route(size) + reveal = 0f + } + + private fun fire(trace: Trace, start: Float) { + if (pulses.size >= 6) pulses.removeAt(0) + pulses += Pulse(trace, start, random.nextFloat() * 2f * PI.toFloat()) + } + + override fun onTap(position: Offset, size: Size) { + seed(size) + // The trace whose route passes closest to the finger is the one that carries the signal. + val nearest = + traces.minByOrNull { trace -> + trace.points.minOf { hypot(it.x - position.x, it.y - position.y) } + } ?: return + + // Start the pulse level with the touch rather than at the board edge, so the tap feels + // like the source of the signal. + var travelled = 0f + var start = 0f + var closest = Float.MAX_VALUE + nearest.lengths.forEachIndexed { index, length -> + val a = nearest.points[index] + val b = nearest.points[index + 1] + val mid = Offset((a.x + b.x) / 2f, (a.y + b.y) / 2f) + val distance = hypot(mid.x - position.x, mid.y - position.y) + if (distance < closest) { + closest = distance + start = travelled + } + travelled += length + } + + fire(nearest, start) + } + + override fun DrawScope.render(tint: Color) { + val width = size.height * 0.005f * scale.coerceAtMost(1.8f) + + traces.forEachIndexed { traceIndex, trace -> + // On a fresh board the traces draw themselves in left to right, one slightly after + // the next, so a re-route looks like a board being laid rather than a hard cut. + val stagger = (reveal * traces.size - traceIndex).coerceIn(0f, 1f) + if (stagger <= 0f) return@forEachIndexed + + var drawn = 0f + val target = trace.total * stagger + trace.points.zipWithNext { a, b -> + val segment = hypot(b.x - a.x, b.y - a.y) + if (drawn >= target) return@zipWithNext + val fraction = ((target - drawn) / segment).coerceIn(0f, 1f) + drawLine( + // The board is the picture rather than a texture behind one, and much below + // this it disappears against a light wallpaper. + tint.copy(alpha = 0.13f), + a, + Offset(a.x + (b.x - a.x) * fraction, a.y + (b.y - a.y) * fraction), + strokeWidth = width, + ) + drawn += segment + } + + if (stagger < 1f) return@forEachIndexed + + // The lands where a trace starts and ends: drawn as rings, so a trace that stops short + // of the edge reads as having arrived somewhere rather than as unfinished. + val land = Stroke(width = width * 0.9f) + drawCircle(tint.copy(alpha = 0.20f), width * 2.6f, trace.points.first(), style = land) + drawCircle(tint.copy(alpha = 0.20f), width * 2.6f, trace.points.last(), style = land) + + // Vias, on the elbows. One the signal just crossed flares. + trace.pads.forEachIndexed { index, pad -> + // Scanned rather than filtered: this runs for every via on every frame, and + // there are never more than a handful of pulses to look through. + var lit = 0f + pulses.forEach { pulse -> + if (pulse.trace === trace) { + lit = maxOf(lit, pulse.litPads[index] ?: 0f) + } + } + drawCircle( + color = tint.copy(alpha = 0.17f + 0.45f * lit), + radius = width * (1.6f + 2.4f * lit), + center = pad.center, + ) + } + } + + pulses.forEach { pulse -> + if (pulse.distance > pulse.trace.total) return@forEach + + // A signal is a swell of current, so it is drawn as one: a body of light along the + // trace, at its widest and brightest under the head and drawn down to nothing behind. + // + // As one tapering ribbon rather than a chain of segments, because a chain beads + // visibly the moment its stroke is wider than the step between samples, and a row of + // beads reads as something being towed. The thickness breathes a little as the signal + // travels, which is what a pulse does and what the swing this once spent on a sideways + // sway is better spent on. + val tail = size.width * TAIL_LENGTH + val swell = 1f + TAIL_SWELL * sin(wag + pulse.phase) + var samples = 0 + tailPath.reset() + for (i in 0..TAIL_STEPS) { + val along = i / TAIL_STEPS.toFloat() + val behind = pulse.distance - tail * along + if (behind < 0f) break + val point = pulse.trace.pointAt(behind) + // The heading, taken a little further along, so the ribbon lies across the trace + // and follows it round the elbows rather than cutting the corner. + val ahead = pulse.trace.pointAt(behind + width) + val dx = ahead.x - point.x + val dy = ahead.y - point.y + val length = hypot(dx, dy).coerceAtLeast(0.001f) + val half = width * TAIL_WIDTH * swell * (1f - along) * (1f - along) / 2f + val acrossX = -dy / length * half + val acrossY = dx / length * half + if (samples == 0) tailPath.moveTo(point.x + acrossX, point.y + acrossY) + else tailPath.lineTo(point.x + acrossX, point.y + acrossY) + // The far edge is kept until the near one has been walked, so the outline closes + // in one pass and the ribbon costs nothing but the two arrays it is written into. + tailEdge[samples * 2] = point.x - acrossX + tailEdge[samples * 2 + 1] = point.y - acrossY + samples++ + } + + val head = pulse.trace.pointAt(pulse.distance) + if (samples > 1) { + for (i in samples - 1 downTo 0) { + tailPath.lineTo(tailEdge[i * 2], tailEdge[i * 2 + 1]) + } + tailPath.close() + drawPath( + tailPath, + Brush.linearGradient( + 0f to tint.copy(alpha = 0.85f), + 0.4f to tint.copy(alpha = 0.38f), + 1f to tint.copy(alpha = 0f), + start = head, + end = pulse.trace.pointAt((pulse.distance - tail).coerceAtLeast(0f)), + ), + ) + } + + // The head is the rounded end of that body rather than a ball towing it: its radius is + // half the ribbon's width, so the two meet flush. The glow behind it is a hint of one — + // enough to lift the head off the board, not enough to smudge it. + drawCircle(color = tint.copy(alpha = 0.10f), radius = width * 3.4f, center = head) + drawCircle( + color = tint.copy(alpha = 0.92f), + radius = width * TAIL_WIDTH / 2f, + center = head, + ) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MatrixRenderer.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MatrixRenderer.kt new file mode 100644 index 000000000..4203d8da9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MatrixRenderer.kt @@ -0,0 +1,425 @@ +package org.matrix.vector.manager.ui.components.ambience + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * Falling code. + * + * Columns of glyphs descending at their own speeds, each with a bright head and a tail that dims + * behind it, and glyphs that occasionally flip to something else mid-fall — the flicker is what + * makes it read as *code* rather than as text scrolling past. + * + * Three ways to interact, and the point of all of them is that the rain is normally too fast to + * read: + * - **Hold** and it stops dead. The whole field freezes so it can actually be read, and the glyph + * under your finger is lifted out of the column — drawn larger and brighter, held between the + * fingertip and the surface it came from. Let go and it drops back and the rain resumes. + * - **Pinch** to change the glyph size. Larger glyphs mean fewer, wider columns and a rain that can + * be read at a glance; smaller ones mean a dense fine drizzle. It is a scale, not a camera: a + * flat field of text has no parallax cues, so a simulated approach reads as the columns sliding + * sideways. + * - **Tap** to seed a new column at that point, so a bare stretch can be filled in. + */ +class MatrixRenderer : AmbienceRenderer { + + /** + * One falling column. + * + * [weight] is only variety — heavier columns fall a little faster and draw a little brighter, + * so the field does not read as a metronome. It is deliberately *not* a depth coordinate: the + * size of a glyph is one global number, so what a pinch changes is legibility rather than an + * imaginary camera position. + */ + private class Column( + /** Position across the field, 0 (left edge) to 1 (right edge). */ + val lane: Float, + var head: Float, + val weight: Float, + val length: Int, + val glyphs: MutableList, + ) + + private val random = Random(0x4D41) + private val columns = mutableListOf() + private var sized = Size.Zero + private var clock = 0f + + /** + * Glyph size, as a multiple of the resting size. + * + * Held in these terms because it is what the surface persists and what a pinch multiplies, but + * what it really sets is [across] — how many columns fit over the header's width — and the + * glyph size is worked back from that. The bounds are therefore the column bounds read the + * other way round: thirty across at the largest, a hundred and twenty at the smallest. See + * [MIN_SCALE]. + */ + override var scale: Float = 1f + set(value) { + field = value.coerceIn(MIN_SCALE, MAX_SCALE) + } + + /** + * How fast the rain falls, as a multiple of its resting speed. + * + * A vertical drag sets it, which is the one axis the rain itself already means: dragging down + * pushes it along, dragging up holds it back. Pinch was already spoken for by the glyph size, + * and the two are genuinely different questions — how much can I read at once, and how long do + * I get to read it. + */ + override var speed: Float = 1f + set(value) { + field = value.coerceIn(MIN_SPEED, MAX_SPEED) + } + + private var frozen = false + private var heldAt: Offset? = null + private var heldGlyph: Char? = null + /** Eases the freeze so the rain slows to a stop rather than snapping. */ + private var motion = 1f + + override val isAnimating: Boolean + // Still frozen? Then the only thing that could change is the held glyph's own pulse, and + // that is worth a frame. Otherwise the rain is always moving. + get() = true + + /** + * The alphabets a double tap cycles through. + * + * Half-width katakana is what the original used, and it is why the effect reads as *code* and + * not as prose: the glyphs are dense, unfamiliar and uniform in width. Unfamiliarity is doing + * the work — which is exactly why the other sets are chosen for the same property rather than + * for being Latin. Hexadecimal and the punctuation of a source file are both alphabets a reader + * does not parse into words at a glance, so they keep the effect while dropping the reference. + * + * Katakana stays first, and so stays the default: this offers a way out for someone who does + * not want it, which is not the same as deciding nobody should have it. + */ + private val alphabets: List> = + listOf( + buildList { + for (c in 'ア'..'ン') add(c) + for (c in '0'..'9') add(c) + addAll("VECTORXPOSED".toList()) + }, + // Hexadecimal, which is what a hex dump of anything looks like. + buildList { + for (c in '0'..'9') add(c) + for (c in 'A'..'F') add(c) + }, + // The punctuation that makes text look like source rather than like sentences. + "{}[]()<>/\\|;:=+-*&^%$#@!?~".toList(), + // Latin letters and digits, for a reader who wants none of the above. + buildList { + for (c in 'A'..'Z') add(c) + for (c in '0'..'9') add(c) + }, + ) + + override var variant: Int = 0 + set(value) { + val next = ((value % alphabets.size) + alphabets.size) % alphabets.size + if (next == field) return + field = next + // Re-rolled rather than left to cycle out on its own: a switch that takes twenty + // seconds to become visible does not read as an answer to the gesture. + columns.forEach { column -> + for (i in column.glyphs.indices) column.glyphs[i] = randomGlyph() + } + } + + override val hasVariants: Boolean + get() = true + + override fun onDoubleTap() { + variant += 1 + } + + private fun randomGlyph(): Char = + alphabets[variant].let { it[random.nextInt(it.size)] } + + private fun seed(size: Size) { + if (sized == size && columns.isNotEmpty()) return + sized = size + columns.clear() + repeat(target()) { columns += newColumn(size) } + } + + /** + * Lanes taken in golden-ratio steps rather than drawn at random. + * + * Eighty random numbers on the width do not cover it evenly: they clump into doubled streams + * in one place and leave the header bare in another, and at the far end of a zoom in — where + * thirty large glyphs have to carry the whole field — the gaps are most of what you see. + * Stepping by the golden ratio instead spreads every prefix of the sequence as evenly as that + * many points can be spread, so the field looks like the count it is carrying whether it is + * holding thirty streams or a hundred and twenty, and columns can still be added one at a time + * without knowing how many will follow. + */ + private var lanesTaken = 0 + + private fun nextLane(): Float { + lanesTaken++ + return (lanesTaken * 0.618_034f) % 1f + } + + private fun newColumn(size: Size, atLane: Float? = null, atHead: Float? = null): Column { + val length = 3 + random.nextInt(6) + return Column( + lane = atLane ?: nextLane(), + head = atHead ?: (-random.nextFloat() * size.height * 1.6f), + weight = 0.45f + random.nextFloat() * 0.85f, + length = length, + glyphs = MutableList(length + 1) { randomGlyph() }, + ) + } + + /** + * How many columns fit side by side across the header at the current zoom. + * + * This is the number the pinch actually sets. The glyph size follows from it rather than the + * other way round, because "how much of this can I read at once" is the question somebody + * pinching a field of text is asking, and a size in pixels answers it differently on every + * screen. [MIN_COLUMNS] and [MAX_COLUMNS] are therefore a limit on how large and how small a + * glyph is allowed to get, expressed in the only units that mean the same thing everywhere. + */ + private fun across(): Int = + (COLUMNS / scale).roundToInt().coerceIn(MIN_COLUMNS, MAX_COLUMNS) + + /** + * The height of one glyph cell. + * + * Worked back from the width a column is allowed: a monospace glyph advances about six tenths + * of its font size, and the font is drawn at [GLYPH_HEIGHT] of the cell, so the cell that puts + * [across] columns across the header is the width of one divided by the two together. + */ + private fun cell(): Float = sized.width / (across() * GLYPH_ADVANCE * GLYPH_HEIGHT) + + /** How many columns to carry: one to each lane that fits, plus whatever was tapped in. */ + private fun target(): Int = (across() + seeded).coerceAtMost(MAX_COLUMNS) + + /** Columns the reader has tapped in, kept across a pinch so a seeded stream is not stolen. */ + private var seeded = 0 + + override fun update(dt: Float, size: Size) { + if (size.width <= 0f || size.height <= 0f) return + seed(size) + clock += dt + + // Follow the glyph size without restarting the rain: new streams arrive from above the + // top edge, and the ones taken away are those that have already fallen furthest, so a + // pinch never blanks a stretch of the header. + val want = target() + while (columns.size < want) columns += newColumn(size) + while (columns.size > want) { + columns.removeAt(columns.indexOf(columns.maxByOrNull { it.head })) + } + + // Ease into and out of the freeze. + val motionTarget = if (frozen) 0f else 1f + motion += (motionTarget - motion) * (dt / 220f).coerceAtMost(1f) + + val seconds = dt / 1000f + val cell = cell() + columns.forEach { column -> + // Speed follows the glyph size, so zooming in does not turn the rain into a crawl. + column.head += cell * column.weight * 1.5f * seconds * motion * speed + + // Glyphs flicker as they fall; this is the detail that makes it look alive. + if (motion > 0.05f && random.nextFloat() < dt / 340f) { + column.glyphs[random.nextInt(column.glyphs.size)] = randomGlyph() + } + + if (column.head - column.length * cell > size.height) { + column.head = -random.nextFloat() * size.height * 0.6f + for (i in column.glyphs.indices) column.glyphs[i] = randomGlyph() + } + } + } + + override fun onTap(position: Offset, size: Size) { + seed(size) + if (target() >= MAX_COLUMNS) return + // Seeded right where the finger went down, so it is plainly the one you just made, and + // counted, so the next frame does not take it straight back off the field. + seeded++ + columns += newColumn(size, atLane = position.x / size.width, atHead = position.y) + } + + override fun onLongPress(position: Offset, size: Size) { + seed(size) + frozen = true + heldAt = position + // Whichever column the finger is over gives up its glyph. Nearer columns win ties, + // because those are the ones the eye was on. + val column = columns.minByOrNull { abs(screenX(it, sized) - position.x) } + heldGlyph = column?.glyphs?.firstOrNull() ?: randomGlyph() + } + + override fun onRelease() { + frozen = false + heldAt = null + heldGlyph = null + } + + /** + * A vertical drag changes how fast it falls. + * + * Scaled against the header's own height, so the same physical gesture does the same thing on + * any screen, and multiplicative so it is as easy to slow a fast rain as to speed a slow one. + */ + override fun onDrag(pan: Offset, at: Offset, size: Size) { + if (size.height <= 0f || size.width <= 0f) return + + // Sideways reshuffles, downwards changes the speed, and the two do not fight because each + // reads only its own axis. A drag is almost never purely one or the other, so the vertical + // component is ignored while the finger is clearly travelling sideways; otherwise every + // reshuffle would also shove the speed somewhere the reader did not ask for. + val sideways = abs(pan.x) > abs(pan.y) * 1.5f + if (sideways) { + swipedX += pan.x / size.width + if (abs(swipedX) >= RESHUFFLE_FRACTION) { + swipedX = 0f + reshuffle(size) + } + return + } + + val delta = pan.y / size.height + if (abs(delta) < 0.0005f) return + speed *= 1f + delta * 1.6f + } + + /** + * A fresh fall: same alphabet, new arrangement. + * + * Not a reset — the speed, the zoom and the chosen alphabet are the reader's settings and + * survive. What changes is the thing that cannot be chosen: which lanes are busy, how long the + * streams are, where each one happens to be. A rain that has been watched for a while settles + * into a recognisable pattern, and this is the way to ask for another one. + * + * The heads start above the top rather than at their old positions, so the new fall arrives + * from off-screen instead of appearing mid-air. + */ + private fun reshuffle(size: Size) { + columns.clear() + repeat(target()) { columns += newColumn(size) } + } + + /** How much of the width a sideways drag must cover before the rain is redrawn. */ + private var swipedX = 0f + + /** Where a column lands on screen. Lanes are fixed; only the glyphs on them change size. */ + private fun screenX(column: Column, size: Size): Float = column.lane * size.width + + override fun DrawScope.render(tint: Color) { + val measurer = textMeasurer ?: return + if (sized.width < 1f) return + + // The simulation works in pixels; text is specified in sp, so the conversion goes + // through the draw scope's own density rather than a guess. + val style = TextStyle(fontFamily = FontFamily.Monospace) + val cell = cell() + val glyphSize = cell * GLYPH_HEIGHT + if (glyphSize < 2f) return + + columns.forEach { column -> + val x = screenX(column, size) + if (x < -cell || x > size.width + cell) return@forEach + + for (i in 0..column.length) { + val y = column.head - i * cell + if (y < -cell || y > size.height + cell) continue + + val fade = 1f - i / (column.length + 1f) + // Weight varies brightness only, so columns differ from one another without the + // field pretending to a depth it cannot show. + val weightAlpha = (column.weight / 1.3f).coerceIn(0.25f, 1f) + val alpha = (if (i == 0) 0.50f else 0.26f * fade * fade) * weightAlpha + if (alpha < 0.005f) continue + + drawText( + textMeasurer = measurer, + text = column.glyphs.getOrElse(i) { ' ' }.toString(), + style = + style.copy(color = tint.copy(alpha = alpha), fontSize = glyphSize.toSp()), + topLeft = Offset(x, y), + ) + } + } + + // The glyph lifted out of the rain, held under the finger. + val held = heldAt + val glyph = heldGlyph + if (held != null && glyph != null) { + val pulse = 0.82f + 0.18f * kotlin.math.sin(clock / 260f) + drawText( + textMeasurer = measurer, + text = glyph.toString(), + style = + style.copy( + color = tint.copy(alpha = 0.85f), + fontSize = (glyphSize * 2.1f * pulse).toSp(), + ), + topLeft = Offset(held.x - glyphSize * 0.6f, held.y - glyphSize * 1.5f), + ) + } + } + + /** + * Text needs a measurer, which a [DrawScope] does not carry. The surface injects it. + * + * Kept as a plain field rather than a constructor parameter so every renderer can share one + * factory signature. + */ + var textMeasurer: TextMeasurer? = null + + private companion object { + /** + * How many columns fit across the header: at rest, and the range the pinch may take it to. + * + * This is the glyph size, said in the units that matter. Thirty columns across is as large + * as a glyph is allowed to be — beyond that the rain reads as a headline someone left + * behind the text — and a hundred and twenty is as small, which is where the glyphs stop + * being glyphs and become the texture of glyphs. + */ + const val COLUMNS = 80 + const val MIN_COLUMNS = 30 + const val MAX_COLUMNS = 120 + + /** + * How a monospace glyph sits in its cell: it advances about six tenths of its font size, + * and the font is drawn at a little over four fifths of the cell's height. Together they + * turn the width one column is allowed into the height of a cell. + */ + const val GLYPH_ADVANCE = 0.6f + const val GLYPH_HEIGHT = 0.82f + + /** + * How far the pinch goes, derived from the counts above rather than chosen separately. + * + * The column count is the honest limit — fewer than thirty across and the glyphs are too + * large to be rain, more than a hundred and twenty and they are too small to be glyphs — + * so the zoom stops exactly where the count would otherwise have to be clamped. Bounding + * the glyph size independently would let the pinch carry on moving after the field had + * stopped answering it, which reads as a broken gesture. + */ + val MIN_SCALE = COLUMNS.toFloat() / MAX_COLUMNS + val MAX_SCALE = COLUMNS.toFloat() / MIN_COLUMNS + + /** A quarter of the width: past a flick, short of a deliberate sweep. */ + const val RESHUFFLE_FRACTION = 0.25f + + const val MIN_SPEED = 0.15f + const val MAX_SPEED = 6f + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MazeRenderer.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MazeRenderer.kt new file mode 100644 index 000000000..0c0bc96c7 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MazeRenderer.kt @@ -0,0 +1,431 @@ +package org.matrix.vector.manager.ui.components.ambience + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * A maze, with something finding its way through it. + * + * The opposite of the circuit beside it, and that is the point of having both: a circuit is a + * designed path that many signals share, a maze is an undesigned one that a single wanderer has to + * solve. A pulse choosing at random on a trace reads as a fault; a wanderer choosing at random in a + * maze is the whole idea. + * + * Carved rather than sprinkled — see [build] — then braided open, because a perfect maze is all + * dead ends and a wanderer in one mostly reverses. **Several openings on both edges** mean there is + * never one true path and its choices are never forced. + * + * **One wanderer at a time.** It enters at an opening, turns at random wherever it has a choice, + * and only when it leaves through some other opening does the next one set out. Several at once + * read as traffic, and the point of the thing is watching a single decision being made and then + * another. + * + * Tap to drop the wanderer where you touched. Swipe for a different maze. + */ +class MazeRenderer : AmbienceRenderer { + + private companion object { + const val BASE_COLS = 13 + const val BASE_ROWS = 5 + const val MIN_SCALE = 0.5f + const val MAX_SCALE = 2.5f + /** Cells per second. Slow: this sits behind text somebody is reading. */ + const val SPEED = 3.4f + /** + * How often a dead end is opened up again. + * + * A perfect maze is all dead ends and one route; braiding some of them away leaves + * corridors, junctions and loops — the shape a maze on paper actually has — and gives the + * wanderer real choices to make instead of a single path it cannot deviate from. + */ + const val BRAID = 0.45f + const val TRAIL = 14 + } + + /** + * Cell size, as a multiple of the resting size. + * + * A maze is the one ambience where a scale changes the *problem* and not just the picture: + * zooming out gives a finer grid with more corridors to solve, zooming in a few large rooms. + * Changing it rebuilds the maze, because a grid cannot be resized in place — and a fresh maze + * is the honest answer to "make it finer" anyway. + */ + override var scale: Float = 1f + set(value) { + val next = value.coerceIn(MIN_SCALE, MAX_SCALE) + if (next == field) return + field = next + resize() + } + + private var cols = BASE_COLS + private var rows = BASE_ROWS + + /** + * Walls as edges between cells, held as two grids. + * + * `right[x][y]` is the wall between (x, y) and (x + 1, y); `down[x][y]` between (x, y) and + * (x, y + 1). Storing edges rather than cells is what makes "is this move legal" a single + * lookup with no bounds arithmetic in the hot path. + */ + private var right = Array(cols) { BooleanArray(rows) } + private var down = Array(cols) { BooleanArray(rows) } + + /** Rows where the left and right edges are open. Several of each, by construction. */ + private val leftDoors = mutableListOf() + private val rightDoors = mutableListOf() + + private val random = Random(0x4D5A) + private var sized = Size.Zero + + private var cx = 0 + private var cy = 0 + private var dx = 1 + private var dy = 0 + /** How far between the current cell and the next, 0..1. */ + private var step = 0f + private var travelling = false + private var restDelay = 0f + + private val trail = ArrayDeque>() + + override val isAnimating: Boolean + // Between one wanderer leaving and the next setting out there is nothing to draw, but the + // pause is counted down in update() — and a parked frame loop stops calling it, so a maze + // that admitted to resting would never send anything through itself again. + get() = true + + /** A finer or coarser grid is a different maze, so the grids are replaced and re-carved. */ + private fun resize() { + cols = (BASE_COLS / scale).roundToInt().coerceIn(4, 40) + rows = (BASE_ROWS / scale).roundToInt().coerceIn(2, 16) + right = Array(cols) { BooleanArray(rows) } + down = Array(cols) { BooleanArray(rows) } + build() + } + + /** + * Carves a maze, then loosens it. + * + * An independent coin flip per edge does not produce a maze, it produces speckle: sealed + * pockets and open plazas in the same picture, with a wanderer that looks like it is bouncing + * around a room rather than working something out. + * + * This is a randomised depth-first carve: start everywhere walled, walk to an unvisited + * neighbour knocking the wall between as you go, and back up when boxed in. What comes out is a + * *perfect* maze — every cell reachable, exactly one route between any two — which is the + * structure that reads as a maze at a glance: long corridors, forced turns, junctions. + * + * Then it is braided. A perfect maze is all dead ends, and a wanderer in one spends most of its + * time reversing out of them; opening roughly half the dead ends back up leaves loops, so there + * is more than one way through and its turns are choices rather than the only legal move. + */ + private fun build() { + for (x in 0 until cols) for (y in 0 until rows) { + right[x][y] = x < cols - 1 + down[x][y] = y < rows - 1 + } + + val visited = Array(cols) { BooleanArray(rows) } + val stack = ArrayDeque>() + var sx = random.nextInt(cols) + var sy = random.nextInt(rows) + visited[sx][sy] = true + stack.addLast(sx to sy) + + while (stack.isNotEmpty()) { + val (x, y) = stack.last() + val unvisited = + DIRECTIONS.filter { (ddx, ddy) -> + val nx = x + ddx + val ny = y + ddy + nx in 0 until cols && ny in 0 until rows && !visited[nx][ny] + } + if (unvisited.isEmpty()) { + stack.removeLast() + continue + } + val (ddx, ddy) = unvisited.random(random) + carve(x, y, ddx, ddy) + sx = x + ddx + sy = y + ddy + visited[sx][sy] = true + stack.addLast(sx to sy) + } + + for (x in 0 until cols) for (y in 0 until rows) { + val exits = DIRECTIONS.count { (ddx, ddy) -> open(x, y, ddx, ddy) } + if (exits <= 1 && random.nextFloat() < BRAID) { + val closed = + DIRECTIONS.filter { (ddx, ddy) -> + val nx = x + ddx + val ny = y + ddy + nx in 0 until cols && ny in 0 until rows && !open(x, y, ddx, ddy) + } + closed.randomOrNull(random)?.let { (ddx, ddy) -> carve(x, y, ddx, ddy) } + } + } + + // Doors are punched, not left to chance: a maze whose exits depend on the same draw as its + // walls can come out sealed, and a sealed maze has nothing to watch. + leftDoors.clear() + rightDoors.clear() + val candidates = (0 until rows).toMutableList() + candidates.shuffle(random) + val doorCount = 2 + random.nextInt(2) + leftDoors += candidates.take(doorCount) + candidates.shuffle(random) + rightDoors += candidates.take(doorCount) + + trail.clear() + travelling = false + restDelay = 0f + } + + /** Knocks down the wall between a cell and its neighbour. */ + private fun carve(x: Int, y: Int, ddx: Int, ddy: Int) { + when { + ddx == 1 -> right[x][y] = false + ddx == -1 -> right[x - 1][y] = false + ddy == 1 -> down[x][y] = false + ddy == -1 -> down[x][y - 1] = false + } + } + + private fun seed(size: Size) { + if (sized == size && (leftDoors.isNotEmpty() || rightDoors.isNotEmpty())) return + sized = size + build() + } + + /** + * True when a move from (x, y) in a direction is blocked by neither a wall nor the edge of the + * grid. Leaving through a door is therefore not "open": [update] carries the wanderer out. + */ + private fun open(x: Int, y: Int, ddx: Int, ddy: Int): Boolean = + when { + ddx == 1 -> x < cols - 1 && !right[x][y] + ddx == -1 -> x > 0 && !right[x - 1][y] + ddy == 1 -> y < rows - 1 && !down[x][y] + else -> y > 0 && !down[x][y - 1] + } + + private fun enter() { + val fromLeft = random.nextBoolean() || rightDoors.isEmpty() + if (fromLeft && leftDoors.isNotEmpty()) { + cx = 0 + cy = leftDoors.random(random) + dx = 1 + } else if (rightDoors.isNotEmpty()) { + cx = cols - 1 + cy = rightDoors.random(random) + dx = -1 + } else { + // build() always punches doors, so this is unreachable — but returning without + // arming the delay would retry on every frame forever, which is the wrong way for an + // impossible branch to fail. + restDelay = 1_000f + return + } + dy = 0 + step = 0f + travelling = true + trail.clear() + trail.addLast(cx to cy) + } + + /** + * Picks the next direction. + * + * Every legal move except turning straight back is a candidate and one is taken at random, so + * the route is decided at each junction rather than planned. Reversing is allowed only from a + * dead end, which is the one case where there is nothing else to do. + */ + private fun turn() { + val options = + DIRECTIONS.filter { (ndx, ndy) -> + !(ndx == -dx && ndy == -dy) && open(cx, cy, ndx, ndy) + } + val pick = + when { + options.isNotEmpty() -> options.random(random) + // A dead end: about-face rather than stall. + open(cx, cy, -dx, -dy) -> -dx to -dy + else -> null + } + if (pick == null) { + travelling = false + restDelay = 900f + return + } + dx = pick.first + dy = pick.second + } + + override fun update(dt: Float, size: Size) { + if (size.width <= 0f || size.height <= 0f) return + seed(size) + + if (!travelling) { + // Only ever one wanderer: the next sets out after this one has left, never beside it. + restDelay -= dt + if (restDelay <= 0f) enter() + return + } + + step += SPEED * dt / 1000f + while (step >= 1f) { + step -= 1f + val nx = cx + dx + val ny = cy + dy + + // Leaving through a door on either edge ends the run. + if (nx < 0 || nx >= cols) { + travelling = false + restDelay = 700f + random.nextFloat() * 900f + return + } + + cx = nx + cy = ny + trail.addLast(cx to cy) + while (trail.size > TRAIL) trail.removeFirst() + + // At an edge door, carry straight on out; otherwise choose. + val leaving = + (cx == 0 && dx == -1 && cy in leftDoors) || + (cx == cols - 1 && dx == 1 && cy in rightDoors) + if (!leaving) turn() + } + } + + /** + * Puts the wanderer where you touched. + * + * Not a second wanderer — there is only ever one — and not an edit to the walls. Moving it is + * the one interaction that makes the maze feel like something you are watching rather than + * something playing to itself: drop it into a corner you want solved and watch it find its way + * out from there. + */ + override fun onTap(position: Offset, size: Size) { + seed(size) + val (x, y) = cellAt(position, size) ?: return + cx = x + cy = y + step = 0f + trail.clear() + trail.addLast(cx to cy) + travelling = true + restDelay = 0f + // Face somewhere it can actually go, so the first move after the tap is not a reversal. + val ways = DIRECTIONS.filter { (ddx, ddy) -> open(cx, cy, ddx, ddy) } + val heading = ways.randomOrNull(random) ?: (1 to 0) + dx = heading.first + dy = heading.second + } + + /** A different maze. Watching one lay itself out is half of what the surface is for. */ + private var dragged = 0f + + override fun onDrag(pan: Offset, at: Offset, size: Size) { + if (size.width <= 0f) return + // Sideways travel only, accumulated across the drag: a maze is rebuilt by a deliberate + // sweep, not by the vertical wobble of a finger resting on the header. + dragged += pan.x + if (abs(dragged) < size.width * 0.12f) return + dragged = 0f + seed(size) + build() + } + + private fun cellAt(position: Offset, size: Size): Pair? { + val w = size.width / cols + val h = size.height / rows + if (w <= 0f || h <= 0f) return null + val x = (position.x / w).toInt().coerceIn(0, cols - 1) + val y = (position.y / h).toInt().coerceIn(0, rows - 1) + return x to y + } + + override fun DrawScope.render(tint: Color) { + if (sized.width <= 0f) return + val w = size.width / cols + val h = size.height / rows + // Given in dp and converted here — a DrawScope is a Density — because a wall counted in + // raw pixels is a different weight on every screen, and thins away on a dense one. + val stroke = Stroke(width = 1.6.dp.toPx()) + + // Walls first, faint: they are the setting, not the subject. + val wallColor = tint.copy(alpha = 0.16f) + for (x in 0 until cols) for (y in 0 until rows) { + if (right[x][y]) { + drawLine( + color = wallColor, + start = Offset((x + 1) * w, y * h), + end = Offset((x + 1) * w, (y + 1) * h), + strokeWidth = stroke.width, + ) + } + if (down[x][y]) { + drawLine( + color = wallColor, + start = Offset(x * w, (y + 1) * h), + end = Offset((x + 1) * w, (y + 1) * h), + strokeWidth = stroke.width, + ) + } + } + + // The outer frame, minus the doors — which is what makes the openings legible as openings. + for (y in 0 until rows) { + if (y !in leftDoors) { + drawLine(wallColor, Offset(0f, y * h), Offset(0f, (y + 1) * h), stroke.width) + } + if (y !in rightDoors) { + drawLine( + wallColor, + Offset(size.width, y * h), + Offset(size.width, (y + 1) * h), + stroke.width, + ) + } + } + drawLine(wallColor, Offset(0f, 0f), Offset(size.width, 0f), stroke.width) + drawLine( + wallColor, + Offset(0f, size.height), + Offset(size.width, size.height), + stroke.width, + ) + + if (!travelling) return + + // The trail, fading behind the wanderer, so a turn stays legible for a moment after it is + // taken — the decision is the thing worth seeing. + trail.forEachIndexed { index, (tx, ty) -> + val age = (index + 1f) / trail.size + drawCircle( + color = tint.copy(alpha = 0.10f * age * age), + radius = minOf(w, h) * 0.16f, + center = Offset((tx + 0.5f) * w, (ty + 0.5f) * h), + ) + } + + val headX = (cx + 0.5f + dx * step) * w + val headY = (cy + 0.5f + dy * step) * h + drawCircle( + color = tint.copy(alpha = 0.42f), + radius = minOf(w, h) * 0.17f, + center = Offset(headX, headY), + ) + } +} + +private val DIRECTIONS = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/SnowRenderer.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/SnowRenderer.kt new file mode 100644 index 000000000..0ed98509b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/SnowRenderer.kt @@ -0,0 +1,279 @@ +package org.matrix.vector.manager.ui.components.ambience + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.random.Random + +/** + * Snowfall you can pick apart. + * + * Flakes are drawn as actual six-armed crystals — three crossed arms with a pair of barbs on each, + * slowly rotating — rather than dots. A dot field reads as static; a crystal reads as a snowflake + * even at eight pixels across, and the slow spin is what sells it. + * + * Two things answer a touch, and which one you get depends on whether you hit anything: + * - **On a flake:** it bursts. The six arms detach, spin outward and fade over about a second, and + * the flake is gone. Slow on purpose — a fast pop would read as a glitch rather than an event. + * - **On empty space:** a new flake *grows* there from nothing over a second and a half, then + * joins the fall. So the field is something you can prune and reseed rather than only disturb. + */ +class SnowRenderer : AmbienceRenderer { + + private companion object { + const val BASE_FLAKES = 22 + const val MIN_SCALE = 0.4f + const val MAX_SCALE = 3f + + /** Slow enough to read as almost-still air; fast enough to read as a squall. */ + const val MIN_SPEED = 0.2f + const val MAX_SPEED = 5f + } + + private class Flake( + var x: Float, + var y: Float, + val fullRadius: Float, + val fallSpeed: Float, + val swayPhase: Float, + val swayRate: Float, + val spin: Float, + /** 0 while growing in from a tap, 1 once fully formed. */ + var growth: Float = 1f, + ) { + var angle: Float = 0f + } + + /** A burst flake's arm, flying outward. */ + private class Shard( + var x: Float, + var y: Float, + val vx: Float, + val vy: Float, + val length: Float, + val spin: Float, + ) { + var age = 0f + var angle = 0f + } + + private val random = Random(0x5E6C) + private val flakes = mutableListOf() + private val shards = mutableListOf() + private var clock = 0f + private var sized = Size.Zero + + private val shardLifeMs = 1100f + private val growMs = 1500f + + override val isAnimating: Boolean + // Snowfall has no rest to park on: the floor under [speed] is above zero precisely so that + // it never stops, and there is always a crystal somewhere between the top and the bottom. + get() = true + + /** + * Crystal size, and with it how many there are. + * + * Inversely: zooming out gives a fine dense drizzle, zooming in a few large crystals. Trading + * count against size is what makes it read as one snowfall seen closer or further rather than + * as two different settings. + */ + override var scale: Float = 1f + set(value) { + field = value.coerceIn(MIN_SCALE, MAX_SCALE) + } + + /** + * How hard it is snowing, on the same vertical drag the code rain uses. + * + * The same gesture in the same place should mean the same thing whichever ambience is on, and + * "how fast is this moving" is the one question every moving render can answer. Snow reads it + * as weather: slowed right down it is the still air after a fall, pushed up it is a squall. + * + * The floor is above zero on purpose. A snowfall frozen mid-air reads as a rendering fault + * rather than as a setting, and there is already a way to have no motion at all — the ambience + * picker. + */ + override var speed: Float = 1f + set(value) { + field = value.coerceIn(MIN_SPEED, MAX_SPEED) + } + + override fun onDrag(pan: Offset, at: Offset, size: Size) { + if (size.height <= 0f) return + val delta = pan.y / size.height + if (abs(delta) < 0.0005f) return + speed *= 1f + delta * 1.6f + } + + private fun target(): Int = (BASE_FLAKES / scale).roundToInt().coerceIn(6, 90) + + private fun seed(size: Size) { + if (sized == size && flakes.isNotEmpty()) return + sized = size + flakes.clear() + repeat(target()) { flakes += newFlake(size, random.nextFloat() * size.height) } + } + + private fun newFlake(size: Size, atY: Float, atX: Float? = null, growing: Boolean = false) = + Flake( + x = atX ?: (random.nextFloat() * size.width), + y = atY, + fullRadius = size.height * (0.018f + random.nextFloat() * 0.030f) * scale, + // Bigger crystals read as nearer, so they fall faster. + fallSpeed = 10f + random.nextFloat() * 22f, + swayPhase = random.nextFloat() * 2f * PI.toFloat(), + swayRate = 0.3f + random.nextFloat() * 0.6f, + spin = (random.nextFloat() - 0.5f) * 24f, + growth = if (growing) 0f else 1f, + ) + + override fun update(dt: Float, size: Size) { + if (size.width <= 0f || size.height <= 0f) return + seed(size) + // Follow the scale without restarting the snowfall: new crystals drift in from above and + // surplus ones are taken from the top, so a pinch never blanks the field. + val want = target() + while (flakes.size < want) flakes += newFlake(size, -random.nextFloat() * size.height) + while (flakes.size > want) flakes.removeAt(flakes.indexOf(flakes.minByOrNull { it.y })) + clock += dt + val seconds = dt / 1000f + + flakes.forEach { flake -> + if (flake.growth < 1f) { + flake.growth = (flake.growth + dt / growMs).coerceAtMost(1f) + } + flake.angle += flake.spin * seconds * speed + val sway = sin(clock / 1000f * flake.swayRate + flake.swayPhase) * size.width * 0.010f + // Sway and spin scale with the fall as well: a crystal that drifts and turns at full + // rate while descending slowly does not read as slow snow, it reads as broken snow. + flake.x += sway * seconds * speed + flake.y += flake.fallSpeed * seconds * flake.growth * speed + + if (flake.y - flake.fullRadius > size.height) { + flake.y = -flake.fullRadius + flake.x = random.nextFloat() * size.width + } + if (flake.x < -flake.fullRadius) flake.x = size.width + flake.fullRadius + if (flake.x > size.width + flake.fullRadius) flake.x = -flake.fullRadius + } + + shards.forEach { shard -> + // The debris of a burst crystal ages in real time whatever the speed — its lifetime is + // how long the reader gets to watch it come apart, which is not a property of the + // weather. + shard.age += dt + shard.x += shard.vx * seconds * speed + shard.y += shard.vy * seconds * speed + shard.angle += shard.spin * seconds * speed + } + shards.removeAll { it.age > shardLifeMs } + } + + override fun onTap(position: Offset, size: Size) { + seed(size) + + // Generous hit area — these are small targets and a miss that silently spawns a flake + // instead would feel like the tap was ignored. + val hit = + flakes + .filter { it.growth > 0.5f } + .minByOrNull { hypot(it.x - position.x, it.y - position.y) } + ?.takeIf { hypot(it.x - position.x, it.y - position.y) < it.fullRadius * 2.2f } + + if (hit != null) { + burst(hit) + flakes.remove(hit) + } else if (flakes.size < 40) { + flakes += newFlake(size, position.y, position.x, growing = true) + } + } + + private fun burst(flake: Flake) { + val radius = flake.fullRadius * flake.growth + repeat(6) { arm -> + val theta = flake.angle * PI.toFloat() / 180f + arm * PI.toFloat() / 3f + // Slow: the point is to watch it come apart, not to see it vanish. + val speed = radius * (2.2f + random.nextFloat() * 1.6f) + shards += + Shard( + x = flake.x, + y = flake.y, + vx = cos(theta) * speed, + vy = sin(theta) * speed - radius * 0.6f, + length = radius, + spin = (random.nextFloat() - 0.5f) * 220f, + ) + } + } + + override fun DrawScope.render(tint: Color) { + flakes.forEach { flake -> + val radius = flake.fullRadius * flake.growth + if (radius <= 0.4f) return@forEach + val depth = (flake.fullRadius / (size.height * 0.048f)).coerceIn(0.35f, 1f) + val alpha = (0.10f + 0.14f * depth) * flake.growth + rotate(degrees = flake.angle, pivot = Offset(flake.x, flake.y)) { + drawCrystal(Offset(flake.x, flake.y), radius, tint.copy(alpha = alpha), width = radius * 0.16f) + } + } + + shards.forEach { shard -> + val t = shard.age / shardLifeMs + val alpha = 0.22f * (1f - t) * (1f - t) + if (alpha < 0.004f) return@forEach + rotate(degrees = shard.angle, pivot = Offset(shard.x, shard.y)) { + val half = shard.length * (1f - t * 0.35f) + drawLine( + color = tint.copy(alpha = alpha), + start = Offset(shard.x - half, shard.y), + end = Offset(shard.x + half, shard.y), + strokeWidth = shard.length * 0.16f, + ) + } + } + } + + /** Three crossed arms with barbs — the smallest shape that still reads as a snowflake. */ + private fun DrawScope.drawCrystal(centre: Offset, radius: Float, color: Color, width: Float) { + for (arm in 0 until 3) { + val theta = arm * PI.toFloat() / 3f + val dx = cos(theta) * radius + val dy = sin(theta) * radius + drawLine( + color = color, + start = Offset(centre.x - dx, centre.y - dy), + end = Offset(centre.x + dx, centre.y + dy), + strokeWidth = width, + ) + // A barb near each tip, angled back along the arm. + for (side in listOf(1f, -1f)) { + val tip = Offset(centre.x + dx * side, centre.y + dy * side) + val inner = Offset(centre.x + dx * side * 0.55f, centre.y + dy * side * 0.55f) + for (branch in listOf(0.55f, -0.55f)) { + val bTheta = theta + branch + drawLine( + color = color, + start = inner, + end = + Offset( + inner.x + cos(bTheta) * radius * 0.34f * side, + inner.y + sin(bTheta) * radius * 0.34f * side, + ), + strokeWidth = width * 0.75f, + ) + } + // A dot at the tip keeps the silhouette from looking like a bare cross. + drawCircle(color = color, radius = width * 0.7f, center = tip) + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/DeepLink.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/DeepLink.kt new file mode 100644 index 000000000..6cf656ca7 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/DeepLink.kt @@ -0,0 +1,191 @@ +package org.matrix.vector.manager.ui.navigation + +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate + +/** + * Where a launch intent asked the app to open: a tab, and at most one screen above it. + * + * [tab] is not decoration. Pushing [detail] onto whatever the reader happened to be looking at + * would leave them one back press away from a screen they never opened, and on a cold start — where + * the stack is nothing but Home — one back press away from leaving the app the notification just + * brought them into. Laying the tab down first gives the destination somewhere to go back to. + */ +data class PendingDestination(val tab: TopLevelRoute, val detail: Route? = null) + +/** + * The destination a launch intent named, handed from the activity to the composition. + * + * The framework encodes the module a notification is about in the intent's data as + * `module://:`; the daemon copies that Uri onto the manager's launch intent, + * and it survives the parasitic redirection intact. That redirection touches the intent in exactly + * one way — `ParasiticManagerHooker` rewrites the *component* of every intent passing through + * `ActivityClientRecord` to this manager's `MainActivity` — while `ParasiticManagerSystemHooker` + * answers `system_server`'s resolution with a copy of the host activity's `ActivityInfo` and leaves + * the intent alone. Neither touches the data Uri, and neither touches the extras, which is the + * answer to the next reader wondering what else may safely ride on a launch intent. (The hooker + * does more besides, including capturing and restoring saved state; none of it is the intent.) So + * by the time the activity starts, the intent really does + * say which module the reader tapped a notification about — but the activity has no back stack to + * act on. That belongs to the composition, which on a cold start does not exist yet because the + * splash is still playing. This is the hand-off across that gap. + * + * [consume] empties the flow, and emptying it is the point: a destination left behind would be + * applied again on the next recomposition after a configuration change, dragging the reader back to + * the module's scope editor every time they rotated the phone away from it. + */ +object DeepLink { + + private val _pending = MutableStateFlow(null) + + /** + * What [consume] last handed to the shell, for as long as this process lives. + * + * This is the only thing that tells a launch apart from a replay of one. `getIntent()` answers + * the intent the activity was *created* with for the whole life of the task — the platform + * documents that on `Activity.onNewIntent` itself — so every recreation of the activity offers + * that same intent again, minutes or hours after its destination was already applied. + * + * A process kill empties this, and a task restored from recents afterwards is handed the + * original intent once more, so the link is applied there a second time. What that costs is the + * restored position and nothing else: `Navigator` persists the back stack across process death + * through SavedState, so the reader does come back to a stack they will then be moved off — but + * no unapplied edit can survive the kill, the scope editor's draft being plain state flows in a + * ViewModel. Landing on the link's destination is what a cold start from that notification does + * anyway, so it is the same screen reached a different way rather than work thrown away. + */ + private var lastApplied: PendingDestination? = null + + /** Non-null while a launch intent's destination is waiting for the shell to apply it. */ + val pending: StateFlow = _pending.asStateFlow() + + /** + * Records where the intent the activity was *created* with asks the app to open. + * + * A creation is not a launch. Rotation, the automatic dark-mode flip at sunset, a font or + * locale change, an unfold and a restore from recents all build the activity again and hand it + * the intent it was originally started with, so a destination that was applied long ago is + * offered here over and over. Taking it would clear the back stack under a reader who has since + * navigated elsewhere — `Navigator.switchTo` empties the stack, and the scope editor's draft + * goes with the entry it was scoped to — which is why an offer naming [lastApplied] is dropped + * on the floor here rather than judged in the shell: by the time the shell sees a destination + * it can only ask where the reader is standing, not whether this link has already had its turn. + * + * The cost of that rule is one miss, and it is narrower than it first looks because [forget] + * closes the common half of it: an activity that finishes takes the memory with it, so backing + * out of the manager and tapping a second notice for the same module still works even though + * the process was cached in between. + * + * What is left is the parasitic shape with the activity still alive. The resolved activity is a + * copy of the host's, so this manifest's `singleTop` is not necessarily the launch mode the + * system applies and the daemon's `openManager` asks for none; a second notice for the module + * already applied can therefore arrive at a fresh [onCreate] beside the living one, be dropped + * here, and open Home. It is the smaller failure of the two — one notification that opens the + * wrong screen, against a stack emptied under the reader on every rotation — and it needs the + * same module to be announced twice inside one activity's life. + * + * An intent naming nothing this understands still clears the field, exactly as in + * [offerFromNewIntent]; only a repeat of the destination already applied is ignored. + */ + fun offerFromCreate(intent: Intent?) { + val destination = parse(intent) + if (destination != null && destination == lastApplied) return + _pending.value = destination + } + + /** + * Records where an intent delivered to the *running* activity asks the app to open. + * + * This one is a launch by definition — the system only delivers it because something started + * the manager again — so it takes even when it names the destination last applied. That is what + * makes a second tap on the same module's notification move a reader who has wandered off. + * + * The newest launch wins, and an intent naming nothing this understands clears the field rather + * than leaving it. That looks like the more destructive of the two choices and is the safer + * one: this object outlives the activity, and a destination can be offered and never applied — + * `SplashGate` holds the shell out of the composition for the best part of a second, and a back + * press inside that window finishes the activity before anything consumes it. Left in place, it + * would be applied to the *next* launch instead, so opening the app from the launcher would + * drop the reader into the scope editor of a module they were last told about. + * + * Nothing is steered by the clearing itself. The status notification carries no data at all and + * the `*#*#832867#*#*` dialer code arrives under its own `android_secret_code` scheme; neither + * has a screen in mind, and Home — or wherever the reader last was — is the right answer for + * both. + */ + fun offerFromNewIntent(intent: Intent?) { + _pending.value = parse(intent) + } + + /** + * Forgets what was last applied, because the screen it was applied to has gone. + * + * [lastApplied] exists to tell a rebuilt activity from a new launch, and that distinction stops + * meaning anything once the activity finishes: there is no stack left to empty and no reader + * standing anywhere, so whatever starts the manager next is a real launch whoever it names. + * + * Without this the memory would outlive the activity — a Kotlin object lives as long as the + * process, and backing out of the manager leaves that process cached — and the *next* + * notification about the same module would be dropped as a replay and open Home instead. That + * is the failure this whole file exists to prevent, arriving by the back door. + */ + fun forget() { + lastApplied = null + } + + /** + * Takes the waiting destination, leaving nothing for a later recomposition to re-apply. + * + * Taking one is also what marks it applied, and it counts as applied even when the shell then + * decides the reader is already standing there: either way the reader has been given what the + * link asked for, and a later recreation offering it again is a replay. + */ + fun consume(): PendingDestination? = _pending.getAndUpdate { null }?.also { lastApplied = it } + + private fun parse(intent: Intent?): PendingDestination? { + val data = intent?.data ?: return null + if (data.scheme == MODULE_SCHEME) return moduleScope(data) + + // The bare strings the pre-Compose manager accepted. Nothing in this build sends one — the + // pinned launcher shortcut carries no data, and what the framework sends is either nothing, + // the module scheme above or the dialer code's own — but they were this app's launch + // contract for years and honouring one costs a branch. "settings" is deliberately absent: + // settings are sheets raised from Home rather than a destination of their own, so there is + // no screen to open and guessing at one would be worse than ignoring the request. + return when (data.toString()) { + "modules" -> PendingDestination(TopLevelRoute.Modules) + "logs" -> PendingDestination(TopLevelRoute.Logs) + "repo" -> PendingDestination(TopLevelRoute.Store) + else -> null + } + } + + /** + * `module://:`, taken apart exactly the way the framework put it together. + * + * The authority is built with `encodedAuthority("$packageName:$userId")` and split back out + * with a plain `split(":", limit = 2)` on the daemon's own side, so doing the same here is what + * keeps the two ends agreeing about where the package name stops. + * + * [Uri.getHost] and [Uri.getPort] look like the obvious reading and quietly answer something + * else. Neither can fail: an authority the platform cannot find a numeric port in is handed + * back whole as the host, with -1 for the port — so `module://com.example.foo:bad` would open + * the scope editor for a package literally named `com.example.foo:bad` under user -1 rather + * than being rejected. Splitting by hand makes a user id that is not a number no link at all. + */ + private fun moduleScope(data: Uri): PendingDestination? { + val parts = data.encodedAuthority?.split(":", limit = 2) ?: return null + if (parts.size != 2) return null + val packageName = parts[0] + if (packageName.isEmpty()) return null + val userId = parts[1].toIntOrNull() ?: return null + return PendingDestination(TopLevelRoute.Modules, Scope(packageName, userId)) + } +} + +/** The framework's scheme for "open the manager on this module". */ +private const val MODULE_SCHEME = "module" diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/FloatingPanelNav.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/FloatingPanelNav.kt new file mode 100644 index 000000000..9c76bbf5f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/FloatingPanelNav.kt @@ -0,0 +1,577 @@ +package org.matrix.vector.manager.ui.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.math.asin +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.di.ServiceLocator + +/** Drawn size of the ball, which is also its touch target — hence not smaller than 48dp. */ +private val BALL_SIZE = 52.dp + +/** How much clear space is left between the parked ball and the edge it is parked against. */ +private val BALL_INSET = 10.dp + +/** How far from the ball a panel comes to rest once the arc is open. */ +private val FAN_RADIUS = 116.dp + +private val FAN_ITEM_SIZE = 48.dp + +/** Wide enough for a panel name at `labelSmall`, and the width the arc is clamped by. */ +private val FAN_ITEM_WIDTH = 88.dp + +private val FAN_LABEL_GAP = 6.dp + +/** Room kept below a fanned panel for its name, so a label cannot land off the window. */ +private val FAN_LABEL_ROOM = 22.dp + +/** How far apart two panels sit on the arc when the window has room for the whole fan. */ +private val FAN_STEP = Math.toRadians(46.0).toFloat() + +/** + * How far off a panel's own direction the finger may point and still be pointing at it, as the + * cosine of that angle so the test is a dot product rather than an `atan2` per panel per event. + * + * Wider than half the step on purpose: neighbouring cones overlap and the nearest one wins, so the + * only thing this number really decides is how far the finger has to stray before it has chosen + * nothing at all. + */ +private val FAN_CONE = cos(Math.toRadians(32.0)).toFloat() + +/** + * Within this fraction of [FAN_RADIUS] of the ball, the finger has chosen nothing. + * + * There has to be somewhere to let go that does not navigate, or a held press that turned out to be + * a mistake would have no ending but an unwanted panel. Near the ball — where the finger already is + * when the arc opens — is the one place nobody reaches by accident on the way to a panel. + */ +private const val FAN_DEAD_ZONE = 0.45f + +/** + * The panels as a ball you can put where you like, for when there is no bar at all. + * + * Not a [androidx.compose.foundation.layout.BoxScope] extension and it takes no `Alignment`: it + * fills the space it is given and places the ball itself, because the arc has to know the window it + * must stay inside, and an alignment handed in from outside would describe the ball while telling + * this nothing about the room left around it. + * + * It is drawn inside the app window, as the last child of the shell's content Box. Never a `Popup`, + * never a `Dialog`, and above all never a system overlay: parasitically this app *is* + * `com.android.shell`, and a manager that asked for `SYSTEM_ALERT_WINDOW` would be asking the + * shell's uid for permission to draw over every other app on the device. A floating control is + * worth exactly none of that. + * + * It reads `WindowInsets.safeDrawing` itself, as bounds rather than as padding: with the navigation + * container set to `NavigationSuiteType.None` the scaffold consumes no insets at all, so nothing + * above this has reserved the system bars and the ball would otherwise park itself under the + * gesture handle. Bounds rather than padding because the tap that dismisses the open arc has to + * cover the whole window, insets included, while the ball itself must stay out of them. + * + * Two ways in, on purpose. Holding the ball blooms the arc and the same finger picks from it, which + * is the fast one; a plain tap latches the arc open so each panel is an ordinary target, which is + * the only one a screen reader can use. A drag-only selector is unreachable by touch exploration, + * so the latched path is not polish — it is the accessible path, and every fanned panel is its own + * focusable, clickable node with its own description rather than a hit test over one canvas. + */ +@Composable +fun FloatingPanelNav( + panels: NavPanels, + current: TopLevelRoute, + onSelect: (TopLevelRoute) -> Unit, + modifier: Modifier = Modifier, +) { + val settings = ServiceLocator.settings + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val rtl = layoutDirection == LayoutDirection.Rtl + val scope = rememberCoroutineScope() + // The gesture below outlives its recompositions — see the pointerInput keys — so the callback + // is read through a State rather than captured, or a release could call last frame's lambda. + val select by rememberUpdatedState(onSelect) + + // Read once and written on release. The two accessors carry no flow precisely so that dragging + // the ball does not recompose the ball, and the value is only ever authored from here. + var atEnd by remember { mutableStateOf(settings.floatingNavAtEnd()) } + var yFraction by remember { mutableFloatStateOf(settings.floatingNavY()) } + + var latched by remember { mutableStateOf(false) } + var held by remember { mutableStateOf(false) } + var highlighted by remember { mutableIntStateOf(-1) } + // True from the moment the arc starts to bloom until it has finished collapsing, which is what + // keeps the panels composed long enough to animate out instead of vanishing. + var fanned by remember { mutableStateOf(false) } + val open = held || latched + + // Read inside placement and layer lambdas rather than during composition, so that dragging the + // ball re-places it without recomposing anything at all. The ball's own animation is created + // further down, where the window's shape is known. + val bloom = remember { Animatable(0f) } + val bloomSpec = MaterialTheme.motionScheme.fastSpatialSpec() + val settleSpec = MaterialTheme.motionScheme.defaultSpatialSpec() + + LaunchedEffect(open) { + if (open) { + fanned = true + bloom.animateTo(1f, bloomSpec) + } else { + bloom.animateTo(0f, bloomSpec) + fanned = false + } + } + + // Ahead of NavDisplay's own handler because this composable is the shell content's last child + // and back callbacks fire last-registered-first: a latched arc closes before the stack moves. + BackHandler(enabled = latched) { latched = false } + + // Every coordinate below is counted from the left of the window, because that is what the + // insets, the constraints and a pointer's own position are counted from. The alignment has to + // be the absolute one to match: Alignment.TopStart places a child against the *right* edge in + // an RTL locale, and values-ar makes that reachable. Which side the ball parks on is decided by + // `atEnd` against the layout direction, in one place, rather than by the layout mirroring it. + BoxWithConstraints(modifier.fillMaxSize(), contentAlignment = AbsoluteAlignment.TopLeft) { + val insets = WindowInsets.safeDrawing + val width = constraints.maxWidth.toFloat() + val height = constraints.maxHeight.toFloat() + + val ballRadius = with(density) { BALL_SIZE.toPx() } / 2f + val ballInset = with(density) { BALL_INSET.toPx() } + val radius = with(density) { FAN_RADIUS.toPx() } + val itemRadius = with(density) { FAN_ITEM_SIZE.toPx() } / 2f + val itemHalfWidth = with(density) { FAN_ITEM_WIDTH.toPx() } / 2f + val labelRoom = with(density) { (FAN_LABEL_GAP + FAN_LABEL_ROOM).toPx() } + + // Where the ball's centre may sit, and where a fanned panel's centre may sit. The second + // is the taller of the two allowances because a panel carries its name underneath it. + val ballBounds = + Rect( + left = insets.getLeft(density, layoutDirection) + ballInset + ballRadius, + top = insets.getTop(density) + ballInset + ballRadius, + right = width - insets.getRight(density, layoutDirection) - ballInset - ballRadius, + bottom = height - insets.getBottom(density) - ballInset - ballRadius, + ) + val fanBounds = + Rect( + left = insets.getLeft(density, layoutDirection) + itemHalfWidth, + top = insets.getTop(density) + itemRadius, + right = width - insets.getRight(density, layoutDirection) - itemHalfWidth, + bottom = height - insets.getBottom(density) - itemRadius - labelRoom, + ) + + // Seeded during composition rather than from an effect, because an effect can land after + // the frame it was scheduled in has already drawn and the ball would flash in the corner + // of the window on the way to where it was left. + val ball = + remember { + Animatable( + resting(ballBounds, atEnd != rtl, yFraction, height), + Offset.VectorConverter, + ) + } + + // Keyed on the room available rather than on where the ball is: this is what puts it back + // after a rotation or a fold, which is exactly why the position is persisted as a side and + // a fraction rather than as a coordinate. A move by hand animates itself and must not be + // snapped out from under the finger, so `atEnd` is read here and not keyed on. + LaunchedEffect(ballBounds) { + ball.snapTo(resting(ballBounds, atEnd != rtl, yFraction, height)) + } + + val visible = panels.visible + val deadZone = radius * FAN_DEAD_ZONE + + // One gesture loop rather than a stack of detectors. A long press, a tap and a drag on the + // same node cannot be split across `detectTapGestures` and `detectDragGestures`: the tap + // detector consumes everything from the moment its long press fires, which starves exactly + // the drag this needs to keep following afterwards. + val gesture = + Modifier.pointerInput( + visible, + ballBounds, + fanBounds, + radius, + width, + height, + layoutDirection, + ) { + // This block restarts whenever a key changes, which can happen in the middle of a + // press. The press it was in will never be released, so the hold it announced is + // released here instead — otherwise the arc stays bloomed with nothing left + // holding it and no gesture able to close it. + held = false + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val longPress = awaitLongPressOrCancellation(down.id) + + if (longPress != null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + // Held before unlatched, never the other way round: the two are separate + // writes, and dropping both for the instant between them would tell the + // arc to collapse and then to bloom again on a press that never closed it. + held = true + latched = false + highlighted = -1 + // The arc is a pure function of where the ball is, so the drawing below + // and this hit test cannot describe different arcs. The ball does not move + // while the arc is open, so one reading of it serves the whole gesture. + val centre = ball.value + val corner = Offset(centre.x - ballRadius, centre.y - ballRadius) + val inward = if (atEnd != rtl) -1f else 1f + val points = fanPoints(visible.size, centre, inward, radius, fanBounds) + val released = + drag(down.id) { change -> + val hit = pick(points, centre, change.position + corner, deadZone) + if (hit != highlighted) { + if (hit >= 0) { + haptics.performHapticFeedback( + HapticFeedbackType.SegmentTick + ) + } + highlighted = hit + } + change.consume() + } + val chosen = highlighted + held = false + highlighted = -1 + // A cancelled gesture is not a choice. `drag` returns false when the + // pointer was taken away rather than lifted, and navigating on that would + // mean a panel opening because a phone call arrived. + if (released && chosen in visible.indices) { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + select(visible[chosen].route) + } + return@awaitEachGesture + } + + // Not a long press, which is two different gestures: the finger came up inside + // the timeout, or it moved far enough to be dragging the ball. Whether the + // pointer is still down is the only thing that tells them apart, and it is + // still the current event that ended the wait. + val moving = currentEvent.changes.firstOrNull { it.id == down.id }?.pressed + if (moving != true) { + latched = !latched + haptics.performHapticFeedback( + if (latched) HapticFeedbackType.ContextClick + else HapticFeedbackType.ToggleOff + ) + return@awaitEachGesture + } + + latched = false + // An absolute target rather than reading the animation back on every event: + // the snaps are launched, and a base read inside one of them could be a frame + // behind the finger. + var target = ball.value + drag(down.id) { change -> + target = within(target + change.positionChange(), ballBounds) + scope.launch { ball.snapTo(target) } + change.consume() + } + val atRight = target.x > width / 2f + atEnd = atRight != rtl + yFraction = (target.y / height).coerceIn(0f, 1f) + settings.setFloatingNavAtEnd(atEnd) + settings.setFloatingNavY(yFraction) + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + scope.launch { + ball.animateTo(resting(ballBounds, atRight, yFraction, height), settleSpec) + } + } + } + + // Composed before the ball and the panels so that it takes only the taps they do not, and + // present only while the arc is latched — the rest of the time every touch that is not on + // the ball itself belongs to the screen underneath. + if (latched) { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTapGestures { latched = false } + } + ) + } + + if (fanned) { + val centre = ball.value + val inward = if (atEnd != rtl) -1f else 1f + val points = fanPoints(visible.size, centre, inward, radius, fanBounds) + visible.forEachIndexed { index, destination -> + FanItem( + destination = destination, + selected = destination.route == current, + highlighted = index == highlighted, + // While the finger is picking, only the panel under it is named — four labels + // at once would be four things to read on a gesture that is already decided. + // Latched, nothing is under a finger, so every panel has to say what it is. + labelled = latched || index == highlighted, + onClick = { + latched = false + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + select(destination.route) + }, + modifier = + // absoluteOffset, like the ball's: these are window coordinates, and the + // mirroring `offset` applies under RTL would put the arc on the far side + // of the window from the ball it belongs to. + Modifier.absoluteOffset { + val grown = bloom.value + val point = points[index] + IntOffset( + (centre.x + (point.x - centre.x) * grown - itemHalfWidth) + .roundToInt(), + (centre.y + (point.y - centre.y) * grown - itemRadius) + .roundToInt(), + ) + } + .graphicsLayer { alpha = bloom.value }, + ) + } + } + + val colors = MaterialTheme.colorScheme + val ballLabel = + stringResource( + if (latched) R.string.panels_ball_close else R.string.panels_ball_open + ) + Surface( + shape = CircleShape, + color = if (open) colors.primary else colors.primaryContainer, + contentColor = if (open) colors.onPrimary else colors.onPrimaryContainer, + shadowElevation = 6.dp, + modifier = + Modifier.absoluteOffset { + IntOffset( + (ball.value.x - ballRadius).roundToInt(), + (ball.value.y - ballRadius).roundToInt(), + ) + } + .size(BALL_SIZE) + // The gesture above is invisible to touch exploration, so the node states its + // own click action: performing it is what a screen reader's double tap does, + // and it lands on the latched arc, which is the path that can then be walked. + .semantics(mergeDescendants = true) { + contentDescription = ballLabel + role = Role.Button + onClick { + latched = !latched + true + } + } + .then(gesture), + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // The surface above carries the description; announcing the glyph as well would + // name the same control twice. + Icon(Icons.Rounded.Apps, contentDescription = null, modifier = Modifier.size(26.dp)) + } + } + } +} + +/** + * One panel in the open arc. + * + * [highlighted] is the panel the finger is over, [selected] the one the app is already on, and the + * two have to be told apart without relying on colour: under Material You the hues come from the + * wallpaper, so the highlight also grows and the current panel also wears a ring. + */ +@Composable +private fun FanItem( + destination: TopLevelDestination, + selected: Boolean, + highlighted: Boolean, + labelled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val label = stringResource(destination.labelRes) + val lift by animateFloatAsState(if (highlighted) 1.18f else 1f, label = "panelLift") + + Column( + modifier = modifier.width(FAN_ITEM_WIDTH), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + onClick = onClick, + shape = CircleShape, + color = if (highlighted) colors.primary else colors.surfaceContainerHigh, + contentColor = if (highlighted) colors.onPrimary else colors.onSurfaceVariant, + shadowElevation = if (highlighted) 8.dp else 3.dp, + border = if (selected) BorderStroke(2.dp, colors.primary) else null, + modifier = + Modifier.size(FAN_ITEM_SIZE).scale(lift).semantics { + contentDescription = label + }, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon(destination.icon, contentDescription = null, modifier = Modifier.size(24.dp)) + } + } + if (labelled) { + Spacer(Modifier.height(FAN_LABEL_GAP)) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = if (highlighted) colors.primary else colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // The circle above already carries this name as its description, so the text under + // it is decoration for the eyes and must not be read out a second time. + modifier = Modifier.clearAndSetSemantics {}, + ) + } + } +} + +/** + * Where each panel's centre goes, in the coordinates of the whole overlay. + * + * Angles are measured from "straight inward" and positive downwards, so one piece of arithmetic + * serves both edges and the side the ball is parked on only decides which way [inward] points — + * `-1` from the right edge, `+1` from the left. + * + * The arc opens inward and stays inside [bounds] by construction rather than by clipping. How far + * it may reach up and down is turned into an angle first, the step between panels is narrowed until + * that many of them fit, and the whole fan is then tipped away from whichever edge is too close. + * A ball parked at the bottom of the window therefore fans upwards rather than fanning off-screen + * and having half its panels clamped into a heap in the corner. + */ +private fun fanPoints( + count: Int, + centre: Offset, + inward: Float, + radius: Float, + bounds: Rect, +): List { + if (count <= 0 || radius <= 0f) return emptyList() + val up = -asin(((centre.y - bounds.top) / radius).coerceIn(0f, 1f)) + val down = asin(((bounds.bottom - centre.y) / radius).coerceIn(0f, 1f)) + val step = if (count == 1) 0f else min(FAN_STEP, (down - up) / (count - 1)) + val half = step * (count - 1) / 2f + val tilt = within(0f, up + half, down - half) + return List(count) { index -> + val angle = tilt + (index - (count - 1) / 2f) * step + // The arithmetic above already keeps the arc inside the window on any shape this app can + // be handed. These two calls are what make "never outside the window" true rather than + // merely overwhelmingly likely — a window narrower than the arc is wide has no honest + // answer, and a panel stacked on its neighbour is a better one than a panel nobody can see. + Offset( + within(centre.x + inward * radius * cos(angle), bounds.left, bounds.right), + within(centre.y + radius * sin(angle), bounds.top, bounds.bottom), + ) + } +} + +/** + * Which panel the finger at [at] is pointing at, or -1 for none. + * + * Direction rather than distance, so the panels behave as sectors around the ball: overshooting one + * still chooses it, which matters because the arc is drawn at a radius the thumb has to stretch to + * and a selector that only answered inside the circles would be a selector that missed. + */ +private fun pick(points: List, centre: Offset, at: Offset, deadZone: Float): Int { + val reach = at - centre + val length = reach.getDistance() + if (length < deadZone) return -1 + var best = -1 + var closest = FAN_CONE + points.forEachIndexed { index, point -> + val toPanel = point - centre + val span = toPanel.getDistance() + if (span <= 0f) return@forEachIndexed + val alignment = (reach.x * toPanel.x + reach.y * toPanel.y) / (length * span) + if (alignment > closest) { + closest = alignment + best = index + } + } + return best +} + +/** Where the ball sits when nobody is holding it: against one side, [fraction] of the way down. */ +private fun resting(bounds: Rect, atRight: Boolean, fraction: Float, height: Float): Offset = + Offset( + if (atRight) bounds.right else bounds.left, + within(fraction * height, bounds.top, bounds.bottom), + ) + +/** [point] pulled inside [bounds], which is how a dragged ball is kept in its own window. */ +private fun within(point: Offset, bounds: Rect): Offset = + Offset(within(point.x, bounds.left, bounds.right), within(point.y, bounds.top, bounds.bottom)) + +/** + * [value] pulled inside [low]..[high], tolerating the case where the two have met or crossed. + * + * `coerceIn` answers that case by throwing, and it is reachable here in two ordinary ways: a window + * shorter than the arc it is being asked to hold, and an arc whose room is exactly what it needs, + * where float arithmetic can leave the lower bound a hair above the upper one. Meeting in the + * middle is the only thing either case can mean. + */ +private fun within(value: Float, low: Float, high: Float): Float = + if (low < high) value.coerceIn(low, high) else (low + high) / 2f diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/NavPanels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/NavPanels.kt new file mode 100644 index 000000000..26c39a532 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/NavPanels.kt @@ -0,0 +1,119 @@ +package org.matrix.vector.manager.ui.navigation + +import androidx.compose.runtime.Immutable + +/** + * Which panels the navigation container shows, in which order, and which of them are hidden. + * + * Every rule the arrangement has to obey lives here rather than at the surfaces that display it, so + * that the bar, the floating ball and the appearance sheet cannot each remember a different half of + * it: [visible] is never empty, [start] is the first visible panel rather than Home, and + * [withHidden] refuses to hide the last one standing. A surface that asks the right question here + * cannot produce a state the rest of the app has no answer for. + * + * [order] holds every panel, hidden ones included. Hiding is the reader's opinion of the container + * and not a deletion — a panel that is not drawn still needs its route type and its NavDisplay + * registration, because a back stack saved before it was hidden still names it. + */ +@Immutable +data class NavPanels(val order: List, val hidden: Set) { + + /** Every panel in the reader's order, hidden ones included — what edit mode shows. */ + val all: List = order.ifEmpty { TOP_LEVEL_DESTINATIONS } + + /** + * What the navigation container shows. Never empty. + * + * The `ifEmpty` is not reachable through [withHidden] or [decodeNavPanels], both of which + * refuse to leave nothing behind. It is here because a container with no items has no defined + * behaviour at all — Navigator.switchTo clears the stack before it adds, and NavDisplay has no + * empty-stack branch — and this is the one place that can promise it never happens. + */ + val visible: List = + all.filterNot { it.key in hidden }.ifEmpty { all.take(1) } + + /** The panel a cold start opens on, and the fallback for a root that is no longer shown. */ + val start: TopLevelRoute = visible.first().route + + fun isHidden(destination: TopLevelDestination): Boolean = destination.key in hidden + + fun isVisible(route: TopLevelRoute): Boolean = visible.any { it.route == route } + + /** False on the last visible panel: a badge that does nothing is never offered. */ + fun canHide(destination: TopLevelDestination): Boolean = + !isHidden(destination) && visible.size > 1 + + /** + * Hide or restore the panel with this [key], or `this` when that would change nothing. + * + * The refusal to hide the last visible panel is enforced here rather than at the badge that + * asked, so that the screen-reader action, the appearance sheet and anything added later + * inherit the same answer [canHide] gives the badge instead of each re-deciding it. + */ + fun withHidden(key: String, hide: Boolean): NavPanels { + if (all.none { it.key == key }) return this + if (hide == (key in hidden)) return this + if (hide && visible.size <= 1) return this + return copy(hidden = if (hide) hidden + key else hidden - key) + } + + /** + * Move the panel at [from] to [to]. Both are indices into [all]. + * + * Out-of-range or equal indices return `this` rather than throwing: a drag that ends where it + * started is the common case, and a gesture whose captured index went stale because the list + * changed underneath it must not take the app down. + */ + fun withMoved(from: Int, to: Int): NavPanels { + if (from == to || from !in all.indices || to !in all.indices) return this + val moved = all.toMutableList() + moved.add(to, moved.removeAt(from)) + return copy(order = moved) + } +} + +/** + * The arrangement as one preference string: keys in order, comma separated, a hidden one prefixed + * with '!' — `"logs,home,!store,modules"`. + * + * One delimited string rather than a set of keys, because `putStringSet` does not preserve order + * and the order is the whole point. Route keys rather than ordinals or class names, because R8 + * rewrites class names in a release build and an ordinal would silently mean a different panel the + * day a fifth one is declared. + */ +fun encodeNavPanels(panels: NavPanels): String = + panels.all.joinToString(",") { if (panels.isHidden(it)) "!${it.key}" else it.key } + +/** + * Total: any string at all decodes to a usable [NavPanels] — the empty one a fresh install has, one + * written by a build that knew a different set of panels, one edited by hand. + * + * Unknown and duplicate keys are dropped, and every entry of TOP_LEVEL_DESTINATIONS the string does + * not name is appended at the end and visible. That last rule is what makes a newly added panel + * appear for someone who arranged theirs a year ago, rather than being invisible to precisely the + * readers who care most about the arrangement. If what survives would hide everything, the first + * entry is un-hidden. + */ +fun decodeNavPanels(stored: String): NavPanels { + val order = mutableListOf() + val hidden = mutableSetOf() + for (raw in stored.split(',')) { + val token = raw.trim() + val hide = token.startsWith('!') + val key = if (hide) token.substring(1) else token + val destination = TOP_LEVEL_DESTINATIONS.firstOrNull { it.key == key } ?: continue + if (order.any { it.key == key }) continue + order += destination + if (hide) hidden += key + } + // Appended rather than slotted back into its declared position: someone who has arranged their + // panels has said where the ones they know about go and has said nothing at all about this one, + // so the end is the only honest place for it. + for (destination in TOP_LEVEL_DESTINATIONS) { + if (order.none { it.key == destination.key }) order += destination + } + // The one invariant a stored string can break by itself, since nothing stops the file being + // edited or written by an older build than this one. + if (order.all { it.key in hidden }) hidden -= order.first().key + return NavPanels(order, hidden) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt new file mode 100644 index 000000000..91725a92d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt @@ -0,0 +1,142 @@ +package org.matrix.vector.manager.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.di.ServiceLocator + +/** + * The back stack, as an object with intent-revealing operations. + * + * Navigation 3 hands you the stack as a plain observable list, so this is a handful of operations + * over that list rather than a graph definition. + * + * Switching tabs truncates to a single root entry, so the stack can never grow without bound and + * system back leaves the app instead of retracing every tab that was visited. + * + * It owns the panel arrangement too, which is why every surface reaches for it as + * `LocalNavigator.current.panels`, and why there is no second CompositionLocal and no + * `rememberNavPanels` anywhere: a second source of truth is exactly what would let the bar, the + * ball and the appearance sheet disagree about which panels exist while the reader is rearranging + * them. The two writes are here for the same reason — the encoding has one home. + */ +@Stable +class Navigator( + val backStack: NavBackStack, + private val panelsState: State, + private val settings: SettingsRepository, +) { + + /** The reader's panels. A snapshot read, so a composable that touches it recomposes. */ + val panels: NavPanels + get() = panelsState.value + + /** + * Whether the navigation container is being rearranged. + * + * Transient by construction: a plain `mutableStateOf`, deliberately neither a + * `rememberSaveable` nor a preference. Coming back to the app days later to find it still in + * edit mode would be a puzzle, and the arrangement itself is already written the instant it + * changes, so there is nothing here worth restoring. + */ + var editingPanels: Boolean by mutableStateOf(false) + + val current: NavKey? + get() = backStack.lastOrNull() + + /** + * Which item is highlighted — the root of the stack, or the first visible panel. + * + * The visibility test is not made redundant by [reconcilePanels]: hiding the panel you are + * standing on recomposes the container before the effect that corrects the stack has run, and + * for that frame the root names something the container is no longer drawing. A bar that + * highlights nothing is worse than one highlighting the panel you are about to be moved to. + */ + val currentTopLevel: TopLevelRoute + get() { + val root = backStack.firstOrNull() as? TopLevelRoute ?: return panels.start + return if (panels.isVisible(root)) root else panels.start + } + + val canGoBack: Boolean + get() = backStack.size > 1 + + /** Push a detail destination on top of the current tab. */ + fun go(route: Route) { + if (backStack.lastOrNull() != route) backStack.add(route) + } + + /** Select a bar item, discarding whatever detail screens were open. */ + fun switchTo(tab: TopLevelRoute) { + if (backStack.size == 1 && backStack.firstOrNull() == tab) return + backStack.clear() + backStack.add(tab) + } + + /** Returns false when there is nothing left to pop, so the caller can let the system exit. */ + fun back(): Boolean { + if (!canGoBack) return false + backStack.removeAt(backStack.lastIndex) + return true + } + + /** Hide or restore a panel, and persist it. [key] is a TopLevelDestination.key. */ + fun setPanelHidden(key: String, hidden: Boolean) { + settings.setNavPanels(encodeNavPanels(panels.withHidden(key, hidden))) + } + + /** Reorder, and persist it. Both indices are into [NavPanels.all]. */ + fun movePanel(from: Int, to: Int) { + settings.setNavPanels(encodeNavPanels(panels.withMoved(from, to))) + } + + /** + * Replace a root that names a panel which is no longer shown. + * + * This is one mechanism serving two stories that look unrelated: hiding the panel you are on + * moves you to the first visible one, and a stack restored from before a panel was hidden — a + * real state, since the stack survives process death both through SavedStateRegistry and + * through the hooker's per-activity Bundle cache — is corrected instead of leaving a container + * that highlights nothing. + * + * `backStack[0] = …` rather than clear() then add(): NavBackStack supports set(index, value), + * and emptying the list even for an instant hands NavDisplay a stack with no entries. + */ + fun reconcilePanels() { + val root = backStack.firstOrNull() as? TopLevelRoute ?: return + if (!panels.isVisible(root)) backStack[0] = panels.start + } +} + +val LocalNavigator = staticCompositionLocalOf { error("No Navigator in composition") } + +@Composable +fun rememberNavigator(): Navigator { + val settings = ServiceLocator.settings + val stored = settings.navPanels.collectAsStateWithLifecycle() + // Derived rather than decoded on every recomposition: the string changes when a panel is + // dragged or hidden and at no other time, while everything that reads the panels reads them + // once per frame. + val panels = remember(stored) { derivedStateOf { decodeNavPanels(stored.value) } } + // rememberNavBackStack persists across process death via SavedState, which matters here: + // parasitically the manager's activity state is hand-managed by the zygisk hooker, so + // anything that relies on the system restoring it needs to survive that path too. The first + // visible panel is the seed and only the seed — a restored stack skips it entirely, which is + // why the correction below is an effect that runs on every arrangement rather than a one-off. + val backStack = rememberNavBackStack(panels.value.start) + val navigator = remember(backStack, panels, settings) { Navigator(backStack, panels, settings) } + LaunchedEffect(navigator, panels.value) { navigator.reconcilePanels() } + return navigator +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/PanelBar.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/PanelBar.kt new file mode 100644 index 000000000..158a67365 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/PanelBar.kt @@ -0,0 +1,516 @@ +package org.matrix.vector.manager.ui.navigation + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ShortNavigationBarItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.WideNavigationRailItem +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlin.math.abs +import kotlin.math.roundToInt +import org.matrix.vector.manager.R + +/** What is left of a hidden panel in edit mode: Material's disabled alpha, saying the same. */ +private const val HIDDEN_ALPHA = 0.38f + +/** How much the dragged item grows, so a finger is visibly carrying it rather than pushing it. */ +private const val DRAG_LIFT = 1.08f + +/** + * The four panels, as the navigation container's items. + * + * A plain composable, not a scope extension: the scaffold's `navigationItems` slot has no receiver, + * and whatever this emits becomes a direct child of ShortNavigationBar or WideNavigationRail. It + * must therefore emit its items as flat siblings — ShortNavigationBar measures every direct child + * as one equal-width slot, so wrapping the four in a Row would hand a single slot the whole bar. + * + * [editing] shows all four, hidden ones dimmed, with a badge and a drag; otherwise it shows + * [NavPanels.visible] and a long press asks for [onEdit]. Both index arguments of [onMove] are + * indices into [NavPanels.all], which is what [editing] is showing when a drag is possible at all. + */ +@Composable +fun PanelBar( + panels: NavPanels, + current: TopLevelRoute, + editing: Boolean, + suiteType: NavigationSuiteType, + onSelect: (TopLevelRoute) -> Unit, + onEdit: () -> Unit, + onToggleHidden: (key: String, hidden: Boolean) -> Unit, + onMove: (from: Int, to: Int) -> Unit, +) { + val horizontal = isHorizontal(suiteType) + val items = if (editing) panels.all else panels.visible + // Rebuilt whenever the arrangement it describes stops being the one on screen. A drag cannot + // outlive either change — leaving edit mode ends it, and the axis only flips on a rotation — + // so there is nothing in flight to lose, and a stale slot table is exactly how a reorder ends + // up dropping an item in the wrong place. + val drag = remember(items.size, horizontal) { PanelDrag(items.size) } + + items.forEachIndexed { index, destination -> + PanelItem( + destination = destination, + index = index, + count = items.size, + selected = destination.route == current, + editing = editing, + hidden = panels.isHidden(destination), + canHide = panels.canHide(destination), + horizontal = horizontal, + drag = drag, + onSelect = { onSelect(destination.route) }, + onEdit = onEdit, + onToggleHidden = { hidden -> onToggleHidden(destination.key, hidden) }, + onMove = onMove, + ) + } +} + +/** + * The way out of edit mode that is not the back gesture. + * + * Goes in the scaffold's `primaryActionContent`, which the suite draws above a bar and as the + * header of a rail — the one place that is present on both axes without this having to know which + * it is on. A FloatingActionButton because that is what the slot is documented to hold; anything + * flatter reads as one more navigation item rather than as the way out. + */ +@Composable +fun PanelEditDone(onDone: () -> Unit) { + val haptics = LocalHapticFeedback.current + FloatingActionButton( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onDone() + } + ) { + Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.panels_done)) + } +} + +/** + * Whether [type] lays its items along the bottom of the window rather than down its side. + * + * The library's own `isNavigationBar` is private and NavigationSuiteType is a value class with no + * `values()`, so this is re-derived by comparison against the three bar values. Public so that + * VectorApp and this file cannot come to different answers about the same window. + */ +fun isHorizontal(type: NavigationSuiteType): Boolean = + type == NavigationSuiteType.ShortNavigationBarCompact || + type == NavigationSuiteType.ShortNavigationBarMedium || + type == NavigationSuiteType.NavigationBar + +/** + * One panel: the container's item, and in edit mode the badge and the drag over the top of it. + * + * The Box is the slot the container measured, so everything that has to move the whole item — + * [zIndex], the drag offset, the lift — goes on it, and everything that describes the panel itself + * goes on the item inside it. The badge is a later sibling than the item so that it wins the hit + * test over the item's own click. + */ +@Composable +private fun PanelItem( + destination: TopLevelDestination, + index: Int, + count: Int, + selected: Boolean, + editing: Boolean, + hidden: Boolean, + canHide: Boolean, + horizontal: Boolean, + drag: PanelDrag, + onSelect: () -> Unit, + onEdit: () -> Unit, + onToggleHidden: (Boolean) -> Unit, + onMove: (from: Int, to: Int) -> Unit, +) { + val haptics = LocalHapticFeedback.current + val name = stringResource(destination.labelRes) + val rearrange = stringResource(R.string.settings_rearrange_panels) + val moveEarlier = stringResource(R.string.panels_move_earlier) + val moveLater = stringResource(R.string.panels_move_later) + + val dragged = drag.from == index + // Both are kept as State and read inside the placement and layer lambdas below rather than + // unwrapped here, so an animation frame re-places the item instead of recomposing it. LogPan + // keeps its pan offset the same way and for the same reason. + val lift = animateFloatAsState(if (dragged) DRAG_LIFT else 1f, label = "panelLift") + // Only the items the dragged one has crossed animate; the dragged one follows the finger + // exactly, which is the whole difference between carrying something and nudging it. + val shift = animateFloatAsState(drag.displacement(index), label = "panelShift") + + // Two detectors on one node would fight — a drag detector swallows the long press it starts + // from — so the item runs exactly one of them, chosen by what it is currently for. Both are + // keyed on everything they close over, since a pointerInput block captures its lambda at its + // keys and a stale capture is how a drag ends up reordering the index it began at yesterday. + val slotGesture = + if (editing) { + Modifier.pointerInput(drag, index, horizontal, editing) { + detectDragGestures( + onDragStart = { drag.start(index) }, + onDragEnd = { + val from = drag.from + val to = drag.to + drag.cancel() + if (from >= 0 && from != to) { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onMove(from, to) + } + }, + onDragCancel = { drag.cancel() }, + onDrag = { change, amount -> + change.consume() + val along = if (horizontal) amount.x else amount.y + if (drag.drag(along)) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + }, + ) + } + } else { + Modifier.pointerInput(index, editing) { + awaitPanelLongPress { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onEdit() + } + } + } + + Box( + modifier = + Modifier.zIndex(if (dragged) 1f else 0f) + // Ahead of the offset below it on purpose: an outer modifier is placed by the + // container and an inner one by it, so what is recorded here is the slot the bar + // or the rail assigned rather than wherever the finger has since dragged the item. + .then( + if (!editing) Modifier + else + Modifier.onGloballyPositioned { + val position = it.positionInParent() + drag.reportSlot(index, if (horizontal) position.x else position.y) + } + ) + // absoluteOffset rather than offset: the displacements are computed from recorded + // positions and from a raw drag delta, both of which count pixels rightwards, and + // the mirroring `offset` applies in an RTL locale would undo exactly one of them. + .absoluteOffset { + val along = if (drag.from == index) drag.offset else shift.value + if (horizontal) IntOffset(along.roundToInt(), 0) + else IntOffset(0, along.roundToInt()) + } + .graphicsLayer { + scaleX = lift.value + scaleY = lift.value + } + .then(slotGesture), + contentAlignment = Alignment.Center, + ) { + val itemSemantics = + if (editing) { + // A drag is unreachable by touch exploration, so the two moves it can make are + // also offered as actions on the item a screen reader is already focused on. + Modifier.semantics { + val moves = mutableListOf() + if (index > 0) { + moves += + CustomAccessibilityAction(moveEarlier) { + onMove(index, index - 1) + true + } + } + if (index < count - 1) { + moves += + CustomAccessibilityAction(moveLater) { + onMove(index, index + 1) + true + } + } + customActions = moves + } + } else { + // Declared rather than detected: the gesture above resolves the long press on the + // pointer, which touch exploration never delivers. Nothing on Android teaches + // long-press on a navigation bar anyway, so this label is the only place a screen + // reader is told the gesture exists at all. + Modifier.semantics { + onLongClick(label = rearrange) { + onEdit() + true + } + } + } + val itemModifier = + // The bar hands its slot a fixed size, and before this Box stood between them the item + // received that size directly; filling it back up keeps the whole slot tappable rather + // than only the icon and label in the middle of it. The rail measures loosely and gets + // no such modifier — filling there would stretch one item down the whole rail. + (if (horizontal) Modifier.fillMaxSize() else Modifier) + .graphicsLayer { alpha = if (hidden) HIDDEN_ALPHA else 1f } + .then(itemSemantics) + // The label doubles as the item's accessibility name, so the icon carries no + // contentDescription of its own — otherwise TalkBack announces every selected tab twice. + val icon: @Composable () -> Unit = { Icon(destination.icon, contentDescription = null) } + val label: @Composable () -> Unit = { Text(name) } + // Nothing to select while rearranging: a tap in edit mode is either the badge or the start + // of a drag, and moving to another panel underneath the arrangement being edited is not + // something anyone asked for. + val onClick: () -> Unit = { if (!editing) onSelect() } + + if (horizontal) { + ShortNavigationBarItem( + selected = selected, + onClick = onClick, + icon = icon, + label = label, + modifier = itemModifier, + ) + } else { + WideNavigationRailItem( + selected = selected, + onClick = onClick, + icon = icon, + label = label, + // The suite keeps its rail collapsed; an expanded item here would lay its label + // beside the icon in a rail that is not wide enough for it. + railExpanded = false, + modifier = itemModifier, + ) + } + + if (editing && (hidden || canHide)) { + PanelBadge( + hidden = hidden, + name = name, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onToggleHidden(!hidden) + }, + ) + } + } +} + +/** + * The minus or plus at the corner of a panel being rearranged. + * + * Drawn at eighteen points and hit at twenty-four: it has to read as a mark on the item rather than + * as a second button beside it, and at 360dp each of four slots is around ninety, so the target it + * needs costs nothing. It is offered only where it does something — [NavPanels.canHide] answers + * that — because a badge that refuses is worse than no badge. + */ +@Composable +private fun BoxScope.PanelBadge(hidden: Boolean, name: String, onClick: () -> Unit) { + val description = + stringResource(if (hidden) R.string.panels_show else R.string.panels_hide, name) + val container = + if (hidden) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer + val content = + if (hidden) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onErrorContainer + + Box( + modifier = + Modifier.align(Alignment.TopEnd) + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.size(18.dp), + shape = CircleShape, + color = container, + contentColor = content, + // Enough to lift it off the icon underneath without reading as a floating control. + shadowElevation = 2.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + if (hidden) Icons.Rounded.Add else Icons.Rounded.Remove, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + } + } + } +} + +/** + * Long press to enter edit mode, resolved on the initial pointer pass. + * + * `combinedClickable` on the slot is the obvious way and it does not work. The item inside carries + * its own `selectable`, which is the nearer node and therefore handles the release first, so a long + * press on Logs would open edit mode *and* switch to Logs. Watching the pointer on + * [PointerEventPass.Initial] gets ahead of it: nothing is consumed while the press might still turn + * out to be a tap — in which case the item's own click handles it, ripple and indicator and all — + * and everything from the moment the press has been held long enough is consumed, which is what + * cancels the click that would otherwise land on the way up. + * + * Timing it out by hand rather than through foundation's own `waitForLongPress`: that one takes the + * pass to watch, which is exactly what is wanted here, but it and its `LongPressResult` are + * `internal` — public in the bytecode, invisible to Kotlin. + */ +private suspend fun PointerInputScope.awaitPanelLongPress(onLongPress: () -> Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation(PointerEventPass.Initial) + } + // Let go, or taken over by something else, before the press became a hold. + return@awaitEachGesture + } catch (_: PointerEventTimeoutCancellationException) { + onLongPress() + } + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + if (event.changes.none { it.pressed }) return@awaitEachGesture + } + } +} + +/** + * A reorder in flight, shared by every item in the container. + * + * The axis never appears here. [PanelBar] hands over the one number that differs between a bottom + * bar and a rail — where each slot sits along whichever direction the container runs in — so the + * arithmetic below is written once and both layouts get the same behaviour rather than two + * implementations that drift. + * + * Slot positions rather than a single pitch, because the rail spaces its items apart and the bar + * does not: the distance between two recorded positions is right in both, whereas an item's own + * measured extent is short by the gap in the rail and would leave every shift a few points behind + * the finger. + */ +@Stable +private class PanelDrag(count: Int) { + + /** Which item is being carried, as an index into the displayed list, or -1 for none. */ + var from by mutableIntStateOf(-1) + private set + + /** Where it would land if the finger lifted now. */ + var to by mutableIntStateOf(-1) + private set + + /** How far it has travelled from its own slot. Read during placement, so no recomposition. */ + var offset by mutableFloatStateOf(0f) + private set + + // Deliberately not snapshot state: written from a layout callback and read from the gesture, + // never composed against. Observing them would invalidate the very layout pass that produced + // them — the same reason LogPan keeps its measured widths as plain fields. + private val slots = FloatArray(count) + + private val active: Boolean + get() = from in slots.indices + + /** + * Record where the container placed the item at [index]. + * + * Ignored while a drag is running. The items are displaced then, and reading the displacement + * back in as the slot table would walk the targets along under the finger. + */ + fun reportSlot(index: Int, position: Float) { + if (!active && index in slots.indices) slots[index] = position + } + + fun start(index: Int) { + from = index + to = index + offset = 0f + } + + /** Advance by [delta] pixels along the axis. True when the target slot changed. */ + fun drag(delta: Float): Boolean { + if (!active) return false + offset += delta + val target = nearest(slots[from] + offset) + if (target == to) return false + to = target + return true + } + + fun cancel() { + from = -1 + to = -1 + offset = 0f + } + + /** + * How far the item at [index] is pushed aside by the drag in flight, in pixels along the axis. + * + * Every item the dragged one has crossed moves into the slot next to its own, and does so by + * the real distance between those two slots rather than by an assumed pitch, so an uneven + * arrangement lands as exactly as an even one. + */ + fun displacement(index: Int): Float { + if (!active || index == from) return 0f + return when { + index in (from + 1)..to -> slots[index - 1] - slots[index] + index in to until from -> slots[index + 1] - slots[index] + else -> 0f + } + } + + private fun nearest(position: Float): Int { + var best = 0 + var bestDistance = Float.MAX_VALUE + for (index in slots.indices) { + val distance = abs(slots[index] - position) + if (distance < bestDistance) { + bestDistance = distance + best = index + } + } + return best + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt new file mode 100644 index 000000000..9898476b2 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -0,0 +1,124 @@ +package org.matrix.vector.manager.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.automirrored.rounded.ReceiptLong +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import org.matrix.vector.manager.R + +/** + * Every destination, as a type. + * + * Navigation 3 models the back stack as a plain observable list of these rather than a graph of + * route strings, so an argument like a module's user id is a constructor parameter and cannot be + * mis-parsed out of a URL-shaped route. + */ +@Serializable sealed interface Route : NavKey + +/** + * Every panel that exists, and the order a fresh install starts with. + * + * Not the order on screen: the reader's own order, and which panels they have hidden, live in + * SettingsRepository under `nav_panels` and are modelled by NavPanels. What is declared here is the + * catalogue and the default. + * + * Hiding a panel never removes it from this file. The back stack persists NavKeys by class name — + * NavKeySerializer resolves them with a bare `Class.forName` and has no fallback — and NavDisplay's + * entryProvider throws for a key it was never given, so a saved stack naming a panel that had been + * deleted would be a crash rather than a stale tab. Hiding is a fact about the navigation + * container, and about nothing else. + */ +@Serializable +sealed interface TopLevelRoute : Route { + @Serializable data object Home : TopLevelRoute + + @Serializable data object Modules : TopLevelRoute + + @Serializable data object Store : TopLevelRoute + + @Serializable data object Logs : TopLevelRoute +} + +@Serializable data class Scope(val packageName: String, val userId: Int) : Route + +@Serializable data class StoreDetail(val packageName: String) : Route + +@Serializable data object SystemStatus : Route + +/** + * The newest recorded crash, frame by frame. + * + * Carries no argument: there is only ever one crash worth opening — the newest — and the screen + * reads it from disk itself, so the route survives the process death that following a crash report + * is unusually likely to involve. + */ +@Serializable data object CrashTrace : Route + +/** + * A stack trace found in the log, on a screen of its own. + * + * Carries the text rather than a line number, because the log window it came from is paged and + * filtered and may have moved on by the time this is opened — and because the text is the whole of + * what the screen needs. A trace is a few kilobytes at worst, which the back stack can hold. + */ +@Serializable data class LogTrace(val text: String) : Route + +/** CI builds, as prereleases anyone can download. */ +@Serializable data object Canary : Route + +/** What to try, and what to bring, before opening an issue. */ +@Serializable data object Troubleshoot : Route + +/** + * What is in a build, and the installer's output while it is flashed. + * + * [versionCode] names the build to open on, which is how the canary list hands one over; 0 means + * "whichever is worth offering", the screen's own default. + */ +@Serializable data class FrameworkUpdate(val versionCode: Long = 0) : Route + +/** GitHub, shown in the built-in viewer rather than handed to a browser. */ +@Serializable data class Web(val url: String) : Route + +/** + * Label and icon for a bar item. Titles come from resources; no hard-coded English. + * + * [key] is the only stable identity this type has, and so the only thing that is ever written to + * preferences: R8 rewrites class names in a release build, and an ordinal would quietly name a + * different panel the day a fifth one is declared. See NavPanels for what is stored. + */ +data class TopLevelDestination( + val key: String, + val route: TopLevelRoute, + val icon: ImageVector, + val labelRes: Int, +) + +val TOP_LEVEL_DESTINATIONS: List = + listOf( + TopLevelDestination("home", TopLevelRoute.Home, Icons.Rounded.Home, R.string.nav_home), + TopLevelDestination( + "modules", + TopLevelRoute.Modules, + Icons.Rounded.Extension, + R.string.nav_modules, + ), + // A cloud, not a shopfront. Nothing here is sold, and the tab's real subject is "modules + // that live somewhere else and can be brought here". + TopLevelDestination( + "store", + TopLevelRoute.Store, + Icons.Rounded.CloudDownload, + R.string.nav_store, + ), + TopLevelDestination( + "logs", + TopLevelRoute.Logs, + Icons.AutoMirrored.Rounded.ReceiptLong, + R.string.nav_logs, + ), + ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt new file mode 100644 index 000000000..90cd4d472 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt @@ -0,0 +1,550 @@ +package org.matrix.vector.manager.ui.screens.canary + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Science +import androidx.compose.material.icons.rounded.SystemUpdateAlt +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.github.TimelineCommit +import org.matrix.vector.manager.data.repository.CanaryItem +import org.matrix.vector.manager.data.repository.CanaryOverview +import org.matrix.vector.manager.data.repository.CanarySpan +import org.matrix.vector.manager.ui.components.InstalledMarkerRow +import org.matrix.vector.manager.ui.components.exactTime +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * Canary builds: what has landed since the reader's own build, and how to go and run it. + * + * **Nobody signs in here, and that is what decides where the zips come from.** GitHub gates artifact + * downloads behind an account even on a public repository — `actions/artifacts//zip` answers 401 + * to an anonymous caller where a release asset answers 206 — so listing Actions artifacts would mean + * asking every would-be tester for an OAuth grant to work around where the zips happen to live, and + * would lose the people who cannot reach GitHub's login page at all. CI attaches the same zips to a + * rolling `canary-` prerelease, and this lists those. + * + * **This page chooses; it does not install.** It used to do both, badly: each row carried the zip + * names, their sizes and an install button, all of which the build page does better — it states the + * sizes, remembers which variant was last taken, checks the root implementation, shows the download + * and the installer's own output. Duplicating that left no room for the one thing this screen is + * for, which is deciding whether tonight's build is worth an evening. + * + * So each row answers that instead. A version code is `git rev-list --count`, and the commit feed + * counts the same way, so the commits between two builds are exact — the rows name them, and say + * how many were fixes. That number is the honest argument for testing: not "please help", but "four + * of the nine commits since your build are fixes". + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CanaryScreen( + onNavigateBack: () -> Unit, + onOpenUrl: (String) -> Unit, + onInstall: (Long) -> Unit, + onOpenReport: () -> Unit, + viewModel: CanaryViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), +) { + val board by viewModel.board.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.home_test_canary)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + // The only route out to GitHub on the page. It used to be three — this, a + // button under the list and a third in the empty state — which is two more + // than a screen has reasons to leave itself. + IconButton(onClick = { onOpenUrl(GitHubRepository.CANARY_URL) }) { + Icon( + Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = stringResource(R.string.canary_open_actions), + ) + } + }, + ) + } + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + when { + !board.loaded -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + board.items.isEmpty() -> CanaryEmpty(onOpenUrl = onOpenUrl) + else -> + LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) { + item { Preamble(board.overview) } + items( + items = board.items, + key = { item: CanaryItem -> itemKey(item) }, + ) { item -> + when (item) { + is CanaryItem.Build -> + BuildCard( + span = item.span, + onInstall = onInstall, + onOpenUrl = onOpenUrl, + ) + is CanaryItem.Installed -> + InstalledMarkerRow( + versionCode = item.versionCode, + commitsAhead = item.commitsAhead, + aheadOfMaster = item.ahead, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + } + item { ReportFoot(onOpenReport = onOpenReport) } + } + } + } + } +} + +private fun itemKey(item: CanaryItem): Any = + when (item) { + is CanaryItem.Build -> item.span.release.tag + is CanaryItem.Installed -> "installed" + } + +/** + * What a canary is, and what taking one would get *this* reader. + * + * The second half is the part that matters, and it is the part the screen never had. "Try a canary" + * asks for a favour; naming three issues that have been fixed since their build states a reason. + * + * The two halves fail independently, on purpose. The commit count is version-code arithmetic and + * needs nothing but the release list, so it survives a cold cache; the issues come from the tracker + * and simply do not appear when that request fails or when the running build cannot be dated. What + * is never shown is a zero — an empty answer here means "not known", and printing it as "0 issues + * fixed" would turn a missing request into a discouraging fact. + */ +@Composable +private fun Preamble(overview: CanaryOverview) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.Science, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.canary_what_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.canary_what_body), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) + + Spacer(Modifier.height(10.dp)) + Text( + text = + when { + // Past every canary and not on one: a release cut after the last nightly, which + // is the normal state for a day or two after every release. Ordinary news, and + // must not borrow the sentence written for a build of unknown provenance. + overview.ahead && !overview.onCanary -> + stringResource(R.string.canary_after_release) + // Past every canary while on the canary channel: built locally or from a + // branch. Not a position in this list at all, and worth saying rather than + // leaving the reader to wonder why nothing below is marked as theirs. + overview.ahead -> stringResource(R.string.canary_ahead) + // Wearing the newest canary's number without being it. Saying "you are running + // the newest canary" here would contradict the card below, which marks itself + // "same number, other build" from the same comparison. + overview.diverged -> stringResource(R.string.canary_diverged) + !overview.behind -> stringResource(R.string.canary_current) + else -> + pluralStringResource( + R.plurals.canary_since_commits, + overview.commitsAhead, + overview.commitsAhead, + ) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + // Caution only for the two that say the running build is not what its number claims. + color = + if (overview.diverged || (overview.ahead && overview.onCanary)) colors.tertiary + else colors.primary, + ) + + // The strongest argument the page has, and the only one that is about the reader's own + // complaints rather than the project's activity. Named rather than counted: someone who + // filed one of these recognises it, and a count never gives them that. + if (overview.fixed.isNotEmpty() && overview.behind) { + Spacer(Modifier.height(12.dp)) + Text( + text = + pluralStringResource( + R.plurals.canary_fixed_since, + overview.fixed.size, + overview.fixed.size, + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = colors.onSurface, + ) + Spacer(Modifier.height(4.dp)) + overview.fixed.take(ISSUES_SHOWN).forEach { issue -> + Row(Modifier.fillMaxWidth().padding(top = 3.dp)) { + Text("#${issue.number}", style = VectorMono, color = colors.primary) + Spacer(Modifier.width(8.dp)) + Text( + text = issue.title, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Spacer(Modifier.height(10.dp)) + // The fear that stops people is not that a nightly might break; it is that they would be + // stuck with it. Saying otherwise costs one line and is the difference between a page that + // asks and a page that reassures. + Text( + stringResource(R.string.canary_keep_body, GitHubRepository.CANARY_KEEP), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = colors.outlineVariant.copy(alpha = 0.4f)) + } +} + +/** + * One canary: when it was built, what it brought, and one tap to the page that installs it. + * + * The whole card is the target rather than a button on it. There is one thing to do with a build, + * the row is already about that build, and a button would only repeat what the row means while + * shrinking the area that means it. + */ +@Composable +private fun BuildCard(span: CanarySpan, onInstall: (Long) -> Unit, onOpenUrl: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val release = span.release + + Column( + Modifier.fillMaxWidth() + .clickable { onInstall(release.versionCode) } + .padding(horizontal = 20.dp, vertical = 14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.canary_build, release.versionCode), + style = VectorMono, + color = colors.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.width(10.dp)) + // Which of these is running, by the same rule the version picker uses: the number + // alone is not enough, because a build made from another branch wears it too. + when { + span.diverged -> + StatusChip(stringResource(R.string.update_same_number), colors.tertiary) + span.installed -> + StatusChip(stringResource(R.string.update_installed), colors.primary) + } + Spacer(Modifier.weight(1f)) + Icon( + Icons.Rounded.SystemUpdateAlt, + contentDescription = null, + tint = colors.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + Spacer(Modifier.height(3.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = exactTime(release.epochSeconds), + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + // Who wrote it, in place of a count of how many commits went by. The same credit line + // the rail uses, and the same recognition: a contributor's name in the accent colour, + // on the screen the project uses to ask for testers. + span.head?.let { head -> + Text( + text = " · ", + style = MaterialTheme.typography.labelMedium, + color = colors.outlineVariant, + ) + Text( + text = credit(head), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (head.isCommunity) FontWeight.SemiBold else FontWeight.Normal, + color = if (head.isCommunity) colors.primary else colors.onSurfaceVariant, + ) + } + } + + span.subject?.let { subject -> + Spacer(Modifier.height(8.dp)) + // Bottom-aligned, because the pull-request slot is a fixed corner of the card and the + // subject grows upward from it: a title that wraps to three lines still ends level + // with its own number. + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + Text( + // Wrapped, never truncated. A commit subject is a sentence written to be read, + // and the half of it that an ellipsis eats is usually the half that says what + // the change actually does. + text = subject, + style = MaterialTheme.typography.bodyLarge, + color = colors.onSurface, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(10.dp)) + PullRequestSlot(number = span.head?.pullRequest, onOpenUrl = onOpenUrl) + } + } + } + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) +} + +/** + * Everyone credited on the commit, written the way the rail writes it. + * + * The same three shapes and the same two strings as `CommitRow`, so a name reads identically + * wherever the reader meets it. + */ +@Composable +private fun credit(commit: TimelineCommit): String = + when (commit.coAuthors.size) { + 0 -> commit.authorLogin + 1 -> + stringResource( + R.string.home_with_coauthor, + commit.authorLogin, + commit.coAuthors.first().login, + ) + else -> + stringResource( + R.string.home_with_coauthors, + commit.authorLogin, + commit.coAuthors.size, + ) + } + +/** + * The bottom-right corner of a card, where the build's pull request lives. + * + * **The space is held whether or not there is a number in it.** The subject beside it wraps into + * whatever room is left, so a slot that appeared and disappeared would re-wrap the titles from one + * card to the next and the column would look ragged for a reason the reader cannot see. + * + * Its width is measured from the widest number the tracker could plausibly reach rather than + * written down as a dp, so it is still correct at a large font scale — where a guessed width clips + * the digits it exists to show. + * + * Tapping it opens the pull request rather than the build, which is the one place on this screen + * where a reader can read the discussion, see the review and answer it. + */ +@Composable +private fun PullRequestSlot(number: Int?, onOpenUrl: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val width = + remember(measurer, density) { + with(density) { measurer.measure(WIDEST_PR, VectorMono).size.width.toDp() } + + PR_CHIP_PADDING * 2 + + PR_CHIP_BORDER * 2 + } + + Box(Modifier.width(width), contentAlignment = Alignment.CenterEnd) { + if (number != null) { + Text( + text = "#$number", + style = VectorMono, + color = colors.primary, + maxLines = 1, + modifier = + Modifier.clip(RoundedCornerShape(4.dp)) + .border( + PR_CHIP_BORDER, + colors.primary.copy(alpha = 0.4f), + RoundedCornerShape(4.dp), + ) + .clickable { + onOpenUrl("${GitHubRepository.REPO_URL}/pull/$number") + } + .padding(horizontal = PR_CHIP_PADDING, vertical = 2.dp), + ) + } + } +} + +@Composable +private fun StatusChip(label: String, color: Color) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier.clip(CircleShape) + .border(1.dp, color.copy(alpha = 0.5f), CircleShape) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +/** + * The other half of testing. + * + * A canary that misbehaves is only useful to the project if somebody says so, and the reader most + * likely to hit one is on this screen. The debug-build advice sits here rather than on the build + * page because this is where it is still actionable — by the time the variant picker is on screen + * the reader has already decided what they are installing and why. + */ +@Composable +private fun ReportFoot(onOpenReport: () -> Unit) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.BugReport, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.canary_report_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.canary_report_body), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + FilledTonalButton(onClick = onOpenReport) { + Text(stringResource(R.string.home_open_issue)) + } + } +} + +/** + * Nothing published yet. + * + * Says what to do about it rather than only reporting the absence: before CI has pushed its first + * prerelease this is the normal state, not a fault. + */ +@Composable +private fun CanaryEmpty(onOpenUrl: (String) -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Rounded.Science, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.canary_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { onOpenUrl(GitHubRepository.CANARY_URL) }) { + Text(stringResource(R.string.canary_open_actions)) + } + } +} + +/** + * The number the pull-request slot is sized to hold. + * + * Five digits: this repository is in the eight hundreds, and a slot that has to be widened later is + * a slot that re-wraps every subject on the screen when it is. + */ +private const val WIDEST_PR = "#99999" + +private val PR_CHIP_PADDING = 6.dp +private val PR_CHIP_BORDER = 1.dp + +/** + * How many closed issues the header names before it stops. + * + * Three, for the same reason: enough that a reader waiting on one has a fair chance of seeing it, + * short enough that the list still reads as evidence rather than as a changelog. + */ +private const val ISSUES_SHOWN = 3 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryViewModel.kt new file mode 100644 index 000000000..f51f8b0ea --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryViewModel.kt @@ -0,0 +1,91 @@ +package org.matrix.vector.manager.ui.screens.canary + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.matrix.vector.manager.BuildConfig +import org.matrix.vector.manager.data.github.ClosedIssue +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.repository.CanaryBoard +import org.matrix.vector.manager.data.repository.CanaryLayout +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.logW + +/** + * The canary list, joined to the history it comes from. + * + * **Nothing here fetches anything the app was not already holding.** The builds are the release + * list the update page reads, filtered to the canaries; the commits are the feed the home screen + * loads on launch, served from disk. The screen this feeds used to make a request of its own for a + * second view of the first of those, and could still say nothing about the second. + */ +class CanaryViewModel : ViewModel() { + + private val daemon = ServiceLocator.daemon + private val github = ServiceLocator.github + private val updates = ServiceLocator.frameworkUpdates + + private val feed = MutableStateFlow(CommunityFeed()) + private val closed = MutableStateFlow>(emptyList()) + + /** + * True once the release list has answered, however it answered. + * + * Without it an unreachable GitHub is indistinguishable from a fetch in flight, and the screen + * spins forever on the devices least able to reach it. + */ + private val attempted = MutableStateFlow(false) + + val board: StateFlow = + combine(feed, updates.state, closed, attempted) { commits, state, issues, asked -> + CanaryLayout.build(commits, state, issues, asked) + } + // Off the main thread for the same reason the rail is: laying this out is a pass over + // an archive that runs to thousands of commits, once per canary shown. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CanaryBoard()) + + init { + viewModelScope.launch { + // The framework's version when the daemon is up, otherwise this manager's own. Both + // are `git rev-list --count origin/master` on the same repository, so either locates a + // build among the canaries correctly — and the fallback matters more here than + // anywhere else, because a reader whose framework is not answering is exactly the + // reader who has come looking for a build that works. + val installed = + daemon + .getFrameworkVersionCode() + .getOrElse { e -> + logW("canary: framework version unavailable, using the manager's own", e) + 0L + } + .takeIf { it > 0 } ?: BuildConfig.VERSION_CODE.toLong() + updates.refresh(installed, daemon.getBuildStamp().getOrNull()) + attempted.value = true + } + viewModelScope.launch { + // The one request this screen adds, and the only way to know what has actually been + // fixed: see `GitHubRepository.closedIssues`. Revalidated rather than forced, so + // coming back to the screen inside the half-hour window costs nothing. + closed.value = github.closedIssues() + } + viewModelScope.launch { + // Disk first, which is where the home screen's launch-time load has already put it, so + // arriving here costs no request and no wait. Only a reader who reached this screen + // before that finished — or on the first run of a fresh install — pays for a fetch. + val cached = github.load(GitHubRepository.Freshness.Cached) + feed.value = cached + if (cached.commits.isEmpty()) { + feed.value = github.load(GitHubRepository.Freshness.Revalidate) + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt new file mode 100644 index 000000000..d7848bfb0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/CrashTraceScreen.kt @@ -0,0 +1,135 @@ +package org.matrix.vector.manager.ui.screens.home + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.CrashRecorder +import org.matrix.vector.manager.data.log.CrashReport +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.StackTrace +import org.matrix.vector.manager.ui.components.stackTraceItems +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.copyToClipboard +import org.matrix.vector.manager.ui.components.show + +/** + * The newest crash, read as a list rather than as a wall of text. + * + * The trace itself is [StackTrace]'s doing, and the reasoning about how it is laid out lives + * there; this screen is the header above it and the copy action beside it. + * + * The record is read here rather than passed through the route, because the process is quite likely + * to have died since the card was drawn — that is, after all, the subject. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashTraceScreen(onNavigateBack: () -> Unit) { + val context = LocalContext.current + val report = remember { CrashRecorder.newest(context) } + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.copied) + val frameCopied = stringResource(R.string.crash_frame_copied) + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.crash_trace)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + // Every record, not the one on screen. The screen shows the newest because + // that is the one being asked about, but a crash loop writes several and a + // maintainer wants all of them; and this stays enabled when the newest could + // not be parsed, since a record we failed to read is exactly the one worth + // getting off the device by hand. + IconButton( + onClick = { + copyToClipboard(context, CrashRecorder.read(context).orEmpty()) + scope.launch { snackbars.show(copied, SnackbarTone.Success) } + } + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = stringResource(R.string.action_copy_all), + ) + } + }, + ) + }, + ) { padding -> + if (report == null || report.sections.isEmpty()) { + Text( + stringResource(R.string.crash_unreadable), + modifier = Modifier.padding(padding).padding(20.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Scaffold + } + + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 24.dp), + ) { + item(key = "when") { + Text( + crashWhen(report), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + report.build, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + } + stackTraceItems(report.sections) { frame -> + copyToClipboard(context, frame.line) + scope.launch { snackbars.show(frameCopied, SnackbarTone.Success) } + } + } + } +} + +/** + * When it happened, and on which thread — shared with the card on the status screen. + * + * The thread is dropped rather than left blank when the record does not name one. Only a record + * written before the header carried a thread is in that state, and it outlives the update that + * changed the format, since the crashes are kept in the cache directory. + */ +@Composable +internal fun crashWhen(report: CrashReport): String = + if (report.thread.isEmpty()) report.at + else stringResource(R.string.crash_when_value, report.at, report.thread) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt new file mode 100644 index 000000000..4ad181b43 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt @@ -0,0 +1,557 @@ +package org.matrix.vector.manager.ui.screens.home + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.BrightnessAuto +import androidx.compose.material.icons.rounded.BubbleChart +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Colorize +import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.Dashboard +import androidx.compose.material.icons.rounded.Dns +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.LightMode +import androidx.compose.material.icons.rounded.OpenInBrowser +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Reorder +import androidx.compose.material.icons.rounded.Waves +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.components.ChoiceRow +import org.matrix.vector.manager.ui.components.SheetAction +import org.matrix.vector.manager.ui.components.SheetHeading +import org.matrix.vector.manager.ui.components.StatusNote +import org.matrix.vector.manager.ui.components.ToggleRow +import org.matrix.vector.manager.net.DohStatus +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.ColorWheel +import org.matrix.vector.manager.ui.components.ambience.AmbienceKind +import org.matrix.vector.manager.ui.navigation.LocalNavigator +import org.matrix.vector.manager.ui.theme.SeedScheme +import org.matrix.vector.manager.ui.theme.ThemeMode + +/** + * How this screen looks, edited from this screen. + * + * Vector deliberately does not gather every switch into one Settings screen. A preference is easier + * to find, and far easier to understand, next to the thing it changes — so what governs Home lives + * behind Home's own button, backup lives on the module list it backs up, and so on. There is no + * catch-all Settings screen at all: a screen that collects unrelated switches is where preferences + * go to be forgotten, and every one of them has a place it actually belongs. + * + * The navigation section stretches that rule the furthest and still keeps to it. Where the panels + * live is not a property of Home — but neither is the theme, and both answer the same question, + * which is what this app looks like. What is deliberately *not* here is the arrangement itself: + * panels are reordered on the navigation container, by long-pressing one, because a drag only means + * something where you can watch the others move aside. The row below is a way in rather than a + * second place to do it, and it has to exist because nothing on Android teaches that a navigation + * bar can be long-pressed at all — and with the floating style on, there is no bar left to try it + * on. + * + * Everything here takes effect immediately behind the sheet, which is the point of a sheet rather + * than a screen: change the surface or the theme and you can watch it happen without losing your + * place. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeAppearanceSheet(onDismiss: () -> Unit) { + val settings = ServiceLocator.settings + // Read out here rather than inside the sheet. A ModalBottomSheet is a subcomposition, so the + // locals VectorApp provides do reach into it, but the navigator is wanted for one callback and + // nothing about it changes between here and there. + val navigator = LocalNavigator.current + val themeMode by settings.themeMode.collectAsStateWithLifecycle() + val dynamicColor by settings.dynamicColor.collectAsStateWithLifecycle() + val amoled by settings.amoledBlack.collectAsStateWithLifecycle() + val seed by settings.seedColor.collectAsStateWithLifecycle() + val ambience by settings.headerAmbience.collectAsStateWithLifecycle() + val floating by settings.floatingNav.collectAsStateWithLifecycle() + val contributorOrder by settings.contributorOrder.collectAsStateWithLifecycle() + val resolvedDark = + when (ThemeMode.from(themeMode)) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val windowMonths by settings.activityWindowMonths.collectAsStateWithLifecycle() + val openExternally by settings.openLinksExternally.collectAsStateWithLifecycle() + val doh by settings.dohEnabled.collectAsStateWithLifecycle() + val dohStatus by ServiceLocator.dns.status.collectAsStateWithLifecycle() + + // Every value stays enabled, deliberately. Dropping PartiallyExpanded removes the half-height + // stop, which is the only thing a drag on a sheet can *do* other than dismiss it, so a sheet + // taller than half the screen would open at full height and could not be made smaller. Left + // alone, Material caps that stop at the sheet's own height, so short sheets still open at + // their own height and nothing gains a useless drag. + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { +LocalizedOverlay { + + Column( + // Scrollable, so the sheet is usable at the half-height stop rather than only when + // dragged to full — and so nested scroll can hand the drag to the sheet at the top + // of the content, which is what makes pulling it up feel like one gesture. + modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp) + ) { + SheetHeading(stringResource(R.string.appearance_theme), Icons.Rounded.Palette) + BrightnessSelector( + selected = ThemeMode.from(themeMode), + onSelect = { settings.setThemeMode(it.key) }, + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading(stringResource(R.string.appearance_color), Icons.Rounded.Colorize) + ColorSection( + dynamicColor = dynamicColor, + seed = seed, + dark = resolvedDark, + onDynamic = settings::setDynamicColor, + onSeed = settings::setSeedColor, + ) + ToggleRow( + title = stringResource(R.string.appearance_amoled), + icon = Icons.Rounded.DarkMode, + subtitle = stringResource(R.string.appearance_amoled_summary), + checked = amoled, + onCheckedChange = settings::setAmoledBlack, + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading(stringResource(R.string.settings_ambience), Icons.Rounded.Waves) + ChoiceRow { + AmbienceKind.entries.forEach { kind -> + FilterChip( + selected = AmbienceKind.from(ambience) == kind, + onClick = { settings.setHeaderAmbience(kind.key) }, + label = { Text(stringResource(kind.labelRes)) }, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading(stringResource(R.string.settings_activity), Icons.Rounded.History) + ChoiceRow { + // Zero is "as far back as there is", last because it is the widest. + listOf(1, 3, 6, 12, 0).forEach { months -> + FilterChip( + selected = windowMonths == months, + onClick = { settings.setActivityWindowMonths(months) }, + label = { + Text( + if (months == 0) { + stringResource(R.string.settings_window_all) + } else { + pluralStringResource( + R.plurals.settings_window_months, + months, + months, + ) + } + ) + }, + ) + } + } + ChoiceRow { + ContributorOrder.entries.forEach { option -> + FilterChip( + selected = ContributorOrder.from(contributorOrder) == option, + onClick = { settings.setContributorOrder(option.key) }, + label = { Text(stringResource(option.labelRes)) }, + ) + } + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading(stringResource(R.string.settings_navigation), Icons.Rounded.Dashboard) + ToggleRow( + title = stringResource(R.string.settings_floating_nav), + icon = Icons.Rounded.BubbleChart, + subtitle = stringResource(R.string.settings_floating_nav_summary), + checked = floating, + onCheckedChange = settings::setFloatingNav, + ) + SheetAction( + title = stringResource(R.string.settings_rearrange_panels), + icon = Icons.Rounded.Reorder, + onClick = { + // Edit mode and the dismissal in the one click, and deliberately without + // animating the sheet out first: hiding it through its own sheetState would + // leave this dialog's window, scrim and all, over the container for the length + // of the animation, and the first thing anyone does in edit mode is drag an + // item. Dropping the sheet out of composition takes its window with it in the + // same frame the container enters edit mode, so the first touch that lands + // lands on a panel. + navigator.editingPanels = true + onDismiss() + }, + subtitle = stringResource(R.string.settings_rearrange_panels_summary), + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + // Here rather than under the Store's filters, where it used to sit. It was never a + // filter on that list: VectorDns applies it to the one OkHttp client, so it governs + // the activity feed and the framework update check as much as the module mirrors — + // and a reader whose network breaks GitHub has no reason to look for it under Store. + SheetHeading(stringResource(R.string.settings_network), Icons.Rounded.Dns) + ToggleRow( + title = stringResource(R.string.settings_doh), + icon = Icons.Rounded.Dns, + subtitle = stringResource(R.string.settings_doh_summary), + checked = doh, + onCheckedChange = settings::setDohEnabled, + ) + // The switch says what was asked for; this says what happened. They come apart more + // often than the switch admits — a proxy takes the decision away entirely, and one + // unreachable lookup latches the fallback for the rest of the session — and until now + // all three cases looked identical from here. + // + // Only while the switch is on. Off, the switch has already said so, and a second line + // repeating it would be the one piece of this that carries no information. + if (doh) { + when (val state = dohStatus) { + is DohStatus.Untested -> + StatusNote(stringResource(R.string.settings_doh_untested)) + + is DohStatus.Bypassed -> + StatusNote(stringResource(R.string.settings_doh_bypassed)) + + is DohStatus.Working -> + StatusNote( + stringResource(R.string.settings_doh_working, state.host), + tone = MaterialTheme.colorScheme.primary, + ) + + // The one state with something to offer. Not an error colour: falling back is + // the designed behaviour and the app is working, so this is the shade the rest + // of the app uses for "worth knowing", not for "something is broken". + is DohStatus.FellBack -> + StatusNote( + stringResource(R.string.settings_doh_fell_back, state.reason), + tone = MaterialTheme.colorScheme.tertiary, + actionLabel = stringResource(R.string.settings_doh_retry), + onAction = ServiceLocator.dns::retry, + ) + + // Reachable only in the gap between flipping the switch on and the next lookup + // recording something newer. + is DohStatus.Disabled -> + StatusNote(stringResource(R.string.settings_doh_untested)) + } + } + + ToggleRow( + title = stringResource(R.string.settings_open_externally), + icon = Icons.Rounded.OpenInBrowser, + subtitle = stringResource(R.string.settings_open_externally_summary), + checked = openExternally, + onCheckedChange = settings::setOpenLinksExternally, + ) + + } + } +} +} + +/** + * Light, dark, or whatever the system says. + * + * A segmented row rather than three chips because these three are exclusive and cover the whole + * choice — a chip row says "pick any of these", a segmented row says "it is one of these", and the + * shared outline makes the third state visibly part of the same decision rather than an extra. + */ +@Composable +private fun BrightnessSelector(selected: ThemeMode, onSelect: (ThemeMode) -> Unit) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)) { + ThemeMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = selected == mode, + onClick = { onSelect(mode) }, + shape = + SegmentedButtonDefaults.itemShape(index = index, count = ThemeMode.entries.size), + // Icon only. A sun, a moon and an auto-brightness glyph are already unambiguous, + // and three words beside them would push the row past the width of the screen for + // no information — the content description still carries the name for screen + // readers. + icon = {}, + label = { + Icon( + mode.icon(), + contentDescription = stringResource(mode.labelRes()), + modifier = Modifier.size(20.dp), + ) + }, + ) + } + } +} + +/** + * Where the accent comes from. + * + * One row of sources — the wallpaper first, then a set of seeds, then the wheel — because they are + * alternatives to each other, and a switch labelled "dynamic colour" sitting above an unrelated + * list of swatches hides that. Choosing any swatch turns the wallpaper off; choosing the wallpaper + * turns the swatches off. The strip underneath shows the tones the choice actually produces, which + * is the part that ends up on real surfaces: a seed that looks lovely as a dot can still make a + * muddy container, and this shows that before it is applied. + */ +@Composable +private fun ColorSection( + dynamicColor: Boolean, + seed: Int, + dark: Boolean, + onDynamic: (Boolean) -> Unit, + onSeed: (Int) -> Unit, +) { + val supportsDynamic = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + var wheelOpen by remember { mutableStateOf(false) } + val custom = !dynamicColor && seed !in SeedScheme.PRESETS + + Row( + modifier = + Modifier.fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (supportsDynamic) { + SourceSwatch( + selected = dynamicColor, + onClick = { + onDynamic(true) + wheelOpen = false + }, + ) { + // The wallpaper source cannot show its own colour — it does not have one until + // the system resolves it — so it shows what it means instead. + Icon( + Icons.Rounded.AutoAwesome, + contentDescription = stringResource(R.string.appearance_dynamic_color), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp), + ) + } + } + + SeedScheme.PRESETS.forEach { preset -> + val presetScheme = remember(preset, dark) { SeedScheme.of(preset, dark) } + SourceSwatch( + selected = !dynamicColor && seed == preset, + fill = presetScheme.primary, + onClick = { + onDynamic(false) + onSeed(preset) + wheelOpen = false + }, + ) {} + } + + SourceSwatch( + selected = custom, + fill = if (custom) MaterialTheme.colorScheme.primary else null, + onClick = { + onDynamic(false) + wheelOpen = !wheelOpen + }, + ) { + if (!custom) { + Icon( + Icons.Rounded.Colorize, + contentDescription = stringResource(R.string.appearance_custom_color), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } + + AnimatedVisibility(visible = wheelOpen && !dynamicColor) { + Column( + modifier = Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val (chroma, hue) = + remember(seed) { + with(SeedScheme) { Color(seed).toWheel() } + } + ColorWheel( + hue = hue, + chroma = chroma, + dark = dark, + onChange = { h, c -> onSeed(SeedScheme.wheelColor(h, c).toArgb()) }, + modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(0.72f), + ) + Text( + text = with(SeedScheme) { Color(seed).toHex() }, + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + + TonalPreview(dynamicColor = dynamicColor, seed = seed, dark = dark) +} + +/** One choosable colour source: a filled circle that gains a ring when it is the live one. */ +@Composable +private fun SourceSwatch( + selected: Boolean, + onClick: () -> Unit, + fill: Color? = null, + content: @Composable () -> Unit, +) { + val ring by + animateColorAsState( + if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + label = "swatch ring", + ) + Box( + modifier = + Modifier.size(44.dp) + .border(width = 2.dp, color = ring, shape = CircleShape) + .padding(4.dp) + .clip(CircleShape) + .background(fill ?: MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + content() + if (selected) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = + if (fill != null) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** + * The tones the current source produces, light to dark. + * + * Not a decoration: these are the values the scheme hands to containers, outlines and text, so a + * choice that will not have enough contrast at either end is visible here first. + */ +@Composable +private fun TonalPreview(dynamicColor: Boolean, seed: Int, dark: Boolean) { + val scheme = MaterialTheme.colorScheme + val swatches = + if (dynamicColor) { + // A dynamic scheme keeps its tones private, so the preview shows the roles that are + // reachable — which is what the user sees on screen anyway. + listOf( + scheme.primary, + scheme.onPrimaryContainer, + scheme.primaryContainer, + scheme.secondary, + scheme.secondaryContainer, + scheme.tertiary, + scheme.tertiaryContainer, + scheme.surfaceContainerHighest, + scheme.surfaceContainer, + scheme.surface, + ) + } else { + remember(seed, dark) { + val ramp = SeedScheme.Ramp(with(SeedScheme) { Color(seed).toWheel().second }, 48f) + SeedScheme.PREVIEW_TONES.map { ramp[it] } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + swatches.forEachIndexed { index, colour -> + Box( + modifier = + Modifier.weight(1f) + .height(28.dp) + .clip( + when (index) { + 0 -> RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp) + swatches.lastIndex -> + RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp) + else -> RectangleShape + } + ) + .background(colour) + ) + } + } +} + +private fun ThemeMode.icon() = + when (this) { + ThemeMode.System -> Icons.Rounded.BrightnessAuto + ThemeMode.Light -> Icons.Rounded.LightMode + ThemeMode.Dark -> Icons.Rounded.DarkMode + } + +private fun ThemeMode.labelRes(): Int = + when (this) { + ThemeMode.System -> R.string.appearance_theme_system + ThemeMode.Light -> R.string.appearance_theme_light + ThemeMode.Dark -> R.string.appearance_theme_dark + } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt new file mode 100644 index 000000000..f4f0e80df --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt @@ -0,0 +1,978 @@ +package org.matrix.vector.manager.ui.screens.home + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.CallSplit +import androidx.compose.material.icons.automirrored.rounded.AddToHomeScreen +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Bedtime +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FilterAlt +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.material3.InputChip +import androidx.compose.material3.TextButton +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowDown +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.launch +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.theme.currentLocale +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.VectorAlertDialog +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.show +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.FeedItem +import org.matrix.vector.manager.data.github.FeedLayout +import org.matrix.vector.manager.data.github.Contributor +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.github.TimelineCommit +import org.matrix.vector.manager.ui.components.BotBundleRow +import org.matrix.vector.manager.ui.components.CommitRow +import org.matrix.vector.manager.ui.components.InstalledMarkerRow +import org.matrix.vector.manager.ui.components.MonthMarkerRow +import org.matrix.vector.manager.ui.components.GapRow +import org.matrix.vector.manager.ui.components.HistoryFootRow +import org.matrix.vector.manager.ui.components.ContributorAvatar +import org.matrix.vector.manager.ui.components.TakePartSection +import org.matrix.vector.manager.ui.components.StatusHeader +import org.matrix.vector.manager.ui.components.ambience.AmbienceKind +import org.matrix.vector.manager.ui.screens.splash.WingedVictory +import org.matrix.vector.manager.ui.components.compactCount +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * Home is the front page of the *project*, not only of the app. + * + * A framework manager is opened by every user, and Vector is built by volunteers, so this screen + * spends its space on the two questions that matter on opening it: is the framework healthy (one + * line), and what has the project been doing (everything else). + * + * The activity window is a span of time rather than "the latest N commits" — six months by default, + * and the reader's to change from the appearance sheet. In a quiet stretch the page honestly reads + * *7 commits by 4 people*, which is real information about the project; a rolling N would hide that. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onOpenStatus: () -> Unit, + onOpenUrl: (String) -> Unit, + onOpenCanary: () -> Unit, + onOpenReport: () -> Unit, + onOpenUpdate: () -> Unit, + viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory), +) { + val status by viewModel.status.collectAsStateWithLifecycle() + val feed by viewModel.feed.collectAsStateWithLifecycle() + val refreshing by viewModel.refreshing.collectAsStateWithLifecycle() + val openExternally by viewModel.openLinksExternally.collectAsStateWithLifecycle() + val feedItems by viewModel.feedItems.collectAsStateWithLifecycle() + val loadingHistory by viewModel.loadingHistory.collectAsStateWithLifecycle() + val historyStalled by viewModel.historyStalled.collectAsStateWithLifecycle() + val authorFilter by viewModel.authorFilter.collectAsStateWithLifecycle() + val windowChanged by viewModel.windowChanged.collectAsStateWithLifecycle() + val frameworkUpdate by viewModel.frameworkUpdate.collectAsStateWithLifecycle() + val ambienceKey by viewModel.headerAmbience.collectAsStateWithLifecycle() + val presence by viewModel.presence.collectAsStateWithLifecycle() + val promptDismissed by viewModel.launcherPromptDismissed.collectAsStateWithLifecycle() + val hintStatus by viewModel.statusBadgeHint.collectAsStateWithLifecycle() + val context = LocalContext.current + var showSplash by rememberSaveable { mutableStateOf(false) } + var showAppearance by rememberSaveable { mutableStateOf(false) } + var showLanguage by rememberSaveable { mutableStateOf(false) } + // Answered or waved away once per visit, not once per return to Home. Saved so that a rotation + // does not put a dialog back in front of someone who has just dismissed it. + var showLauncherPrompt by rememberSaveable { mutableStateOf(true) } + + // The status screen has its own copy of this ViewModel — a nav destination is its own store — + // so a shortcut pinned or an app installed from there is invisible to this one until it is + // asked again. Coming back to Home is when it is worth asking, and it is also the only moment + // the badge's hint can start running again, so today's tally of it is re-cut here too. + LaunchedEffect(Unit) { + viewModel.refreshPresence() + viewModel.refreshStatusBadgeHint() + } + + // Four taps on the wordmark, with the remaining count announced from the second. Two taps + // could be an accident; past that the reader is clearly poking at it, so the app plays along + // rather than keeping a secret nobody would find. + var brandTaps by remember { mutableStateOf(0) } + var lastBrandTapAt by remember { mutableStateOf(0L) } + val twoMore = stringResource(R.string.egg_two_more) + val oneMore = stringResource(R.string.egg_one_more) + val haptics = LocalHapticFeedback.current + val snackbars = remember { SnackbarHostState() } + val eggScope = rememberCoroutineScope() + + fun onBrandTap() { + val now = System.currentTimeMillis() + brandTaps = if (now - lastBrandTapAt > BRAND_TAP_WINDOW_MS) 1 else brandTaps + 1 + lastBrandTapAt = now + when (brandTaps) { + // The app's own snackbar, not a platform toast. A toast is drawn by the system in the + // system's style and ignores the theme entirely, which on a screen whose whole point + // is the surface underneath it reads as a message from another app. + 2 -> eggScope.launch { snackbars.show(twoMore) } + 3 -> eggScope.launch { snackbars.show(oneMore) } + BRAND_TAPS_TO_SUMMON -> { + brandTaps = 0 + lastBrandTapAt = 0L + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + showSplash = true + } + } + } + + // Every GitHub link goes through the in-app viewer by default; the setting sends them to a + // browser instead for users who would rather stay in one. + fun open(url: String) { + if (openExternally) { + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (_: ActivityNotFoundException) { + // Nothing on the device took the intent. Falling back to the built-in viewer beats + // a link tap that does nothing at all. + onOpenUrl(url) + } + } else { + onOpenUrl(url) + } + } + + Scaffold( + // The header draws its own status-bar inset so it can run under the bar; letting the + // Scaffold consume it here would leave a band of plain background above the pane. The + // bottom is the Scaffold's to reserve, though: with the panels floating there is no + // navigation container underneath to have taken it, and the last row of the feed would end + // up behind three-button navigation. Already-consumed insets are excluded from this, so it + // still adds nothing in the arrangements where a container is there. + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.only(WindowInsetsSides.Bottom), + snackbarHost = { VectorSnackbarHost(snackbars) }, + ) { padding -> + val listState = rememberLazyListState() + var headerHeightPx by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + + // How far the feed has climbed into the header, 0 to 1. The header is not a list item — + // it is pinned behind one — so this is derived from the scroll position rather than from + // the header's own layout, which never moves. + val collapse by remember { + derivedStateOf { + when { + headerHeightPx == 0 -> 0f + listState.firstVisibleItemIndex > 0 -> 1f + else -> + (listState.firstVisibleItemScrollOffset / headerHeightPx.toFloat()) + .coerceIn(0f, 1f) + } + } + } + + // Changing the filter changes the whole rail underneath the reader, and a long press on a + // name three hundred commits down would otherwise leave them stranded in a list that no + // longer contains what they were looking at. Riding up to the headline puts the answer — + // the count, the faces, the chips — on screen at the moment it changes. + LaunchedEffect(authorFilter) { + if (authorFilter.isNotEmpty() && listState.firstVisibleItemIndex > 1) { + listState.animateScrollToItem(1) + } + } + + Box(modifier = Modifier.padding(padding).fillMaxSize()) { + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { viewModel.refreshFeed(GitHubRepository.Freshness.Force) }, + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + // The feed starts below the header and scrolls up underneath it, which is + // what lets the header get out of the way as the content arrives. + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = with(density) { headerHeightPx.toDp() } + 16.dp, + bottom = 16.dp, + ), + ) { + // Everything short and actionable comes first. The activity rail is + // open-ended — six months can be a hundred rows — so anything placed after it + // is effectively unreachable without a long scroll. + item { + TakePartSection( + onOpen = ::open, + onCanary = onOpenCanary, + onReport = onOpenReport, + ) + Spacer(Modifier.height(14.dp)) + ProjectFooter(feed = feed, onClick = { open(GitHubRepository.REPO_URL) }) + Spacer(Modifier.height(26.dp)) + } + + communitySection( + feed = feed, + items = feedItems, + loadingHistory = loadingHistory, + historyStalled = historyStalled, + windowChanged = windowChanged, + authorFilter = authorFilter, + onLoadMoreHistory = viewModel::loadMoreHistory, + onToggleAuthor = viewModel::toggleAuthorFilter, + onClearAuthors = viewModel::clearAuthorFilter, + onOpenCommit = { c -> open(c.htmlUrl ?: GitHubRepository.REPO_URL) }, + onOpenPullRequest = { pr -> open("${GitHubRepository.REPO_URL}/pull/$pr") }, + onOpenProfile = { c -> open(c.profileUrl ?: GitHubRepository.REPO_URL) }, + ) + + item { Spacer(Modifier.height(24.dp)) } + } + } + + ScrollControls( + listState = listState, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 12.dp, bottom = 12.dp), + ) + + StatusHeader( + state = status.state, + version = status.versionLabel, + apiVersion = status.apiVersion, + hasUpdate = frameworkUpdate.hasUpdate, + onOpenUpdate = onOpenUpdate, + ambience = AmbienceKind.from(ambienceKey), + hintStatus = hintStatus, + onOpenStatus = { + // Counted before the navigation, not after arriving: the tap is what proves the + // badge was understood, and the page has other ways in that prove nothing. + viewModel.noteStatusBadgeOpened() + onOpenStatus() + }, + onOpenAppearance = { showAppearance = true }, + onOpenLanguage = { showLanguage = true }, + onBrandTap = ::onBrandTap, + modifier = + Modifier.onSizeChanged { headerHeightPx = it.height } + .graphicsLayer { + // Fades and drifts upward together, so the feed appears to pass over + // it rather than to shove it off screen. + alpha = 1f - collapse + translationY = -collapse * headerHeightPx * 0.5f + }, + ) + } + } + + if (showLanguage) { + LanguageSheet(onOpen = ::open, onDismiss = { showLanguage = false }) + } + + if (showAppearance) { + HomeAppearanceSheet(onDismiss = { showAppearance = false }) + } + + // Nothing in the launcher points at a parasitic manager, so someone who reached this screen + // through the root manager's action button has no way of finding it again — which is what #815 + // reported. Asked once, on the first launch that could act on the answer, and never again after + // "Don't ask again" or after either remedy has been applied. Dismissing it any other way means + // "later": the offer stays on the status page and returns on the next launch. + if ( + showLauncherPrompt && + presence.unreachable && + !promptDismissed && + // With no usable daemon there is no APK to install and bigger problems to report + // first. + status.daemonUsable + ) { + LauncherPrompt( + shortcutSupported = presence.shortcutSupported, + onCreateShortcut = { + showLauncherPrompt = false + viewModel.requestShortcut() + }, + onInstall = { + showLauncherPrompt = false + viewModel.installManagerApp() + }, + onNever = { + showLauncherPrompt = false + viewModel.dismissLauncherPrompt() + }, + onLater = { showLauncherPrompt = false }, + ) + } + + // Summoned by four taps on the wordmark. A dialog rather than an overlay inside the content, + // so it covers the navigation bar too — a splash framed by app chrome is not a splash. + if (showSplash) { + Dialog( + onDismissRequest = { showSplash = false }, + properties = + DialogProperties(usePlatformDefaultWidth = false, dismissOnClickOutside = true), + ) { +LocalizedOverlay { + + Box( + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + showSplash = false + } + ) { + WingedVictory() + } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(2800) + showSplash = false + } + } +} + } +} + +/** + * The one prompt Vector shows unasked, and only when there is genuinely no way back in. + * + * Two buttons rather than four: the primary is whichever remedy this device can actually apply, and + * the other is the refusal. "Later" is the dialog's ordinary dismissal — tapping away or pressing + * back — because that is already what dismissing a dialog means, and a third button spelling it out + * would crowd out the two that do something. + */ +@Composable +private fun LauncherPrompt( + shortcutSupported: Boolean, + onCreateShortcut: () -> Unit, + onInstall: () -> Unit, + onNever: () -> Unit, + onLater: () -> Unit, +) { + VectorAlertDialog( + onDismissRequest = onLater, + icon = { Icon(Icons.AutoMirrored.Rounded.AddToHomeScreen, contentDescription = null) }, + title = { Text(stringResource(R.string.launcher_prompt_title)) }, + text = { Text(stringResource(R.string.launcher_prompt_body)) }, + confirmButton = { + // A launcher that refuses pin requests leaves installing as the only remedy, so that is + // what the button offers rather than one that would visibly do nothing. + if (shortcutSupported) { + TextButton(onClick = onCreateShortcut) { + Text(stringResource(R.string.launcher_shortcut_create)) + } + } else { + TextButton(onClick = onInstall) { + Text(stringResource(R.string.launcher_install_action)) + } + } + }, + dismissButton = { + TextButton(onClick = onNever) { Text(stringResource(R.string.launcher_prompt_never)) } + }, + ) +} + +/** + * The window's activity: a headline, the people, then the rail. + * + * People come before commits deliberately. The section exists to make participation visible, and + * faces do that faster than a list of subjects does. + */ +private fun androidx.compose.foundation.lazy.LazyListScope.communitySection( + feed: CommunityFeed, + items: List, + loadingHistory: Boolean, + historyStalled: Boolean, + windowChanged: Boolean, + authorFilter: Set, + onLoadMoreHistory: () -> Unit, + onToggleAuthor: (String) -> Unit, + onClearAuthors: () -> Unit, + onOpenCommit: (TimelineCommit) -> Unit, + onOpenPullRequest: (Int) -> Unit, + onOpenProfile: (Contributor) -> Unit, +) { + item { QuarterHeadline(feed, windowChanged) } + + if (feed.contributors.isNotEmpty()) { + item { + ContributorRow( + contributors = feed.contributors, + selected = authorFilter, + onClick = onOpenProfile, + onLongClick = { onToggleAuthor(it.login) }, + ) + Spacer(Modifier.height(if (authorFilter.isEmpty()) 20.dp else 10.dp)) + } + } + + if (authorFilter.isNotEmpty()) { + item(key = "author-filter") { + AuthorFilterBar( + // Driven by the filter set rather than by the contributor row: a name held from a + // commit row may belong to someone with no place in the row at all — a bot, or + // somebody whose only commit is outside the current window — and a filter you + // cannot see is a filter you cannot lift. + logins = + authorFilter.map { key -> + feed.contributors.firstOrNull { it.login.lowercase() == key }?.login ?: key + }, + // Counted through the bot bundles, not around them: a bundle stands for several + // commits, and a number that disagreed with the same person's tally in the row + // above it would look like one of the two being wrong. + commits = + items.sumOf { item -> + when (item) { + is FeedItem.Commit -> 1 + is FeedItem.Bots -> item.count + else -> 0 + } + }, + onRemove = onToggleAuthor, + onClear = onClearAuthors, + ) + } + } + + items(items = items, key = { it.key() }) { entry -> + when (entry) { + is FeedItem.Commit -> + CommitRow( + commit = entry.commit, + isFirst = entry.isFirst, + isLast = entry.isLast, + onOpenCommit = onOpenCommit, + onOpenPullRequest = onOpenPullRequest, + onFilterAuthor = onToggleAuthor, + ) + is FeedItem.Gap -> + GapRow( + days = entry.days, + heightDp = FeedLayout.railHeightDp(entry.days), + showLabel = entry.days >= FeedLayout.QUIET_THRESHOLD_DAYS, + ) + is FeedItem.InstalledMarker -> + InstalledMarkerRow( + versionCode = entry.versionCode, + commitsAhead = entry.commitsAhead, + aheadOfMaster = entry.aheadOfMaster, + ) + is FeedItem.MonthMarker -> + MonthMarkerRow(entry.month, entry.year, entry.commits, entry.people) + is FeedItem.Bots -> BotBundle(count = entry.count, commits = entry.commits) + } + } + + if (items.isNotEmpty()) { + item(key = "history-foot") { + val locale = currentLocale() + // Only claimed when the whole project is in hand — the count from the `Link` header is + // every commit on the default branch, ever, so holding that many means the row below is + // genuinely the first one. A window that merely reached its own start says nothing, + // because history continues past it. + val wholeProject = feed.totalCommits > 0 && feed.commitCount >= feed.totalCommits + HistoryFootRow( + loading = loadingHistory, + hasMore = feed.hasMoreHistory, + stalled = historyStalled, + beginningDate = + if (!wholeProject) null + else + DateFormat.getDateInstance(DateFormat.LONG, locale) + .format(Date(feed.windowStartEpochSeconds * 1000)), + windowCovered = feed.windowCovered, + onReachEnd = onLoadMoreHistory, + onRetry = onLoadMoreHistory, + autoFetch = authorFilter.isEmpty(), + ) + } + } +} + +/** Stable identity per row, so a refresh does not rebuild the whole rail. */ +private fun FeedItem.key(): String = + when (this) { + is FeedItem.Commit -> "c:${commit.sha}" + is FeedItem.Gap -> "g:$afterSha" + is FeedItem.InstalledMarker -> "installed" + is FeedItem.MonthMarker -> "m:$key" + is FeedItem.Bots -> "bots" + } + +@Composable +private fun BotBundle(count: Int, commits: List) { + var expanded by rememberSaveable { mutableStateOf(false) } + BotBundleRow( + count = count, + expanded = expanded, + onToggle = { expanded = !expanded }, + isLast = true, + ) { + commits.forEach { c -> + Row(modifier = Modifier.padding(start = 36.dp, bottom = 10.dp)) { + Text( + text = c.subject, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun QuarterHeadline(feed: CommunityFeed, windowChanged: Boolean) { + val people = feed.contributors.size + val context = LocalContext.current + Column(Modifier.padding(bottom = 16.dp)) { + Text( + text = stringResource(R.string.home_quarter_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(2.dp)) + if (!feed.loaded) { + Text( + text = stringResource(R.string.home_loading_activity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (feed.isEmpty) { + Text( + text = stringResource(R.string.home_no_activity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val commits = + context.resources.getQuantityString( + R.plurals.home_commit_count, + feed.commitCount, + feed.commitCount, + ) + val by = context.resources.getQuantityString(R.plurals.home_people_count, people, people) + val since = + DateFormat.getDateInstance(DateFormat.MEDIUM, currentLocale()) + .format(Date(feed.windowStartEpochSeconds * 1000)) + Text( + text = "$commits $by · ${stringResource(R.string.home_since, since)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Three different situations, and only one of them is a failure. Home reads the feed from + // disk on most launches *on purpose* — the window moves a few times a week, and + // revalidating every time spends battery and rate limit to redraw identical rows — so a + // cached answer must not be reported as "could not reach GitHub". + // + // A fourth situation, and the only one that asks for something: the window was just + // changed, so what is on screen was re-cut from disk and may not reach as far as the new + // window does. It takes precedence over the other three because it is the newest fact and + // the only actionable one. + if (windowChanged || feed.offline || feed.fromCache) { + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + when { + windowChanged -> Icons.Rounded.Refresh + feed.offline -> Icons.Rounded.CloudOff + else -> Icons.Rounded.Bedtime + }, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = + if (windowChanged) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = + stringResource( + when { + windowChanged -> R.string.home_window_changed + feed.offline -> R.string.home_offline + else -> R.string.home_resting + } + ), + style = MaterialTheme.typography.labelSmall, + color = + if (windowChanged) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * Two ways back through a feed that can be thousands of commits long. + * + * Hidden until they are needed, which is the only way a persistent control earns its place on a + * screen whose subject is the content behind it. "Needed" is defined as having scrolled past the + * headline — before that, the top is already on screen and a button to reach it is furniture. + * + * The page-down button is the answer to a rail that keeps growing as it is read: with history + * arriving in chunks, a flick lands somewhere arbitrary and the reader has to flick again. One + * viewport at a time is a predictable unit, and it stops existing at the end of the list rather + * than sitting there doing nothing. + * + * Deliberately small and tonal rather than a floating action button: nothing here is *the* action + * of the screen, and a full FAB would claim to be. + */ +@Composable +private fun ScrollControls(listState: LazyListState, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + // Past the headline, so the pair appears at the moment the top of the feed stops being + // reachable by eye. + val visible by remember { derivedStateOf { listState.firstVisibleItemIndex >= 2 } } + val atEnd by remember { + derivedStateOf { + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + last != null && last.index >= info.totalItemsCount - 1 + } + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 }, + modifier = modifier, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility(visible = !atEnd, enter = fadeIn(), exit = fadeOut()) { + FilledTonalIconButton( + onClick = { + scope.launch { + // A shade under a full screen, so the line the reader stopped on stays + // visible at the top and the two screens are stitched rather than cut. + listState.animateScrollBy( + listState.layoutInfo.viewportSize.height * 0.9f + ) + } + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Rounded.KeyboardDoubleArrowDown, + contentDescription = stringResource(R.string.home_scroll_down), + modifier = Modifier.size(20.dp), + ) + } + } + FilledTonalIconButton( + onClick = { scope.launch { listState.animateScrollToItem(0) } }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Rounded.KeyboardDoubleArrowUp, + contentDescription = stringResource(R.string.home_scroll_top), + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +/** + * What filter mode looks like: who is being shown, how much of the history that is, and the way out. + * + * It is a bar rather than a badge on the header because it has to carry the exit. A filtered list + * that gives no visible way back is the sort of state people escape by force-quitting the app, and + * the gesture that entered it — a long press, somewhere up the row — is not one anyone should have + * to rediscover. + * + * Each name is its own chip with its own dismiss, so removing the second of two people is one tap + * rather than clearing and starting again. Removing the last one leaves the set empty, which *is* + * the unfiltered state — there is no separate "off" to get out of step with. + */ +@Composable +private fun AuthorFilterBar( + logins: List, + commits: Int, + onRemove: (String) -> Unit, + onClear: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth().padding(bottom = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.FilterAlt, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = pluralStringResource(R.plurals.home_commit_count, commits, commits), + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onClear, contentPadding = PaddingValues(horizontal = 10.dp)) { + Text(stringResource(R.string.home_filter_clear)) + } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + logins.forEach { login -> + InputChip( + selected = true, + onClick = { onRemove(login) }, + label = { Text(login, maxLines = 1) }, + trailingIcon = { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.home_filter_remove, login), + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } +} + +/** + * How the contributor row is ordered. + * + * Recency is not a lesser ordering, it is a different kind of credit: by volume the maintainer is + * first forever and the row never moves, which is accurate and says nothing new; by recency the + * person who last landed something leads, and a first contribution is visible the day it happens. + * Both break ties with the other, so neither is ever arbitrary. + */ +enum class ContributorOrder(val key: String, val labelRes: Int) { + Commits("commits", R.string.home_contributors_by_commits), + Recent("recent", R.string.home_contributors_by_recent); + + fun sort(people: List): List = + when (this) { + Commits -> + people.sortedWith( + compareByDescending { it.commits } + .thenByDescending { it.lastEpochSeconds } + .thenBy { it.login } + ) + Recent -> + people.sortedWith( + compareByDescending { it.lastEpochSeconds } + .thenByDescending { it.commits } + .thenBy { it.login } + ) + } + + companion object { + fun from(key: String?): ContributorOrder = entries.firstOrNull { it.key == key } ?: Commits + } +} + +/** + * The people of the window, the leader wreathed. + * + * Scoped to the window rather than all time on purpose: an all-time leaderboard is a monument and + * never changes, so nobody reads it twice. One cut to a few months moves, and a first-time + * contributor appears on it immediately. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ContributorRow( + contributors: List, + selected: Set, + onClick: (Contributor) -> Unit, + onLongClick: (Contributor) -> Unit, +) { + // The preference is read here rather than threaded down from the screen: the row is emitted + // from a LazyListScope extension, which is not a composable and has no state to hand over. + // Sorting here also means changing the setting reorders the row immediately, with no re-fetch + // of a feed that has not changed. + val order = + ContributorOrder.from( + ServiceLocator.settings.contributorOrder.collectAsStateWithLifecycle().value + ) + val people = remember(contributors, order) { order.sort(contributors) } + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(people, key = { it.login }) { person -> + val leader = person == people.first() + // A co-author signed with a plain email address has no GitHub identity to open, so the + // tap is withheld and the avatar dimmed rather than offering something that goes + // nowhere. They are still shown, still counted, and still filterable — they have + // commits like anyone else — so the long press is offered to the whole row. + val hasProfile = !person.profileUrl.isNullOrBlank() + val picked = person.login.lowercase() in selected + val haptics = LocalHapticFeedback.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.combinedClickable( + onClick = { if (hasProfile) onClick(person) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick(person) + }, + ) + .alpha( + when { + // In filter mode the people not being shown step back, so the row + // says at a glance whose rail is on screen. + selected.isNotEmpty() && !picked -> 0.35f + hasProfile -> 1f + else -> 0.45f + } + ) + .width(72.dp), + ) { + ContributorAvatar( + login = person.login, + avatarUrl = person.avatarUrl, + size = 44.dp, + laurelled = leader, + selected = picked, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = person.login, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = person.commits.toString(), + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ProjectFooter(feed: CommunityFeed, onClick: () -> Unit) { + val repo = feed.repo ?: return + Box( + modifier = Modifier.fillMaxWidth().clickable { onClick() }, + // Centred: it is a standing fact about the project rather than a list item, and centring + // reads as a footer rather than as one more left-aligned row in the stack above. + contentAlignment = Alignment.Center, + ) { + val locale = currentLocale() + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FooterStat(Icons.Rounded.Star, compactCount(repo.stars, locale)) + FooterStat(Icons.AutoMirrored.Rounded.CallSplit, compactCount(repo.forks, locale)) + FooterStat(Icons.Rounded.BugReport, repo.openIssues.toString()) + repo.license?.spdxId?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun FooterStat(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Taps must land within this window of each other to count towards the same run. */ +private const val BRAND_TAP_WINDOW_MS = 2600L + +private const val BRAND_TAPS_TO_SUMMON = 4 diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt new file mode 100644 index 000000000..725ff534d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,712 @@ +package org.matrix.vector.manager.ui.screens.home +import kotlinx.coroutines.CancellationException +import org.matrix.vector.manager.data.repository.FrameworkUpdateState +import org.matrix.vector.manager.data.repository.LaunchShortcut +import org.matrix.vector.manager.data.repository.ManagerInstallStep +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlin.random.Random +import kotlinx.coroutines.launch +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.model.ManagerCopy +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW +import org.matrix.vector.manager.ui.components.FrameworkState + +/** A specific reason the framework is degraded, so the UI never has to say merely "something". */ +enum class HealthIssue { + SepolicyNotLoaded, + SystemServerNotInjected, + Dex2oatWrapperBroken, +} + +data class FrameworkStatus( + val state: FrameworkState = FrameworkState.Checking, + val versionName: String? = null, + val versionCode: Long = 0, + val apiVersion: Int? = null, + val issues: List = emptyList(), + val dex2oatWrapperState: Int = IManagerService.DEX2OAT_OK, + val sepolicyLoaded: Boolean = false, + val systemServerInjected: Boolean = false, + /** + * Which build the running framework is: what commit it came from and where it was built. + * + * The version code is a commit count, so it cannot tell a branch build from the official build + * of the same depth — and the framework and the manager are flashed separately, so they are + * not always the same build. Naming both is the difference between a bug report that can be + * placed and one that cannot. The commit leads: a CI build reads `93d66473-JingMatrix-Vector`, + * a clean local one the bare hash, and a local build from a modified tree marks the machine + * that made it with a `+`. Taken apart by `buildStamp`; nothing compares it as a string. + */ + val commit: String? = null, +) { + val versionLabel: String? + get() = versionName?.let { if (versionCode > 0) "$it ($versionCode)" else it } + + /** + * Whether there is a daemon this manager can actually ask anything. + * + * Not `state != Inactive`, which is what the two callers used to test and which + * [FrameworkState.Mismatched] would answer wrongly: there a framework is plainly running and + * simply speaking a generation of the interface this build does not, so every transaction + * fails. Anything gated on being able to *use* the daemon belongs here rather than on a + * comparison a later state can slip past. + */ + val daemonUsable: Boolean + get() = state != FrameworkState.Inactive && state != FrameworkState.Mismatched +} + +/** + * How the manager can be reached on this device. + * + * Parasitically it is not installed, so the launcher has nothing to show — which is what #815 + * reported. Four routes lead back in: a pinned shortcut, an installed copy, the status + * notification, and the two that need no setup at all, the dialer code and the root manager's + * action button. The first three are what the status page offers, and the fields below decide + * which are worth offering — any one of them already solves it, and a launcher that refuses pin + * requests rules the first out entirely. + */ +data class ManagerPresence( + /** Injected into the host rather than installed. False leaves nothing here to offer. */ + val parasitic: Boolean = true, + val shortcutSupported: Boolean = false, + /** + * A shortcut on the home screen the reader is looking at *now*. + * + * Deliberately narrower than "a shortcut exists": a pin does not follow the reader to a launcher + * they install later, so a device that has switched launchers has a route the platform still + * reports and a home screen with nothing on it — #883. See LaunchShortcut.isPinnedHere. + */ + val shortcutPinned: Boolean = false, + /** + * Which copy of the manager is installed beside this one, if any. + * + * Three answers rather than a boolean because a copy of another build is both at once: it is a + * route back in, so nothing here should press for another, and it is not this build, so the row + * offering the install has something left to offer. See [ManagerCopy] for why the version code + * alone cannot separate the last two. + */ + val manager: ManagerCopy = ManagerCopy.Absent, + /** + * The status notification is a way in, not only a status. + * + * Its content intent opens the manager — see the daemon's NotificationManager — so a device + * showing it is a device with a tap-sized route back, and it is on by default. Leaving it out + * of this made the first-launch prompt claim there was no way back in to a reader who was + * looking at one. + * + * Seeded to what the daemon itself would answer for a device where nobody has touched the + * preference — PreferenceStore.isStatusNotificationEnabled reads the stored value `?: true` — + * because this seed is the answer until the daemon's own has come back, which on a launch that + * has no binder yet is nine round trips away rather than one. Seeded false it said the opposite + * of what was sitting in the shade. + */ + val notificationEnabled: Boolean = true, + /** + * False until the daemon has actually answered [notificationEnabled]. + * + * [unreachable] is a claim about a device, and it must not be made before the read that would + * settle it has come back. How long that takes depends on which of the two arrived first, this + * ViewModel or the binder. With a binder already in hand, `init` issues the toggle read through + * `refreshPresence` before `refreshStatus` asks for anything, and the wait is one cheap round + * trip; with a binder that lands later, that call found nothing to ask and returned, and the + * read that answers is the one the `service` collect makes once `refreshStatus`'s eight + * sequential round trips are done. The second window is long enough to be seen. The seed above + * is what makes both harmless today; this is what keeps them harmless if the seed is ever wrong + * again, because not knowing and knowing there is no way in are not the same thing and only the + * second is worth a modal. + */ + val notificationKnown: Boolean = false, + /** One of the IManagerService.ROOT_* constants, for naming the action button's owner. */ + val rootImplementation: Int = 0, +) { + /** + * True when opening the manager currently depends on remembering how. + * + * False while [notificationKnown] is false as well. The only thing that reads this puts a + * scrimmed dialog in front of the reader, and an unanswered question is not a missing route. + * + * That term withholds nothing an answer has justified: both writers of [notificationEnabled] + * set [notificationKnown] in the same update, so a false [notificationEnabled] already implies + * the daemon answered. What keeps the dialog away from a device whose daemon never answers at + * all is the optimistic seed, and deliberately — HomeScreen already declines to draw it while + * the framework reads inactive, and the offer to install a manager APK is one only a live + * daemon can hand over. + */ + val unreachable: Boolean + get() = + notificationKnown && + parasitic && + !shortcutPinned && + !manager.installed && + !notificationEnabled +} + +data class DeviceInfo( + val androidRelease: String = Build.VERSION.RELEASE ?: "", + val sdkInt: Int = Build.VERSION.SDK_INT, + val device: String = "${Build.MANUFACTURER.replaceFirstChar { it.uppercase() }} ${Build.MODEL}", + val abi: String = Build.SUPPORTED_ABIS.firstOrNull() ?: "", +) + +class HomeViewModel( + private val daemon: DaemonClient, + private val github: GitHubRepository, +) : ViewModel() { + + val openLinksExternally: StateFlow = ServiceLocator.settings.openLinksExternally + + val headerAmbience: StateFlow = ServiceLocator.settings.headerAmbience + + private val _status = MutableStateFlow(FrameworkStatus()) + val status: StateFlow = _status.asStateFlow() + + private val _feed = MutableStateFlow(CommunityFeed()) + val feed: StateFlow = _feed.asStateFlow() + + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = _refreshing.asStateFlow() + + val device = DeviceInfo() + + // --- how the manager can be reached ------------------------------------------------------- + // Above `init` on purpose: a Kotlin class body initialises top to bottom, so the refresh in + // `init` would run against a `_presence` that does not exist yet — it writes there + // synchronously, before it launches anything. The two framework toggles are here for the same + // concern rather than the same certainty: `init` reaches `refreshToggles` through + // `refreshPresence`, and `viewModelScope` dispatches on `Main.immediate`, so that body starts + // running on the thread that is still constructing this object instead of being posted for + // later. What saves it is what it does first rather than where these flows sit — it writes + // nothing until a `runIpc` has answered, and `runIpc` is a `withContext(Dispatchers.IO)` that + // always dispatches — so declared below `init` it would be one changed first statement away + // from writing to a null. + + private val _presence = MutableStateFlow(ManagerPresence()) + + // True is the daemon's own default for `enable_status_notification` — PreferenceStore reads the + // stored value `?: true` — so the switch shows what the framework is doing on a device where + // nobody has touched it rather than reading "off" until the daemon answers. A switch that reads + // off while the notification is sitting in the shade is worse than one that briefly reads + // optimistically: the first contradicts something the reader can see. + private val _statusNotification = MutableStateFlow(true) + val statusNotification: StateFlow = _statusNotification.asStateFlow() + + // True is the platform's own default for `show_hidden_icon_apps_enabled`, so the switch shows + // what the system is doing on a device where nobody has touched it rather than reading "off" + // until the daemon answers. + private val _hiddenIcon = MutableStateFlow(true) + val hiddenIcon: StateFlow = _hiddenIcon.asStateFlow() + + /** + * How this manager can be opened, and how it currently is. + * + * Read from the launcher and the package manager rather than remembered, because both can + * change while the app is not running: a shortcut can be dragged off the home screen, and the + * manager can be installed or uninstalled from anywhere. + */ + val presence: StateFlow = _presence.asStateFlow() + + val managerInstall: StateFlow = ServiceLocator.managerInstaller.state + + /** Set once the reader has said they do not want to be offered a launcher icon again. */ + val launcherPromptDismissed: StateFlow + get() = ServiceLocator.settings.launcherPromptDismissed + + /** + * Whether the status badge should still be pointing out that it opens something. + * + * The settings for how to open Vector are on that page and the badge is the only way to it, so a + * reader who has never tapped it has no reason to think a tick is a button — #856. The header + * makes the case by having the tick turn into a gear for a moment now and then, and this is what + * calls it off once it has been made. + */ + val statusBadgeHint: StateFlow = + ServiceLocator.settings.statusBadgeOpens + .map { it < STATUS_BADGE_HINT_OPENS } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + ServiceLocator.settings.statusBadgeOpens.value < STATUS_BADGE_HINT_OPENS, + ) + + /** + * Counts a badge tap that opened System status. + * + * Only the badge, not every arrival at that page: the hint is teaching where *this control* + * leads, so a visit that came from a notification or a deep link has not learnt it. + */ + fun noteStatusBadgeOpened() = ServiceLocator.settings.noteStatusBadgeOpened() + + /** Re-cuts today's badge count, so a session that crossed midnight offers the hint again. */ + fun refreshStatusBadgeHint() = ServiceLocator.settings.refreshStatusBadgeOpens() + + fun refreshPresence() { + val context = ServiceLocator.context + _presence.update { + // Everything here is answered locally, so it stays synchronous and the first frame is + // already right. The notification and the root implementation come from the daemon and + // are folded in as they arrive, leaving whatever was last known in the meantime. Which + // build the installed manager is belongs to that second group and only half of it is + // asked for here: the package either exists or it does not, and the installer repeats + // what it last compared rather than comparing again on this thread. + it.copy( + parasitic = LaunchShortcut.isParasitic(context), + shortcutSupported = LaunchShortcut.isSupported(context), + shortcutPinned = LaunchShortcut.isPinnedHere(context), + manager = ServiceLocator.managerInstaller.installedManager(), + ) + } + viewModelScope.launch { + // The toggles first, because one of them decides whether the "no way back in" prompt is + // drawn at all while the root implementation only names a button on the card that + // explains the ways in. On a launch that already has a binder this is the first daemon + // read the ViewModel starts, ahead of `refreshStatus`'s: `init` calls `refreshPresence` + // before it begins collecting, and `Main.immediate` runs this body inline on the + // constructing thread. It is also the only retry the toggle reads ever get — arriving + // at Home or at the status page is the moment a stale switch would be looked at, and it + // is a moment somebody chose, unlike a timer. + refreshToggles() + val root = daemon.getRootImplementation().getOrNull() + if (root != null) _presence.update { it.copy(rootImplementation = root) } + // Last, because nothing above waits on it and it is the one read here that can run to + // seconds and to tens of megabytes: an installed copy wearing this build's own version + // code is only settled by hashing both APKs. Until it lands the row shows that copy's + // last known verdict, which for a copy nothing has touched since is still right. + val manager = ServiceLocator.managerInstaller.refreshInstalledManager() + _presence.update { it.copy(manager = manager) } + } + } + + /** Removes the copy of the manager whose signature is refusing the install. */ + fun removeConflictingManager() { + viewModelScope.launch { + ServiceLocator.managerInstaller.removeConflicting() + refreshPresence() + } + } + + /** + * Asks the launcher to pin a Vector icon. + * + * Returns whether the request was accepted, not whether an icon appeared: the launcher puts its + * own confirmation in front of the user, and may never come back. When it does, + * [refreshPresence] runs and the row that offered this reports that it is done. + */ + fun requestShortcut(): Boolean = + LaunchShortcut.request(ServiceLocator.context) { refreshPresence() } + + fun installManagerApp() { + viewModelScope.launch { + ServiceLocator.managerInstaller.install() + refreshPresence() + } + } + + fun acknowledgeManagerInstall() = ServiceLocator.managerInstaller.acknowledge() + + fun dismissLauncherPrompt() = ServiceLocator.settings.dismissLauncherPrompt() + + init { + refreshPresence() + // The binder may arrive after this ViewModel exists — injection order is not ours to + // control — so status is re-derived whenever it changes rather than read once in init. + viewModelScope.launch { + // Both flows, because a refused binder leaves `service` null and only moves + // `peerDescriptor`. Collecting `service` alone would see no change at all — it was + // already null — and the header would sit on "not activated" for a framework that is + // running and simply out of step with this build. + combine(ServiceLocator.service, ServiceLocator.peerMismatch) { service, _ -> service } + .collect { service -> + refreshStatus(service) + // Both switches on the status page hold the daemon's state rather than ours, + // and the binder is what they need. This runs for every binder, including one + // already in hand when `refreshPresence` ran above — a second read of two + // idempotent values — and it is here for the one that arrives afterwards, + // which that call found nothing to ask about and returned. Nor is it the last + // such moment: `refreshPresence` asks again whenever a screen that shows them + // is opened, since this flow does not emit a second time while one binder + // stays alive. + if (service != null) refreshToggles() + } + } + // Returning to Home is not a reason to talk to GitHub. The page renders from disk every + // time and only occasionally goes and checks — the window it shows changes a few times a + // week at most, and the user's battery and their share of an anonymous rate limit are worth + // more than redrawing identical rows. Pull-to-refresh is always there when they do want it. + // + // Opening the app *is* a reason, and the toss used to apply there too: four launches in five + // showed whatever was on disk, which after a while is a feed that has quietly stopped + // moving — and a cold start is exactly when it has had the longest to go stale. So the first + // Home of a process always checks, and the toss governs only the visits after it. Process + // scope rather than a timestamp because that is what "since the app was opened" means here: + // parasitically the host is `com.android.shell` and is killed constantly, but each of those + // deaths is also what makes the next arrival a first launch to the reader. + val firstThisProcess = !homeOpenedThisProcess + homeOpenedThisProcess = true + val checkNow = firstThisProcess || Random.nextFloat() >= FEED_PAUSE_PROBABILITY + refreshFeed( + if (checkNow) GitHubRepository.Freshness.Revalidate + else GitHubRepository.Freshness.Cached + ) + viewModelScope.launch { + // drop(1): the value on subscription is the window already rendered. + ServiceLocator.settings.activityWindowMonths.drop(1).collect { + _windowChanged.value = true + // From disk, not from GitHub. The archive already holds the commits; the window is + // only a view of it, so re-cutting it costs nothing and the feed answers on the + // frame after the sheet closes. + _feed.value = github.load(GitHubRepository.Freshness.Cached) + } + } + } + + private suspend fun refreshStatus(service: IManagerService?) { + if (service == null || !daemon.isAlive) { + // A binder did arrive and was refused for speaking a different generation of the + // interface, which is not the same thing as there being no framework — and saying "not + // activated" for it sends the reader to reinstall something that is already running. + val mismatch = ServiceLocator.peerMismatch.value + _status.value = + FrameworkStatus( + state = + if (mismatch != null) FrameworkState.Mismatched else FrameworkState.Inactive + ) + return + } + + val versionName = daemon.getFrameworkVersionName().getOrNull() + val commit = daemon.getBuildStamp().getOrNull() + val versionCode = + daemon + .getFrameworkVersionCode() + .onFailure { e -> + logW("status: framework version code unavailable, update check skipped", e) + } + .getOrDefault(0L) + val api = daemon.getLibxposedApiVersion().getOrNull() + + // One line for both, because they fail together on a wedged binder and only these two + // defaults synthesise a red HealthIssue card. + val sepolicyResult = daemon.isSepolicyLoaded() + val systemServerResult = daemon.isSystemServerAttached() + val healthFailure = sepolicyResult.exceptionOrNull() ?: systemServerResult.exceptionOrNull() + if (healthFailure != null && healthFailure !is CancellationException) { + logW( + "status: framework health read failed, defaulting to sepolicy/system_server " + + "not loaded", + healthFailure, + ) + } + val sepolicy = sepolicyResult.getOrDefault(false) + val systemServer = systemServerResult.getOrDefault(false) + val dex2oat = daemon.getDex2OatWrapperState().getOrDefault(IManagerService.DEX2OAT_OK) + val inliningDisabled = daemon.isDex2OatInliningDisabled().getOrDefault(true) + + val issues = buildList { + if (!sepolicy) add(HealthIssue.SepolicyNotLoaded) + if (!systemServer) add(HealthIssue.SystemServerNotInjected) + // The wrapper and the property are two routes to one end, not a pair: the daemon + // deletes `dalvik.vm.dex2oat-flags` when it mounts the wrapper over dex2oat and sets + // it when it unmounts, so either route suppresses the inlining. A wrapper that is not + // OK therefore only costs anything when the property is not carrying the flag either. + if (dex2oat != IManagerService.DEX2OAT_OK && !inliningDisabled) { + add(HealthIssue.Dex2oatWrapperBroken) + } + } + + _status.value = + FrameworkStatus( + state = if (issues.isEmpty()) FrameworkState.Active else FrameworkState.Degraded, + commit = commit, + versionName = versionName, + versionCode = versionCode, + apiVersion = api, + issues = issues, + dex2oatWrapperState = dex2oat, + sepolicyLoaded = sepolicy, + systemServerInjected = systemServer, + ) + + if (versionCode > 0) { + viewModelScope.launch { ServiceLocator.frameworkUpdates.refresh(versionCode, commit) } + } + } + + // --- filtering the rail by author --------------------------------------------------------- + + /** + * The logins whose commits are being shown, lower-cased; empty means everyone. + * + * A set rather than a single login because collaboration is the thing this screen is about: + * two people's rails side by side answers "what did we do together", which one person's rail + * cannot. Emptying it is the only way out of filter mode, so there is exactly one way back to + * the whole history and it is the same gesture that got you here. + */ + private val _authorFilter = MutableStateFlow>(emptySet()) + val authorFilter: StateFlow> = _authorFilter.asStateFlow() + + fun toggleAuthorFilter(login: String) { + val key = login.lowercase() + _authorFilter.update { if (key in it) it - key else it + key } + } + + fun clearAuthorFilter() { + _authorFilter.value = emptySet() + } + + /** + * The rail, laid out: commits with their elapsed-time gaps, month boundaries, named silences, + * and the marker showing where the reader's own build sits in the history. + */ + val feedItems: StateFlow> = + combine(_feed, _status, _authorFilter) { feed, status, filter -> + // The framework's version when the daemon is up, otherwise this manager's own. + // Both are `git rev-list --count origin/master` on the same repository, so either + // locates a build on the timeline correctly — and without the fallback the marker + // would never appear at all while the daemon is not answering. + val installed = + if (status.versionCode > 0) status.versionCode + else org.matrix.vector.manager.BuildConfig.VERSION_CODE.toLong() + org.matrix.vector.manager.data.github.FeedLayout.build( + feed.filteredBy(filter), + installed, + ) + } + // Off the main thread, and this is not a precaution. `stateIn(viewModelScope, …)` + // collects on the main dispatcher, and laying the rail out — filtering, grouping by + // month, measuring every gap — is a full pass over an archive that runs to thousands + // of commits. On the main thread a filter toggle freezes the very frame that is meant + // to acknowledge the touch. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Whether a newer framework build exists, on the channel this device is actually on. + * + * Refreshed off the back of the status read rather than on its own timer: the version code it + * compares against comes from the same daemon call, and asking GitHub before we know what we + * are running would compare against zero. + */ + val frameworkUpdate: StateFlow = ServiceLocator.frameworkUpdates.state + + /** + * Re-reads both framework switches from the daemon, and is meant to be called again. + * + * `ServiceLocator.service` does not emit a second time while one binder stays alive, so the + * collect in `init` is a single shot: with no other caller, one dropped transaction pinned both + * switches to their seeds — and the launcher prompt to "not asked yet" — for the life of this + * ViewModel. [refreshPresence] is the other caller, and on a launch that already has a binder + * the earlier one, since `init` calls it before it starts collecting; it puts the retry on + * someone opening a screen that shows these rather than on a timer. A timer is the wrong shape + * here anyway: the launcher-icon read costs the daemon a `settings get global` fork every time. + * + * A read that failed writes nothing at all. It is not an answer, and letting it fall back to a + * default is how a switch read correctly on arrival ends up reporting the opposite after a + * single refused transaction. + */ + private suspend fun refreshToggles() { + // Nothing to ask and nothing gained by asking: with no binder both reads fail without + // leaving the process, and the log fills with unreadable-toggle warnings about a framework + // that is simply not running. + if (!daemon.isAlive) return + daemon + .isStatusNotificationEnabled() + .onSuccess { enabled -> + _statusNotification.value = enabled + // The notification is one of the ways into the manager, so what the card offers + // has to follow the same value the switch above it shows — and it is published + // here, not at the end of this function, because the read below forks + // `settings get global` in the daemon and waits for it. Those hundreds of + // milliseconds used to be spent with the launcher prompt believing there was no + // way back into an app the reader may well have opened from this very + // notification. Moving it up is not the whole fix, which is why the seeds and + // [ManagerPresence.notificationKnown] exist: when the binder arrives after this + // ViewModel was built, the call that reaches here is the one in the `service` + // collect, and it waits out refreshStatus's eight sequential round trips first. + _presence.update { + it.copy(notificationEnabled = enabled, notificationKnown = true) + } + } + .onFailure { e -> logW("status: notification toggle unread", e) } + // Read rather than assumed: this one is a global system setting, so anything on the device + // can have moved it since the manager last wrote it. + daemon + .isForcedLauncherIcons() + .onSuccess { _hiddenIcon.value = it } + .onFailure { e -> logW("status: launcher-icon toggle unread", e) } + } + + fun setStatusNotification(enabled: Boolean) { + viewModelScope.launch { + daemon + .setStatusNotificationEnabled(enabled) + .onSuccess { + _statusNotification.value = enabled + // Known either way now, which matters when `enabled` is false: someone who + // turns the notification off on a parasitic install with nothing else pinned + // has just closed the last easy route back in, and that is precisely the case + // the prompt exists to catch. + _presence.update { + it.copy(notificationEnabled = enabled, notificationKnown = true) + } + } + .onFailure { e -> + logE("framework: setting the status notification to $enabled failed", e) + } + } + } + + fun setForcedLauncherIcons(force: Boolean) { + viewModelScope.launch { + daemon.setForcedLauncherIcons(force).onSuccess { + // Read back rather than assumed. The AIDL call returns nothing, and the daemon + // applies it by running `settings put global`, which can fail without saying so — + // so a transaction that arrived is not yet a setting that changed. + _hiddenIcon.value = daemon.isForcedLauncherIcons().getOrDefault(force) + } + } + } + + fun refreshFeed(freshness: GitHubRepository.Freshness) { + // Claimed before the coroutine starts rather than inside it. A pull-to-refresh that lands + // while init's own load is still running would otherwise fetch the same window twice, and + // the second answer would overwrite the first for no gain. + if (_refreshing.value) return + _refreshing.value = true + viewModelScope.launch { + try { + // Loaded first, assigned second, rather than through `MutableStateFlow.update`. + // That is a compare-and-set spin loop — it re-invokes its lambda whenever another + // writer wins the race — and the lambda here would be a network fetch, an archive + // append, a snapshot rewrite and a several-thousand-commit re-parse. Three writers + // touch this flow: the window collector, pull-to-refresh and the backfill. + val loaded = github.load(freshness) + _feed.value = loaded + // Any load that asked the network for something settles the debt below, whether or + // not the answer came from there in the end. + if (freshness != GitHubRepository.Freshness.Cached) _windowChanged.value = false + } finally { + // Given back even when the load threw. A flag left set spins the indicator for the + // life of the process and turns every later pull into a no-op. + _refreshing.value = false + } + } + } + + /** + * True when the window was changed and nothing has been fetched since. + * + * Changing "the last six months" to "since the beginning" redraws immediately from what is + * already on disk, which for a *narrower* window is the whole answer and for a wider one is as + * much of it as has been walked so far. This flag carries the difference: the page says a fetch + * would help and leaves the choice to the reader, rather than spending their rate limit the + * moment they touch a setting. + */ + private val _windowChanged = MutableStateFlow(false) + val windowChanged: StateFlow = _windowChanged.asStateFlow() + + private val _loadingHistory = MutableStateFlow(false) + val loadingHistory: StateFlow = _loadingHistory.asStateFlow() + + /** + * Reaches further back, when the reader has scrolled far enough to mean it. + * + * Deliberately driven by scrolling rather than by opening Home. Most people never reach the + * bottom of the feed, and walking the whole history for them would spend their share of an + * anonymous rate limit on commits they will not look at — and spend it before the part they + * will look at can be refreshed. Someone at the end of the list has asked, as plainly as + * scrolling can ask. + * + * Each call fetches a few pages and returns, so the rail grows in steps while the reader keeps + * scrolling rather than freezing until the whole history has landed. The count and the + * contributor scoreboard are recomputed from the same reload, which is what makes the stats + * settle as chunks arrive instead of all at the end. + */ + fun loadMoreHistory() { + if (_loadingHistory.value || !_feed.value.hasMoreHistory) return + _loadingHistory.value = true + viewModelScope.launch { + try { + val added = runCatching { github.backfill() }.getOrDefault(0) + // Reads from disk: the pages just walked are already in the archive, and going + // back to GitHub here would spend a request to be told what we have just been + // told. The reload happens even when nothing was added, so that a walk which ended + // by finding no new commits can clear the invitation to keep scrolling. + _feed.value = github.load(GitHubRepository.Freshness.Cached) + // Assigned rather than latched. A walk that came back with commits is proof the + // network and the rate limit are fine, so it has to be able to clear this as well + // as to set it; otherwise one refused walk would leave the rail insisting history + // had run out for the rest of the process. + _exhausted.value = added == 0 + } finally { + // Given back even when the reload threw, since the guard above reads this flag — + // leaving it set would end the rail's history for the life of the process. + _loadingHistory.value = false + } + } + } + + /** + * True when the last walk came back empty-handed for a reason we cannot distinguish from + * failure. + * + * A refused request and a finished history look the same from here, and retrying a refused one + * on every scroll would hammer a rate limit that is already exhausted. This stops the automatic + * retries until a walk brings something back; the foot of the feed stays tappable, so a reader + * who knows they are back online can ask again — and the walk they ask for is what clears it. + */ + private val _exhausted = MutableStateFlow(false) + val historyStalled: StateFlow = _exhausted.asStateFlow() + + companion object { + /** How often *returning* to Home leaves the feed on what is already on disk. */ + private const val FEED_PAUSE_PROBABILITY = 0.8f + + /** + * True once Home has been built in this process, whatever the feed did about it. + * + * On the companion because that is exactly the scope wanted — one per process, shared by + * every HomeViewModel a session builds, and gone when the host is killed. Volatile because + * `init` runs on whichever thread built the ViewModel. + */ + @Volatile private var homeOpenedThisProcess = false + + /** + * How many times a day the badge has to be used before it stops explaining itself. + * + * Five is comfortably more than an accident and less than a sitting spent on that page. The + * count resets daily — see SettingsRepository.statusBadgeOpens — so this is not a budget + * that runs out for good. + */ + private const val STATUS_BADGE_HINT_OPENS = 5 + + val Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + HomeViewModel( + ServiceLocator.daemon, + ServiceLocator.github, + ) + as T + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt new file mode 100644 index 000000000..74f7c1dd2 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt @@ -0,0 +1,263 @@ +package org.matrix.vector.manager.ui.screens.home + +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription +import org.matrix.vector.manager.ui.theme.translatorsFor +import org.matrix.vector.manager.ui.theme.Translator +import org.matrix.vector.manager.ui.theme.CROWDIN_URL +import androidx.compose.material3.AssistChip +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.util.Locale +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.theme.availableLocales +import org.matrix.vector.manager.ui.theme.nativeName + +/** + * The language, in the languages themselves. + * + * Every row is written in its own language and its own script — Deutsch, Русский, 日本語 — because a + * list of English names is unreadable to precisely the person looking for a language they can read. + * The English name follows underneath, since the reader might be choosing on behalf of someone else, + * or checking they picked the right one. + * + * The list comes from `BuildConfig.TRANSLATIONS`, which the manager's build script fills in from the + * `values-*` folders that carry our own `strings.xml` — so a language appears here as soon as a + * translator's folder is built, with nothing to remember to update. It cannot be read from the + * resources at runtime: `AssetManager.getLocales()` reports every locale any dependency ships + * anything for, which is dozens of languages the app has never seen. + * + * Choosing does not close the sheet or restart anything. The strings behind it change immediately + * and the row itself swells into place, so the effect of the choice is visible while the choice is + * still being made — which is also the honest way to preview a language you may not be able to read. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguageSheet(onOpen: (String) -> Unit, onDismiss: () -> Unit) { + val settings = ServiceLocator.settings + val current by settings.appLocale.collectAsStateWithLifecycle() + val context = LocalContext.current + val locales = remember { availableLocales() } + // Every value stays enabled, deliberately. Dropping PartiallyExpanded removes the half-height + // stop, which is the only thing a drag on a sheet can *do* other than dismiss it, so a sheet + // taller than half the screen would open at full height and could not be made smaller. Left + // alone, Material caps that stop at the sheet's own height, so short sheets still open at + // their own height and nothing gains a useless drag. + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { +LocalizedOverlay { + + // Title and invitation share one row. At the half-height rest there are about five rows on + // screen, so a two-line list item for the invitation would spend two of them before the + // reader reached a single language — and it would outweigh the sheet's own title. One row + // keeps the invitation visible at any sheet height and leaves the list its space. + Row( + modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.Translate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.language_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + val invitation = stringResource(R.string.language_help) + AssistChip( + onClick = { onOpen(CROWDIN_URL) }, + label = { Text(stringResource(R.string.language_help_short)) }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + // The chip is short so it fits beside the title in every language; the full + // sentence is what a screen reader should hear, because "Translate" on its own + // could as easily mean translating something *in* the app. + modifier = Modifier.semantics { contentDescription = invitation }, + ) + } + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + + LazyColumn(Modifier.padding(bottom = 24.dp)) { + item { + LanguageRow( + native = stringResource(R.string.language_system), + english = stringResource(R.string.language_system_summary), + selected = current.isBlank(), + onClick = { settings.setAppLocale("") }, + ) + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + } + items(locales, key = { it.toLanguageTag() }) { locale -> + LanguageRow( + native = locale.nativeName(), + english = locale.getDisplayName(Locale.ENGLISH), + selected = current == locale.toLanguageTag(), + onClick = { settings.setAppLocale(locale.toLanguageTag()) }, + credits = translatorsFor(locale), + onOpen = onOpen, + ) + } + } + } +} +} + +/** + * One language. + * + * The selected row is drawn rather than ticked: it lifts onto the primary container and its marker + * springs out. On a list where most rows are in a script the reader cannot parse, a small tick in + * the margin is easy to lose — the shape of the row itself has to carry the answer. + */ +@Composable +private fun LanguageRow( + native: String, + english: String, + selected: Boolean, + onClick: () -> Unit, + credits: List = emptyList(), + onOpen: (String) -> Unit = {}, +) { + val colors = MaterialTheme.colorScheme + val container by + animateColorAsState( + if (selected) colors.primaryContainer else Color.Transparent, + label = "language container", + ) + val markScale by + animateFloatAsState( + if (selected) 1f else 0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "language mark", + ) + + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 3.dp) + .clip(RoundedCornerShape(20.dp)) + .background(container) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text( + text = native, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) colors.onPrimaryContainer else colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = english, + style = MaterialTheme.typography.bodySmall, + color = + if (selected) colors.onPrimaryContainer.copy(alpha = 0.7f) + else colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Only for languages a person put their name to, so the common row keeps its two + // lines. A chip rather than plain text because it is a target: tapping it opens the + // translator's page while a tap anywhere else on the row still picks the language. + if (credits.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + credits.forEach { person -> + AssistChip( + onClick = { person.url?.let(onOpen) }, + enabled = person.url != null, + label = { + Text( + stringResource( + R.string.language_translated_by, + person.name, + ), + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + } + } + Box( + modifier = + Modifier.size(26.dp) + .scale(markScale) + .clip(CircleShape) + .background(colors.primary) + .border(0.dp, Color.Transparent, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier.size(17.dp), + ) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt new file mode 100644 index 000000000..87214af1a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt @@ -0,0 +1,889 @@ +package org.matrix.vector.manager.ui.screens.home + +import android.os.Build +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.AddToHomeScreen +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.InstallMobile +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Switch +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.BuildConfig +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.size +import androidx.compose.ui.draw.alpha +import android.content.res.Configuration +import java.util.Locale +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.CrashRecorder +import org.matrix.vector.manager.data.model.ManagerCopy +import org.matrix.vector.manager.data.model.XposedApi +import org.matrix.vector.manager.data.log.CrashReport +import org.matrix.vector.manager.data.model.buildStamp +import org.matrix.vector.manager.data.repository.ManagerInstallStep +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.copyToClipboard +import org.matrix.vector.manager.ui.components.show +import kotlinx.coroutines.launch +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * Everything a bug report needs about this device, on one page. + * + * A row that reports a *problem* carries its explanation with it rather than just a red word — the + * user of a root framework needs to know what broke and what it costs them, not merely that + * something did. The whole page goes to the clipboard from the top bar. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SystemStatusScreen( + onNavigateBack: () -> Unit, + onOpenCrash: () -> Unit, + viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory), +) { + val status by viewModel.status.collectAsStateWithLifecycle() + val device = viewModel.device + val context = LocalContext.current + val statusNotification by viewModel.statusNotification.collectAsStateWithLifecycle() + val hiddenIcon by viewModel.hiddenIcon.collectAsStateWithLifecycle() + val presence by viewModel.presence.collectAsStateWithLifecycle() + val managerInstall by viewModel.managerInstall.collectAsStateWithLifecycle() + + // Both the shortcut and the install can be undone from outside the app while it is open — + // dragged off the home screen, uninstalled from Settings — so what the rows offer is re-read on + // arrival rather than trusted from whenever the ViewModel was built. + LaunchedEffect(Unit) { viewModel.refreshPresence() } + + val sections = buildSections(status, device, context) + // The same page again, in English, for the clipboard. + // + // This text exists to be pasted into an issue, and the person reading it there is a maintainer + // who may not read the language the reporter's phone is set to. Copying what is on screen is + // the obvious behaviour and the wrong one: a status report in Vietnamese helps nobody triage + // it, and the reporter cannot be expected to switch languages first. The screen stays in the + // reader's language; the clipboard is for someone else. + val englishSections = + remember(status, device) { + val english = + context.createConfigurationContext( + Configuration(context.resources.configuration).apply { + setLocale(Locale.ENGLISH) + } + ) + buildSections(status, device, english) + } + // Read once per visit rather than watched: a crash cannot be recorded while this screen is on + // screen, because the process that would record it is the one drawing it. + var crash by remember { mutableStateOf(CrashRecorder.newest(context)) } + // The two switches below belong to the framework, so they are only live while it is. + val daemonAlive = status.daemonUsable + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.copied) + val shortcutRefused = stringResource(R.string.launcher_shortcut_refused) + val installDone = stringResource(R.string.launcher_install_done) + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.system_status)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + IconButton( + onClick = { + // Copied as it reads, headings and all — this text ends up pasted + // into an issue, where the grouping is as useful as it is on screen. + copyToClipboard( + context, + englishSections.joinToString("\n\n") { (heading, items) -> + heading + + items.joinToString("") { + // With the detail, which the screen only sets apart + // rather than shortens. Where a build came from is + // half of what makes the stamp worth pasting. + "\n ${it.label}: ${it.value}${it.detail.orEmpty()}" + } + }, + ) + scope.launch { snackbars.show(copied, SnackbarTone.Success) } + } + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = stringResource(R.string.action_copy_all), + ) + } + }, + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (status.issues.isNotEmpty()) { + items(status.issues, key = { it.name }) { issue -> IssueCard(issue) } + item { Spacer(Modifier.height(4.dp)) } + } + crash?.let { report -> + item(key = "crashes") { + CrashCard( + report = report, + onOpenTrace = onOpenCrash, + onClear = { + CrashRecorder.clear(context) + crash = null + }, + ) + Spacer(Modifier.height(4.dp)) + } + } + sections.forEach { (heading, items) -> + item(key = "h:$heading") { SectionHeading(heading) } + items(items, key = { it.label }) { row -> InfoRow(row) } + } + + // Framework behaviour, set from the screen that reports on the framework. + item { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(4.dp)) + } + item { + FrameworkToggle( + title = stringResource(R.string.status_notification), + subtitle = stringResource(R.string.status_notification_summary), + checked = statusNotification, + enabled = daemonAlive, + onCheckedChange = viewModel::setStatusNotification, + ) + } + item { + FrameworkToggle( + title = stringResource(R.string.force_launcher_icons), + subtitle = stringResource(R.string.force_launcher_icons_summary), + checked = hiddenIcon, + enabled = daemonAlive, + onCheckedChange = viewModel::setForcedLauncherIcons, + ) + } + + // How to get back in. Only parasitically: installed, the manager has a launcher icon + // like any other app and none of this means anything. + if (presence.parasitic) { + item { + Spacer(Modifier.height(20.dp)) + OpeningVectorCard( + presence = presence, + install = managerInstall, + daemonAlive = daemonAlive, + onCreateShortcut = { + if (!viewModel.requestShortcut()) { + scope.launch { + snackbars.show(shortcutRefused, SnackbarTone.Failure) + } + } + }, + onEnableNotification = { viewModel.setStatusNotification(true) }, + onInstall = viewModel::installManagerApp, + onRemoveConflicting = viewModel::removeConflictingManager, + ) + } + } + } + } + + // Success only. A failure stays on the card, where it can still be read by someone who was not + // looking at this screen when it happened — which is the common case, since the install runs + // while they are free to go elsewhere. Acknowledged first, and shown on the screen's own scope + // rather than this effect's: acknowledging changes the state this effect is keyed on and so + // cancels it, and `show` suspends for as long as the snackbar is up. + LaunchedEffect(managerInstall) { + if (managerInstall !is ManagerInstallStep.Done) return@LaunchedEffect + viewModel.acknowledgeManagerInstall() + scope.launch { snackbars.show(installDone, SnackbarTone.Success) } + } +} + +/** + * The one card that answers "how do I open this again". + * + * A card rather than more rows, because these are not the settings the rows above are and did not + * read as them: a switch is always the same width, so a column of switches lines up, while these + * trailing controls were a long label, a spinner and a button — three different widths that left the + * right-hand edge ragged and squeezed each description into a narrow column with nothing beside it. + * The page already has this shape for "here is a situation, here is what to do about it": IssueCard + * and CrashCard. + * + * One card rather than one per remedy, because the reader's question is not "should I pin a + * shortcut" but "which of these do I have" — and each separate card would have to re-explain the + * same situation before getting to its own answer. + * + * The two routes that need no setup are a closing note rather than rows: nothing can be done to + * them, so a row with no control would be a row that only ever reports. + */ +@Composable +private fun OpeningVectorCard( + presence: ManagerPresence, + install: ManagerInstallStep, + daemonAlive: Boolean, + onCreateShortcut: () -> Unit, + onEnableNotification: () -> Unit, + onInstall: () -> Unit, + onRemoveConflicting: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.launcher_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.launcher_body), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + + RouteRow( + icon = Icons.AutoMirrored.Rounded.AddToHomeScreen, + label = stringResource(R.string.launcher_shortcut), + done = presence.shortcutPinned, + action = stringResource(R.string.launcher_shortcut_create), + // A launcher that refuses pin requests would take the tap and do nothing visible, + // so the row says so rather than offering a button that cannot work. + enabled = presence.shortcutSupported, + onClick = onCreateShortcut, + ) + RouteRow( + icon = Icons.Rounded.Notifications, + // The same setting as the switch above, named the same, because it is the same + // thing seen from the other question: there it is framework behaviour, here it is + // a way in. Both read and write the daemon, so they cannot disagree. + label = stringResource(R.string.status_notification), + done = presence.notificationEnabled, + action = stringResource(R.string.launcher_turn_on), + enabled = daemonAlive, + onClick = onEnableNotification, + ) + RouteRow( + icon = Icons.Rounded.InstallMobile, + label = stringResource(R.string.launcher_install), + // A copy of a different build counts as not done, and has to: a done row is a + // check and nothing else, so marking it done would leave no way to replace it and + // no spinner while it was being replaced. The row's own button then reads as a + // reinstall rather than an install, which is what tells the two apart. + done = presence.manager == ManagerCopy.Present, + action = + stringResource( + if (presence.manager == ManagerCopy.Diverged) + R.string.launcher_install_reinstall + else R.string.launcher_install_action + ), + // The APK comes from the daemon, so there is nothing to install without one. + enabled = daemonAlive, + busy = install is ManagerInstallStep.Installing, + onClick = onInstall, + ) + + // Anything a row cannot say in its one line goes below all three, so that saying it + // does not make one row taller than its neighbours. + if (!presence.shortcutSupported && !presence.shortcutPinned) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.launcher_shortcut_unsupported), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + } + // Said in the reader's own terms rather than left to a button reading "Reinstall" over + // an already installed app, which on its own only looks redundant. Toned like the + // framework's own "same number, different build" note and not like a failure: the + // install worked, and what is installed opens — it is simply not this build. + if (presence.manager == ManagerCopy.Diverged) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.launcher_install_diverged), + style = MaterialTheme.typography.bodySmall, + color = colors.tertiary, + ) + } + if (install is ManagerInstallStep.Failed) { + Spacer(Modifier.height(8.dp)) + InstallFailure(install, onRemoveConflicting) + } + + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.launcher_note, SECRET_CODE, rootManagerName(presence)), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + } + } +} + +/** + * One way in: what it is, and whether this device has it. + * + * The state is an icon rather than a word. "Pinned", "on" and "installed" are three different words + * for one fact — that this route is already available — and reading them as a column made three + * identical answers look like three different ones. + * + * The height is fixed, and that is the whole point of the row existing as its own composable. What + * sits on the right changes as the reader acts — a button becomes a check, or a spinner — and a + * `TextButton` is 40dp tall against an icon's 20dp, so an unpinned row was visibly taller than a + * pinned one and the card jumped every time a state flipped. Nothing here may wrap or stack for the + * same reason: an explanation that needs a second line goes underneath all three rows instead. + */ +@Composable +private fun RouteRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + done: Boolean, + action: String, + enabled: Boolean, + onClick: () -> Unit, + busy: Boolean = false, +) { + val colors = MaterialTheme.colorScheme + Row( + modifier = Modifier.fillMaxWidth().height(ROUTE_ROW_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = if (done) colors.primary else colors.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + // Every trailing slot is either a 20dp icon or a compact button, so the edge stays straight + // however the rows are filled in. + when { + done -> + Icon( + Icons.Rounded.CheckCircle, + contentDescription = stringResource(R.string.launcher_route_available), + tint = colors.primary, + modifier = Modifier.size(20.dp), + ) + busy -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + else -> TextButton(onClick = onClick, enabled = enabled) { Text(action) } + } + } +} + +/** + * Why the install did not happen, on the card rather than in a snackbar. + * + * A snackbar is shown once, to whoever is looking at that screen at that moment. The install can + * finish while the reader is somewhere else — and parasitically the host process is killed often + * enough that they may not even be in the app — so the outcome has to survive on the row that + * offered it. + */ +@Composable +private fun InstallFailure(failure: ManagerInstallStep.Failed, onRemoveConflicting: () -> Unit) { + val colors = MaterialTheme.colorScheme + Column { + Text( + stringResource( + if (failure.signatureConflict) R.string.launcher_install_conflict + else R.string.launcher_install_failed + ), + style = MaterialTheme.typography.bodySmall, + color = colors.error, + ) + // Offered only for the one failure that has an answer. The old copy has to go before the + // platform will accept this one, and it is removed for every user because a copy left in + // another profile refuses the install just as loudly as one in this profile. + if (failure.signatureConflict) { + TextButton(onClick = onRemoveConflicting) { + Text(stringResource(R.string.launcher_install_remove)) + } + } + } +} + +/** + * What to call the root manager in the closing note. + * + * Product names, so they are not translated. The generic fallback covers a daemon that does not + * report one and the two answers that name no single implementation — nothing installed, or two of + * them — where naming one would be a guess. + */ +@Composable +private fun rootManagerName(presence: ManagerPresence): String = + when (presence.rootImplementation) { + IManagerService.ROOT_MAGISK -> "Magisk" + IManagerService.ROOT_KERNELSU -> "KernelSU" + IManagerService.ROOT_APATCH -> "APatch" + else -> stringResource(R.string.launcher_root_generic) + } + +/** Must match `SECRET_CODE` in the daemon's VectorService, which is what actually answers it. */ +private const val SECRET_CODE = "*#*#832867#*#*" + +/** + * Every route row, whatever it currently shows. + * + * 48dp because that is the minimum touch target Material enforces on the button one of these rows + * carries — so it is the tallest state any of them can take, and pinning the rest to it is what + * stops the card resizing under the reader's finger. + */ +private val ROUTE_ROW_HEIGHT = 48.dp + +@Composable +private fun IssueCard(issue: HealthIssue) { + val (title, summary) = + when (issue) { + HealthIssue.SepolicyNotLoaded -> + R.string.issue_sepolicy_title to R.string.issue_sepolicy_summary + HealthIssue.SystemServerNotInjected -> + R.string.issue_system_server_title to R.string.issue_system_server_summary + HealthIssue.Dex2oatWrapperBroken -> + R.string.issue_dex2oat_title to R.string.issue_dex2oat_summary + } + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.Top) { + Icon( + Icons.Rounded.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(Modifier.padding(horizontal = 6.dp)) + Column { + Text(stringResource(title), style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * The manager's own crashes, which nothing else on the device keeps. + * + * On this page rather than under Logs, because every log there is the daemon's and because this is + * the page someone opens when they are about to report something. It summarises the newest crash + * only — the older ones are on file and travel with the log export — since the question being asked + * is "what just happened", not "what has ever happened". + * + * Four facts, not a trace. This card sits among rows that each state one thing, and a block of + * monospace here would be the only thing on the page a reader has to decode rather than read; the + * trace has its own screen, one tap away, where it can be a list instead of a paragraph. The four + * are chosen as the answers to what a maintainer asks first: what threw, what it said, the nearest + * frame that is ours, and when. "Where" is the one worth having on the card at all — it is the + * fact that decides who picks the report up, and it is buried in the middle of the printed trace. + * + * The card is absent when there have been no crashes, which is the normal state and deserves no + * row of its own. + */ +@Composable +private fun CrashCard(report: CrashReport, onOpenTrace: () -> Unit, onClear: () -> Unit) { + val colors = MaterialTheme.colorScheme + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.Top) { + Icon(Icons.Rounded.WarningAmber, contentDescription = null, tint = colors.error) + Spacer(Modifier.padding(horizontal = 6.dp)) + Text( + stringResource(R.string.crash_recorded_title), + style = MaterialTheme.typography.titleSmall, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.crash_recorded_summary), + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + // The root cause rather than what reached the handler: "RuntimeException: Unable to + // start activity" is the platform saying where it noticed, and the end of the chain is + // the sentence that names what actually failed. + report.root?.let { cause -> + CrashFact(stringResource(R.string.crash_what), cause.simpleType, error = true) + cause.message?.let { CrashFact(stringResource(R.string.crash_message), it) } + } + CrashFact( + stringResource(R.string.crash_where), + report.ours?.shortMethod ?: stringResource(R.string.crash_where_unknown), + monospace = report.ours != null, + ) + CrashFact(stringResource(R.string.crash_when), crashWhen(report)) + Spacer(Modifier.height(8.dp)) + Row { + TextButton(onClick = onOpenTrace) { + Text(stringResource(R.string.crash_open_trace)) + } + TextButton(onClick = onClear) { Text(stringResource(R.string.crash_recorded_clear)) } + } + } + } +} + +/** + * One line of the summary, laid out as the rows below it are: label above, fact underneath. + * + * Tighter than [InfoRow] because four of these sit inside a card rather than on the page, and + * because none of them is a status anyone needs to spot from across the room. + */ +@Composable +private fun CrashFact( + label: String, + value: String, + monospace: Boolean = false, + error: Boolean = false, +) { + val colors = MaterialTheme.colorScheme + Column(Modifier.padding(bottom = 8.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = colors.onSurfaceVariant) + Text( + value, + style = + if (monospace) VectorMono.copy(fontSize = 14.sp) + else MaterialTheme.typography.bodyMedium, + color = if (error) colors.error else colors.onSurface, + ) + } +} + +/** + * One fact, at a size meant to be read. + * + * The value is the size of body text rather than of a caption, because the value is what the page + * is *for*. Monospace is kept for identifiers — versions, hashes, package names, ABIs, where + * character-by-character comparison is the point — and dropped for words like "Loaded", which are + * prose and read worse in it. + * + * A fact that can be good or bad says which by its colour, so the page answers "is anything wrong" + * before it is read at all. + * + * A row may end in a [InfoItem.detail], set smaller and in the muted colour. It is part of the same + * value and stays in the same line — a reader copying a version out by hand still gets all of it — + * but it is not what the row is looked up for. On the build rows that is where the stamp came from; + * the commit is what someone comparing two devices reads, and the repository or machine after it is + * two thirds of the characters and almost never the answer. + */ +@Composable +private fun InfoRow(row: InfoItem) { + val colors = MaterialTheme.colorScheme + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = row.label, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = + buildAnnotatedString { + append(row.value) + row.detail?.let { detail -> + val muted = + SpanStyle(fontSize = 12.sp, color = colors.onSurfaceVariant) + withStyle(muted) { append(detail) } + } + }, + style = + if (row.monospace) VectorMono.copy(fontSize = 15.sp) + else MaterialTheme.typography.bodyLarge, + color = + when (row.health) { + Health.Good -> colors.primary + Health.Bad -> colors.error + Health.Neutral -> colors.onSurface + }, + ) + } + if (row.health != Health.Neutral) { + Icon( + imageVector = + if (row.health == Health.Good) Icons.Rounded.CheckCircle + else Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = if (row.health == Health.Good) colors.primary else colors.error, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** Whether a fact is one that can be wrong, and whether it currently is. */ +private enum class Health { + Good, + Bad, + Neutral, +} + +/** A row of the status page. */ +private data class InfoItem( + val label: String, + val value: String, + /** The tail of the value that is context rather than identity; set apart, never dropped. */ + val detail: String? = null, + val health: Health = Health.Neutral, + /** True where the value is an identifier to be compared character by character. */ + val monospace: Boolean = true, +) + +/** + * A build row: the version number, the commit, and — set apart — where that build was made. + * + * The stamp leads with the commit and says where after it, so the split is the commit's own length + * and needs no second opinion about which half is which. What is left includes the separator, which + * is worth keeping visible: `-` is a repository that holds this exact commit and `+` is a machine + * holding changes that no repository does. + * + * A stamp that names no commit — "unknown", from a build made where git could not be asked — is not + * cut at all. It goes to the muted half whole, because none of it is an identifier. + */ +private fun buildRow(label: String, number: String, reported: String?): InfoItem { + val stamp = reported?.takeIf { it.isNotBlank() } ?: return InfoItem(label, number) + val commit = buildStamp(stamp).commit.orEmpty() + return InfoItem(label, "$number · $commit", detail = stamp.removePrefix(commit)) +} + +/** A heading, so the page reads as three short lists rather than one long one. */ +@Composable +private fun SectionHeading(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 20.dp, bottom = 2.dp), + ) +} + +/** + * The page's contents, in three groups. + * + * Grouped because they answer three different questions — what is running, is it working, and on + * what — so a reader after one of them does not have to scan all ten rows to find it. + */ +private fun buildSections( + status: FrameworkStatus, + device: DeviceInfo, + context: Context, +): List>> { + val unknown = "—" + fun str(id: Int) = context.getString(id) + return listOf( + str(R.string.info_section_build) to + listOf( + // The exact build, not just its number. Two builds share a version code whenever + // they sit at the same depth on different branches, so the stamp names where the + // build came from as well as the commit: the repository for a CI build, the machine + // for a local one from a modified tree. That is what a bug report needs and what + // the number alone cannot give. + buildRow( + str(R.string.info_framework_version), + status.versionLabel ?: unknown, + status.commit, + ), + // Named separately from the framework, because they are flashed separately and are + // not always the same build. When these two disagree, that is the answer to a whole + // class of "it behaves oddly" reports. + buildRow( + str(R.string.info_manager_version), + "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + BuildConfig.VERSION_HASH, + ), + // Named by which scale the number is on. The two share a field and nothing else: 93 + // is a legacy Xposed API, 101 is a libxposed one, and calling both "Xposed API" is + // how a reader ends up comparing versions that were never comparable. + InfoItem( + str( + if (status.apiVersion?.let { XposedApi.isLibxposed(it) } == true) + R.string.info_api_version_libxposed + else R.string.info_api_version + ), + status.apiVersion?.toString() ?: unknown, + ), + InfoItem(str(R.string.info_manager_package), context.packageName), + ), + str(R.string.info_section_health) to + listOfNotNull( + InfoItem( + str(R.string.info_selinux), + str( + if (status.sepolicyLoaded) R.string.info_loaded + else R.string.info_not_loaded + ), + health = if (status.sepolicyLoaded) Health.Good else Health.Bad, + monospace = false, + ), + InfoItem( + str(R.string.info_system_server), + str( + if (status.systemServerInjected) R.string.info_injected + else R.string.info_not_injected + ), + health = if (status.systemServerInjected) Health.Good else Health.Bad, + monospace = false, + ), + // Omitted below Android 10, where there is no wrapper to report on: the daemon only + // starts that machinery from Q and answers DEX2OAT_OK before then, so the row read + // "Supported", in green, for a feature the device does not have. A reader chasing a + // module that will not hook was being told this part was fine. + if (device.sdkInt < Build.VERSION_CODES.Q) null + else + InfoItem( + str(R.string.info_dex2oat), + dex2oatLabel(context, status.dex2oatWrapperState), + health = + if (status.dex2oatWrapperState == IManagerService.DEX2OAT_OK) + Health.Good + else Health.Bad, + monospace = false, + ), + ), + str(R.string.info_section_device) to + listOf( + InfoItem( + str(R.string.info_android), + "${device.androidRelease} (API ${device.sdkInt})", + ), + InfoItem(str(R.string.info_device), device.device, monospace = false), + InfoItem(str(R.string.info_abi), device.abi), + ), + ) +} + +private fun dex2oatLabel(context: Context, state: Int): String = + context.getString( + when (state) { + IManagerService.DEX2OAT_OK -> R.string.info_supported + IManagerService.DEX2OAT_CRASHED -> R.string.info_dex2oat_crashed + IManagerService.DEX2OAT_MOUNT_FAILED -> R.string.info_dex2oat_mount_failed + IManagerService.DEX2OAT_SELINUX_PERMISSIVE -> R.string.info_dex2oat_selinux_permissive + IManagerService.DEX2OAT_SEPOLICY_INCORRECT -> R.string.info_dex2oat_sepolicy_incorrect + else -> R.string.info_unsupported + } + ) + +@Composable +private fun FrameworkToggle( + title: String, + subtitle: String, + checked: Boolean, + /** + * False when there is no daemon to write to. + * + * These two are the framework's settings rather than the app's, and only the daemon can reach + * either — one lives in its own preference store, the other in `Settings.Global`, which it + * reads and writes as root. With no daemon there is nothing to read the state from and nothing + * to write it to, so a live switch would show a value it invented and accept a change that went + * nowhere. Dimmed and inert says the truth: the setting exists, and the thing that owns it is + * not running. + */ + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val colors = MaterialTheme.colorScheme + Row( + modifier = + Modifier.fillMaxWidth() + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .padding(vertical = 10.dp) + .alpha(if (enabled) 1f else 0.38f), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = colors.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = null, enabled = enabled) + } +} + diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt new file mode 100644 index 000000000..4fcfd3519 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -0,0 +1,565 @@ +package org.matrix.vector.manager.ui.screens.logs + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.isThrowableHeader +import org.matrix.vector.manager.data.log.parseStackTrace +import org.matrix.vector.manager.ui.components.StackTrace +import org.matrix.vector.manager.data.log.LogLevel +import org.matrix.vector.manager.data.log.LogRow +import org.matrix.vector.manager.ui.theme.VectorLogLine + +/** + * Horizontal panning shared by every row of a log. + * + * Neither `Modifier.horizontalScroll` on the `LazyColumn` nor a shared `ScrollState` on the rows + * will do, and for the same reason: both derive the pan extent from whatever is currently measured. + * A lazy list only measures its visible window, and a `ScrollState` holds one `maxValue` that the + * last row to measure wins — so scrolling vertically brings a longer line into the window, the + * extent is recomputed, and the clamp on the current offset moves under the reader's finger. + * + * So the offset lives here, and the extent is the **running maximum** of every row width measured + * so far. It only ever grows while a window is on screen, which is what makes it impossible for a + * newly composed row to yank the content sideways. It restarts when the reading changes — see + * [reportRow] for why that restart has to be lazy. + */ +@Stable +class LogPan { + /** Read during placement only, so a pan re-places rows without recomposing them. */ + var offset by mutableFloatStateOf(0f) + private set + + // Deliberately not snapshot state: these are written during measurement, and making them + // observable would invalidate the very layout pass that produced them. + private var contentWidth = 0 + private var viewportWidth = 0 + private var epoch = 0 + private var measuredEpoch = -1 + + /** + * The running maximum is restarted by [reset] *lazily*, on the next row measured, rather than + * eagerly. + * + * [reset] must not zero the width itself. It is called from a `LaunchedEffect`, which can land + * after the rows have measured for the frame, and nothing re-measures them afterwards — a + * zeroed extent would then stay zero and the log could not be panned at all. Bumping an epoch + * and restarting on the next measurement is correct whichever order the two land in. + */ + fun reportRow(width: Int) { + if (measuredEpoch != epoch) { + measuredEpoch = epoch + contentWidth = width + } else if (width > contentWidth) { + contentWidth = width + } + } + + fun reportViewport(width: Int) { + viewportWidth = width + } + + fun reset() { + offset = 0f + epoch++ + } + + /** Consumes a horizontal drag, returning how much of it was used. */ + fun consume(delta: Float): Float { + val limit = (contentWidth - viewportWidth).coerceAtLeast(0).toFloat() + val before = offset + offset = (before - delta).coerceIn(0f, limit) + return before - offset + } +} + +@Composable +fun rememberLogPan(): LogPan = remember { LogPan() } + +/** The gesture side of [LogPan]; goes on whatever contains the list. */ +@Composable +fun panGesture(pan: LogPan): Modifier { + val state = rememberScrollableState { delta -> pan.consume(delta) } + return Modifier.scrollable(state, Orientation.Horizontal) +} + +/** The layout side: measure at intrinsic width, place at the shared offset, clip to the viewport. */ +private fun Modifier.panContent(pan: LogPan): Modifier = + clipToBounds().layout { measurable, constraints -> + val placeable = + measurable.measure( + Constraints( + minWidth = 0, + maxWidth = Constraints.Infinity, + minHeight = constraints.minHeight, + maxHeight = constraints.maxHeight, + ) + ) + pan.reportRow(placeable.width) + val width = if (constraints.hasBoundedWidth) constraints.maxWidth else placeable.width + pan.reportViewport(width) + layout(width, placeable.height) { placeable.place(-pan.offset.roundToInt(), 0) } + } + +/** + * One row of the log. + * + * The anatomy is the payoff of parsing: a rail in the level's colour *and* the level letter, since + * under Material You the hue belongs to the wallpaper and no state may be distinguishable by colour + * alone; the time of day only, because the date lives on the day separator; the tag, tappable to + * filter to itself; then the message. `uid:pid:tid` are twenty-two columns wide — `%8d:%6d:%6d` in + * `logcat.cpp` — and are what forces sideways panning, so they hide behind a tap. + * + * All of it is **one** styled `Text` rather than a `Row` of cells. Cells confine the message to + * whatever the metadata leaves over, which on a phone is a narrow column beside a mostly empty + * gutter; as one string the message wraps under the metadata and uses the full width. The cost is + * that the tag is not a `Chip` with its own click target, so the tap is resolved against the text + * layout instead — see [tagRangeOf]. + */ +@Composable +fun LogRowItem( + row: LogRow, + wordWrap: Boolean, + showTag: Boolean, + pan: LogPan, + query: String, + inlineTraces: Boolean, + onTagClick: (String) -> Unit, + onCopy: (String) -> Unit, + onOpenTrace: (String) -> Unit, +) { + when (row) { + is LogRow.DayBreak -> DayBreakRow(row) + is LogRow.Marker -> MarkerRow(row, query) + is LogRow.Entry -> + EntryRow( + row, + wordWrap, + showTag, + pan, + query, + inlineTraces, + onTagClick, + onCopy, + onOpenTrace, + ) + } +} + +@Composable +private fun EntryRow( + entry: LogRow.Entry, + wordWrap: Boolean, + showTag: Boolean, + pan: LogPan, + query: String, + /** Whether a trace opens under the row or on a screen. See `SettingsRepository`. */ + inlineTraces: Boolean, + onTagClick: (String) -> Unit, + onCopy: (String) -> Unit, + onOpenTrace: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var framesOpen by remember { mutableStateOf(false) } + var layout by remember { mutableStateOf(null) } + val accent = levelColor(entry.level) + // Wide enough to read as a colour rather than as a hairline, narrow enough to stay a + // margin rather than a column. + val railWidth = with(LocalDensity.current) { 4.5.dp.toPx() } + + val muted = MaterialTheme.colorScheme.outline + // The badge is washed with the level's own colour rather than one fixed container pair, so the + // level reads from the widest thing on the row and not only from the rail at its edge and the + // single letter at its start. A scan down a page now separates on a band of colour. + // + // A wash, not a fill, and the tag itself stays `onSurface`. The level palette runs from `error` + // to `outlineVariant` — deliberately, since debug and verbose are noise and are meant to recede + // — and tag text in those colours on a tint of themselves would be a badge nobody can read at + // the two levels the log is mostly made of. The colour identifies; the text stays legible. + val tagBackground = accent.copy(alpha = TAG_TINT) + val tagForeground = MaterialTheme.colorScheme.onSurface + val hit = MaterialTheme.colorScheme.primaryContainer + val onHit = MaterialTheme.colorScheme.onPrimaryContainer + val line = + remember(entry, query, showTag, accent, tagBackground, hit) { + buildLine(entry, query, showTag, accent, muted, tagBackground, tagForeground, hit, onHit) + } + // Filtered to one tag, every line carries the same tag — so it is stated once above the list + // and dropped from the lines, which is a quarter of the width back on a narrow screen. + val tagRange = remember(entry, showTag) { if (showTag) tagRangeOf(entry) else IntRange.EMPTY } + + Column( + Modifier.fillMaxWidth() + .drawBehind { drawRect(accent, size = Size(railWidth, size.height)) } + .padding(start = 10.dp, end = 10.dp, top = 2.dp, bottom = 2.dp) + ) { + Text( + line, + style = VectorLogLine, + color = MaterialTheme.colorScheme.onSurface, + softWrap = wordWrap, + maxLines = if (wordWrap) Int.MAX_VALUE else 1, + onTextLayout = { layout = it }, + modifier = + (if (wordWrap) Modifier.fillMaxWidth() else Modifier.panContent(pan)).pointerInput( + entry.index + ) { + detectTapGestures( + // No onLongPress: the long press belongs to the enclosing + // SelectionContainer, so copying the whole line — metadata included — is + // the double tap. + onDoubleTap = { onCopy(rawText(entry)) }, + onTap = { position -> + val offset = layout?.getOffsetForPosition(position) + if (offset != null && offset in tagRange) onTagClick(entry.tag) + else expanded = !expanded + }, + ) + }, + ) + + if (entry.truncated) { + Text( + stringResource(R.string.logs_line_truncated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 18.dp), + ) + } + + if (expanded) { + Text( + stringResource(R.string.logs_row_detail, entry.uid, entry.pid, entry.tid), + style = VectorLogLine, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(start = 18.dp, top = 2.dp), + ) + } + + if (entry.continuation.isNotEmpty()) { + // Parsed once per row, not per recomposition: the expander is tapped rarely and the + // count on it has to be right whether or not anyone ever taps. + // + // The message is offered to the parser because it is often the trace's first line. + // `XposedBridge.log(Throwable)` writes the whole trace as one message, so the header — + // `java.lang.ClassNotFoundException: Didn't find class …` — is the entry's own text and + // only the frames are continuations. Passing the frames alone left the trace headless + // and the reader without the one line naming what was thrown. It is offered rather + // than prepended: when the entry says something of its own, as `logE(msg, tr)` does, + // the header is the first continuation line and the message is not part of the trace. + // + // Only the *type* is taken from it, not the message after the colon: the entry's line + // is right above, already saying it in full. Passing the whole header printed the same + // sentence twice, once in the log's face and once in the trace's. + val sections = + remember(entry.message, entry.continuation) { + val type = throwableTypeOf(entry.message) + val lines = + if (type == null) entry.continuation else listOf(type) + entry.continuation + parseStackTrace(lines) + } + val frameCount = remember(sections) { sections.sumOf { it.frames.size } } + // Only a trace gets the trace treatment. Now that any unprefixed line is attached to + // its entry, most of these blocks are not traces at all — zygisk's mount-argument + // report is a page of plain text — and calling it "20 frames" would be a lie told by + // an expander that then rendered nothing under it. + val isTrace = frameCount > 0 + Text( + if (isTrace) pluralStringResource(R.plurals.logs_frames, frameCount, frameCount) + else + pluralStringResource( + R.plurals.logs_more_lines, + entry.continuation.size, + entry.continuation.size, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier.padding(start = 18.dp, top = 2.dp) + .combinedClickable( + onClick = { + // The screen is a *stack trace* screen; sending a mount-argument + // dump to it would be sending it somewhere that cannot read it. + if (inlineTraces || !isTrace) framesOpen = !framesOpen + else onOpenTrace(traceText(entry)) + } + ), + ) + if (framesOpen && isTrace && inlineTraces) { + // The full renderer, not a run of monospace lines. A trace in the log is the same + // text as a trace on the crash screen and is read for the same reason, so the one + // that reads better wins in both places. Indented to sit under the expander that + // opened it, and given the row's own width rather than the panned one — a trace is + // read as rows, and rows that slide sideways with the log lines above them would + // be read a column at a time. + StackTrace( + sections = sections, + onCopyFrame = { onCopy(it.line) }, + modifier = Modifier.padding(start = 26.dp, top = 4.dp, bottom = 4.dp), + ) + } else if (framesOpen) { + // Plain text, kept as the writer set it out — the alignment in a mount-argument + // report or a table of properties is the whole of its legibility. So it pans + // sideways with the log lines above it rather than wrapping, under the same + // switch, and joins the shared pan extent so the columns stay lined up with them. + Text( + entry.continuation.joinToString("\n"), + style = VectorLogLine, + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = wordWrap, + modifier = + Modifier.padding(start = 26.dp, top = 2.dp, bottom = 2.dp) + .then( + if (wordWrap) Modifier.fillMaxWidth() + else Modifier.panContent(pan) + ), + ) + } + } + } +} + +/** + * A rotation banner, a watchdog line, or a line the scanner could not read. + * + * Worth rendering as its own thing rather than as text: `----part 7 start----` is the daemon + * telling you exactly where it restarted, which is often the answer to "why does the log stop". + */ +@Composable +private fun MarkerRow(marker: LogRow.Marker, query: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HorizontalDivider(modifier = Modifier.width(12.dp)) + Spacer(Modifier.width(8.dp)) + Text( + highlighted(marker.text.trim(), query), + style = VectorLogLine, + color = MaterialTheme.colorScheme.tertiary, + ) + Spacer(Modifier.width(8.dp)) + HorizontalDivider(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun DayBreakRow(day: LogRow.DayBreak) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + day.date, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(10.dp)) + HorizontalDivider(modifier = Modifier.weight(1f)) + } +} + +/** + * The whole line as one styled string: level, time, tag, message. + * + * The tag gets a background span rather than a real chip, with a space either side standing in for + * padding. That is the compromise that buys the message the full width of the screen. + */ +private fun buildLine( + entry: LogRow.Entry, + query: String, + showTag: Boolean, + accent: Color, + muted: Color, + tagBackground: Color, + tagForeground: Color, + hitBackground: Color, + hitForeground: Color, +): AnnotatedString = buildAnnotatedString { + withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { + append(entry.level.char) + } + append(' ') + withStyle(SpanStyle(color = muted)) { append(entry.time) } + append(' ') + if (showTag) { + withStyle(SpanStyle(color = tagForeground, background = tagBackground)) { + append(' ') + append(entry.tag) + append(' ') + } + append(" ") + } + + if (query.isBlank()) { + append(entry.message) + return@buildAnnotatedString + } + var from = 0 + while (true) { + val at = entry.message.indexOf(query, from, ignoreCase = true) + if (at < 0) { + append(entry.message.substring(from)) + return@buildAnnotatedString + } + append(entry.message.substring(from, at)) + withStyle(SpanStyle(background = hitBackground, color = hitForeground)) { + append(entry.message.substring(at, at + query.length)) + } + from = at + query.length + } +} + +/** + * Where the tag sits in the string [buildLine] produced. + * + * Derived from the layout above rather than searched for, because a tag can legitimately appear in + * the message too and tapping the message must not filter. + */ +private fun tagRangeOf(entry: LogRow.Entry): IntRange { + val start = 2 + entry.time.length + 1 + return start until start + entry.tag.length + 2 +} + +/** + * How much of the level's colour the tag badge carries. + * + * Enough to name the level at a glance across a scrolling page, little enough that the tag on top + * of it is read as text rather than as decoration. + */ +private const val TAG_TINT = 0.22f + +/** + * Colour is reinforcement here, never the signal: the level letter carries the meaning, because + * under Material You the hues come from the wallpaper. + */ +@Composable +fun levelColor(level: LogLevel): Color = + when (level) { + LogLevel.ERROR, + LogLevel.FATAL -> MaterialTheme.colorScheme.error + LogLevel.WARN -> MaterialTheme.colorScheme.tertiary + LogLevel.INFO -> MaterialTheme.colorScheme.primary + LogLevel.DEBUG -> MaterialTheme.colorScheme.outlineVariant + else -> MaterialTheme.colorScheme.outline + } + +/** + * The throwable type an entry's message names, or null if it does not name one. + * + * `XposedBridge.log(Throwable)` writes a whole trace as one message, so the header is the entry's + * own line and only the frames arrive as continuations. This recovers the type from it so the trace + * below can be headed by the thing that was thrown. A `Caused by:` line is refused: it is never the + * first line of a trace, so a message shaped like one is not the header this is looking for. + */ +private fun throwableTypeOf(message: String): String? = + message + .takeIf { isThrowableHeader(it) && !it.startsWith("Caused by: ") } + ?.substringBefore(": ") + +/** + * The entry's trace as `printStackTrace` would have written it. + * + * The whole header, message and all, unlike the inline expander's — a screen shows the trace with + * no log line above it, so the sentence naming what failed has nowhere else to come from. + */ +private fun traceText(entry: LogRow.Entry): String = + if (isThrowableHeader(entry.message)) + (listOf(entry.message) + entry.continuation).joinToString("\n") + else entry.continuation.joinToString("\n") + +/** + * Rebuilds the entry as the daemon wrote it, for the clipboard. + * + * One prefix, then the message and everything under it. Where the writer had to cut a long message + * into several entries the second prefix does not come back, because what is being copied is the + * message that was written rather than the transport that carried it — and a stack trace with a + * timestamp wedged into the middle of it is one nobody can paste anywhere useful. + */ +private fun rawText(entry: LogRow.Entry): String = buildString { + append("[ ") + append(entry.date) + append('T') + append(entry.time) + append(' ') + append(entry.uid) + append(':') + append(entry.pid) + append(':') + append(entry.tid) + append(' ') + append(entry.level.char) + append('/') + append(entry.tag) + append(" ] ") + append(entry.message) + entry.continuation.forEach { + append('\n') + append(it) + } +} + +/** Marks every occurrence of the active search text, so a hit is findable inside a long line. */ +@Composable +private fun highlighted(text: String, query: String): AnnotatedString { + if (query.isBlank()) return AnnotatedString(text) + val background = MaterialTheme.colorScheme.primaryContainer + val foreground = MaterialTheme.colorScheme.onPrimaryContainer + return remember(text, query, background) { + buildAnnotatedString { + var from = 0 + while (true) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) { + append(text.substring(from)) + return@buildAnnotatedString + } + append(text.substring(from, hit)) + withStyle(SpanStyle(background = background, color = foreground)) { + append(text.substring(hit, hit + query.length)) + } + from = hit + query.length + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt new file mode 100644 index 000000000..4ccf63ef1 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogTraceScreen.kt @@ -0,0 +1,101 @@ +package org.matrix.vector.manager.ui.screens.logs + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.parseStackTrace +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.copyToClipboard +import org.matrix.vector.manager.ui.components.show +import org.matrix.vector.manager.ui.components.stackTraceItems + +/** + * A trace from the log, given the room the log itself does not have. + * + * The same rows as the crash screen, from the same parser — a trace written by the daemon, by a + * module, or by the manager is the same `printStackTrace` output whichever of them wrote it, so + * there is one way to read it. Reached only when the reader has said they prefer a screen to the + * inline expander; see `SettingsRepository.logTracesInline`. + * + * Copy takes the raw text, exactly as the log holds it, because that is what goes into an issue. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogTraceScreen(text: String, onNavigateBack: () -> Unit) { + val context = LocalContext.current + val sections = remember(text) { parseStackTrace(text) } + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.copied) + val frameCopied = stringResource(R.string.crash_frame_copied) + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.logs_trace_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + IconButton( + onClick = { + copyToClipboard(context, text) + scope.launch { snackbars.show(copied, SnackbarTone.Success) } + } + ) { + Icon( + Icons.Rounded.ContentCopy, + contentDescription = stringResource(R.string.action_copy_all), + ) + } + }, + ) + }, + ) { padding -> + if (sections.isEmpty()) { + Text( + text, + modifier = Modifier.padding(padding).padding(20.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Scaffold + } + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 24.dp), + ) { + stackTraceItems(sections) { frame -> + copyToClipboard(context, frame.line) + scope.launch { snackbars.show(frameCopied, SnackbarTone.Success) } + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt new file mode 100644 index 000000000..7e352792b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt @@ -0,0 +1,1023 @@ +package org.matrix.vector.manager.ui.screens.logs + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Article +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.UnfoldLess +import androidx.compose.material.icons.rounded.UnfoldMore +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.rounded.SearchOff +import androidx.compose.material.icons.rounded.VerticalAlignBottom +import androidx.compose.material.icons.rounded.VerticalAlignTop +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.material.icons.automirrored.rounded.WrapText +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.InputChip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.matrix.vector.manager.data.log.logArchiveName +import org.matrix.vector.manager.ui.components.VectorAlertDialog +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.theme.VectorLogLine +import org.matrix.vector.manager.data.log.LogLevel +import org.matrix.vector.manager.ui.components.PanelHeader +import org.matrix.vector.manager.ui.components.sheetRowColors +import org.matrix.vector.manager.ui.components.SearchField +import org.matrix.vector.manager.ui.components.ToggleRow +import org.matrix.vector.manager.ui.components.copyToClipboard +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * The diagnose surface: two log streams, read from the end. + * + * Everything expensive about this screen lives in `data/log` — the reader indexes line offsets and + * materialises at most a couple of thousand rows at a time, so a log of any size opens at the same + * speed and the pane never holds the file. What is left here is the part that decides whether the + * screen is any good: a parsed line has a level, a tag and a time, so it can be coloured, filtered + * and searched instead of dumped, and a tag chip turns "why is this log 4,700 lines of + * TEESimulator" into one tap. + * + * Only the stream on screen is opened and indexed; the other one is not touched until it is asked + * for. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsScreen( + onOpenTrace: (String) -> Unit, + viewModel: LogsViewModel = viewModel(factory = LogsViewModelFactory()), +) { + // One pane, one search field, and the source is a control inside it. Two tabs would mean two + // search boxes, two filter states and two scroll positions for what is one question — "what + // does the log say" — whose answer often has to be looked for in both streams. + var currentTab by rememberSaveable { mutableStateOf(LogTab.MODULES) } + val currentState by viewModel.state(currentTab).collectAsStateWithLifecycle() + val wordWrap by viewModel.wordWrap.collectAsStateWithLifecycle() + val saveState by viewModel.saveState.collectAsStateWithLifecycle() + + val context = LocalContext.current + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + var menuOpen by remember { mutableStateOf(false) } + var confirmRotate by remember { mutableStateOf(false) } + + val saveLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri: Uri? -> + if (uri != null) viewModel.saveTo(uri) + } + fun launchSave() { + saveLauncher.launch(logArchiveName("zip")) + } + + val savingLabel = stringResource(R.string.logs_saving) + val savedLabel = stringResource(R.string.logs_saved) + val shareLabel = stringResource(R.string.logs_share) + LaunchedEffect(saveState) { + when (val s = saveState) { + is LogSaveState.Saving -> + snackbars.showSnackbar(savingLabel, duration = SnackbarDuration.Indefinite) + is LogSaveState.Saved -> { + snackbars.currentSnackbarData?.dismiss() + // The document belongs to DocumentsUI, not to us — which is the only reason this + // can be shared at all. Parasitically the manifest is never installed, so no + // FileProvider of ours exists at runtime and ACTION_SEND has no content:// URI to + // hand out. That is also why saving goes through SAF rather than a share sheet. + val result = snackbars.showSnackbar(savedLabel, actionLabel = shareLabel) + if (result == SnackbarResult.ActionPerformed) shareZip(context, s.uri) + viewModel.consumeSaveState() + } + is LogSaveState.Failed -> { + snackbars.currentSnackbarData?.dismiss() + // Whatever words came back are shown rather than a generic "failed": the two ways + // this arrives — the document could not be opened, or the transaction did not + // complete — read very differently to someone trying to file a report. + snackbars.showSnackbar( + if (s.message.isNullOrBlank()) context.getString(R.string.logs_save_failed) + else context.getString(R.string.logs_save_failed_reason, s.message) + ) + viewModel.consumeSaveState() + } + LogSaveState.Idle -> Unit + } + } + + LaunchedEffect(currentTab) { viewModel.open(currentTab) } + + Scaffold( + snackbarHost = { SnackbarHost(snackbars) }, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + // The same header the other two list panels use, so the search field below it sits at + // the same height on all three. + PanelHeader( + title = stringResource(R.string.logs_title), + modifier = + Modifier.partSwipe(currentState) { viewModel.selectPart(currentTab, it) }, + description = { + WindowCounter(currentState) { viewModel.selectPart(currentTab, it) } + }, + search = { + LogSearch( + tab = currentTab, + state = currentState, + viewModel = viewModel, + onSelectTab = { currentTab = it }, + ) + }, + actions = { + // Selected, not shouted: a quiet neutral container says pressed-in without + // making a reading preference look like the most important control here. + FilledIconToggleButton( + checked = wordWrap, + onCheckedChange = { viewModel.setWordWrap(it) }, + colors = + IconButtonDefaults.filledIconToggleButtonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + checkedContainerColor = + MaterialTheme.colorScheme.surfaceContainerHighest, + checkedContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + Icons.AutoMirrored.Rounded.WrapText, + contentDescription = stringResource(R.string.logs_word_wrap), + ) + } + IconButton(onClick = { menuOpen = true }) { + Icon( + Icons.Rounded.Tune, + contentDescription = stringResource(R.string.logs_settings), + ) + } + }, + ) + LogPane( + tab = currentTab, + viewModel = viewModel, + wordWrap = wordWrap, + onOpenTrace = onOpenTrace, + ) + } + } + + if (menuOpen) { + LogSettingsSheet( + viewModel = viewModel, + onDismiss = { menuOpen = false }, + onSave = { + menuOpen = false + launchSave() + }, + onRotate = { + menuOpen = false + confirmRotate = true + }, + ) + } + + if (confirmRotate) { + val rotated = stringResource(R.string.logs_rotate_done) + val rotateFailed = stringResource(R.string.logs_rotate_unreachable) + VectorAlertDialog( + onDismissRequest = { confirmRotate = false }, + title = { Text(stringResource(R.string.logs_rotate_title)) }, + // The body names the consequence, which here is not what a delete icon implies: the + // daemon's clearLogs() calls LogcatMonitor.refresh(), which opens a new part rather + // than truncating anything. + text = { Text(stringResource(R.string.logs_rotate_body)) }, + confirmButton = { + TextButton( + onClick = { + confirmRotate = false + viewModel.rotate(currentTab) { ok -> + scope.launch { snackbars.showSnackbar(if (ok) rotated else rotateFailed) } + } + } + ) { + Text(stringResource(R.string.logs_rotate_confirm)) + } + }, + // No "save first" button. Rotating puts nothing out of reach — the closed part stays + // on disk and is one step of the part chevrons away — so there is no loss to guard + // against. + dismissButton = { + TextButton(onClick = { confirmRotate = false }) { + Text(stringResource(R.string.logs_cancel)) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogPane( + tab: LogTab, + viewModel: LogsViewModel, + wordWrap: Boolean, + onOpenTrace: (String) -> Unit, +) { + val inlineTraces by viewModel.tracesInline.collectAsStateWithLifecycle() + val state by viewModel.state(tab).collectAsStateWithLifecycle() + val listState = rememberLazyListState() + val pan = rememberLogPan() + val context = LocalContext.current + + // The jump buttons float over the list, so the list has to end above them. Measured rather than + // hardcoded: a constant stops clearing what it was meant to clear the moment the buttons, the + // density or the font scale change. Both buttons are always present so the height is stable + // once measured; a container that grew and shrank would move the log under the reader's eye. + var jumpInset by remember { mutableIntStateOf(0) } + // Shown whenever the log does not fit, rather than only past a window's worth of lines. A + // freshly rotated module log is a few hundred lines — far under the window — and still far too + // long to thumb to the end of, which is where the line everyone opened this screen for lives. + val showJump by remember { + derivedStateOf { listState.canScrollForward || listState.canScrollBackward } + } + + // The pan extent is a running maximum over the rows measured so far, so it is reset only when + // the whole reading changes — not while paging, which would snap the offset back mid-scroll. + LaunchedEffect(wordWrap, state.query) { pan.reset() } + + // Keyed on the inset as well as on the command, because the first layout measures the buttons + // *after* the open-at-the-tail scroll has run. Without the second pass the newest line — the + // one line everybody opens this screen to read — sits underneath them. + LaunchedEffect(state.scroll?.token, jumpInset) { + val command = state.scroll ?: return@LaunchedEffect + if (state.rows.isNotEmpty()) { + listState.scrollToItem(command.position.coerceIn(0, state.rows.lastIndex)) + } + } + + // Extending the window is driven by where the viewport actually is rather than by a scroll + // callback, so a fling that overshoots several hundred rows still triggers exactly one step. + LaunchedEffect(listState, tab) { + snapshotFlow { + Triple( + listState.firstVisibleItemIndex, + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0, + listState.layoutInfo.totalItemsCount, + ) + } + .collect { (first, last, total) -> + if (total > 0) viewModel.onVisibleRows(tab, first, last, total) + } + } + + Column(Modifier.fillMaxSize()) { + // The active tag is stated once, here, instead of on every line — see the tag column in + // LogRows, which disappears while this is showing. + ActiveFilterRow( + state = state, + onClearTag = { viewModel.setTag(tab, null) }, + onClearLevel = { viewModel.toggleLevel(tab, it) }, + ) + + + if (state.droppedLeading > 0) { + Text( + stringResource(R.string.logs_dropped, state.droppedLeading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp), + ) + } + + val scanning = state.status as? LogStatus.Scanning + if (scanning != null) { + LinearProgressIndicator( + progress = { scanning.progress }, + modifier = Modifier.fillMaxWidth().height(3.dp), + ) + } + + Box(Modifier.fillMaxSize()) { + when (val status = state.status) { + is LogStatus.DaemonUnavailable -> + LogEmptyState( + Icons.Rounded.CloudOff, + stringResource(R.string.logs_state_daemon_title), + stringResource(R.string.logs_state_daemon_body), + ) + is LogStatus.NoLogFile -> + LogEmptyState( + Icons.AutoMirrored.Rounded.Article, + stringResource(R.string.logs_state_nofile_title), + stringResource(R.string.logs_state_nofile_body), + ) + is LogStatus.Empty -> + LogEmptyState( + Icons.AutoMirrored.Rounded.Article, + stringResource(R.string.logs_state_empty_title), + stringResource(R.string.logs_state_empty_body), + ) + is LogStatus.NoMatches -> + LogEmptyState( + Icons.Rounded.SearchOff, + stringResource(R.string.logs_state_nomatches_title), + stringResource(R.string.logs_state_nomatches_body), + ) + is LogStatus.ReadFailed -> + LogEmptyState( + Icons.Rounded.WarningAmber, + stringResource(R.string.logs_state_failed_title), + stringResource(R.string.logs_state_failed_body, status.message ?: ""), + ) + is LogStatus.Loading -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + else -> + LogList( + tab = tab, + viewModel = viewModel, + state = state, + listState = listState, + pan = pan, + wordWrap = wordWrap, + showJump = showJump, + jumpInset = jumpInset, + onJumpInset = { jumpInset = it }, + onCopy = { copyToClipboard(context, it) }, + inlineTraces = inlineTraces, + onOpenTrace = onOpenTrace, + ) + } + } + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogList( + tab: LogTab, + viewModel: LogsViewModel, + state: LogPaneState, + listState: androidx.compose.foundation.lazy.LazyListState, + pan: LogPan, + wordWrap: Boolean, + showJump: Boolean, + jumpInset: Int, + onJumpInset: (Int) -> Unit, + onCopy: (String) -> Unit, + inlineTraces: Boolean, + onOpenTrace: (String) -> Unit, +) { + // The horizontal gesture goes on the container, not on the list and not on the rows: the list + // then owns vertical extent exclusively and each row's sideways extent depends only on its own + // intrinsic width, so nothing is recomputed as the reader scrolls. + val gesture = panGesture(pan) + val density = LocalDensity.current + + Box(Modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = { viewModel.refresh(tab) }, + modifier = if (wordWrap) Modifier else gesture, + ) { + // Text here is selectable the way text anywhere else on the platform is: long press + // and drag. The rows must therefore leave the long press alone — see LogRows, where + // copying a whole line is the double tap. + SelectionContainer { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + top = 8.dp, + bottom = 8.dp + with(density) { if (showJump) jumpInset.toDp() else 0.dp }, + ), + ) { + // Keyed by absolute line number. That is what lets the window be extended upwards + // without the viewport lurching: the list re-resolves its first visible item by key + // after rows are inserted above it. + items(state.rows, key = { it.key }) { row -> + LogRowItem( + row = row, + wordWrap = wordWrap, + showTag = state.query.tag == null, + pan = pan, + query = state.query.text, + inlineTraces = inlineTraces, + onTagClick = { viewModel.setTag(tab, it) }, + onCopy = onCopy, + onOpenTrace = onOpenTrace, + ) + } + } + } + } + + // On a thirty-thousand-line log the newest line is unreachable by thumb, and it is the one + // line everybody opens this screen to read. Both buttons stay put even at an end of the + // file: hiding one would change the container's height, which is the list's bottom inset, + // and so shift the log under the reader as a side effect of scrolling. + if (showJump) { + // Side by side rather than stacked: whatever height these take is height the log + // cannot use, and one button's worth of dead space at the bottom of every log is + // already the most this affordance is worth. + Row( + modifier = + Modifier.align(Alignment.BottomEnd) + .onSizeChanged { onJumpInset(it.height) } + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SmallFloatingActionButton(onClick = { viewModel.jumpToOldest(tab) }) { + Icon( + Icons.Rounded.VerticalAlignTop, + contentDescription = stringResource(R.string.logs_jump_oldest), + ) + } + SmallFloatingActionButton(onClick = { viewModel.jumpToNewest(tab) }) { + Icon( + Icons.Rounded.VerticalAlignBottom, + contentDescription = stringResource(R.string.logs_jump_newest), + ) + } + } + } + } +} + + + +/** The log search field, as the header's third row: query, source and filters together. */ +@Composable +private fun LogSearch( + tab: LogTab, + state: LogPaneState, + viewModel: LogsViewModel, + onSelectTab: (LogTab) -> Unit, +) { + var filterOpen by remember { mutableStateOf(false) } + SearchField( + query = state.query.text, + onQueryChange = { viewModel.setQuery(tab, it) }, + placeholder = stringResource(R.string.logs_search_hint), + trailing = { + LogSourceToggle(tab = tab, onSelect = onSelectTab) + IconButton( + onClick = { + filterOpen = true + viewModel.loadFacets(tab) + } + ) { + Icon( + Icons.Rounded.FilterList, + contentDescription = stringResource(R.string.logs_filter), + tint = + if (state.query.levels.isNotEmpty() || state.query.tag != null) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + if (filterOpen) { + LogFilterSheet( + state = state, + onDismiss = { filterOpen = false }, + onToggleLevel = { viewModel.toggleLevel(tab, it) }, + onTag = { viewModel.setTag(tab, it) }, + onClear = { viewModel.clearFilter(tab) }, + ) + } +} + +/** + * Which log is being read, as one button. + * + * The verbose log is not a different subject, it is the same one with the framework's own lines + * left in — module logs plus everything underneath them. So this is a detail control, not a choice + * between two places: unfold for more, fold for less, in one icon rather than a two-segment control + * spending half the search field on a decision that does not need making. + * + * Not to be confused with the verbose *logging* switch in the settings sheet. That one tells the + * daemon whether to write those lines at all; this one only decides which of the two files is on + * screen. + */ +@Composable +private fun LogSourceToggle(tab: LogTab, onSelect: (LogTab) -> Unit) { + val verbose = tab == LogTab.VERBOSE + IconButton( + onClick = { onSelect(if (verbose) LogTab.MODULES else LogTab.VERBOSE) } + ) { + Icon( + if (verbose) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore, + contentDescription = + stringResource( + if (verbose) R.string.logs_source_less else R.string.logs_source_more + ), + tint = + if (verbose) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * What the log is currently narrowed to, as chips that undo themselves. + * + * A filter that is only visible inside the sheet that set it is a filter people forget they applied + * and then read a log that is quietly missing most of its lines. Stating the tag here is also what + * lets every row stop repeating it. + */ +@Composable +private fun ActiveFilterRow( + state: LogPaneState, + onClearTag: () -> Unit, + onClearLevel: (LogLevel) -> Unit, +) { + val tag = state.query.tag + if (tag == null && state.query.levels.isEmpty()) return + + Row( + modifier = + Modifier.fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (tag != null) { + InputChip( + selected = true, + onClick = onClearTag, + label = { Text(tag, style = VectorLogLine, maxLines = 1) }, + avatar = { + Icon( + Icons.AutoMirrored.Rounded.Label, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + trailingIcon = { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.logs_filter_clear_tag), + modifier = Modifier.size(16.dp), + ) + }, + ) + } + state.query.levels.sortedBy { it.ordinal }.forEach { level -> + InputChip( + selected = true, + onClick = { onClearLevel(level) }, + label = { Text(level.name, style = MaterialTheme.typography.labelMedium) }, + trailingIcon = { + Icon( + Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } +} + +/** + * Everything about the log that is a setting rather than a filter. + * + * A half sheet, matching the filter sheet next to it, because these are the same kind of thing: + * something you open, change, and dismiss. A dropdown menu holds two verbs and nothing that needs a + * switch or a sentence, and the verbose control needs both. + * + * The verbose switch shows the value **the daemon reports**, not the one the user picked. The + * current daemon returns the stored preference unmodified, but an older one OR'd it with its own + * build type and would refuse to move — which the row then explains rather than leaving to be + * guessed at. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogSettingsSheet( + viewModel: LogsViewModel, + onDismiss: () -> Unit, + onSave: () -> Unit, + onRotate: () -> Unit, +) { + val enabled by viewModel.verboseEnabled.collectAsStateWithLifecycle() + val enforced by viewModel.verboseEnforced.collectAsStateWithLifecycle() + val inlineTraces by viewModel.tracesInline.collectAsStateWithLifecycle() + // Every value left enabled rather than dropping PartiallyExpanded, which would remove the + // half-height stop — the only thing a drag on a sheet can do other than dismiss it. Material + // caps that stop at the sheet's own height, so short sheets still open at their own height and + // nothing gains a useless drag. + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { +LocalizedOverlay { + + Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + Text( + stringResource(R.string.logs_settings), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp), + ) + + ListItem( + // Never disabled. A daemon that overrides the setting is a reason to *say so*, not + // a reason to take the control away — and only an older daemon can, since the + // current one reports the stored preference as it stands. + modifier = Modifier.clickable { viewModel.setVerbose(!enabled) }, + supportingContent = { + Column { + Text( + stringResource(R.string.logs_verbose_summary), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (enforced) { + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.logs_verbose_enforced), + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + }, + leadingContent = { + Icon( + Icons.Rounded.Visibility, + contentDescription = null, + // The point of this row is a warning, so it is coloured like one. + tint = MaterialTheme.colorScheme.tertiary, + ) + }, + trailingContent = { + Switch(checked = enabled, onCheckedChange = { viewModel.setVerbose(it) }) + }, + colors = sheetRowColors, + ) { Text(stringResource(R.string.logs_verbose_switch)) } + + ToggleRow( + title = stringResource(R.string.logs_traces_inline), + icon = Icons.AutoMirrored.Rounded.Notes, + checked = inlineTraces, + onCheckedChange = { viewModel.setTracesInline(it) }, + subtitle = stringResource(R.string.logs_traces_inline_summary), + ) + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + ListItem( + modifier = Modifier.clickable(onClick = onSave), + supportingContent = { Text(stringResource(R.string.logs_save_summary)) }, + leadingContent = { Icon(Icons.Rounded.Save, contentDescription = null) }, + colors = sheetRowColors, + ) { Text(stringResource(R.string.logs_save)) } + ListItem( + modifier = Modifier.clickable(onClick = onRotate), + supportingContent = { Text(stringResource(R.string.logs_rotate_summary)) }, + leadingContent = { + Icon( + Icons.Rounded.RestartAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + colors = sheetRowColors, + ) { Text(stringResource(R.string.logs_rotate)) } + } + } +} +} + + +/** + * Drag the title block sideways to move between rotated parts. + * + * The chevrons beside the counter are the discoverable way to do it; this is the fast one, and it + * goes on the header rather than on the log because nothing else in the header wants a horizontal + * drag. Over the log it would have to be arbitrated against the row-level pan and fenced into some + * band of the screen. Dragging leftwards moves to the newer part, the way a carousel does. + */ +@Composable +private fun Modifier.partSwipe(state: LogPaneState, onSelectPart: (Int) -> Unit): Modifier { + if (state.parts.size < 2) return this + val threshold = with(LocalDensity.current) { 72.dp.toPx() } + var travelled by remember(state.partIndex) { mutableFloatStateOf(0f) } + // Dragging leftwards moves forward the way a carousel does — which in a right-to-left language + // means dragging *rightwards*. The sign, not the thresholds, is what has to flip. + val forward = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1f else 1f + + val scroll = rememberScrollableState { delta -> + travelled += delta * forward + when { + travelled <= -threshold && state.partIndex < state.parts.lastIndex -> { + onSelectPart(state.partIndex + 1) + travelled = 0f + } + travelled >= threshold && state.partIndex > 0 -> { + onSelectPart(state.partIndex - 1) + travelled = 0f + } + } + delta + } + return this.scrollable(scroll, Orientation.Horizontal) +} + +/** + * Which lines are on screen, and which rotated part they come from. + * + * This line is the only place that says where you are in the file, so it is also where you move + * between files. The chevrons carry that: they say how many parts there are and which one is up, + * neither of which a gesture on its own can, and they cannot be triggered by a drag meant for + * something else. [partSwipe] on the header is the shortcut for anyone who has found it. + * + * The range follows the **viewport**, not the loaded window. "Which lines am I looking at" is what + * a line counter is read to answer; the window's bounds answer a question about paging. + */ +@Composable +private fun WindowCounter(state: LogPaneState, onSelectPart: (Int) -> Unit) { + val colors = MaterialTheme.colorScheme + val text = + when { + state.status is LogStatus.Ready || state.status is LogStatus.Scanning -> + if (state.filtered) + pluralStringResource( + R.plurals.logs_matches, + state.visibleLines, + state.visibleLines, + ) + else + stringResource( + R.string.logs_window, + state.visibleFirst.coerceAtLeast(1), + state.visibleLast.coerceAtLeast(state.visibleFirst), + state.totalLines, + ) + state.status is LogStatus.Loading -> stringResource(R.string.logs_loading) + else -> null + } + + val parts = state.parts.size + Row(verticalAlignment = Alignment.CenterVertically) { + if (parts > 1) { + // Older is to the left, the way earlier is to the left of later everywhere else. + PartStep( + icon = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, + descriptionRes = R.string.logs_part_older, + enabled = state.partIndex > 0, + onClick = { onSelectPart(state.partIndex - 1) }, + ) + } + if (text != null) { + Text( + text = + if (parts > 1) + "$text · " + stringResource(R.string.logs_part, state.partIndex + 1, parts) + else text, + style = VectorMono, + color = colors.onSurfaceVariant, + maxLines = 1, + ) + } + if (parts > 1) { + PartStep( + icon = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + descriptionRes = R.string.logs_part_newer, + enabled = state.partIndex < parts - 1, + onClick = { onSelectPart(state.partIndex + 1) }, + ) + } + } +} + +/** One step between parts. Dimmed rather than removed at an end, so the row never reflows. */ +@Composable +private fun PartStep( + icon: androidx.compose.ui.graphics.vector.ImageVector, + descriptionRes: Int, + enabled: Boolean, + onClick: () -> Unit, +) { + IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(28.dp)) { + Icon( + icon, + contentDescription = stringResource(descriptionRes), + modifier = Modifier.size(20.dp), + tint = + if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + ) + } +} + +/** + * The five nothing-to-show states — unreachable daemon, no log file, empty file, no matches, read + * failure — each with its own icon and sentence. + * + * Rendered here rather than as a line pushed into the log list, so that "the daemon is down" cannot + * arrive looking like a line the daemon wrote. + */ +@Composable +private fun LogEmptyState(icon: ImageVector, title: String, body: String) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Spacer(Modifier.height(6.dp)) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +/** Levels and tags the file actually contains, with their counts. Never a hardcoded list. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogFilterSheet( + state: LogPaneState, + onDismiss: () -> Unit, + onToggleLevel: (LogLevel) -> Unit, + onTag: (String?) -> Unit, + onClear: () -> Unit, +) { + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { +LocalizedOverlay { + + Column( + Modifier.verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.logs_filter_levels), + style = MaterialTheme.typography.titleSmall, + ) + TextButton(onClick = onClear) { + Text(stringResource(R.string.logs_filter_clear)) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LogLevel.selectable.forEach { level -> + val count = state.facets?.levels?.get(level) ?: 0 + FilterChip( + selected = level in state.query.levels, + onClick = { onToggleLevel(level) }, + enabled = state.facets == null || count > 0, + label = { Text(level.char.toString(), style = VectorMono) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.logs_filter_tags), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(8.dp)) + val facets = state.facets + if (facets == null) { + Text( + stringResource(R.string.logs_filter_scanning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn(modifier = Modifier.height(280.dp)) { + items(facets.tags, key = { it.first }) { (tag, count) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = state.query.tag == tag, + onClick = { onTag(tag) }, + label = { Text(tag, style = VectorMono) }, + ) + Spacer(Modifier.width(10.dp)) + Text( + count.toString(), + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} +} + +private fun shareZip(context: Context, uri: Uri) { + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + runCatching { + context.startActivity( + Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt new file mode 100644 index 000000000..af036d530 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt @@ -0,0 +1,664 @@ +package org.matrix.vector.manager.ui.screens.logs + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.vector.manager.data.log.LogFacets +import org.matrix.vector.manager.data.log.LogFile +import org.matrix.vector.manager.data.log.LogIndex +import org.matrix.vector.manager.data.log.LogLevel +import org.matrix.vector.manager.data.log.LogQuery +import org.matrix.vector.manager.data.log.LogRow +import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** The two log streams the daemon keeps. They are read independently and never both at once. */ +enum class LogTab { + MODULES, + VERBOSE, +} + +/** + * What a pane is showing. + * + * Each of these is a genuinely different situation and the screen renders each as its own thing, so + * "the daemon is unreachable" never arrives looking like a line the daemon wrote. + */ +sealed interface LogStatus { + /** Opening the descriptor and indexing. Short enough that no progress is worth reporting. */ + data object Loading : LogStatus + + /** Filtering, which reads the whole file and therefore reports a real fraction. */ + data class Scanning(val progress: Float) : LogStatus + + data object Ready : LogStatus + + /** The daemon is not reachable. Nothing about the log is known. */ + data object DaemonUnavailable : LogStatus + + /** The daemon answered, and has not opened a log file yet. */ + data object NoLogFile : LogStatus + + /** The file exists and is empty — for the modules log, the normal state of a quiet system. */ + data object Empty : LogStatus + + /** The file has content; the current filter excludes all of it. */ + data object NoMatches : LogStatus + + data class ReadFailed(val message: String?) : LogStatus +} + +/** A one-shot instruction to move the list, delivered as state so it survives recomposition. */ +data class ScrollCommand(val token: Long, val position: Int) + +data class LogPaneState( + val status: LogStatus = LogStatus.Loading, + /** At most [LogsViewModel.WINDOW] lines' worth of rows, never the file. */ + val rows: List = emptyList(), + val totalLines: Int = 0, + /** Lines the current filter admits; equals [totalLines] when nothing is filtered. */ + val visibleLines: Int = 0, + val windowFirst: Int = 0, + val windowLast: Int = 0, + /** + * The lines actually on screen, as positions within the current view. + * + * Distinct from the loaded window: the reader sees a screenful, the window is a few thousand + * lines around it. The line counter reports this pair, because "which lines am I looking at" is + * the question it is read to answer; the window's bounds answer a question about paging. + */ + val visibleFirst: Int = 0, + val visibleLast: Int = 0, + val droppedLeading: Int = 0, + val query: LogQuery = LogQuery(), + val facets: LogFacets? = null, + val refreshing: Boolean = false, + val scroll: ScrollCommand? = null, + /** Rotated parts the daemon holds, oldest first. The last one is the live file. */ + val parts: List = emptyList(), + /** Which of [parts] is on screen. A load with no part pinned selects the last, the live one. */ + val partIndex: Int = 0, +) { + val filtered: Boolean + get() = query.isActive + + val atNewest: Boolean + get() = windowLast >= visibleLines +} + +/** Progress of the zip export, which is slow enough that the UI must say so. */ +sealed interface LogSaveState { + data object Idle : LogSaveState + + data object Saving : LogSaveState + + data class Saved(val uri: Uri) : LogSaveState + + data class Failed(val message: String?) : LogSaveState +} + +/** + * One state machine per log stream, over a windowed reader. + * + * The `StateFlow` only ever carries a window of rows; nothing here holds the file. Every read runs + * on `Dispatchers.IO` behind the pane's own mutex, so a scroll that extends the window cannot race + * the refresh that replaced the file under it, and the binder calls are already off the main thread + * by [DaemonClient]'s construction. + */ +class LogsViewModel(private val daemon: DaemonClient, private val settings: SettingsRepository) : + ViewModel() { + + private class Pane { + var file: LogFile? = null + var index: LogIndex? = null + + /** Line numbers the filter admits, or `null` when nothing is filtered. */ + var matches: IntArray? = null + + var first = 0 + var last = 0 + var opened = false + + /** The part being read, or null for the live one. */ + var part: String? = null + val mutex = Mutex() + var loadJob: Job? = null + var scanJob: Job? = null + var pageJob: Job? = null + val state = MutableStateFlow(LogPaneState()) + + /** Lines addressable in the current view: filtered count, or the whole file. */ + fun viewCount(): Int = matches?.size ?: (index?.lineCount ?: 0) + } + + private val panes = LogTab.entries.associateWith { Pane() } + + private var scrollToken = 0L + + private val _saveState = MutableStateFlow(LogSaveState.Idle) + val saveState: StateFlow = _saveState.asStateFlow() + + private val _verboseEnabled = MutableStateFlow(false) + val verboseEnabled: StateFlow = _verboseEnabled.asStateFlow() + + /** + * True when the user asked for verbose logging off and the daemon kept it on. + * + * The current daemon's `ManagerService.isVerboseLogEnabled()` returns + * `PreferenceStore.isVerboseLogEnabled()` unmodified, so this stays false against it. An older + * daemon OR'd that preference with its own build type and the switch would snap straight back; + * rather than let a control refuse to move with no explanation, the screen reads the value the + * daemon reports *after* the write and says who is overriding whom. + */ + private val _verboseEnforced = MutableStateFlow(false) + val verboseEnforced: StateFlow = _verboseEnforced.asStateFlow() + + val wordWrap: StateFlow = settings.logWordWrap + + val tracesInline: StateFlow = settings.logTracesInline + + init { + viewModelScope.launch { _verboseEnabled.value = daemon.isVerboseLogEnabled().getOrDefault(false) } + } + + fun state(tab: LogTab): StateFlow = panes.getValue(tab).state + + fun setWordWrap(enabled: Boolean) = settings.setLogWordWrap(enabled) + + fun setTracesInline(inline: Boolean) = settings.setLogTracesInline(inline) + + /** Called when a stream comes on screen. Only the stream on screen is ever read. */ + fun open(tab: LogTab) { + val pane = panes.getValue(tab) + if (pane.opened) return + pane.opened = true + reload(tab, jumpTo = Jump.NEWEST) + } + + /** + * Moves to another rotated part. + * + * Selecting the newest clears the pin rather than naming it, so the pane goes back to following + * the live descriptor — the one the daemon keeps appending to — instead of a fixed inode that + * stops growing the moment the log rotates. + */ + fun selectPart(tab: LogTab, index: Int) { + val pane = panes.getValue(tab) + val parts = pane.state.value.parts + if (parts.isEmpty()) return + val target = index.coerceIn(0, parts.lastIndex) + pane.part = if (target == parts.lastIndex) null else parts[target] + pane.state.update { it.copy(partIndex = target) } + reload(tab, jumpTo = if (target == parts.lastIndex) Jump.NEWEST else Jump.OLDEST) + } + + fun refresh(tab: LogTab) { + val pane = panes.getValue(tab) + pane.opened = true + // Keep the reader where it was unless it was already following the tail, in which case + // following it is the whole point of pressing refresh. + reload(tab, jumpTo = if (pane.state.value.atNewest) Jump.NEWEST else Jump.KEEP) + } + + private enum class Jump { + NEWEST, + OLDEST, + KEEP, + } + + private fun reload(tab: LogTab, jumpTo: Jump) { + val pane = panes.getValue(tab) + pane.loadJob?.cancel() + pane.loadJob = + viewModelScope.launch(Dispatchers.IO) { + pane.mutex.withLock { + pane.state.update { + it.copy( + status = if (it.rows.isEmpty()) LogStatus.Loading else it.status, + refreshing = true, + ) + } + val keptFirst = pane.first + + // The old descriptor points at an inode, not at "the current log": once the + // daemon rotates, it keeps resolving to the part that has been retired. So a + // refresh re-asks for the descriptor rather than re-indexing the one we hold. + pane.file?.close() + pane.file = null + pane.index = null + pane.matches = null + + val verbose = tab == LogTab.VERBOSE + val parts = daemon.getLogParts(verbose).getOrDefault(emptyList()) + // A part that has since been rotated away stops existing; falling back to the + // live file beats showing an empty screen with no explanation. + val chosen = pane.part?.takeIf { it in parts } + pane.part = chosen + pane.state.update { + it.copy( + parts = parts, + partIndex = + if (chosen == null) (parts.size - 1).coerceAtLeast(0) + else parts.indexOf(chosen), + ) + } + + val result = + if (chosen == null) daemon.getLiveLogPart(verbose) + else daemon.getLogPart(verbose, chosen) + val pfd = + result.getOrElse { + logW( + "logs: ${tab.name.lowercase()} log (${chosen ?: "live"}) unavailable", + it, + ) + pane.state.value = + pane.state.value.copy( + status = LogStatus.DaemonUnavailable, + rows = emptyList(), + refreshing = false, + ) + return@withLock + } + if (pfd == null) { + pane.state.value = + pane.state.value.copy( + status = LogStatus.NoLogFile, + rows = emptyList(), + refreshing = false, + ) + return@withLock + } + + val index = + try { + val file = LogFile(pfd) + pane.file = file + file.index().also { pane.index = it } + } catch (e: Exception) { + runCatching { pfd.close() } + pane.file = null + pane.state.value = + pane.state.value.copy( + status = LogStatus.ReadFailed(e.message), + rows = emptyList(), + refreshing = false, + ) + return@withLock + } + + pane.state.update { + it.copy( + totalLines = index.lineCount, + visibleLines = index.lineCount, + droppedLeading = index.droppedLeading, + refreshing = false, + ) + } + + if (index.lineCount == 0) { + pane.state.update { + it.copy(status = LogStatus.Empty, rows = emptyList(), windowLast = 0) + } + return@withLock + } + + // A filter set before the refresh still applies to the file that replaced it. + if (pane.state.value.query.isActive) { + runScan(pane, jumpTo) + } else { + applyJump(pane, jumpTo, keptFirst) + } + } + } + } + + private suspend fun applyJump(pane: Pane, jumpTo: Jump, keptFirst: Int) { + val count = pane.viewCount() + when (jumpTo) { + // A log is read from the end: that is where the crash is. + Jump.NEWEST -> loadWindow(pane, count - WINDOW, count, ScrollTo.END) + Jump.OLDEST -> loadWindow(pane, 0, WINDOW, ScrollTo.START) + Jump.KEEP -> loadWindow(pane, keptFirst, keptFirst + WINDOW, ScrollTo.NONE) + } + } + + private enum class ScrollTo { + START, + END, + NONE, + } + + /** + * Materialises `[first, last)` of the current view. + * + * The window size is invariant, so extending one edge trims the other and peak memory is a + * function of [WINDOW] alone — completely independent of how large the file turned out to be. + */ + private suspend fun loadWindow(pane: Pane, first: Int, last: Int, scrollTo: ScrollTo) { + val index = pane.index ?: return + val file = pane.file ?: return + val selection = pane.matches + val count = selection?.size ?: index.lineCount + if (count == 0) return + + var from = first.coerceIn(0, max(0, count - 1)) + val to = min(max(last, from + 1), count) + from = max(0, min(from, to - 1)) + + // Unfiltered, a view position *is* a line number, so the window start can be walked back + // to the entry that owns any stack frames it landed in the middle of. Filtered, the frames + // already travel with their entry. + if (selection == null) from = file.entryStart(index, from) + + val lines = IntArray(to - from) { selection?.get(from + it) ?: (from + it) } + val rows = + try { + file.readRows(index, lines) + } catch (e: Exception) { + pane.state.update { it.copy(status = LogStatus.ReadFailed(e.message)) } + return + } + + pane.first = from + pane.last = to + val command = + when (scrollTo) { + ScrollTo.START -> ScrollCommand(++scrollToken, 0) + ScrollTo.END -> ScrollCommand(++scrollToken, max(0, rows.size - 1)) + ScrollTo.NONE -> null + } + pane.state.update { + it.copy( + status = LogStatus.Ready, + rows = rows, + windowFirst = from, + windowLast = to, + visibleLines = count, + scroll = command ?: it.scroll, + ) + } + } + + /** + * Extends the window as the list approaches an edge. + * + * The list is keyed by absolute line number, so inserting rows above the viewport re-anchors on + * the first visible key instead of shifting it. + */ + fun onVisibleRows(tab: LogTab, firstVisible: Int, lastVisible: Int, rowCount: Int) { + val pane = panes.getValue(tab) + + // Reported first and unconditionally: the counter has to follow the scroll even while a + // page is being loaded, which is exactly when the reader is moving. + if (rowCount > 0) { + val rows = pane.state.value.rows + val from = rows.getOrNull(firstVisible)?.index?.plus(1) ?: 0 + val to = rows.getOrNull(lastVisible)?.index?.plus(1) ?: 0 + if (from != pane.state.value.visibleFirst || to != pane.state.value.visibleLast) { + pane.state.update { it.copy(visibleFirst = from, visibleLast = to) } + } + } + + if (pane.pageJob?.isActive == true || pane.loadJob?.isActive == true) return + if (pane.state.value.status !is LogStatus.Ready) return + val count = pane.viewCount() + + val extendUp = firstVisible < THRESHOLD && pane.first > 0 + val extendDown = lastVisible > rowCount - THRESHOLD && pane.last < count + if (!extendUp && !extendDown) return + + pane.pageJob = + viewModelScope.launch(Dispatchers.IO) { + pane.mutex.withLock { + if (extendUp) { + val first = max(0, pane.first - PAGE) + loadWindow(pane, first, first + WINDOW, ScrollTo.NONE) + } else { + val last = min(count, pane.last + PAGE) + loadWindow(pane, last - WINDOW, last, ScrollTo.NONE) + } + } + } + } + + fun jumpToOldest(tab: LogTab) = jump(tab, Jump.OLDEST) + + fun jumpToNewest(tab: LogTab) = jump(tab, Jump.NEWEST) + + private fun jump(tab: LogTab, to: Jump) { + val pane = panes.getValue(tab) + pane.pageJob?.cancel() + pane.pageJob = + viewModelScope.launch(Dispatchers.IO) { + pane.mutex.withLock { applyJump(pane, to, pane.first) } + } + } + + // --- Filtering --------------------------------------------------------------------------- + + fun setQuery(tab: LogTab, text: String) = + updateQuery(tab, debounce = true) { it.copy(text = text) } + + fun toggleLevel(tab: LogTab, level: LogLevel) = + updateQuery(tab, debounce = false) { + it.copy(levels = if (level in it.levels) it.levels - level else it.levels + level) + } + + fun setTag(tab: LogTab, tag: String?) = + updateQuery(tab, debounce = false) { it.copy(tag = if (it.tag == tag) null else tag) } + + fun clearFilter(tab: LogTab) = updateQuery(tab, debounce = false) { LogQuery() } + + /** + * Computes the facet counts without narrowing anything, for the filter sheet. + * + * The sheet lists the tags the file actually contains with their counts rather than a + * hardcoded set that goes stale, and that list is a by-product of the same pass that builds a + * filter — so opening the sheet runs the scan with an unchanged query and keeps the window + * exactly where the reader left it. + */ + fun loadFacets(tab: LogTab) { + val pane = panes.getValue(tab) + if (pane.state.value.facets != null || pane.index == null) return + updateQuery(tab, debounce = false, jumpTo = Jump.KEEP) { it } + } + + private fun updateQuery( + tab: LogTab, + debounce: Boolean, + jumpTo: Jump = Jump.NEWEST, + transform: (LogQuery) -> LogQuery, + ) { + val pane = panes.getValue(tab) + pane.state.update { it.copy(query = transform(it.query)) } + pane.scanJob?.cancel() + pane.scanJob = + viewModelScope.launch(Dispatchers.IO) { + // Typing should not launch a full-file scan per keystroke; the in-flight one is + // cancelled above and the reader's `yield()` per block lets it stop promptly. + if (debounce) delay(QUERY_DEBOUNCE_MS) + pane.mutex.withLock { runScan(pane, jumpTo) } + } + } + + private suspend fun runScan(pane: Pane, jumpTo: Jump) { + val index = pane.index ?: return + val file = pane.file ?: return + val query = pane.state.value.query + + pane.state.update { it.copy(status = LogStatus.Scanning(0f)) } + var reported = 0f + val scan = + try { + file.scan(index, query) { progress -> + // A repaint per 256 KB block would be pure churn on a small file. + if (progress - reported >= PROGRESS_STEP) { + reported = progress + pane.state.update { it.copy(status = LogStatus.Scanning(progress)) } + } + } + } catch (e: Exception) { + pane.state.update { it.copy(status = LogStatus.ReadFailed(e.message)) } + return + } + + pane.matches = scan.matches + val count = scan.matches?.size ?: index.lineCount + pane.state.update { it.copy(facets = scan.facets, visibleLines = count) } + + if (count == 0) { + pane.first = 0 + pane.last = 0 + pane.state.update { + it.copy( + status = if (query.isActive) LogStatus.NoMatches else LogStatus.Empty, + rows = emptyList(), + windowFirst = 0, + windowLast = 0, + ) + } + return + } + applyJump(pane, jumpTo, pane.first) + } + + // --- Destructive and export actions -------------------------------------------------------- + + /** + * Rotates the current log. + * + * Named that way because that is what happens: the daemon's `startNewLogPart()` calls + * `LogcatMonitor.refresh()`, which opens a fresh part and leaves the closed one on disk under a + * ten-part LRU, still reachable from the part chevrons and still carried by the zip export. + * Nothing is truncated, so this reloads and re-indexes rather than emptying anything. + * + * [onResult] reports whether the daemon took the request, which is as much as there is to know: + * the call answers nothing, because the daemon asks its reader to rotate by writing a sentinel + * into the log and never learns whether it acted. Folded on the `Result` itself — it used to + * fold on `getOrDefault(false)` over a `Result` whose boolean was the daemon's + * constant `true`, so the only thing that answer ever reported was that a transaction had + * happened, while reading as though the rotation had. + */ + fun rotate(tab: LogTab, onResult: (Boolean) -> Unit) { + viewModelScope.launch { + daemon + .startNewLogPart(tab == LogTab.VERBOSE) + .fold( + onSuccess = { + reload(tab, Jump.NEWEST) + onResult(true) + }, + onFailure = { + logE("logs: rotating the ${tab.name.lowercase()} log failed", it) + onResult(false) + }, + ) + } + } + + /** + * Writes the daemon's bug report into [uri] as a zip — far more than the logs, as below. + * + * This is the slowest binder transaction on the screen by a wide margin — `FileSystem.getLogs` + * walks `/data/tombstones` and `/data/anr`, shells out to `logcat -b all -d` and `dmesg`, + * sweeps `/data/adb/modules` and deflates the lot at best compression — so it is seconds, it is + * synchronous, and [DaemonClient]'s guarantee that no binder call touches the main thread is + * load-bearing here more than anywhere else. + * + * Each side owns its own copy of the descriptor and closes it: `use` closes this one, and + * `FileSystem.getLogs` closes the copy the daemon received. + * + * A failure reported here is a failure of the transaction or of opening the document. The + * daemon logs and swallows every error it hits while filling the zip, so a partial archive + * arrives looking like a complete one. + */ + fun saveTo(uri: Uri) { + if (_saveState.value == LogSaveState.Saving) return + viewModelScope.launch(Dispatchers.IO) { + _saveState.value = LogSaveState.Saving + _saveState.value = + try { + ServiceLocator.context.contentResolver.openFileDescriptor(uri, "wt").use { fd -> + if (fd == null) LogSaveState.Failed(null) + else + daemon + .writeBugReportTo(fd) + .fold( + onSuccess = { LogSaveState.Saved(uri) }, + onFailure = { LogSaveState.Failed(it.message) }, + ) + } + } catch (e: Exception) { + LogSaveState.Failed(e.message) + } + } + } + + fun consumeSaveState() { + _saveState.value = LogSaveState.Idle + } + + fun setVerbose(enabled: Boolean) { + viewModelScope.launch { + daemon.setVerboseLogEnabled(enabled).onFailure { + logE("logs: setting verbose logging to $enabled failed", it) + } + val actual = daemon.isVerboseLogEnabled().getOrDefault(enabled) + _verboseEnabled.value = actual + _verboseEnforced.value = !enabled && actual + if (actual) refresh(LogTab.VERBOSE) + } + } + + override fun onCleared() { + super.onCleared() + panes.values.forEach { pane -> + pane.loadJob?.cancel() + pane.scanJob?.cancel() + pane.pageJob?.cancel() + pane.file?.close() + pane.file = null + } + } + + companion object { + /** Rows held at once. At ~150 bytes a line this is a third of a megabyte of text. */ + const val WINDOW = 2_000 + + /** + * How far the window's near edge moves per extension. The far edge follows it, so a step + * re-reads a whole [WINDOW] either way; this only sets how often that happens. + */ + private const val PAGE = 500 + + /** How close to an edge the viewport gets before the window is extended. */ + private const val THRESHOLD = 60 + + private const val QUERY_DEBOUNCE_MS = 250L + + private const val PROGRESS_STEP = 0.02f + } +} + +class LogsViewModelFactory : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + LogsViewModel(ServiceLocator.daemon, ServiceLocator.settings) as T +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt new file mode 100644 index 000000000..39b3afe77 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt @@ -0,0 +1,1454 @@ +package org.matrix.vector.manager.ui.screens.modules + +import org.matrix.vector.manager.ui.components.UpdatableVersion +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.SettingsBackupRestore +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.SaveAlt +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.matrix.vector.manager.ui.components.VectorAlertDialog +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import android.text.format.Formatter +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.rounded.ArrowCircleUp +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreEntry +import org.matrix.vector.manager.data.repository.ModuleUpdateQueue +import org.matrix.vector.manager.ui.components.SheetHeading +import org.matrix.vector.manager.ui.components.sheetRowColors +import org.matrix.vector.manager.ui.screens.repo.StoreChannel +import org.matrix.vector.manager.ui.screens.repo.releasesOn +import org.matrix.vector.ipc.IManagerService +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.model.InstalledModule +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.AppIcon +import org.matrix.vector.manager.ui.components.PackageActionSheet +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.show +import org.matrix.vector.manager.ui.components.PackageActionResult +import org.matrix.vector.manager.ui.components.PanelHeader +import org.matrix.vector.manager.ui.components.SearchField +import org.matrix.vector.manager.ui.theme.VectorMono +import androidx.compose.material3.Surface +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback + +class ModulesViewModelFactory : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + ModulesViewModel( + ServiceLocator.daemon, + ServiceLocator.modules, + ServiceLocator.context.packageManager, + ) + as T +} + +/** + * The module list. + * + * Its first job is to answer *what is running*, so enabled modules sort to the top, a disabled row + * is dimmed and the module's own name carries the state in its colour — legible from the shape of + * the list itself, not only from the position of a switch. The header says the same thing + * numerically, and the filter turns it into a question that can be asked directly. + * + * Each row also carries the module's **reach**: which apps it is scoped to, as icons. That is the + * fact behind most trips into the scope editor, so showing it here saves the trip. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModulesScreen( + onModuleClick: (packageName: String, userId: Int) -> Unit, + onOpenStore: (packageName: String) -> Unit, + viewModel: ModulesViewModel = viewModel(factory = ModulesViewModelFactory()), +) { + val tabs by viewModel.userModulesTabs.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val query by viewModel.query.collectAsStateWithLifecycle() + val filter by viewModel.filter.collectAsStateWithLifecycle() + val sort by viewModel.sort.collectAsStateWithLifecycle() + val facts by viewModel.facts.collectAsStateWithLifecycle() + val counts by viewModel.counts.collectAsStateWithLifecycle() + val daemonAvailable by viewModel.daemonAvailable.collectAsStateWithLifecycle() + + val selection by viewModel.selection.collectAsStateWithLifecycle() + val upgradable by viewModel.upgradable.collectAsStateWithLifecycle() + val mutedUpgradable by viewModel.mutedUpgradable.collectAsStateWithLifecycle() + val updateQueue by viewModel.updateQueue.collectAsStateWithLifecycle() + val storeEntries by viewModel.storeEntries.collectAsStateWithLifecycle() + val updateChannel by viewModel.updateChannel.collectAsStateWithLifecycle() + var confirmUninstall by remember { mutableStateOf(false) } + var showUpdates by remember { mutableStateOf(false) } + + val context = LocalContext.current + val snackbars = remember { SnackbarHostState() } + val actionScope = rememberCoroutineScope() + + /** + * One sentence for a batch: whatever actually happened. + * + * The three outcomes stay separate — what changed, what was already so, and what refused — so + * that a run where nothing needed doing says so rather than claiming work it did not do. + */ + fun batchResult( + doneRes: Int, + alreadyRes: Int, + allAlreadyRes: Int, + outcome: ModulesViewModel.BatchOutcome, + ): Pair { + val (changed, already, failed) = outcome + if (failed > 0) { + return context.getString( + R.string.modules_batch_partial, + "$changed/${changed + failed}", + ) to SnackbarTone.Failure + } + if (changed == 0 && already > 0) { + return context.resources.getQuantityString(allAlreadyRes, already, already) to + SnackbarTone.Neutral + } + val done = context.resources.getQuantityString(doneRes, changed, changed) + if (already == 0) return done to SnackbarTone.Success + val alreadySaid = context.resources.getQuantityString(alreadyRes, already, already) + return "$done · $alreadySaid" to SnackbarTone.Success + } + + fun reportBatch(result: Pair) { + actionScope.launch { snackbars.show(result.first, result.second) } + } + + // Long-press actions all speak through this one snackbar, and a two-stage action calls it more + // than once — that it started, and how it ended. + fun report(result: PackageActionResult) { + val text = + result.argument?.let { context.getString(result.messageRes, it) } + ?: context.getString(result.messageRes) + actionScope.launch { snackbars.show(text, result.tone) } + } + val scope = rememberCoroutineScope() + val backedUp = stringResource(R.string.modules_backup_done) + val backupFailed = stringResource(R.string.modules_backup_failed) + val restored = stringResource(R.string.modules_restore_done) + val restoreFailed = stringResource(R.string.modules_restore_failed) + + val backupLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/gzip")) { + uri -> + if (uri != null) { + viewModel.backupTo(uri) { count -> + scope.launch { + if (count != null) snackbars.show(String.format(backedUp, count), SnackbarTone.Success) + else snackbars.show(backupFailed, SnackbarTone.Failure) + } + } + } + } + + val selectionBackupLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/gzip")) { + uri -> + if (uri != null) { + viewModel.backupSelectedTo(uri) { count -> + scope.launch { + if (count != null) + snackbars.show(String.format(backedUp, count), SnackbarTone.Success) + else snackbars.show(backupFailed, SnackbarTone.Failure) + } + } + } + } + + val restoreLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.restoreFrom(uri) { outcome -> + scope.launch { + if (outcome != null) + snackbars.show( + String.format(restored, outcome.restored, outcome.skipped), + SnackbarTone.Success, + ) + else snackbars.show(restoreFailed, SnackbarTone.Failure) + } + } + } + } + + Scaffold(snackbarHost = { VectorSnackbarHost(snackbars) }) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + // Hoisted above the header: the count in it is the *visible* profile's, so the header + // has to know which page is showing. Aggregating across profiles made "4 of 6 active" + // describe a set the user was not looking at. + val pagerState = rememberPagerState(pageCount = { tabs.size }) + val visible = tabs.getOrNull(pagerState.currentPage) + // The sheet lives outside the pager, so it needs the current page's answer handed to + // it rather than reading the whole device's. + val present = visible?.modules?.map { it.packageName }?.toSet().orEmpty() + val visibleUpgradable = upgradable intersect present + val visibleMutedUpgradable = mutedUpgradable intersect present + + // Inside the Column so the per-profile sets are in scope; a modal sheet draws in its + // own window, so where it sits in the tree costs nothing. + if (showUpdates) { + ModuleUpdatesSheet( + entries = storeEntries, + upgradable = visibleUpgradable, + mutedUpgradable = visibleMutedUpgradable, + channel = StoreChannel.of(updateChannel), + onStart = viewModel::startUpdates, + onDismiss = { showUpdates = false }, + ) + } + + // The selection bar takes the title and description rows and nothing else, so the + // search field below stays exactly where the thumb left it and the list does not jump + // the moment a module is picked up. Filling the whole header would leave one row of + // controls floating in a band of colour half the height of the header. + ModulesHeader( + active = visible?.modules?.count { it.isEnabled } ?: counts.first, + total = visible?.modules?.size ?: counts.second, + onBackup = { backupLauncher.launch("vector-modules.bak") }, + onRestore = { restoreLauncher.launch(arrayOf("*/*")) }, + titleOverlay = + if (selection.isEmpty()) null + else { + { + SelectionBar( + count = selection.size, + onClose = viewModel::clearSelection, + onEnable = { + viewModel.setSelectedEnabled(true) { outcome -> + reportBatch( + batchResult( + R.plurals.modules_batch_enabled, + R.plurals.modules_batch_already_on, + R.plurals.modules_batch_all_already_on, + outcome, + ) + ) + } + }, + onDisable = { + viewModel.setSelectedEnabled(false) { outcome -> + reportBatch( + batchResult( + R.plurals.modules_batch_disabled, + R.plurals.modules_batch_already_off, + R.plurals.modules_batch_all_already_off, + outcome, + ) + ) + } + }, + onBackup = { selectionBackupLauncher.launch("vector-modules.bak") }, + onUninstall = { confirmUninstall = true }, + ) + } + }, + search = { ModulesSearch(query, viewModel, filter, sort) }, + ) + + // No blocking spinner: the pull-to-refresh indicator already reports the reload, and + // a full-screen spinner on every route in made the list flash. + if (isLoading && tabs.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Column + } + + if (tabs.isEmpty() || tabs.all { it.modules.isEmpty() }) { + // A filter empties the list exactly as a search does, so both count as narrowing. + // Otherwise picking "Inactive" on a device where everything is on would say "you + // have no modules installed" over a list the filter had just hidden. + EmptyState( + daemonAvailable = daemonAvailable, + filtered = query.isNotBlank() || filter != ModuleFilter.All, + ) + return@Column + } + + if (tabs.size > 1) { + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { Text(tab.user.name, fontWeight = FontWeight.Medium) }, + ) + } + } + } + + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + // Pull to re-read the installed packages and the daemon's enabled set. A module + // installed or removed outside the manager is the common case, and the broadcast + // that catches it does not fire for every route in. + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = { viewModel.loadModules() }, + ) { + val modules = tabs[page].modules + // Sections only make sense when the order is by state. Under any other sort the + // groups would interleave, and a header that lies about what follows it is worse + // than no header. + val sectioned = sort == ModuleSort.EnabledFirst && query.isBlank() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 4.dp, bottom = 20.dp), + ) { + item(key = "updates") { + UpdateLine( + // Counted against the modules on *this* page. A profile has its own + // set of installed modules, and a count carried over from another one + // offers updates for packages that are not there — the sheet would + // then be empty, or worse, install into the wrong profile. + updates = modules.count { it.packageName in upgradable }, + queue = updateQueue, + onOpen = { showUpdates = true }, + onAcknowledge = viewModel::acknowledgeUpdates, + ) + } + if (sectioned) { + val active = modules.filter { it.isEnabled } + val inactive = modules.filterNot { it.isEnabled } + + if (active.isNotEmpty()) { + stickyHeader(key = "h:active") { + SectionHeader(stringResource(R.string.modules_section_active), active.size) + } + moduleRows(active, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report) + } + if (inactive.isNotEmpty()) { + stickyHeader(key = "h:inactive") { + SectionHeader( + stringResource(R.string.modules_section_inactive), + inactive.size, + ) + } + moduleRows(inactive, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report) + } + } else { + moduleRows(modules, facts, selection, upgradable, onModuleClick, onOpenStore, viewModel::toggleSelected, ::report) + } + } + } + } + } + } + + if (confirmUninstall) { + VectorAlertDialog( + onDismissRequest = { confirmUninstall = false }, + icon = { Icon(Icons.Rounded.Delete, contentDescription = null) }, + title = { Text(stringResource(R.string.modules_uninstall_title)) }, + // Names the consequence rather than asking "are you sure". The backup on this screen + // holds the enabled flag and the scope; the module's own stored settings go with it + // and nothing here can bring them back. + text = { + Text( + pluralStringResource( + R.plurals.modules_uninstall_body, + selection.size, + selection.size, + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmUninstall = false + viewModel.uninstallSelected { outcome -> + reportBatch( + batchResult( + R.plurals.modules_batch_uninstalled, + // Nothing is ever "already uninstalled" here: the list only + // holds what is installed, so these two are unreachable and + // are the same string rather than an invented sentence. + R.plurals.modules_batch_uninstalled, + R.plurals.modules_batch_uninstalled, + outcome, + ) + ) + } + } + ) { + Text( + stringResource(R.string.action_uninstall), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirmUninstall = false }) { + Text(stringResource(R.string.logs_cancel)) + } + }, + ) + } +} + +/** + * What the selection can be done to. + * + * Laid over the header rather than replacing it, and inset so it reads as a panel that has come + * forward over the screen rather than a coloured slab bolted to the top of it. The count takes the + * place the title held, the actions take the place the backup and restore icons held, so the eye + * does not have to find anything twice. + * + * Uninstall is last and in the error colour, and asks before it does anything — it is the only + * irreversible thing on this screen. + */ +@Composable +private fun SelectionBar( + count: Int, + onClose: () -> Unit, + onEnable: () -> Unit, + onDisable: () -> Unit, + onBackup: () -> Unit, + onUninstall: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + tonalElevation = 3.dp, + ) { + Row( + modifier = Modifier.fillMaxSize().padding(start = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onClose) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.modules_selection_clear), + ) + } + Text( + text = pluralStringResource(R.plurals.modules_selected, count, count), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(start = 2.dp), + ) + SelectionAction(Icons.Rounded.CheckCircle, R.string.modules_batch_enable, onEnable) + SelectionAction(Icons.Rounded.Block, R.string.modules_batch_disable, onDisable) + SelectionAction(Icons.Rounded.SaveAlt, R.string.modules_backup, onBackup) + SelectionAction( + Icons.Rounded.Delete, + R.string.action_uninstall, + onUninstall, + tint = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun SelectionAction( + icon: androidx.compose.ui.graphics.vector.ImageVector, + descriptionRes: Int, + onClick: () -> Unit, + tint: Color? = null, +) { + IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) { + Icon( + icon, + contentDescription = stringResource(descriptionRes), + tint = tint ?: LocalContentColor.current, + modifier = Modifier.size(26.dp), + ) + } +} + +/** The module search field, as the header's third row. */ +@Composable +private fun ModulesSearch( + query: String, + viewModel: ModulesViewModel, + filter: ModuleFilter, + sort: ModuleSort, +) { + SearchField( + query = query, + onQueryChange = viewModel::setQuery, + placeholder = stringResource(R.string.modules_search_hint), + ) { + ModuleFilterButton( + filter = filter, + onFilterChange = viewModel::setFilter, + sort = sort, + onSortChange = viewModel::setSort, + ) + } +} + +/** The filter menu that lives in the search field's trailing slot. */ +@Composable +private fun ModuleFilterButton( + filter: ModuleFilter, + onFilterChange: (ModuleFilter) -> Unit, + sort: ModuleSort, + onSortChange: (ModuleSort) -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + val filtering = filter != ModuleFilter.All || sort != ModuleSort.EnabledFirst + + Box { + IconButton(onClick = { menuOpen = true }) { + BadgedBox( + badge = { + // A filter that narrows the list must never be silent — an empty list with + // no visible cause reads as "nothing installed". + if (filtering) Badge(modifier = Modifier.size(6.dp)) + } + ) { + Icon( + Icons.Rounded.FilterList, + contentDescription = stringResource(R.string.modules_filter), + tint = + if (filtering) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { +LocalizedOverlay { + + ModuleFilter.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes())) }, + trailingIcon = { + if (option == filter) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { + onFilterChange(option) + menuOpen = false + }, + ) + } + HorizontalDivider() + ModuleSort.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes())) }, + trailingIcon = { + if (option == sort) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { + onSortChange(option) + menuOpen = false + }, + ) + } + } +} + } +} + +@Composable +private fun ModulesHeader( + active: Int, + total: Int, + onBackup: () -> Unit, + onRestore: () -> Unit, + modifier: Modifier = Modifier, + titleOverlay: (@Composable () -> Unit)? = null, + search: @Composable () -> Unit, +) { + PanelHeader( + title = stringResource(R.string.nav_modules), + modifier = modifier, + titleOverlay = titleOverlay, + actions = { + // Both shown rather than hidden behind an overflow. There are exactly two, they are + // opposites, and a menu holding two items costs a tap to say what a glance could. + // + // Deliberately *not* a mirrored pair: at 24dp two mirror images of the same shape read + // as one shape, and telling them apart means stopping to work out which way the arrow + // points. Two different pictures instead — a tray to save into, and the platform's own + // restore glyph — each naming the outcome rather than the mechanism. Nothing here + // uploads anywhere either; the file goes wherever the document picker is pointed. + IconButton(onClick = onRestore) { + Icon( + Icons.Rounded.SettingsBackupRestore, + contentDescription = stringResource(R.string.modules_restore), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onBackup) { + Icon( + Icons.Rounded.SaveAlt, + contentDescription = stringResource(R.string.modules_backup), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + description = { + if (total > 0) { + Text( + text = stringResource(R.string.modules_active_of, active, total), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + search = search, + ) +} + +/** + * A module, as a row. + * + * No card and no tinted background. Three states have to be distinguishable at a glance — running, + * off, and asking for an API the framework does not provide — and painting the whole row for each + * would turn the list into stacked blocks of colour fighting the icons and the text. **The + * module's own name carries the state instead**: the accent colour when it is running, muted when + * it is off, the error colour when the framework is too old for it. + * + * The icon is left exactly as the module ships it. Wrapping it in a coloured well would make every + * module look like it belonged to Vector rather than to its author. + * + * Two columns for the three questions the row answers: what it is (icon, and the API it needs), + * and what it does (name and description). How it is configured — the version and the reach — is + * laid over the second column rather than given a third, as the Box below explains. + */ +@Composable +private fun ModuleRow( + module: InstalledModule, + facts: ModuleFacts?, + hasUpdate: Boolean, + selected: Boolean, + onClick: () -> Unit, + onIconClick: () -> Unit, + onLongClick: () -> Unit, + onOpenStore: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val incompatible = facts?.incompatible == true + + val nameColor by + animateColorAsState( + when { + incompatible -> colors.error + module.isEnabled -> colors.primary + else -> colors.onSurfaceVariant + }, + label = "moduleNameColor", + ) + + var expanded by rememberSaveable(module.packageName) { mutableStateOf(false) } + var truncated by remember { mutableStateOf(false) } + + Row( + modifier = + Modifier.fillMaxWidth() + // Intrinsic height so the right column can push its lower item to the bottom of + // whatever the description made this row. + .height(IntrinsicSize.Min) + // A module that is off recedes rather than merely changing colour: the list is + // read first as "what is running", and everything else should sit behind that. + .alpha(if (module.isEnabled || incompatible) 1f else 0.45f) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top, + ) { + // The icon is the switch. Double-tapping it turns the module on or off without leaving the + // list, which is what someone flipping several modules actually wants; a single tap only + // says what state it is in, because a one-tap toggle here would fire every time a thumb + // brushed the list. + Column( + modifier = Modifier.combinedClickable(onClick = onIconClick, onLongClick = onLongClick), + // Against the text, not centred over the badge. The badge below is wider than the icon + // — "Xposed 54" is — so centring left the icon a few pixels short of the edge the + // names and descriptions all start from, and every row in the list showed that gap. + horizontalAlignment = Alignment.End, + ) { + // Fixed at the icon's own size whatever is drawn inside it, so that picking a module + // up cannot resize its row. A tick larger than the icon would grow this box, and with + // it the icon column, the row's intrinsic height and every row below — selecting one + // module would reflow the list under the thumb that selected it. + Box(modifier = Modifier.size(ICON_SIZE), contentAlignment = Alignment.Center) { + AppIcon( + applicationInfo = module.applicationInfo, + contentDescription = null, + size = ICON_SIZE, + ) + // The tick covers the icon rather than sitting beside it. A selected row has to be + // unmistakable at a glance across a screen of them, and the icon is the one part + // of the row the eye is already using to tell the rows apart. + if (selected) { + Box( + modifier = + Modifier.fillMaxSize() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.85f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(30.dp), + ) + } + } + } + Spacer(Modifier.height(6.dp)) + ApiBadge(module = module, incompatible = incompatible) + } + + Spacer(Modifier.width(16.dp)) + + // Only this area opens the scope, so that a tap on the icon beside it — which is the + // selection handle — cannot navigate away instead. + // + // A Box, not a third column. Reserving a column for the version and the reach would take + // its width from *every line* of the description, the one piece of prose on this screen, + // and take it whether or not anything was there to put in it. They overlap the text column + // instead and are kept clear of the text by *vertical* placement: the version sits in the + // title's band, the reach in the band below the last line. Nothing is reserved + // horizontally, so the description runs the full width. + Box( + Modifier.weight(1f) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + ) { + Column(Modifier.padding(bottom = REACH_BAND)) { + // The title's band. Both halves are fixed and both scroll, so neither can ever reach + // the other however long the module's name or its version string becomes — which is + // not a hypothetical: names run to "Enable Screenshot (formerly known as Disable + // FLAG_SECURE)" and versions to a tag with a commit hash on the end. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = module.appName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = nameColor, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = + Modifier.weight(1f) + .basicMarquee(iterations = 1, repeatDelayMillis = 3_000), + ) + Spacer(Modifier.width(10.dp)) + UpdatableVersion( + text = module.versionName.ifBlank { "" }, + hasUpdate = hasUpdate, + marquee = true, + color = colors.onSurfaceVariant, + // With an update in hand the version is the shortest route to the release that + // would replace it, so it becomes the link. Without one it is inert: a tap + // that sometimes navigates and sometimes does nothing teaches nothing. + modifier = + Modifier.width(VERSION_WIDTH) + .then( + if (!hasUpdate) Modifier + else + Modifier.clip(RoundedCornerShape(6.dp)).clickable { + onOpenStore() + } + ), + ) + } + val brokenSince = facts?.apiBrokenSince + val loadFailure = facts?.loadFailure + if (loadFailure != null) { + // First, above every other note. A module that cannot be loaded is doing nothing + // at all, and unsaid that is indistinguishable from a switch that turned itself + // off. + Spacer(Modifier.height(4.dp)) + Text( + text = + stringResource( + when (loadFailure) { + // Named separately from "could not load it" because it is the one + // refusal that is not brokenness: the module is old, and its + // author is the only one who can move it forward. + IManagerService.MODULE_LOAD_UNSUPPORTED_API -> + R.string.modules_load_unsupported_api + IManagerService.MODULE_LOAD_NO_APK -> + R.string.modules_load_no_apk + IManagerService.MODULE_LOAD_UNUSABLE -> + R.string.modules_load_unusable + // Every other reason, including one this build does not know: + // `ModuleLoadFailure.reason` is never 0, so an unrecognised value + // is a reason a newer daemon has and this manager has not. Saying + // the module could not be loaded is the whole of what is + // established; naming the nearest reason we do know would be a + // guess. + else -> R.string.modules_load_unusable + } + ), + style = MaterialTheme.typography.bodySmall, + color = colors.error, + ) + } else if (incompatible) { + Spacer(Modifier.height(4.dp)) + Text( + text = + stringResource( + if (module.isLegacy) R.string.modules_incompatible_legacy + else R.string.modules_incompatible, + module.minVersion, + ), + style = MaterialTheme.typography.bodySmall, + color = colors.error, + ) + } else if (brokenSince != null) { + // Not an error: the framework will load this and it may work perfectly. It is a + // caution, in the caution colour, naming the version that changed underneath it so + // the reader can go and ask its author about that specific thing. + Spacer(Modifier.height(4.dp)) + Text( + text = + stringResource( + R.string.modules_api_behind, + // "Built for" is the target, and it is what decided this caution was + // due; showing the floor beside a verdict reached from the target + // would be two numbers disagreeing in one sentence. + module.apiVersion, + brokenSince, + ), + style = MaterialTheme.typography.bodySmall, + color = colors.tertiary, + ) + } else if (module.description.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.Bottom) { + Text( + // The only prose on this screen, and the thing that says what the module + // actually does — so it gets room. + text = module.description, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + maxLines = if (expanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + // Whether there is more to read is a property of this description at this + // width, which only the layout knows — so the control appears only when + // it would do something. + onTextLayout = { truncated = it.hasVisualOverflow || expanded }, + modifier = Modifier.weight(1f, fill = false), + ) + if (truncated) { + Icon( + imageVector = + if (expanded) Icons.Rounded.ExpandLess + else Icons.Rounded.ExpandMore, + contentDescription = + stringResource( + if (expanded) R.string.modules_collapse + else R.string.modules_expand + ), + tint = colors.primary, + modifier = + Modifier.padding(start = 4.dp) + .size(20.dp) + .clip(CircleShape) + .clickable { expanded = !expanded }, + ) + } + } + } + } + + // The reach, in the band the row already left empty under the last line of text. It + // is allowed to run left past where a column would have ended — nothing is there — so + // it costs the description no width at all. + ScopePreview( + module = module, + facts = facts, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } + } +} + +/** + * The strip along the bottom of a row that the reach sits in. + * + * The row already ended in a gap of about this size, so the icons landed in space that was being + * left empty anyway: full-width prose and a right-aligned reach, for a few density-independent + * pixels rather than a whole column. + */ +private val REACH_BAND = 22.dp + +/** Room for a version and its mark. Anything longer scrolls past instead of pushing. */ +private val VERSION_WIDTH = 104.dp + +/** + * The module's icon, and the slot it is drawn in whether or not it is selected. + * + * Comfortably a touch target — it is the selection handle — while leaving the width a larger icon + * would take to the column that holds the name and the description, where the reading happens. + */ +private val ICON_SIZE = 48.dp + +/** + * Who the module actually touches. + * + * A count alone answers a question nobody asked; three recognisable icons answer "does this touch + * anything I care about" without opening anything, and the remainder collapses to a number after + * them. Nothing is drawn at all when the scope is empty or not yet known. + */ +@Composable +private fun ScopePreview( + module: InstalledModule, + facts: ModuleFacts?, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val reach = facts?.scopeCount ?: -1 + val framework = facts?.scopeFramework == true + // Nothing to depict, so nothing is drawn. A row saying "no apps" would spend a line on an + // absence, and would say it of every module that hooks only the framework. + if (reach <= 0 && !framework) return + + val preview = facts?.scopePreview.orEmpty() + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + if (framework) { + // The framework is a scope target with no icon, so it gets a mark of its own rather + // than silently becoming part of a number. + Icon( + Icons.Rounded.Android, + contentDescription = stringResource(R.string.modules_scope_framework), + tint = colors.primary, + modifier = Modifier.padding(start = 3.dp).size(20.dp), + ) + } + preview.forEach { info -> + AppIcon( + applicationInfo = info, + contentDescription = null, + size = 20.dp, + modifier = Modifier.padding(start = 3.dp), + ) + } + val remainder = reach - preview.size + if (remainder > 0) { + Spacer(Modifier.width(5.dp)) + Text( + text = stringResource(R.string.modules_scope_more, remainder), + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +/** + * `API 101` / `Xposed 93`, with the scale small and quiet and the number carrying the colour. + * + * The scale name is context that rarely changes; the number is the fact being checked. A module + * that declares no API at all shows `API ?` rather than a sentence — it is the same shape as every + * other badge, so the missing value reads as missing rather than as a different kind of thing. + */ +@Composable +private fun ApiBadge(module: InstalledModule, incompatible: Boolean) { + val colors = MaterialTheme.colorScheme + val undeclared = !module.declaresApiVersion + + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = + stringResource( + if (module.isLegacy) R.string.modules_api_scale_legacy + else R.string.modules_api_scale_modern + ), + // Barely there: the scale is context, and it repeats down every row. The number is + // the only part anyone reads twice. + style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp), + color = colors.onSurfaceVariant.copy(alpha = 0.7f), + ) + Text( + text = if (undeclared) "?" else module.apiVersion.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = if (incompatible || undeclared) colors.error else colors.primary, + ) + } +} + +@Composable +private fun EmptyState(daemonAvailable: Boolean, filtered: Boolean) { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Rounded.Extension, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = + stringResource( + when { + !daemonAvailable -> R.string.modules_no_daemon + filtered -> R.string.modules_no_match + else -> R.string.modules_empty + } + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +private fun ModuleFilter.labelRes(): Int = + when (this) { + ModuleFilter.All -> R.string.modules_filter_all + ModuleFilter.Active -> R.string.modules_filter_active + ModuleFilter.Inactive -> R.string.modules_filter_inactive + } + +private fun ModuleSort.labelRes(): Int = + when (this) { + ModuleSort.EnabledFirst -> R.string.modules_sort_enabled + ModuleSort.Name -> R.string.modules_sort_name + ModuleSort.RecentlyUpdated -> R.string.modules_sort_recent + ModuleSort.WidestScope -> R.string.modules_sort_scope + } + +/** Emits one row per module, plus its divider. */ +private fun androidx.compose.foundation.lazy.LazyListScope.moduleRows( + modules: List, + facts: Map, + selection: Set, + upgradable: Set, + onModuleClick: (String, Int) -> Unit, + onOpenStore: (String) -> Unit, + onSelect: (InstalledModule) -> Unit, + onAction: (PackageActionResult) -> Unit, +) { + items(modules, key = { "${it.packageName}:${it.userId}" }) { module -> + ModuleListItem( + module = module, + facts = facts[ModuleKey(module.packageName, module.userId)], + hasUpdate = module.packageName in upgradable, + selected = ModuleKey(module.packageName, module.userId) in selection, + selectionActive = selection.isNotEmpty(), + onClick = { onModuleClick(module.packageName, module.userId) }, + onOpenStore = { onOpenStore(module.packageName) }, + onSelect = { onSelect(module) }, + onAction = onAction, + ) + // Inset from both ends. A full-bleed rule cuts the list into slabs; a short one reads as + // a breath between rows, which is all it is for. + HorizontalDivider( + modifier = Modifier.padding(start = 108.dp, end = 32.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + } +} + +/** + * A module row, with the sheet its long press opens. + * + * There is deliberately no swipe-to-toggle: a horizontal drag on a row inside a vertically + * scrolling list competes with the scroll for every gesture that is not perfectly straight. + * + * **The icon is the selection handle.** Tapping it picks the module up; from there the same tap on + * any other icon adds to the set and the bar at the top acts on all of them at once, which is what + * makes enabling, removing or backing up eight modules one act rather than eight. + */ +@Composable +private fun ModuleListItem( + module: InstalledModule, + facts: ModuleFacts?, + hasUpdate: Boolean, + selected: Boolean, + selectionActive: Boolean, + onClick: () -> Unit, + onOpenStore: () -> Unit, + onSelect: () -> Unit, + onAction: (PackageActionResult) -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + + ModuleRow( + module = module, + facts = facts, + hasUpdate = hasUpdate, + selected = selected, + onOpenStore = onOpenStore, + // Once anything is selected the whole row joins the selection, because that is what every + // other list on the platform does and aiming at a 48dp icon to add the ninth module would + // be its own small ordeal. + onClick = if (selectionActive) onSelect else onClick, + onIconClick = { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onSelect() + }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + menuOpen = true + }, + ) + + if (menuOpen) { + PackageActionSheet( + packageName = module.packageName, + userId = module.userId, + appName = module.appName, + applicationInfo = module.applicationInfo, + isModule = true, + onDismiss = { menuOpen = false }, + onResult = onAction, + onOpenStore = { onOpenStore() }, + ) + } +} + +/** A pinned label saying which half of the list you are in, and how big it is. */ +@Composable +private fun SectionHeader(title: String, count: Int) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = count.toString(), + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The one line in the panel that says how many modules are behind, and how the run is going. + * + * A line under the header rather than a badge on a tab or a banner over the list. The panel's own + * first sentence is already "3 of 11 active"; "4 can be updated" is the same kind of fact about the + * same set, and it reads as the second half of that sentence rather than as an interruption. + * + * It is absent when there is nothing to update. A row that says "everything is current" is a row + * that has to be read to learn nothing, on every visit, forever. + * + * During a run it stops being a button and becomes the report: which module, how far through. That + * is why it is here and not inside the sheet — updating four modules takes longer than anyone will + * hold a sheet open, so the progress has to live somewhere they will actually be. + */ +@Composable +private fun UpdateLine( + updates: Int, + queue: ModuleUpdateQueue.State, + onOpen: () -> Unit, + onAcknowledge: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val running = queue.running + val settled = !running && queue.total > 0 + if (!running && !settled && updates == 0) return + + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(colors.primary.copy(alpha = 0.09f)) + .clickable(onClick = if (settled) onAcknowledge else onOpen) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + when { + settled && queue.failed.isNotEmpty() -> Icons.Rounded.ErrorOutline + settled -> Icons.Rounded.CheckCircle + else -> Icons.Rounded.ArrowCircleUp + }, + contentDescription = null, + tint = if (settled && queue.failed.isNotEmpty()) colors.error else colors.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = + when { + running -> + stringResource( + R.string.modules_updating, + queue.current?.title ?: "", + queue.finished + 1, + queue.total, + ) + settled && queue.failed.isNotEmpty() -> + pluralStringResource( + R.plurals.modules_update_failed, + queue.failed.size, + queue.failed.size, + ) + settled -> + pluralStringResource( + R.plurals.modules_updated, + queue.done.size, + queue.done.size, + ) + else -> pluralStringResource(R.plurals.modules_updates, updates, updates) + }, + style = MaterialTheme.typography.bodyMedium, + color = if (settled && queue.failed.isNotEmpty()) colors.error else colors.primary, + ) + } +} + +/** + * Which of the modules that are behind to bring forward. + * + * Checkboxes rather than a single "update everything" button, because these are other people's + * APKs going onto someone's phone: the reader gets to see the list and say which. Everything that + * can be installed in one step is ticked to begin with, since that is what someone opening this + * usually means. + * + * Modules whose updates were silenced are listed too, below the rest and unticked. They are + * genuinely out of date, and this is the one screen where saying so is useful rather than nagging + * — it is also the only way to find what you muted six months ago without going through the store + * one module at a time. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModuleUpdatesSheet( + entries: Map, + upgradable: Set, + mutedUpgradable: Set, + channel: StoreChannel, + onStart: (List) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + val colors = MaterialTheme.colorScheme + val context = LocalContext.current + + // One APK is installable from here; several is a choice this sheet has no room to make, so + // those keep their row, uncheckable, pointing at the store page that does. + data class Row( + val entry: StoreEntry, + val release: RepoVersion?, + val asset: ReleaseAsset?, + val muted: Boolean, + ) + + val rows = + remember(entries, upgradable, mutedUpgradable, channel) { + (upgradable + mutedUpgradable).mapNotNull { name -> + val entry = entries[name] ?: return@mapNotNull null + val release = entry.module.releasesOn(channel).firstOrNull() + val apks = release?.releaseAssets.orEmpty().filter { it.isApk } + Row(entry, release?.version, apks.singleOrNull(), name in mutedUpgradable) + } + .sortedWith(compareBy({ it.muted }, { it.entry.module.title.lowercase() })) + } + + var chosen by + remember(rows) { + mutableStateOf( + rows.filter { !it.muted && it.asset != null }.map { it.entry.module.name }.toSet() + ) + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + LocalizedOverlay { + Column(Modifier.padding(bottom = 24.dp)) { + SheetHeading( + stringResource(R.string.modules_updates_title), + Icons.Rounded.ArrowCircleUp, + ) + Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { + rows.forEach { row -> + val name = row.entry.module.name + val selectable = row.asset != null + ListItem( + // Toggleable rather than clickable, for the same reason the checkbox + // takes no callback: the row *is* the tick. A plain clickable is + // announced as a button carrying the module's name, saying nothing + // about whether it is going to be updated. + modifier = + Modifier.toggleable( + value = name in chosen, + enabled = selectable, + role = Role.Checkbox, + onValueChange = { checked -> + chosen = if (checked) chosen + name else chosen - name + }, + ), + supportingContent = { + Text( + text = + when { + row.asset == null -> + stringResource(R.string.action_update_choose) + // "1.1.1 → 1.1.1" is not a thing to say to anyone. + row.entry.sameVersion -> + stringResource( + R.string.modules_update_reinstall, + row.entry.latest?.versionName.orEmpty(), + Formatter.formatShortFileSize( + context, + row.asset.size, + ), + ) + else -> + stringResource( + R.string.modules_update_versions, + row.entry.installed?.versionName.orEmpty(), + row.entry.latest?.versionName.orEmpty(), + Formatter.formatShortFileSize( + context, + row.asset.size, + ), + ) + } + ) + }, + leadingContent = { + Checkbox( + checked = name in chosen, + onCheckedChange = null, + enabled = selectable, + ) + }, + trailingContent = + if (!row.muted) null + else { + { + Text( + text = stringResource(R.string.modules_update_ignored), + style = MaterialTheme.typography.labelSmall, + color = colors.onSurfaceVariant, + ) + } + }, + colors = sheetRowColors, + ) { Text(row.entry.module.title) } + } + } + Spacer(Modifier.height(12.dp)) + Button( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + enabled = chosen.isNotEmpty(), + onClick = { + onStart( + rows + .filter { it.entry.module.name in chosen && it.asset != null } + .map { + ModuleUpdateQueue.Item( + packageName = it.entry.module.name, + title = it.entry.module.title, + asset = it.asset!!, + release = it.release, + ) + } + ) + onDismiss() + }, + ) { + Text(stringResource(R.string.modules_update_selected, chosen.size)) + } + } + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt new file mode 100644 index 000000000..27b647203 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt @@ -0,0 +1,563 @@ +package org.matrix.vector.manager.ui.screens.modules + +import android.os.SystemClock +import android.content.pm.PackageManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.vector.ipc.DeviceUser +import org.matrix.vector.ipc.ScopeEntry +import org.matrix.vector.manager.data.model.InstalledModule +import org.matrix.vector.manager.data.model.MATCH_ANY_USER +import org.matrix.vector.manager.data.model.PER_USER_RANGE +import org.matrix.vector.manager.data.repository.ModuleRepository +import org.matrix.vector.manager.data.model.StoreEntry +import org.matrix.vector.manager.data.repository.ModuleUpdateQueue +import org.matrix.vector.manager.data.model.XposedApi +import org.matrix.vector.manager.data.model.versionCodeCompat +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logI +import org.matrix.vector.manager.logW + +/** One tab: a user, and the modules installed for them. */ +data class UserModulesState(val user: DeviceUser, val modules: List) + +/** What the list is showing. Answering "what is running" should not need a scroll. */ +enum class ModuleFilter { + All, + Active, + Inactive, +} + +/** Identifies one module row: a package can be installed for several users at once. */ +data class ModuleKey(val packageName: String, val userId: Int) + +/** + * How far a module reaches, and whether it can run at all. + * + * The scope count is the useful part: it lets the list answer "what does this module actually + * touch" without opening it, which is the question behind most visits to the scope editor. + */ +data class ModuleFacts( + val scopeCount: Int = -1, + val incompatible: Boolean = false, + /** + * The libxposed version that broke what this module was built against, if one has. + * + * Distinct from [incompatible], which is "the framework does not go that high". This is the + * opposite direction: the module is *behind* a break, so the framework can load it and it may + * still misbehave. The number is kept rather than a flag because naming the version is the + * explanation. + */ + val apiBrokenSince: Int? = null, + /** + * Why the framework could not load this module, though it is enabled and installed, as one of + * `IManagerService.MODULE_LOAD_*`. + * + * Null when the daemon did not name this module at all, which is every other case: it loaded, + * or it is switched off and there was nothing to load. This is the daemon's two notions of a + * module disagreeing, and it is the only case where a row can be enabled and doing nothing — + * worth a sentence, because from the outside it is indistinguishable from the switch having + * turned itself off. + */ + val loadFailure: Int? = null, + /** + * The first few apps in the module's scope, for the row's preview. + * + * A count tells you the *size* of a scope; the icons tell you the scope. "5 apps" answers a + * question nobody asked, where three recognisable icons answer "does this touch anything I + * care about" at a glance. + */ + val scopePreview: List = emptyList(), + /** Whether the module hooks the system framework, which has no icon of its own to show. */ + val scopeFramework: Boolean = false, +) + +/** How the list is ordered. Only [EnabledFirst] groups into sections; the others are flat. */ +enum class ModuleSort { + EnabledFirst, + Name, + RecentlyUpdated, + WidestScope, +} + +/** How many scoped apps a row previews before collapsing the rest into a count. */ +const val SCOPE_PREVIEW_LIMIT = 3 + +/** The framework itself, which the daemon names in a scope like any package but which is not one. */ +private const val SYSTEM_FRAMEWORK = "system" + +class ModulesViewModel( + private val daemonClient: DaemonClient, + private val moduleRepository: ModuleRepository, + private val packageManager: PackageManager, +) : ViewModel() { + + private val _discovered = MutableStateFlow>(emptyList()) + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + /** + * Whether the daemon answered at all. + * + * An empty list means two completely different things — "you have no modules" and "the + * framework is not running" — and the empty state has to say which. Set from whether the user + * list came back at all. + */ + private val _daemonAvailable = MutableStateFlow(true) + val daemonAvailable: StateFlow = _daemonAvailable.asStateFlow() + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _filter = MutableStateFlow(ModuleFilter.All) + val filter: StateFlow = _filter.asStateFlow() + + private val _sort = MutableStateFlow(ModuleSort.EnabledFirst) + val sort: StateFlow = _sort.asStateFlow() + + /** + * Per module *per user*, not per package. Filled in after the list appears, so it never delays + * first paint. + * + * A module installed in both the owner's space and a private space is two rows with two + * scopes. Keying by package alone would hand the second row the first one's facts, and + * filtering the scope by user cannot repair that on its own — the entry being filtered would + * already belong to the wrong copy of the module. + */ + private val _facts = MutableStateFlow>(emptyMap()) + val facts: StateFlow> = _facts.asStateFlow() + + /** + * Which of the installed modules the catalogue has something newer for, minus the muted ones. + * + * The catalogue is the Store's, not a second fetch: both this and the Store's own header count + * derive from `StoreEntry.upgradable` over the same flow, so a mark on a row and the number on + * the Store panel cannot disagree. + */ + val upgradable: StateFlow> = ServiceLocator.upgradablePackages + + val mutedUpgradable: StateFlow> = ServiceLocator.mutedUpgradablePackages + + val storeEntries: StateFlow> = ServiceLocator.storeEntries + + val updateChannel: StateFlow = ServiceLocator.settings.updateChannel + + val updateQueue: StateFlow = ServiceLocator.moduleUpdates.state + + fun startUpdates(items: List) = + ServiceLocator.moduleUpdates.start(items) + + fun acknowledgeUpdates() = ServiceLocator.moduleUpdates.acknowledge() + + /** "8 of 14 active", without needing the filtered list. */ + val counts: StateFlow> = + combine(_discovered, moduleRepository.enabledModulesState) { tabs, enabled -> + val all = tabs.flatMap { it.modules } + all.count { it.packageName in enabled } to all.size + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0 to 0) + + /** + * The enabled set is owned by [ModuleRepository] and merged in here rather than baked into the + * discovered list, so enabling a module updates one flow and every screen showing it agrees. + */ + val userModulesTabs: StateFlow> = + combine(_discovered, moduleRepository.enabledModulesState, _query, _filter, _sort) { + tabs, + enabled, + query, + filter, + sort -> + tabs.map { tab -> + tab.copy( + modules = + tab.modules + .map { it.copy(isEnabled = it.packageName in enabled) } + .filter { module -> + val matchesQuery = + query.isBlank() || + module.appName.contains(query, ignoreCase = true) || + module.packageName.contains(query, ignoreCase = true) + val matchesFilter = + when (filter) { + ModuleFilter.All -> true + ModuleFilter.Active -> module.isEnabled + ModuleFilter.Inactive -> !module.isEnabled + } + matchesQuery && matchesFilter + } + .sortedWith(comparatorFor(sort)) + ) + } + } + // Filtering happens off the main thread. stateIn(viewModelScope) alone collects on + // Dispatchers.Main.immediate, which would run this over the full list on every + // keystroke in the search field. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Reach is read from the facts map rather than the module, because it arrives after the list + * does — sorting by it before it loads would reshuffle the list under the user's finger. + */ + private fun comparatorFor(sort: ModuleSort): Comparator = + when (sort) { + // Active first: the list's first job is to say what is running. + ModuleSort.EnabledFirst -> + compareByDescending { it.isEnabled } + .thenBy { it.appName.lowercase() } + ModuleSort.Name -> compareBy { it.appName.lowercase() } + ModuleSort.RecentlyUpdated -> + compareByDescending { it.lastUpdateTime } + .thenBy { it.appName.lowercase() } + ModuleSort.WidestScope -> + compareByDescending { + _facts.value[ModuleKey(it.packageName, it.userId)]?.scopeCount ?: -1 + } + .thenBy { it.appName.lowercase() } + } + + init { + loadModules() + moduleRepository.refresh() + // The update marks, the header line and the sheet all read the catalogue, so this screen + // asks for it rather than relying on the splash prefetch having succeeded. Cheap to + // repeat: the request is cache-controlled and a concurrent caller returns immediately + // rather than starting a second 1.2 MB download. + ServiceLocator.appScope.launch { runCatching { ServiceLocator.store.refresh() } } + viewModelScope.launch { + // drop(1): the current value is the state we just rendered, not a change. + moduleRepository.scopeRevision.drop(1).collect { loadFacts(_discovered.value) } + } + viewModelScope.launch { + // A package appearing or going away is the only thing that can change *which* modules + // exist, so it is the only thing that needs a rediscovery. Everything else reuses the + // cached answer. + moduleRepository.packageRevision.drop(1).collect { loadModules() } + } + } + + fun setQuery(value: String) { + _query.value = value + } + + fun setFilter(value: ModuleFilter) { + _filter.value = value + } + + fun setSort(value: ModuleSort) { + _sort.value = value + } + + fun backupTo(uri: android.net.Uri, onResult: (Int?) -> Unit) { + viewModelScope.launch { + val count = ServiceLocator.backup.backupTo(uri).getOrNull() + onResult(count) + } + } + + fun restoreFrom( + uri: android.net.Uri, + onResult: (org.matrix.vector.manager.data.repository.BackupRepository.RestoreOutcome?) -> Unit, + ) { + viewModelScope.launch { + val outcome = ServiceLocator.backup.restoreFrom(uri).getOrNull() + // Whatever happened, the list on screen is now stale. + moduleRepository.refresh() + loadModules() + onResult(outcome) + } + } + + // --- selection ------------------------------------------------------------------------------ + + /** + * Which modules are selected, by package and user. + * + * Empty means the screen is in its ordinary reading mode; anything in it puts the screen into + * selection mode. There is no separate `selectionMode` flag because two sources of truth for + * one state is how a screen ends up in selection mode with nothing selected. + */ + private val _selection = MutableStateFlow>(emptySet()) + val selection: StateFlow> = _selection.asStateFlow() + + fun toggleSelected(module: InstalledModule) { + val key = ModuleKey(module.packageName, module.userId) + _selection.update { if (key in it) it - key else it + key } + } + + fun clearSelection() { + _selection.value = emptySet() + } + + /** + * Enables or disables everything selected, then reports how many actually changed. + * + * Sequential rather than concurrent: each toggle is a Binder call that rewrites the same + * configuration, and the rebuild it asks for is conflated and serialised at the daemon anyway, + * so firing twenty at once buys nothing but a harder failure to explain. + */ + fun setSelectedEnabled(enable: Boolean, onResult: (BatchOutcome) -> Unit) { + val targets = _selection.value.toList() + viewModelScope.launch { + // What the daemon already thinks, so a module that is in the asked-for state is neither + // toggled nor counted as a change. The daemon reports a row it rewrote as written + // whether or not the value moved, so counting its answers alone would announce five + // enabled when two of them already were. The report is the only evidence the user has + // of what happened, so it has to distinguish "done" from "was already so". + val current = moduleRepository.enabledModulesState.value + var changed = 0 + var failed = 0 + var already = 0 + targets.forEach { key -> + if ((key.packageName in current) == enable) { + already++ + return@forEach + } + if (moduleRepository.toggleModule(key.packageName, enable)) changed++ else failed++ + } + // No re-read, and no rediscovery. Each toggle above already returned the daemon's own + // answer and the repository recorded it; asking again could only replace something + // confirmed with something less fresh, and a toggle changes nothing about which + // packages are installed. + _selection.value = emptySet() + onResult(BatchOutcome(changed = changed, already = already, failed = failed)) + } + } + + /** + * What a batch actually did. + * + * Three numbers rather than two, because "it was already like that" is not a success and not a + * failure — reporting it as either is how a count comes to mean nothing. + */ + data class BatchOutcome(val changed: Int, val already: Int, val failed: Int) + + /** Uninstalls everything selected. The screen confirms first; this does not. */ + fun uninstallSelected(onResult: (BatchOutcome) -> Unit) { + val targets = _selection.value.toList() + viewModelScope.launch { + var removed = 0 + var failed = 0 + targets.forEach { key -> + val result = daemonClient.uninstallPackage(key.packageName, key.userId) + val ok = result.getOrDefault(false) + // On `!ok`, not on onFailure: the daemon returns a bare `false` for a refusal, so + // onFailure would miss the case this line exists for. + if (ok) removed++ + else { + failed++ + logE( + "modules: uninstall of ${key.packageName} for user ${key.userId} failed", + result.exceptionOrNull(), + ) + } + } + moduleRepository.refresh() + loadModules() + _selection.value = emptySet() + onResult(BatchOutcome(changed = removed, already = 0, failed = failed)) + } + } + + /** Backs up only what is selected, using the same file format as a whole-collection backup. */ + fun backupSelectedTo(uri: android.net.Uri, onResult: (Int?) -> Unit) { + val targets = _selection.value.map { it.packageName }.toSet() + viewModelScope.launch { + val count = ServiceLocator.backup.backupTo(uri, targets).getOrNull() + _selection.value = emptySet() + onResult(count) + } + } + + fun loadModules() { + viewModelScope.launch { + _isLoading.update { true } + val tabs = withContext(Dispatchers.IO) { discover() } + _discovered.update { tabs } + _isLoading.update { false } + loadFacts(tabs) + } + } + + /** + * Reads each module's scope size and API requirement after the list is already on screen. + * + * One binder call per module, so it is deliberately not on the path to first paint — the list + * appears immediately and the reach figures fill in. + */ + private fun loadFacts(tabs: List) { + viewModelScope.launch(Dispatchers.IO) { + val api = daemonClient.getLibxposedApiVersion().getOrDefault(0) + // One call for the whole list, package to reason. Absence is the answer for every + // other module — it loaded, or it is switched off and there was nothing to load — so + // nothing here invents a reason for a row the daemon did not name. On a daemon that + // would not answer at all this is empty, which claims nothing about any module. + val loadFailures = daemonClient.getModuleLoadFailures().getOrDefault(emptyMap()) + // One lookup table for every scope preview, rather than a package-manager query per + // scoped app per module. Keyed by package *and* user: a device with a work profile or + // a private space holds the same package twice, and collapsing them would let a row + // depict the wrong profile's copy of an app. + val byPackage = + ServiceLocator.apps.getInstalledApps().associateBy { + it.packageName to it.userId + } + + val collected = mutableMapOf() + // The daemon holds one scope per module package covering every user, so it is read + // once here and split per user below — the loop runs over every copy of every module, + // because each row needs its own answer. + val scopeCache = mutableMapOf?>() + tabs.flatMap { it.modules }.forEach { module -> + val scope = + scopeCache.getOrPut(module.packageName) { + daemonClient.getModuleScope(module.packageName).getOrNull() + } + // A module is almost always in its own scope, and its icon is already the row's + // leading image — counting or showing it again says nothing. + // + // Only this user's targets. A module installed in two profiles has one scope list + // covering both, so an unfiltered row would show the other profile's apps too — + // visibly, as the same app icon twice. + val targets = + scope?.filter { + it.packageName != module.packageName && it.userId == module.userId + } + val framework = targets?.any { it.packageName == SYSTEM_FRAMEWORK } == true + val apps = targets?.filter { it.packageName != SYSTEM_FRAMEWORK }.orEmpty() + + collected[ModuleKey(module.packageName, module.userId)] = + ModuleFacts( + // Counts what the row actually depicts, from the same list the icons come + // from. Counting raw scope rows instead would have a module scoped to + // itself and the framework claim "2 apps" while showing neither. + scopeCount = if (targets == null) -1 else apps.size, + // The *minimum*, deliberately: it is the author's stated floor, and this + // is the only place it is honoured at all. The daemon picks its loading + // strategy from `targetApiVersion` alone, so a module saying it needs 102 + // on a framework implementing 101 is loaded anyway — and its author has + // said not to expect it to work. + incompatible = api > 0 && module.minVersion > api, + // Judged on what the module was built against, not on the floor it asks + // for: a module declaring min 100 and target 101 is a 101 module, and + // judging by the floor would warn it about a break it is on the far side + // of. + apiBrokenSince = + if (api <= 0) null + else XposedApi.brokenSince(module.apiVersion, api), + loadFailure = loadFailures[module.packageName], + scopeFramework = framework, + scopePreview = + apps + .asSequence() + .mapNotNull { byPackage[it.packageName to it.userId]?.applicationInfo } + .take(SCOPE_PREVIEW_LIMIT) + .toList(), + ) + } + // Published once, when every row's answer is in. Handing out a map per module copies + // the whole thing again for each one, and every copy is a new map to the list, so the + // rows all recompose for one row's worth of news. + _facts.value = collected.toMap() + } + } + + private suspend fun discover(): List { + val usersResult = daemonClient.getUsers() + usersResult.onFailure { e -> + logW("modules: user list unavailable, treating the daemon as unreachable", e) + } + _daemonAvailable.value = usersResult.isSuccess + val users = usersResult.getOrNull() ?: emptyList() + + val flags = + PackageManager.GET_META_DATA or + PackageManager.MATCH_UNINSTALLED_PACKAGES or + MATCH_ANY_USER + + val packages = + daemonClient + .getInstalledPackagesFromAllUsers(flags, filterNoProcess = false) + .getOrElse { e -> + logE("modules: installed package list unavailable, showing no modules", e) + emptyList() + } + + // Through the cache, not straight to ModuleDetection: inspecting a package means opening + // its APK and every split as a zip, and there are ~550 of those on a normal device. Keyed + // by version code and install time, so an unchanged package is a map lookup and only a + // newly installed or updated one is actually opened. + val detection = ServiceLocator.moduleDetection + val startedAt = SystemClock.elapsedRealtime() + val allModules = + packages.mapNotNull { pkg -> + val appInfo = pkg.applicationInfo ?: return@mapNotNull null + val manifest = + detection.inspect( + appInfo, + packageManager, + pkg.versionCodeCompat, + pkg.lastUpdateTime, + ) + if (!manifest.isModule) return@mapNotNull null + + InstalledModule( + packageName = pkg.packageName, + userId = appInfo.uid / PER_USER_RANGE, + appName = appInfo.loadLabel(packageManager).toString(), + versionName = pkg.versionName ?: "", + versionCode = pkg.versionCodeCompat, + description = manifest.description, + minVersion = manifest.minApiVersion, + targetVersion = manifest.targetApiVersion, + isLegacy = manifest.isLegacy, + declaresApiVersion = manifest.declaresApiVersion, + lastUpdateTime = pkg.lastUpdateTime, + isEnabled = false, // merged in from the repository above + applicationInfo = appInfo, + ) + } + + detection.flush(packages.mapNotNull { it.packageName }.toSet()) + // The one number that explains a slow Modules panel: how many APKs this scan had to open. + // Everything else is a map lookup, so a large figure here on a second run means the cache + // key is wrong rather than that the device is slow. + logI( + "modules: scanned ${packages.size} packages in " + + "${SystemClock.elapsedRealtime() - startedAt}ms, " + + "opened ${detection.inspectedThisRun}", + ) + + val perUser = + users.map { user -> + UserModulesState( + user = user, + modules = + allModules + .filter { it.userId == user.id } + .sortedBy { it.appName.lowercase() }, + ) + } + + // A profile with no modules in it is not a tab worth having: a private space that has never + // had one adds a second tab to a screen that otherwise has none, and the tab bar then + // implies a choice where there is nothing to choose. If that empties the list entirely, + // keep it as it was so the empty state has a user to name. + return perUser.filter { it.modules.isNotEmpty() }.ifEmpty { perUser.take(1) } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt new file mode 100644 index 000000000..4b1fe622f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt @@ -0,0 +1,1085 @@ +package org.matrix.vector.manager.ui.screens.modules + +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd +import androidx.compose.material.icons.rounded.RemoveDone +import androidx.compose.material.icons.rounded.SettingsBackupRestore +import androidx.compose.material.icons.rounded.SaveAlt +import androidx.compose.material.icons.rounded.SwapVert +import androidx.compose.material3.FilterChip +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.ui.graphics.vector.ImageVector +import org.matrix.vector.manager.ui.components.ChoiceRow +import org.matrix.vector.manager.ui.components.SheetAction +import org.matrix.vector.manager.ui.components.SheetHeading +import org.matrix.vector.manager.ui.components.ToggleRow +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material3.Button +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.matrix.vector.manager.ui.components.VectorAlertDialog +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.model.AppInfo +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.AppIcon +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.show +import org.matrix.vector.manager.ui.components.PackageActionResult +import org.matrix.vector.manager.ui.components.PackageActionSheet +import org.matrix.vector.manager.ui.components.SearchField +import org.matrix.vector.manager.ui.theme.VectorMono + +class ScopeViewModelFactory(private val packageName: String, private val userId: Int) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + ScopeViewModel( + modulePackageName = packageName, + userId = userId, + daemonClient = ServiceLocator.daemon, + appRepository = ServiceLocator.apps, + moduleRepository = ServiceLocator.modules, + packageManager = ServiceLocator.context.packageManager, + ) + as T +} + +/** + * Which apps a module may hook. + * + * The screen's shape follows from one fact: **a scope is written whole, never incrementally.** The + * daemon deletes every scope row of the module, writes the new set and rebuilds its configuration, + * so sending that on each tap would mean ten rewrites to tick ten apps. Edits are therefore a + * draft the user builds up, and applying is a deliberate act with its size stated — *3 to add, 1 + * to remove*. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScopeScreen( + packageName: String, + userId: Int, + onNavigateBack: () -> Unit, + viewModel: ScopeViewModel = viewModel(factory = ScopeViewModelFactory(packageName, userId)), +) { + val apps by viewModel.filteredApps.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + val state by viewModel.uiState.collectAsStateWithLifecycle() + val pending by viewModel.pendingChanges.collectAsStateWithLifecycle() + val applying by viewModel.applying.collectAsStateWithLifecycle() + val query by viewModel.searchQuery.collectAsStateWithLifecycle() + val showSystem by viewModel.showSystemApps.collectAsStateWithLifecycle() + val showGames by viewModel.showGames.collectAsStateWithLifecycle() + val recommendedOnly by viewModel.showRecommendedOnly.collectAsStateWithLifecycle() + val showModules by viewModel.showModules.collectAsStateWithLifecycle() + val sortOrder by viewModel.sort.collectAsStateWithLifecycle() + val reversed by viewModel.reverseSort.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val hasCompanion by viewModel.hasCompanion.collectAsStateWithLifecycle() + + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val scopeSaved = stringResource(R.string.scope_backup_done) + val scopeFailed = stringResource(R.string.scope_backup_failed) + + fun report(result: PackageActionResult) { + val text = + result.argument?.let { context.getString(result.messageRes, it) } + ?: context.getString(result.messageRes) + scope.launch { snackbars.show(text, result.tone) } + } + + val scopeBackupLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri != null) { + viewModel.backupScopeTo(uri) { ok -> + scope.launch { + if (ok) snackbars.show(scopeSaved, SnackbarTone.Success) + else snackbars.show(scopeFailed, SnackbarTone.Failure) + } + } + } + } + + val scopeRestoreLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.restoreScopeFrom(uri) { ok -> + if (!ok) scope.launch { snackbars.show(scopeFailed, SnackbarTone.Failure) } + } + } + } + val haptics = LocalHapticFeedback.current + var confirmStranded by remember { mutableStateOf(false) } + // Whether the stranding question has already been put this visit, and answered by neither + // button. Two slots cannot hold three answers, so when the module has asked for something the + // buttons are "give it that" and "switch it off" and there is none that says "leave it exactly + // as it is" — cancelling the dialog is the only way to say that, and a warning that comes + // straight back on the next back press turns cancelling into a wall the reader cannot get + // past. Asked once, then believed. + var strandWarned by remember { mutableStateOf(false) } + val frameworkRestartNeeded by viewModel.frameworkRestartNeeded.collectAsStateWithLifecycle() + + val staticScopeNotice = stringResource(R.string.scope_static) + val applied = stringResource(R.string.scope_applied) + val applyFailed = stringResource(R.string.scope_apply_failed) + val toggleFailed = stringResource(R.string.scope_toggle_failed) + val nothingToOpen = stringResource(R.string.action_no_launcher) + + LaunchedEffect(message) { + val text = + when (message) { + ScopeMessage.Applied -> applied + ScopeMessage.ApplyFailed -> applyFailed + ScopeMessage.ToggleFailed, + ScopeMessage.IncludeNewAppsFailed -> toggleFailed + ScopeMessage.NothingToOpen -> nothingToOpen + null -> null + } + if (text != null) { + haptics.performHapticFeedback( + if (message == ScopeMessage.Applied) HapticFeedbackType.Confirm + else HapticFeedbackType.Reject + ) + snackbars.show( + text, + if (message == ScopeMessage.Applied) SnackbarTone.Success else SnackbarTone.Failure, + ) + viewModel.consumeMessage() + } + } + + // The view model is scoped to the navigation entry, so it survives leaving the app entirely, + // and nothing else re-reads the scope after `init`. Without this the screen would go on showing + // what the table held when it opened. See `refreshSavedScope` for who else writes it. + LifecycleResumeEffect(packageName, userId) { + viewModel.refreshSavedScope() + onPauseOrDispose {} + } + + // Leaving a module enabled with nothing to hook does nothing at all but looks like it works. + fun attemptBack() { + if (!strandWarned && viewModel.wouldStrandModule()) confirmStranded = true + else onNavigateBack() + } + + // The gesture leaves this screen exactly as the arrow does, so it asks the same question + // first. Declared here it wins over the navigator's own back handling. + BackHandler { attemptBack() } + + Scaffold( + topBar = { + // One line: back, who this is about, and the switch. A large two-line bar would spend + // a fifth of the screen restating a name the user has just tapped, on a screen whose + // whole job is a long list. + TopAppBar( + title = { + Column { + Text( + text = state.moduleName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + // The column is a fixed slice of one row, and module names are not. + // Rather than truncate the end of a name — often exactly the part that + // distinguishes two builds of the same module — it scrolls itself. + // + // Finite, not endless: this is a screen someone sits on while working + // through a long list, and a title that never stops moving is a + // distraction. It says its piece and settles. + modifier = Modifier.basicMarquee(iterations = 3), + ) + Text( + text = packageName, + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + // A package name is read from both ends: the head says who publishes + // it, the tail says which one it is. Ellipsising the middle keeps both + // — "org.matrix…chromext" — where cutting the end would throw away the + // only part that distinguishes it. Static, so the line above is the + // only thing on this bar that ever moves. + overflow = TextOverflow.MiddleEllipsis, + ) + } + }, + navigationIcon = { + IconButton(onClick = ::attemptBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + // The master switch, in the bar rather than in a card competing with the app + // list: it is the single most consequential control on the screen. What an + // overflow menu would hold here lives in the search field instead, next to the + // list it acts on. + Switch( + checked = state.isEnabled, + onCheckedChange = { enable -> + haptics.performHapticFeedback( + if (enable) HapticFeedbackType.ToggleOn + else HapticFeedbackType.ToggleOff + ) + viewModel.setModuleEnabled(enable) + }, + modifier = Modifier.padding(end = 12.dp), + ) + }, + ) + }, + snackbarHost = { VectorSnackbarHost(snackbars) }, + // The module's own screen, in the corner rather than in the bar. The bar holds what the + // screen *is* — whose scope, and whether it runs — and this is a departure from it: it + // leaves for somewhere else. + floatingActionButton = { + // Only when there is something behind it. A module with no companion and no launcher + // entry — which is most of them — would otherwise carry a button whose whole function + // is to report that it has nothing to do. + if (hasCompanion == true) { + FloatingActionButton(onClick = viewModel::openModule) { + Icon( + Icons.AutoMirrored.Rounded.Launch, + contentDescription = stringResource(R.string.action_open_companion), + ) + } + } + }, + bottomBar = { + // Appears only when the draft differs from what the daemon holds, so the count is + // stated exactly when there is one. + AnimatedVisibility( + visible = pending.any, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + ApplyBar( + added = pending.added, + removed = pending.removed, + applying = applying, + onDiscard = viewModel::discard, + onApply = viewModel::apply, + ) + } + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + SearchField( + query = query, + onQueryChange = { viewModel.searchQuery.value = it }, + placeholder = stringResource(R.string.scope_search_hint), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + // Everything that changes *what the list shows or contains* lives here, beside + // the list it acts on, rather than behind an overflow menu in the title bar. + ScopeSelectMenu( + hasRecommended = !state.recommended.isEmpty, + includeNewApps = state.includeNewApps, + onUseRecommended = viewModel::useRecommended, + onSelectAll = viewModel::selectAllVisible, + onSelectNone = viewModel::clearAllVisible, + onIncludeNewApps = viewModel::setIncludeNewApps, + onBackup = { scopeBackupLauncher.launch("$packageName-scope.json") }, + onRestore = { scopeRestoreLauncher.launch(arrayOf("*/*")) }, + ) + ScopeFilterMenu( + showSystem = showSystem, + showGames = showGames, + showModules = showModules, + hasRecommended = !state.recommended.isEmpty, + recommendedOnly = recommendedOnly, + onToggleRecommendedOnly = { + viewModel.setRecommendedOnly(!recommendedOnly) + }, + locked = state.recommended.staticScope, + onLockedClick = { scope.launch { snackbars.show(staticScopeNotice) } }, + onToggleSystem = { viewModel.showSystemApps.value = !showSystem }, + onToggleGames = { viewModel.showGames.value = !showGames }, + onToggleModules = { viewModel.setShowModules(!showModules) }, + ) + ScopeSortMenu( + sort = sortOrder, + reversed = reversed, + onSort = viewModel::setSort, + onReverse = viewModel::toggleReverse, + ) + } + + Spacer(Modifier.height(10.dp)) + + // Every installed package, with its label and its icon, read through the package + // manager: on a phone with a few hundred of them that is a visible wait, and without + // this there is no way to tell a slow load from a filter that has matched nothing. + // + // Held until the load *finishes*, not merely until there is something to draw. The app + // list is published early and the saved scope arrives after it, so a list drawn in + // between is in the wrong order, and the re-sort that follows inserts the scope's own + // rows above whatever LazyColumn had anchored — the screen opens part-way down, with + // the module's targets scrolled off the top, which reads as though they are not there. + if (state.loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Column + } + + if (apps.isEmpty()) { + ScopeEmptyState() + return@Column + } + + // Pinned to the top until the reader scrolls, rather than scrolled to the top once. + // + // The list is computed on a background dispatcher and lags the loading flag, so the + // first thing drawn is the previous emission — the one built before the saved scope + // arrived, without the scope's own rows at its head. When the real list lands it + // prepends them, and `items` being keyed means LazyColumn holds the row it had + // anchored and lets the new ones appear above it: the screen opens exactly one + // scope's-worth of rows down. Scrolling once on arrival cannot fix that, because on + // arrival there is nothing yet to scroll past. + // + // So every change to the head of the list re-pins, until a drag says the reader has + // taken over. A drag rather than any scroll, because the pin itself is a scroll. + var readerHasScrolled by remember(packageName, userId) { mutableStateOf(false) } + LaunchedEffect(listState) { + listState.interactionSource.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) readerHasScrolled = true + } + } + val headKey = apps.firstOrNull()?.let { "${it.packageName}:${it.userId}" } + LaunchedEffect(headKey) { if (!readerHasScrolled) listState.scrollToItem(0) } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 12.dp), + ) { + items(apps, key = { "${it.packageName}:${it.userId}" }) { app -> + AppRow( + app = app, + // A static scope fixes *which apps may be listed*, not which of them the + // user wants. "Users should not apply the module on apps outside the scope + // list" is the whole of what module.prop claims, the list above is already + // narrowed to that set, and the daemon refuses only targets beyond it — a + // subset is accepted. Disabling every row here went further than any of + // that and made the declared scope all or nothing: a module naming three + // apps could be given all three from the selection menu or none, and one + // of them never, with no way to drop one afterwards either. + enabled = !app.isImplicitInScope, + origin = + when { + app.isImplicitInScope -> ScopeOrigin.Derived + state.recommended.staticScope && app.isRecommended -> + ScopeOrigin.Locked + app.isRecommended -> ScopeOrigin.Requested + else -> ScopeOrigin.Chosen + }, + // The framework's note only on a device that has more than one user: + // someone editing a work profile module's scope has no other way to know + // that this target is not scoped to their profile, but on a single-user + // phone it is a sentence about a distinction that does not exist. + note = + when { + app.isImplicitInScope -> R.string.scope_self_hook + app.packageName == SYSTEM_FRAMEWORK_PACKAGE && + state.multipleUsers -> R.string.scope_framework_shared + else -> null + }, + onToggle = { checked -> + haptics.performHapticFeedback( + if (checked) HapticFeedbackType.ToggleOn + else HapticFeedbackType.ToggleOff + ) + viewModel.toggle(app, checked) + }, + onAction = ::report, + ) + } + } + } + } + + // Asked after the apply has already succeeded, so it is not a confirmation — the scope is + // stored either way. It exists because system_server is the one target that cannot pick a + // scope up by itself. + if (frameworkRestartNeeded) { + VectorAlertDialog( + onDismissRequest = { viewModel.dismissFrameworkRestart() }, + icon = { Icon(Icons.Rounded.RestartAlt, contentDescription = null) }, + title = { Text(stringResource(R.string.scope_framework_restart_title)) }, + text = { Text(stringResource(R.string.scope_framework_restart_body)) }, + confirmButton = { + TextButton(onClick = { viewModel.softRebootForFramework() }) { + Text( + stringResource(R.string.action_soft_reboot), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissFrameworkRestart() }) { + Text(stringResource(R.string.scope_framework_restart_later)) + } + }, + ) + } + + if (confirmStranded) { + // Three things the reader might mean and two slots to say them in — `VectorAlertDialog` + // wraps Material's `AlertDialog`, which has a confirm button and a dismiss button and + // nothing else. Which two are offered depends on whether the module asked for anything, + // and the third is always reachable by cancelling the dialog. + // + // A module with a recommendation is the interesting case: the useful answer there is not + // "switch it off" but "give it what it asked for", which is what the pre-Compose manager + // offered as its positive button whenever a recommendation existed, keeping disable for + // the negative one. Offering to disable a module that has told us exactly which apps it + // wants is offering to throw away the answer while holding it. + val hasRecommended = !state.recommended.isEmpty + // Written once and dropped into whichever slot is free: the same act — turn the module off + // and leave — is the positive answer when there is nothing better to offer and the + // negative one when there is. + val disableAndLeave: @Composable () -> Unit = { + TextButton( + onClick = { + viewModel.setModuleEnabled(false) + confirmStranded = false + onNavigateBack() + } + ) { + Text(stringResource(R.string.scope_empty_disable)) + } + } + VectorAlertDialog( + // Tapping outside, or the system back the dialog handles itself, is a cancel and not + // an answer — so it goes back to the list being edited rather than off the screen. + // It is also the only way to say "leave it exactly as it is" when the buttons are + // taken, which is why it records that the question has now been asked; see + // [strandWarned]. + onDismissRequest = { + strandWarned = true + confirmStranded = false + }, + title = { Text(stringResource(R.string.scope_empty_title)) }, + text = { Text(stringResource(R.string.scope_empty_message)) }, + confirmButton = { + if (hasRecommended) { + // Ticks the recommendation and returns the reader to the list, deliberately + // without leaving: this is an edit like every other on this screen and still + // has to be applied, and navigating away from it would drop the draft on the + // floor a moment after offering it. + TextButton( + onClick = { + viewModel.useRecommended() + confirmStranded = false + } + ) { + Text(stringResource(R.string.scope_use_recommended)) + } + } else { + disableAndLeave() + } + }, + dismissButton = { + if (hasRecommended) { + disableAndLeave() + } else { + // Leaves, which the label has always promised and the button never did: + // dismissing the dialog alone put the reader back on the page they were trying + // to leave, where pressing back asked them the same question again. + TextButton( + onClick = { + confirmStranded = false + onNavigateBack() + } + ) { + Text(stringResource(R.string.scope_empty_keep)) + } + } + }, + ) + } +} + +/** + * Everything that changes the *selection*, in the search field's trailing slot. + * + * A sheet rather than a dropdown, as elsewhere in the app: these entries are sentences, not words. + * "Sélectionner tout ce qui est affiché" does not fit the width a menu gives itself, so in French + * every second row wraps and the menu reads as a paragraph. A sheet has the full width, and it can + * carry the leading icons that tell an action from a setting. + */ +@Composable +private fun ScopeSelectMenu( + hasRecommended: Boolean, + includeNewApps: Boolean, + onUseRecommended: () -> Unit, + onSelectAll: () -> Unit, + onSelectNone: () -> Unit, + onIncludeNewApps: (Boolean) -> Unit, + onBackup: () -> Unit, + onRestore: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + IconButton(onClick = { open = true }) { + Icon( + Icons.Rounded.Checklist, + contentDescription = stringResource(R.string.scope_select), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (open) { + ScopeSheet( + stringResource(R.string.scope_select), + Icons.Rounded.Checklist, + { open = false }, + ) { + if (hasRecommended) { + SheetAction( + title = stringResource(R.string.scope_use_recommended), + icon = Icons.Rounded.AutoAwesome, + onClick = { + onUseRecommended() + open = false + }, + ) + } + SheetAction( + title = stringResource(R.string.scope_select_visible), + icon = Icons.Rounded.DoneAll, + onClick = { + onSelectAll() + open = false + }, + ) + SheetAction( + title = stringResource(R.string.scope_clear_visible), + icon = Icons.Rounded.RemoveDone, + onClick = { + onSelectNone() + open = false + }, + ) + // The one entry in this sheet that changes the *future* of the scope rather than its + // present, so its label says exactly that and not something narrower. + ToggleRow( + title = stringResource(R.string.scope_include_new_apps), + subtitle = stringResource(R.string.scope_include_new_apps_summary), + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + checked = includeNewApps, + onCheckedChange = onIncludeNewApps, + ) + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + // This module's scope alone, separate from the whole-list backup on the module + // screen — useful when moving one module's configuration between devices. + SheetAction( + title = stringResource(R.string.scope_backup), + icon = Icons.Rounded.SaveAlt, + onClick = { + onBackup() + open = false + }, + ) + SheetAction( + title = stringResource(R.string.scope_restore), + icon = Icons.Rounded.SettingsBackupRestore, + onClick = { + onRestore() + open = false + }, + ) + } + } +} + +/** + * What the list *contains*. + * + * All three were in the legacy manager and all three earn their place: system apps are usually + * noise but occasionally the target, games are bulk, and other modules are installed apps that are + * rarely what you are hooking. + * + * Chips rather than rows: these are short, all of one kind, and several are on at once — which a + * column of ticks states less clearly than a row of filled chips. + */ +@Composable +private fun ScopeFilterMenu( + showSystem: Boolean, + showGames: Boolean, + showModules: Boolean, + hasRecommended: Boolean, + recommendedOnly: Boolean, + onToggleRecommendedOnly: () -> Unit, + locked: Boolean, + onLockedClick: () -> Unit, + onToggleSystem: () -> Unit, + onToggleGames: () -> Unit, + onToggleModules: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + // Anything other than the defaults must not be silent — and "other than the defaults" is the + // point, because the defaults themselves hide system apps and other modules. Asking whether + // anything is hidden would light the mark on a device nobody had touched, which says nothing. + // It matters because these choices survive the visit: returning to a list filtered the way you + // left it a week ago is exactly when you need telling. + val filtering = + !locked && + (showSystem != ScopeViewModel.DEFAULT_SHOW_SYSTEM || + showGames != ScopeViewModel.DEFAULT_SHOW_GAMES || + showModules != ScopeViewModel.DEFAULT_SHOW_MODULES || + recommendedOnly) + + // Under a static scope the list is already exactly the module's own fixed set, so there is + // nothing to filter. The control stays present but visibly dead, and says why when pressed — + // removing it entirely would just raise the same question silently. + IconButton(onClick = { if (locked) onLockedClick() else open = true }) { + BadgedBox(badge = { if (filtering) Badge(modifier = Modifier.size(6.dp)) }) { + Icon( + Icons.Rounded.FilterList, + contentDescription = stringResource(R.string.modules_filter), + tint = + when { + locked -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + filtering -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + if (open) { + ScopeSheet( + stringResource(R.string.modules_filter), + Icons.Rounded.FilterList, + { open = false }, + ) { + if (hasRecommended) { + // The static-scope view, on request. Offered only when the module has actually + // asked for something — otherwise it would narrow the list to nothing. + ChoiceRow { + FilterChip( + selected = recommendedOnly, + onClick = { onToggleRecommendedOnly() }, + label = { Text(stringResource(R.string.scope_recommended_only)) }, + ) + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + } + // Off while the module's own request is what the list is answering. That question has + // one answer — what it asked for, and what it has been given — and these three can + // only subtract from it: Chrome is a system app, so a module asking for Chrome would + // show an empty list to anyone who had not also turned system apps on. Greyed rather + // than hidden, so the reader can see the settings are still there and why they are not + // in play. + ChoiceRow { + FilterChip( + selected = showSystem, + enabled = !recommendedOnly, + onClick = { onToggleSystem() }, + label = { Text(stringResource(R.string.scope_system_apps)) }, + ) + FilterChip( + selected = showGames, + enabled = !recommendedOnly, + onClick = { onToggleGames() }, + label = { Text(stringResource(R.string.scope_games)) }, + ) + FilterChip( + selected = showModules, + enabled = !recommendedOnly, + onClick = { onToggleModules() }, + label = { Text(stringResource(R.string.scope_modules)) }, + ) + } + } + } +} + +/** What order it is in: every [ScopeSort], and a reverse toggle over whichever is chosen. */ +@Composable +private fun ScopeSortMenu( + sort: ScopeSort, + reversed: Boolean, + onSort: (ScopeSort) -> Unit, + onReverse: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + IconButton(onClick = { open = true }) { + Icon( + Icons.AutoMirrored.Rounded.Sort, + contentDescription = stringResource(R.string.scope_sort), + tint = + if (sort != ScopeSort.Relevance || reversed) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (open) { + ScopeSheet( + stringResource(R.string.scope_sort), + Icons.AutoMirrored.Rounded.Sort, + { open = false }, + ) { + ChoiceRow { + ScopeSort.entries.forEach { option -> + FilterChip( + selected = option == sort, + onClick = { onSort(option) }, + label = { Text(stringResource(option.labelRes())) }, + ) + } + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + ToggleRow( + title = stringResource(R.string.scope_sort_reverse), + icon = Icons.Rounded.SwapVert, + checked = reversed, + onCheckedChange = { onReverse() }, + ) + } + } +} + +/** The shell all three of this screen's sheets share, so they cannot drift apart. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScopeSheet( + title: String, + icon: ImageVector, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + LocalizedOverlay { + Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + SheetHeading(title, icon) + content() + } + } + } +} + + +/** + * Why the list is empty, which the list itself can never say. + * + * Search and the two filter sheets sit directly above, and any of them can narrow this to nothing — + * so an empty area under them reads as a screen that failed rather than as a question that was + * asked and answered. + */ +@Composable +private fun ScopeEmptyState() { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.scope_no_match), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +/** + * How a row came to be in the scope, which decides the ring around its icon. + * + * Different mechanisms can put an app in a module's scope and they behave differently when the + * world changes — two are the module naming a target, one is only ever what you ticked, and one is + * the framework's own doing. Rendered as an identical checkbox, a row the module asked for looks + * exactly like a row someone went and found for themselves. + * + * There is deliberately no "auto-included" origin. The include-new-apps setting reacts to packages + * installed *from now on*, and nothing records how an app already in the scope got there, so any + * such label would be a guess. It is explained in the sheet, where it is a property of the module + * rather than a claim about a row. + */ +private enum class ScopeOrigin { + /** + * The module asked for it and fixed the list it came from: no app it did not name reaches this + * screen. Which of the ones it did name are in the scope is still the user's — the daemon + * refuses only targets beyond the declared set, and takes any subset of it. + */ + Locked, + /** The module asked for it, and it is the user's choice. */ + Requested, + /** Nothing asked for it; it is in the scope because someone ticked it. */ + Chosen, + /** + * The framework put it there, and no row in the scope table records it. + * + * A legacy module's own app: the daemon derives that target every time it rebuilds its + * configuration, so the tick is neither the user's nor the module's to give. + */ + Derived, +} + +@Composable +private fun ScopeOrigin.color(): Color = + when (this) { + // Locked and Requested are the same claim by the module — it named this app — and the tick + // beside either is the reader's to give or to take back, so they share the colour that + // invites a tap. It matters most under a static scope, where the list is the declared set + // and so every row of it is Locked: an outline meaning "not yours to change" would be + // saying that about every row of a list the reader is expected to work through. What is + // fixed there is which apps may be listed at all, which is a property of the list and not + // of any row in it — the caption says so, and the dead filter button and its snackbar say + // it in full. + ScopeOrigin.Locked, + ScopeOrigin.Requested -> MaterialTheme.colorScheme.primary + ScopeOrigin.Chosen -> Color.Transparent + // The one ring that does mean "not yours": a derived row is the only row on this screen + // that refuses a tap, because nothing here writes it and nothing here can take it away. The + // disabled-ish outline says so before the caption below it does. + ScopeOrigin.Derived -> MaterialTheme.colorScheme.outline + } + +private fun ScopeOrigin.labelRes(): Int = + when (this) { + ScopeOrigin.Locked -> R.string.scope_origin_locked + ScopeOrigin.Requested -> R.string.scope_recommended + ScopeOrigin.Chosen -> R.string.scope_origin_chosen + ScopeOrigin.Derived -> R.string.scope_origin_derived + } + +@Composable +private fun AppRow( + app: AppInfo, + enabled: Boolean, + origin: ScopeOrigin, + /** + * A sentence under the package name, for a row whose behaviour a label cannot carry. + * + * One slot rather than one flag per case: the two rows that have something to explain — the + * framework, and a legacy module's own app — are never the same row, and a boolean apiece + * would grow with every one that follows. + */ + note: Int?, + onToggle: (Boolean) -> Unit, + onAction: (PackageActionResult) -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + val ring = origin.color() + + ListItem( + modifier = + Modifier.combinedClickable( + onClick = { if (enabled) onToggle(!app.isSelectedInScope) }, + onLongClick = { + // The long press is where re-optimize lives, and re-optimize is the fix + // for a hook that silently never fires because ART inlined its target. + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + menuOpen = true + }, + ) + .semantics { role = Role.Checkbox }, + leadingContent = { + // The ring is drawn outside the icon rather than tinting it: an app icon is the user's + // own landmark for finding a row and recolouring it would destroy that. + AppIcon( + applicationInfo = app.applicationInfo, + contentDescription = null, + size = 36.dp, + modifier = + Modifier.border(width = 2.dp, color = ring, shape = CircleShape).padding(4.dp), + ) + }, + supportingContent = { + Column { + Text( + ScopeViewModel.displayPackageName(app.packageName), + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (origin != ScopeOrigin.Chosen) { + Text( + text = stringResource(origin.labelRes()), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = ring, + ) + } + // Why this row does not behave like the rest: the framework is one process shared + // by every user, and a legacy module's own app is in the scope without anyone + // having put it there. Both are things a checkbox cannot say. + if (note != null) { + Text( + text = stringResource(note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + trailingContent = { Checkbox(checked = app.isSelectedInScope, onCheckedChange = null) }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent + ), + ) { Text(app.appName) } + if (menuOpen) { + PackageActionSheet( + packageName = app.packageName, + userId = app.userId, + appName = app.appName, + applicationInfo = app.applicationInfo, + isModule = false, + onDismiss = { menuOpen = false }, + onResult = onAction, + ) + } +} + +/** States how much applying will change — so many to add, so many to remove — before it does it. */ +@Composable +private fun ApplyBar( + added: Int, + removed: Int, + applying: Boolean, + onDiscard: () -> Unit, + onApply: () -> Unit, +) { + Surface(tonalElevation = 3.dp, color = MaterialTheme.colorScheme.surfaceContainerHigh) { + Row( + // The bar is the last child of the window, and this screen is a detail screen: the + // navigation container is hidden here, so nothing above has reserved the system bars + // and Scaffold hands its bottom slot the whole window. Without this the buttons sit + // under three-button navigation, where what is left of them is a few pixels tall. + // + // Inside the Surface rather than on it, so the tonal fill still runs to the bottom + // edge and the bar reads as one surface rather than as a strip floating above the + // system's own. Insets already consumed count for nothing here, so the same call is + // correct in the arrangements where a container below has taken them. + modifier = + Modifier.fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.scope_pending, added, removed), + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = stringResource(R.string.scope_apply_effect), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onDiscard, enabled = !applying) { + Text(stringResource(R.string.scope_discard)) + } + Button(onClick = onApply, enabled = !applying) { + Text(stringResource(R.string.scope_apply)) + } + } + } +} + +private fun ScopeSort.labelRes(): Int = + when (this) { + ScopeSort.Relevance -> R.string.scope_sort_relevance + ScopeSort.Name -> R.string.scope_sort_name + ScopeSort.PackageName -> R.string.scope_sort_package + ScopeSort.InstallTime -> R.string.scope_sort_installed + ScopeSort.UpdateTime -> R.string.scope_sort_updated + } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt new file mode 100644 index 000000000..23eef4c82 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt @@ -0,0 +1,1018 @@ +package org.matrix.vector.manager.ui.screens.modules +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.vector.ipc.ScopeEntry +import org.matrix.vector.manager.data.model.AppInfo +import org.matrix.vector.manager.data.model.ModuleDetection +import org.matrix.vector.manager.data.model.RecommendedScope +import org.matrix.vector.manager.data.repository.AppRepository +import org.matrix.vector.manager.data.repository.ModuleRepository +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.ipc.DaemonClient +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW + +/** + * A package/user pair, as a value type so set arithmetic is correct. + * + * Not [ScopeEntry], which carries the same two fields over the wire: it is a generated AIDL bean + * with identity equality, so a set of them would count two readings of the same target as two + * targets and every difference taken here would come out as "everything added and everything + * removed". It is built from these only at the point of writing, in [ScopeViewModel.apply]. + */ +data class ScopeTarget(val packageName: String, val userId: Int) + +/** + * How the list is ordered. + * + * Five orderings and a reverse toggle, which is more than a picker usually earns: sorting by + * package name is how you find something whose display name you cannot recall, and by install time + * is how you find the app you added five minutes ago. + */ +enum class ScopeSort { + /** Selected first, then recommended, then alphabetical — the working order. */ + Relevance, + Name, + PackageName, + InstallTime, + UpdateTime, +} + +data class ScopeUiState( + val moduleName: String = "", + val isEnabled: Boolean = false, + val includeNewApps: Boolean = false, + val recommended: RecommendedScope = RecommendedScope.NONE, + val loading: Boolean = true, + /** + * Whether this device has more than one user at all. + * + * The framework row explains that it is shared across users, which is only ever news on a + * device that has more than one. On a single-user phone — most of them — it is a sentence + * about a distinction that does not exist, so it is not shown. + */ + val multipleUsers: Boolean = false, + /** + * Whether the module is loaded into its own process whatever the scope table says. + * + * A legacy module reports being active by hooking a method in its own app, so it has to be in + * its own scope before it can say anything at all, and the daemon derives that one target + * rather than storing it. Nothing comes back from `getModuleScope` to say so — so without + * this, the one row the module certainly hooks is the one row shown unticked, and with the + * module filter at its default it is not shown at all. + */ + val selfHooked: Boolean = false, +) + +class ScopeViewModel( + private val modulePackageName: String, + /** + * The user whose copy of the module was opened, and so whose apps this screen offers. + * + * Not a second scope. A module is one package and one APK for the whole device and has one + * scope set; what varies per user is which apps exist to point at, and the daemon will not + * expand a row for a user that does not hold the module. So this selects the half of the + * device being edited — [apply] merges into the whole stored set rather than replacing it, + * which is what leaves another user's rows alone. + */ + private val userId: Int, + private val daemonClient: DaemonClient, + private val appRepository: AppRepository, + private val moduleRepository: ModuleRepository, + private val packageManager: android.content.pm.PackageManager, + private val settings: SettingsRepository = ServiceLocator.settings, +) : ViewModel() { + + private val allApps = MutableStateFlow>(emptyList()) + + /** + * What the daemon held when this screen last looked. + * + * The baseline the draft is measured against, and not an oracle: the scope table has writers + * other than this screen, so it goes stale the moment one of them runs. That is why + * [refreshSavedScope] exists, and why [apply] re-reads instead of trusting it. + */ + private val savedScope = MutableStateFlow>(emptySet()) + + /** + * What the user has built up but not yet applied. + * + * Writing a scope is not incremental — the daemon deletes every scope row of the module and + * writes the new set in one transaction, then asks for a configuration rebuild. Sending that + * on every checkbox tap means ten rewrites and ten rebuilds to tick ten apps, so edits + * accumulate here and go out as one write. + */ + private val draftScope = MutableStateFlow>(emptySet()) + + /** + * Every target whose tick this visit has actually changed and could otherwise be filtered away + * — one at a time from [toggle], in a batch from [clearAllVisible]. + * + * [selectAllVisible] changes ticks too and deliberately marks nothing: a row it ticks is in the + * draft, and the draft already exempts a row from every filter. Only unticking can drop a row + * out of the list, so only unticking has anything to record here. + * + * Only the list's own filters read it, and only to keep a row present. Unticking is an edit + * like any other and the row has to survive it — an app that vanishes the moment it is + * unticked cannot be re-ticked, so a slip becomes permanent for as long as the reader does not + * think to go and turn a filter on. It never shrinks: a row put in play stays in play until + * the screen is left, which is the point. + * + * Which is also why nothing may go in that was not really changed. A row the reader merely + * *saw* would be exempted from the system, game and module filters for the rest of the visit, + * and since this never shrinks there would be no way back: turning system apps off again would + * hide nothing. + */ + private val touched = MutableStateFlow>(emptySet()) + + private val _uiState = MutableStateFlow(ScopeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val searchQuery = MutableStateFlow("") + /** + * System apps are hidden by default. + * + * A device carries several hundred of them and a handful of apps the user installed; showing + * both at once buries the second set. Anything already in the scope is exempt from every + * filter, so turning this off can never hide a choice that has been made. + */ + val showSystemApps = MutableStateFlow(settings.scopeShowSystemApps.value) + + val showGames = MutableStateFlow(settings.scopeShowGames.value) + + /** + * Narrow the list to what the module asked for, plus whatever is already in the scope. + * + * Close to the view a static scope gets, reachable on purpose. A module that declares a scope + * but does not fix it leaves the user to find those apps among several hundred, and the list + * already knows which they are — so this is the "just show me what it wants" the static case + * gets for free. + * + * Not exclusive, despite the name: it exempts what is already in the draft, as every other + * filter on this screen does. Dropping every app the module had not named would hide a stray + * selection and leave it un-untickable, and would narrow the list to nothing for a module that + * declares no scope at all. + */ + val showRecommendedOnly = MutableStateFlow(false) + + /** + * Whether other Xposed modules appear in the list. + * + * They are installed apps like any other and a module *can* legitimately hook one, but that + * is rare enough that the default is off: on a device with two dozen modules, listing them + * all among the hookable apps is two dozen rows of noise for one plausible use. + */ + val showModules = MutableStateFlow(settings.scopeShowModules.value) + + val sort = + MutableStateFlow( + ScopeSort.entries.firstOrNull { it.name.equals(settings.scopeSort.value, true) } + ?: ScopeSort.Relevance + ) + val reverseSort = MutableStateFlow(settings.scopeSortReversed.value) + + /** + * Whether this module has a screen to open at all. + * + * Null until asked, so the control does not flicker into existence on arrival. Most modules + * have no companion and no launcher entry, and offering to open one is offering nothing — + * which is why this is worth a lookup rather than a snackbar after the fact. + * + * Declared above [init] rather than beside the function that fills it, and it has to stay + * there. `viewModelScope` dispatches on `Main.immediate`, so [findCompanion] starts inline on + * the constructing thread and reads this field before the first suspension — while every + * property declared below the `init` block is still null. + */ + private val _companion = MutableStateFlow(null) + val hasCompanion: StateFlow = _companion.asStateFlow() + + init { + findCompanion() + // Written back as they change rather than on the way out: this screen is left by a back + // gesture, by the process being killed, and by the host application deciding it is done — + // and only the first of those runs any teardown of ours. + viewModelScope.launch { + showSystemApps.collect { settings.setScopeShowSystemApps(it) } + } + viewModelScope.launch { showGames.collect { settings.setScopeShowGames(it) } } + viewModelScope.launch { showModules.collect { settings.setScopeShowModules(it) } } + viewModelScope.launch { sort.collect { settings.setScopeSort(it.name.lowercase()) } } + viewModelScope.launch { reverseSort.collect { settings.setScopeSortReversed(it) } } + } + + /** + * Packages that are themselves modules. + * + * Null until known. Deciding this means inspecting every installed package, so it is computed + * once per process by [AppRepository] and shared; until it arrives the filter simply does not + * apply, which shows a few extra rows for a moment rather than blocking the list on disk I/O. + */ + private val modulePackages = MutableStateFlow?>(null) + + private val _applying = MutableStateFlow(false) + val applying: StateFlow = _applying.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + /** Added and removed relative to what the daemon holds, so the UI can say what Apply will do. */ + val pendingChanges: StateFlow = + combine(savedScope, draftScope) { saved, draft -> + PendingChanges( + added = (draft - saved).size, + removed = (saved - draft).size, + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PendingChanges()) + + val filteredApps: StateFlow> = + combine(allApps, draftScope, searchQuery, showSystemApps, showGames) { + apps, + draft, + query, + showSys, + showGame -> + Filters(apps, draft, query, showSys, showGame, false) + } + .combine(showRecommendedOnly) { filters, only -> filters.copy(recommendedOnly = only) } + // The saved set as well as the draft: the difference between them is what "newly + // ticked" means, and the order below leads with it. + .combine(savedScope) { filters, saved -> filters.copy(saved = saved) } + .combine(touched) { filters, t -> filters.copy(touched = t) } + // Two typed halves rather than one list of Any. The inputs outnumber the arities + // `combine` provides, and carrying them positionally through a `List` and casting + // each one back out lets a rename or a reorder compile and then fail at runtime. + .combine( + combine(showModules, modulePackages, sort, reverseSort, _uiState) { + showMods, + modules, + order, + reverse, + state -> + View(showMods, modules, order, reverse, state) + } + ) { filters, view -> + val showMods = view.showModules + val modules = view.modulePackages + val order = view.sort + val reverse = view.reverse + val recommended = view.state.recommended.packages.toSet() + // A static scope fixes which apps may be *listed*, so the list is exactly that set. + // The daemon refuses every target beyond it, so a row for one of the other few + // hundred apps would offer a choice that does not exist. Absolute, unlike the + // filters below: this is not a view of the list the reader chose, it is the list. + val locked = view.state.recommended.staticScope + // The row the daemon hooks whether or not the table names it. Matched on the user + // as well as the package: the same module in a work profile is another copy with + // its own row, and only the copy this screen is editing is the one being loaded + // into the process in front of it. + fun implicit(app: AppInfo) = + view.state.selfHooked && + app.packageName == modulePackageName && + app.userId == userId + filters.apps + .asSequence() + .filter { app -> !locked || app.packageName in recommended } + .filter { app -> + val matchesQuery = + filters.query.isBlank() || + app.appName.contains(filters.query, ignoreCase = true) || + app.packageName.contains(filters.query, ignoreCase = true) + // A row the reader is working with is never filtered away — and unticking + // it is working with it. Keying this on the draft alone meant that + // unticking a system app dropped it through the default filter and out of + // the list mid-edit, taking with it the only tick that could undo the + // mistake. So a row counts as in play if it was in force when this screen + // opened, is in the draft now, or has been touched during this visit. + // Derived counts throughout, so the module's own row survives every filter + // — including the module filter, which is off by default and would + // otherwise hide the row this whole exemption exists to show. + val target = ScopeTarget(app.packageName, app.userId) + val inPlay = + implicit(app) || + target in filters.draft || + target in filters.saved || + target in filters.touched + val requested = app.packageName in recommended + if (locked || filters.recommendedOnly) { + // Both answer one question — what does this module want, and what have + // I given it — and the other filters have no say in either. Chrome is + // a system app, so letting them apply would show nothing at all for a + // module asking for Chrome unless the reader had also thought to turn + // system apps on; a module that fixes its scope on system packages, + // which is most of them, showed an empty list for exactly that reason. + // The screen greys the other three out in both cases, and this is the + // code that makes that honest rather than decorative. + return@filter matchesQuery && (inPlay || requested) + } + // The framework, when the module has asked for it, and nothing else. + // + // A request does not generally outrank the reader's filters: a module may + // name dozens of system packages, and exempting all of them would leave + // the system-apps switch turning nothing off on exactly the modules whose + // lists are longest. The reader has "What the module asks for" for that + // view, and it already overrides all three. + // + // The framework is the exception because it is not one of the several + // hundred rows the filters exist to thin out. It is not an installed + // package at all — it is a synthetic row this view model adds — so it + // cannot be found by turning any filter on and hunting for it, and a + // reader who has never seen it has no reason to think it exists. Hidden, + // a module whose whole declared scope is the framework shows an empty + // list, which is the one case where the filters do not thin a list but + // erase it. + val frameworkRequested = + requested && app.packageName == SYSTEM_FRAMEWORK_PACKAGE + val matchesSys = + inPlay || frameworkRequested || filters.showSystem || !app.isSystemApp + // No exemption needed on either of these: the framework row is built with + // `isGame = false` and is not an installed package, so it is not in the + // module set. Both already pass it. + val matchesGame = inPlay || filters.showGames || !app.isGame + val matchesModule = + inPlay || showMods || modules == null || app.packageName !in modules + matchesQuery && matchesSys && matchesGame && matchesModule + } + .map { app -> + app.copy( + isSelectedInScope = + implicit(app) || + ScopeTarget(app.packageName, app.userId) in filters.draft, + isImplicitInScope = implicit(app), + isRecommended = app.packageName in recommended, + ) + } + .sortedWith(comparatorFor(order)) + .toList() + .let { if (reverse) it.reversed() else it } + // What is in the scope comes first, then the framework, then everything else. + // + // Grouping the chosen at the top applies to every ordering, not just to + // Relevance, because this is a picker: the rows you have already ticked are + // the ones you come back to check, and hunting for them alphabetically among + // several hundred is the work the sort was supposed to save. Each sort still + // orders within the two groups. + // + // The framework needs a pin of its own because it is not an app and does not + // sort like one: by name it lands under S, by install time wherever its + // borrowed timestamp puts it, and either way the one target that is not + // discoverable any other way is lost in a list of thousands. It sits below the + // chosen rather than above them, so a target nobody has picked never leads the + // ones they have. + // + // After the reverse, so reversing cannot bury any of it at the bottom. + .let { list -> + fun frameworkFirst(group: List): List { + val (framework, others) = + group.partition { it.packageName == SYSTEM_FRAMEWORK_PACKAGE } + return framework + others + } + val (chosen, rest) = list.partition { it.isSelectedInScope } + // What is in force, then what is about to be, then everything else. The + // framework leads the last two but not the first: it is the one row that + // cannot be found by scrolling, so it has to lead the group it is being + // picked from — and once it is in the scope it is a member like any other, + // with no claim to sit above targets that are already in force. + // A derived row is in force by definition: it is not waiting on an apply, + // and grouping it with the newly ticked would promise a write that will + // never happen. + val (inForce, newlyTicked) = + chosen.partition { + it.isImplicitInScope || + ScopeTarget(it.packageName, it.userId) in filters.saved + } + inForce + frameworkFirst(newlyTicked) + frameworkFirst(rest) + } + } + // Filtering and sorting the full installed-app list is real work — often thousands of + // entries — and stateIn(viewModelScope) alone would run it on Dispatchers.Main.immediate + // on every keystroke. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Everything outside the app list that decides what the list shows. */ + private data class View( + val showModules: Boolean, + val modulePackages: Set?, + val sort: ScopeSort, + val reverse: Boolean, + val state: ScopeUiState, + ) + + private data class Filters( + val apps: List, + val draft: Set, + val query: String, + val showSystem: Boolean, + val showGames: Boolean, + val recommendedOnly: Boolean, + val saved: Set = emptySet(), + val touched: Set = emptySet(), + ) + + private fun comparatorFor(order: ScopeSort): Comparator = + when (order) { + ScopeSort.Name -> compareBy { it.appName.lowercase() } + ScopeSort.PackageName -> compareBy { it.packageName } + ScopeSort.InstallTime -> + compareByDescending { it.firstInstallTime } + .thenBy { it.appName.lowercase() } + ScopeSort.UpdateTime -> + compareByDescending { it.lastUpdateTime }.thenBy { it.appName.lowercase() } + // Whatever the user is working on floats up: what they have chosen, then what the + // module asked for, then everything else. + ScopeSort.Relevance -> + compareByDescending { it.isSelectedInScope } + .thenByDescending { it.isRecommended } + .thenBy { it.appName.lowercase() } + } + + init { + load() + // Hidden by default, so the set has to be known without anyone asking for it. It is cached + // per process, so this is free after the first scope screen of the session. + viewModelScope.launch { modulePackages.value = appRepository.modulePackages() } + } + + fun load() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(loading = true) + + // Only the apps belonging to the user this module is installed for. A module in a + // work profile can only hook that profile's apps, so listing the owner's alongside + // them offers choices the framework will not honour — and the same package appears + // once per user, so an unfiltered list shows visible duplicates. + val apps = + withContext(Dispatchers.IO) { + appRepository.getInstalledApps().filter { it.userId == userId } + } + + // The system server is a hook target like any other and modules ask for it by name, + // but it is not an installed package so it never appears in the package list. Without + // this entry a module whose entire recommended scope is the framework — Core Patch, + // for one — offers the user nothing to tick. + // + // Offered to every user, not only the owner. There is exactly one system_server on the + // device, so it is not a per-user target that other users happen to lack — it is one + // process they all share, and a module in a work profile or a private space would + // otherwise have no way to ask for the only target it may need (issue #136). The + // daemon agrees: `ModuleDatabase.setModuleScope` stores a `system` row under user 0 + // whoever asked, and `ConfigCache` maps it to system_server without looking at whose + // module it was. + val withFramework = listOf(systemFrameworkEntry(apps)) + apps + allApps.value = withFramework + + // Asked once per load rather than per row, and held here until the state is built at + // the end of this function — anything written into `_uiState` before then is discarded + // when that fresh ScopeUiState replaces it. A failure to ask means not explaining, + // never explaining wrongly. + val userCount = + withContext(Dispatchers.IO) { daemonClient.getUsers().getOrNull()?.size ?: 1 } + + // A scope the daemon will not hand over shows as none rather than keeping the screen + // shut; [readSavedScope] logs why, and keeps that case apart from the empty list a + // module with nothing ticked legitimately has. + val saved = readSavedScope() ?: emptySet() + savedScope.value = saved + draftScope.value = saved + + val info = + withContext(Dispatchers.IO) { + runCatching { + packageManager.getApplicationInfo( + modulePackageName, + android.content.pm.PackageManager.GET_META_DATA, + ) + } + .onFailure { e -> + logW( + "scope: package info for $modulePackageName (user $userId) " + + "unavailable, no recommended scope", + e, + ) + } + .getOrNull() + } + // One inspection, two answers: what the module asks to hook, and which generation of + // module it is. Both come out of the same pass over the APK, and opening it is the + // expensive part. + val manifest = + info?.let { + withContext(Dispatchers.IO) { ModuleDetection.inspect(it, packageManager) } + } + val recommended = + manifest?.let { RecommendedScope(it.scope, it.staticScope) } ?: RecommendedScope.NONE + + _uiState.value = + ScopeUiState( + moduleName = + info?.loadLabel(packageManager)?.toString() ?: modulePackageName, + isEnabled = modulePackageName in moduleRepository.enabledModulesState.value, + includeNewApps = + daemonClient.getIncludeNewApps(modulePackageName).getOrDefault(false), + recommended = recommended, + loading = false, + multipleUsers = userCount > 1, + // The manager's own reading of the APK, not the daemon's. The daemon settles + // this while it loads the module and never tells anyone — and it only holds an + // answer for a module that is enabled, which is precisely not the state a + // module is in while its scope is being chosen for the first time. + selfHooked = manifest?.isLegacy == true, + ) + } + } + + /** + * What the daemon holds for this module right now, or null when it would not say. + * + * Null and empty are different answers and every caller here has to tell them apart: a fresh + * module with nothing ticked is a success carrying an empty list, and treating a refused read + * as "no rows" would turn a broken connection into an erased scope. + */ + private suspend fun readSavedScope(): Set? { + val result = daemonClient.getModuleScope(modulePackageName) + result.exceptionOrNull()?.let { e -> + logE("scope: reading the saved scope of $modulePackageName failed", e) + } + val rows = result.getOrNull() ?: return null + return rows.map { ScopeTarget(it.packageName, it.userId) }.toSet().asStored() + } + + /** + * The set spelled the way the daemon stores it, so two readings of it can be subtracted. + * + * `ModuleDatabase.setModuleScope` files the framework under user 0 whoever asked for it, so it + * always comes back as user 0 — while a scope restored from a backup written by an older + * manager still names it under the module's own user, and this screen's own row for it is + * user 0. Comparing the two spellings without this makes the framework look added and removed + * at once, and a merge built on that difference would act on both. + */ + private fun Set.asStored(): Set = + mapTo(mutableSetOf()) { + if (it.packageName == SYSTEM_FRAMEWORK_PACKAGE) it.copy(userId = 0) else it + } + + /** + * Re-reads the stored scope and folds whatever arrived from elsewhere into the draft. + * + * Called every time the screen comes back to the front, because this editor is not the only + * writer of that table and the other writer is one tap away: the button in the corner opens + * the module, a libxposed module asks for a target while it runs, and the user approves it + * from the notification shade. Nothing here would ever notice — the load runs once, from + * `init` — so the list would go on drawing an empty box beside a target the module is already + * being loaded into, and the apply bar would count a removal nobody asked for. + * + * Additive on purpose. What the user has ticked here is theirs and survives untouched; a row + * that appeared outside joins both the baseline and the draft, so it reads as in force rather + * than as a pending change. A row that *vanished* outside is left in the draft, where it shows + * honestly as something applying would put back — unticking it here would undo a choice on the + * user's behalf and say nothing. + */ + fun refreshSavedScope() { + // The screen's first resume lands while the load started in `init` is still in flight, and + // that load is this same read: running both races two answers into the same field for no + // gain, and the stale one can win. An apply is likewise mid-write and publishes its own + // result, which this would only be able to contradict. + if (_uiState.value.loading || _applying.value) return + viewModelScope.launch { + val current = readSavedScope() ?: return@launch + val baseline = savedScope.value.asStored() + if (current == baseline) return@launch + savedScope.value = current + draftScope.value = draftScope.value + (current - baseline) + } + } + + /** + * A stand-in for the system server. + * + * Borrows an existing entry's [android.content.pm.ApplicationInfo] purely so the row has + * something to draw an icon from; only the package name and label are meaningful. + */ + private fun systemFrameworkEntry(apps: List): AppInfo { + val donor = apps.firstOrNull() + return AppInfo( + packageName = SYSTEM_FRAMEWORK_PACKAGE, + userId = 0, + appName = FRAMEWORK_LABEL, + isSystemApp = true, + isGame = false, + isSelectedInScope = false, + isRecommended = false, + lastUpdateTime = Long.MAX_VALUE, + firstInstallTime = Long.MAX_VALUE, + applicationInfo = donor?.applicationInfo ?: android.content.pm.ApplicationInfo(), + ) + } + + /** Local only. Nothing reaches the daemon until [apply]. */ + fun toggle(app: AppInfo, selected: Boolean) { + // A derived row is not the scope table's to give or to take away. Writing a row of our own + // for it would neither add the target — it is already there — nor let it be removed, and + // unticking it would draw an empty box beside a process the module is still loaded into. + if (app.isImplicitInScope) return + val target = ScopeTarget(app.packageName, app.userId) + // Before the draft changes, so the row cannot be filtered out by the very edit being made. + touched.value = touched.value + target + draftScope.value = + if (selected) draftScope.value + target else draftScope.value - target + } + + // Both skip the derived row for the reason [toggle] gives: it is shown among the visible rows + // but it is not one of the ones being written, and either of these sweeping it up would report + // a change to a row whose tick nothing here decides. + fun selectAllVisible() { + draftScope.value = + draftScope.value + + filteredApps.value + .filterNot { it.isImplicitInScope } + .map { ScopeTarget(it.packageName, it.userId) } + } + + fun clearAllVisible() { + val draft = draftScope.value + // The rows this call really unticks, which is only the visible part of the draft — the rest + // of the list is already clear and nothing is being done to it. + val cleared = + filteredApps.value + .filterNot { it.isImplicitInScope } + .map { ScopeTarget(it.packageName, it.userId) } + .filterTo(mutableSetOf()) { it in draft } + // Unticking everything visible must not empty the list as well. Without this, clearing a + // list of system apps removes the rows along with the ticks and leaves the reader looking + // at nothing, with no way to put any of it back. Marking the whole visible list instead of + // the part that changed would buy that at the price of the filters: [touched] never + // shrinks, so every row that happened to be on screen would stay on screen however the + // filters were set for the rest of the visit. + touched.value = touched.value + cleared + draftScope.value = draft - cleared + } + + /** Replace the draft with exactly what the module asked for. */ + fun useRecommended() { + val recommended = _uiState.value.recommended.packages.toSet() + if (recommended.isEmpty()) return + draftScope.value = + allApps.value + .filter { it.packageName in recommended } + .map { ScopeTarget(it.packageName, it.userId) } + .toSet() + } + + fun discard() { + draftScope.value = savedScope.value + } + + fun setSort(order: ScopeSort) { + sort.value = order + } + + fun toggleReverse() { + reverseSort.value = !reverseSort.value + } + + /** Turns the "show modules" filter on or off. */ + fun setShowModules(show: Boolean) { + showModules.value = show + } + + fun setRecommendedOnly(only: Boolean) { + showRecommendedOnly.value = only + } + + fun setIncludeNewApps(enabled: Boolean) { + viewModelScope.launch { + daemonClient + .setIncludeNewApps(modulePackageName, enabled) + .onSuccess { stored -> + // The daemon's answer, not merely the fact that it answered: it refuses a + // package it holds no row for, and moving the switch on a refusal would show a + // setting that was never saved. + if (stored) { + _uiState.value = _uiState.value.copy(includeNewApps = enabled) + } else { + logE( + "scope: daemon refused include-new-apps=$enabled for " + + modulePackageName, + ) + _message.value = ScopeMessage.IncludeNewAppsFailed + } + } + .onFailure { e -> + logE( + "scope: setting include-new-apps=$enabled for $modulePackageName failed", + e, + ) + _message.value = ScopeMessage.IncludeNewAppsFailed + } + } + } + + /** + * Set when an apply changed whether the framework is in this scope. + * + * A dialog rather than the usual snackbar: this one asks for a decision, and a message that + * scrolls away on its own would leave the reader believing a scope is in force when it is + * stored and inert. + */ + private val _frameworkRestartNeeded = MutableStateFlow(false) + val frameworkRestartNeeded: StateFlow = _frameworkRestartNeeded.asStateFlow() + + fun dismissFrameworkRestart() { + _frameworkRestartNeeded.value = false + } + + /** + * Restarts the primary zygote, and with it system_server. + * + * Sufficient on its own: the daemon survives — it holds a death recipient on the bridge and + * re-injects into the replacement — and the new system_server asks for its module list on the + * way up, by which time the write that prompted this has already rebuilt the cache. + */ + fun softRebootForFramework() { + _frameworkRestartNeeded.value = false + viewModelScope.launch { + daemonClient.softReboot().onFailure { e -> + logE("scope: soft reboot after a framework scope change failed", e) + } + } + } + + /** Fills [hasCompanion], which is declared next to [init] for the reason given there. */ + private fun findCompanion() { + viewModelScope.launch { + _companion.value = + daemonClient + .findAppUi(modulePackageName, userId, companionFirst = true) + .onFailure { e -> + logW( + "scope: companion lookup for $modulePackageName user $userId failed", + e, + ) + } + .getOrNull() != null + } + } + + /** + * Opens the module's own screen — its companion activity, or its launcher entry. + * + * Here as well as in the long-press sheet because this is the screen you are on when you are + * thinking about that module: a scope is half of its configuration and the other half lives + * inside the module, so reaching it from the list would be a detour through a place you had + * just come from. + * + * Passes `companionFirst` exactly as [findCompanion] does, so the control cannot appear for a + * module whose only screen this call would then decline to open. + */ + fun openModule() { + viewModelScope.launch { + val opened = + daemonClient + .openAppUi(modulePackageName, userId, companionFirst = true) + .onFailure { e -> + logE( + "scope: companion open of $modulePackageName for user $userId failed", + e, + ) + } + .getOrDefault(false) + if (!opened) _message.value = ScopeMessage.NothingToOpen + } + } + + fun setModuleEnabled(enabled: Boolean) { + viewModelScope.launch { + if (moduleRepository.toggleModule(modulePackageName, enabled)) { + _uiState.value = _uiState.value.copy(isEnabled = enabled) + } else { + _message.value = ScopeMessage.ToggleFailed + } + } + } + + /** + * Writes what the user did, once. + * + * Not the draft as it stands, deliberately. One `setModuleScope` replaces *every* scope row of + * the module, so sending a draft built when the screen opened sends a set that has never heard + * of anything written since — and a row can arrive while this screen sits in the background, + * because the user approving a module's own `requestScope` from the notification shade adds + * one. This screen's own button for opening the module is exactly how a module gets to run and + * ask. The approval would then be deleted by the next apply, minutes after the module had + * already been told it was granted, and nothing anywhere would say so. + * + * So the edit is what travels: the targets ticked and unticked here, applied to whatever the + * daemon holds at the moment of writing rather than to what the user was shown. When the fresh + * read fails there is nothing better than the picture on screen — falling back to the baseline + * reduces the expression below to exactly the draft, which is the behaviour this replaces, and + * refusing to apply at all would leave an edit that can never be committed. + * + * One write means one configuration rebuild. The new scope reaches an app when its process + * next starts; nothing running is restarted here. + * + * The daemon enables the module as a side effect of storing a scope. + */ + fun apply() { + if (_applying.value) return + viewModelScope.launch { + _applying.value = true + // The snapshot the draft was built from, so the difference between the two is exactly + // what was done on this screen and nothing else. + val baseline = savedScope.value.asStored() + val draft = draftScope.value.asStored() + val current = readSavedScope() + if (current == null) { + logW( + "scope: could not re-read the scope of $modulePackageName before writing; " + + "applying the draft as it stands" + ) + } + val before = current ?: baseline + val merged = before + (draft - baseline) - (baseline - draft) + val aidl = + merged.map { target -> + ScopeEntry().apply { + packageName = target.packageName + userId = target.userId + } + } + daemonClient + .setModuleScope(modulePackageName, aidl) + .onSuccess { stored -> + // The daemon's answer, not merely the fact that it answered: it refuses a set + // that reaches beyond a scope the module fixes for itself, and moving the saved + // set on a refusal would show a scope the framework never took. + if (!stored) { + logE("scope: daemon refused ${merged.size} targets for $modulePackageName") + _message.value = ScopeMessage.ApplyFailed + } else { + // Whether the framework itself just joined or left this scope. Compared + // against what the daemon actually held a moment ago rather than against + // the picture the screen was showing, so it is the change this write made + // that is reported — not the mere presence of the row, and not a change + // somebody else had already made. + val framework = ScopeTarget(SYSTEM_FRAMEWORK_PACKAGE, 0) + val wasThere = framework in before + val isThere = framework in merged + savedScope.value = merged + // The draft moves with it. What went out is now what is stored, and a row + // that arrived from elsewhere and has just been written would otherwise sit + // in the saved set but not the draft — the apply bar would come straight + // back up offering to remove it. + draftScope.value = merged + // Storing a scope enables the module, so applying one to a disabled + // module would leave the switch here and the row in the module list + // both saying it is off. Followed through the switch's own path, so + // the enabled set keeps a single keeper. + if (!_uiState.value.isEnabled) setModuleEnabled(true) + _message.value = ScopeMessage.Applied + // system_server reads its module list once, when it starts: + // SystemServerService hands the zygisk module whatever + // ConfigCache.getModulesForSystemServer() holds at that moment. Every + // other target picks a scope up when its own process next starts, which + // happens on its own; this one does not until the framework is restarted, + // so the change is stored and inert and nothing on screen would say so. + // True for leaving as well as joining — a module already loaded into + // system_server stays loaded until that process goes. + if (wasThere != isThere) _frameworkRestartNeeded.value = true + // The module list depicts this scope as a row of app icons. It is a + // different screen with a different view model, so it is told rather than + // left to discover the change on the next manual refresh. + ServiceLocator.modules.noteScopeChanged() + } + } + .onFailure { e -> + logE("scope: apply of ${merged.size} targets to $modulePackageName failed", e) + _message.value = ScopeMessage.ApplyFailed + } + _applying.value = false + } + } + + /** + * True when leaving now would leave the module enabled with nothing to hook. + * + * That combination does nothing at all but looks like it works, so the user is warned and + * offered the switch rather than left to discover it. + */ + fun wouldStrandModule(): Boolean = + _uiState.value.isEnabled && draftScope.value.isEmpty() && savedScope.value.isEmpty() + + /** + * This one module's scope, as plain JSON. + * + * Not gzipped like the whole-list backup: a single scope is small, and a readable file is + * worth more here — it is the kind of thing someone hand-edits or pastes into an issue. + */ + fun backupScopeTo(uri: android.net.Uri, onDone: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = + withContext(Dispatchers.IO) { + runCatching { + val payload = + draftScope.value.joinToString(",\n ") { + """{"packageName":"${it.packageName}","userId":${it.userId}}""" + } + ServiceLocator.context.contentResolver.openOutputStream(uri)?.use { + it.write("[\n $payload\n]".toByteArray()) + } ?: error("could not open the file") + } + .onFailure { e -> + logE("scope: backup of $modulePackageName failed", e) + } + .isSuccess + } + onDone(ok) + } + } + + fun restoreScopeFrom(uri: android.net.Uri, onDone: (Boolean) -> Unit) { + viewModelScope.launch { + val targets = + withContext(Dispatchers.IO) { + runCatching { + val text = + ServiceLocator.context.contentResolver.openInputStream(uri)?.use { + it.readBytes().decodeToString() + } ?: error("could not open the file") + Regex("\"packageName\"\\s*:\\s*\"([^\"]+)\"[^}]*?\"userId\"\\s*:\\s*(\\d+)") + .findAll(text) + .map { ScopeTarget(it.groupValues[1], it.groupValues[2].toInt()) } + .toSet() + } + .onFailure { e -> + if (e is CancellationException) throw e + logE("scope: restore for $modulePackageName failed", e) + } + .getOrNull() + } + if (targets == null) { + onDone(false) + } else { + // Into the draft, not straight to the daemon: a restore is an edit like any + // other, and the user should see what it will do before it is written. + draftScope.value = targets + onDone(true) + } + } + } + + fun consumeMessage() { + _message.value = null + } + + // Not private: the scope list has to recognise the framework row to explain what it is, and + // a second copy of the literal in the screen would be a second thing to keep in step. + internal companion object { + /** What the list looks like before anyone touches it; the filter sheet marks a change. */ + const val DEFAULT_SHOW_SYSTEM = false + const val DEFAULT_SHOW_GAMES = true + const val DEFAULT_SHOW_MODULES = false + + /** How the daemon names the system server in a scope list. */ + const val SYSTEM_FRAMEWORK_PACKAGE = "system" + + /** + * What to *show* for it, which is not what it is stored as. + * + * The scope table has said `system` since long before this manager, and the daemon, the + * CLI and every backup file on every device say it too — so the stored name stays. But the + * process it actually means is `system_server`, and a reader looking at a package name + * expects the name of the thing. The rename lives here, at the point of display, and + * nothing written back to the daemon ever passes through it. + */ + const val SYSTEM_FRAMEWORK_DISPLAY_NAME = "system_server" + + /** The package name as it should appear on screen. */ + fun displayPackageName(packageName: String): String = + if (packageName == SYSTEM_FRAMEWORK_PACKAGE) SYSTEM_FRAMEWORK_DISPLAY_NAME + else packageName + const val FRAMEWORK_LABEL = "System Framework" + } +} + +data class PendingChanges(val added: Int = 0, val removed: Int = 0) { + val any: Boolean + get() = added > 0 || removed > 0 +} + +enum class ScopeMessage { + Applied, + ApplyFailed, + ToggleFailed, + IncludeNewAppsFailed, + NothingToOpen, +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt new file mode 100644 index 000000000..07a66f5a4 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt @@ -0,0 +1,935 @@ +package org.matrix.vector.manager.ui.screens.repo + +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.matrix.vector.manager.ui.components.ConfirmInstall +import org.matrix.vector.manager.ui.components.ToggleRow +import org.matrix.vector.manager.ui.components.SheetHeading +import org.matrix.vector.manager.ui.components.sheetRowColors +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.MoreVert +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.text.format.Formatter +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.ui.draw.rotate +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.runtime.derivedStateOf +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Today +import androidx.compose.material.icons.rounded.TrackChanges +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.theme.currentLocale +import org.matrix.vector.manager.data.model.ModuleDetection +import org.matrix.vector.manager.data.model.OnlineModule +import org.matrix.vector.manager.data.model.Release +import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.repository.InstallStep +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.navigation.LocalNavigator +import org.matrix.vector.manager.ui.navigation.Web +import org.matrix.vector.manager.ui.theme.VectorMono + +class RepoDetailsViewModelFactory(private val packageName: String) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + RepoDetailsViewModel( + packageName = packageName, + repository = ServiceLocator.store, + installer = ServiceLocator.installer, + settings = ServiceLocator.settings, + backgroundScope = ServiceLocator.appScope, + ) + as T +} + +/** + * One module, in full. + * + * The page is seeded from the catalogue entry the list already holds, so it paints immediately and + * — because that entry carries the newest release and its APK — can be installed from before the + * detail request has finished, or at all. A failed fetch costs the README and the older releases, + * never the page. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepoDetailsScreen(packageName: String, onNavigateBack: () -> Unit) { + val viewModel: RepoDetailsViewModel = + viewModel(factory = RepoDetailsViewModelFactory(packageName)) + val state by viewModel.state.collectAsState() + val installedScope by viewModel.installedScope.collectAsState() + val installedIsLegacy by viewModel.installedIsLegacy.collectAsState() + val install by viewModel.installState.collectAsState() + + val context = LocalContext.current + val navigator = LocalNavigator.current + val openExternally by ServiceLocator.settings.openLinksExternally.collectAsState() + + // Links go through the app's own browser by default. Handing them to the system is doubly + // jarring parasitically, where "the app" the user leaves is the shell process. + val openUrl: (String) -> Unit = { url -> + if (openExternally) { + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (_: ActivityNotFoundException) { + navigator.go(Web(url)) + } + } else { + navigator.go(Web(url)) + } + } + + var choosing by remember { mutableStateOf(null) } + var optionsOpen by remember { mutableStateOf(false) } + // The release travels with the asset: what is installed is recorded against the release it came + // from, and picking an older release from the list must not silence the newest one. + var confirming by remember { mutableStateOf?>(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = state.module?.title ?: packageName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = packageName, + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + state.module?.let { module -> + IconButton(onClick = { openUrl(module.repoUrl) }) { + Icon( + Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = stringResource(R.string.store_open_module), + ) + } + } + IconButton(onClick = { optionsOpen = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.store_options), + ) + } + }, + ) + }, + bottomBar = { + InstallBar( + state = state, + install = install, + onInstall = { release -> + val assets = release.apks + // One file is the overwhelmingly common case, and asking which of one is + // noise. More than one and the choice is the user's — some modules ship a + // variant per architecture. + if (assets.size == 1) confirming = release to assets.first() + else choosing = release + }, + onAcknowledge = viewModel::acknowledgeInstall, + ) + }, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + val module = state.module + if (module == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (state.fetch == DetailFetch.Loading) CircularProgressIndicator() + else + RetryMessage( + message = stringResource(R.string.store_unreachable), + onRetry = viewModel::fetchDetails, + ) + } + return@Column + } + + val tabs = + listOf( + R.string.store_tab_readme, + R.string.store_tab_releases, + R.string.store_tab_information, + ) + val pagerState = rememberPagerState(pageCount = { tabs.size }) + val scope = rememberCoroutineScope() + // Hoisted so the pager can ask whether the reader is in the middle of a scroll. Kept + // here rather than inside each tab also means a tab returned to is where it was left. + val releasesScroll = rememberLazyListState() + val informationScroll = rememberLazyListState() + + /** + * Whether the page in front of the reader is moving under their finger. + * + * A vertical scroll and a horizontal page turn are siblings in Compose's gesture + * arbitration, not parent and child, so a drag that is mostly-but-not-entirely vertical + * — which is every real drag on a phone held in one hand — is split between them: the + * list scrolls *and* the pager slides partway to the next tab. On a screen whose three + * tabs are all long documents, that happens constantly while simply reading. + * + * So while a list is scrolling the pager stops accepting drags at all, and the gesture + * cannot be taken away mid-read. It becomes available again the moment the list + * settles, which is also the moment someone who wants the next tab would ask for it. + * + * The README tab is absent on purpose: it is a WebView, which claims its own vertical + * drags — see `claimVerticalDrags` — so there is no Compose scroll state to read here. + */ + val reading by remember { + derivedStateOf { + when (pagerState.currentPage) { + 1 -> releasesScroll.isScrollInProgress + 2 -> informationScroll.isScrollInProgress + else -> false + } + } + } + + PrimaryTabRow(selectedTabIndex = pagerState.currentPage) { + tabs.forEachIndexed { index, label -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { Text(stringResource(label)) }, + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + userScrollEnabled = !reading, + // And when a page turn *is* offered, it has to be meant. Asking for most of the + // width makes an accidental sideways component fall back to where it started, the + // way a navigation gesture does. + flingBehavior = + PagerDefaults.flingBehavior( + state = pagerState, + snapPositionalThreshold = COMMIT_FRACTION, + ), + ) { page -> + when (page) { + 0 -> + ReadmeTab( + module = module, + fetch = state.fetch, + onRetry = viewModel::fetchDetails, + onOpenUrl = openUrl, + ) + 1 -> + ReleasesTab( + state = state, + listState = releasesScroll, + onOpenUrl = openUrl, + onInstall = { release -> + val assets = release.apks + if (assets.size == 1) confirming = release to assets.first() + else choosing = release + }, + ) + else -> + InformationTab( + module = module, + listState = informationScroll, + installedScope = installedScope, + installedIsLegacy = installedIsLegacy, + onOpenUrl = openUrl, + ) + } + } + } + } + + if (optionsOpen) { + val muted by viewModel.updatesMuted.collectAsStateWithLifecycle() + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + ModalBottomSheet(onDismissRequest = { optionsOpen = false }, sheetState = sheetState) { + LocalizedOverlay { + Column(Modifier.padding(bottom = 24.dp)) { + SheetHeading(stringResource(R.string.store_options), Icons.Rounded.Tune) + ToggleRow( + title = stringResource(R.string.store_mute_updates), + icon = Icons.Rounded.NotificationsOff, + checked = muted, + onCheckedChange = viewModel::setUpdatesMuted, + subtitle = stringResource(R.string.store_mute_updates_summary), + ) + } + } + } + } + + choosing?.let { release -> + AssetSheet( + release = release, + onDismiss = { choosing = null }, + onPick = { asset -> + choosing = null + confirming = release to asset + }, + ) + } + + confirming?.let { (release, asset) -> + ConfirmInstall( + module = state.module, + packageName = packageName, + asset = asset, + onDismiss = { confirming = null }, + onConfirm = { + confirming = null + viewModel.install(asset, release.version) + }, + ) + } +} + +/** + * The primary action, on every tab. + * + * It lives in a bar rather than on the Releases tab because installing is what the page is *for*, + * and burying it one swipe away behind a tab makes the reader hunt for it after they have decided. + */ +@Composable +private fun InstallBar( + state: RepoDetailsState, + install: InstallStep, + onInstall: (Release) -> Unit, + onAcknowledge: () -> Unit, +) { + val context = LocalContext.current + val newest = state.releases.firstOrNull { it.apks.isNotEmpty() } ?: return + + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + Column( + modifier = + Modifier.fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + when (install) { + is InstallStep.Downloading -> { + val done = Formatter.formatShortFileSize(context, install.bytes) + val total = Formatter.formatShortFileSize(context, install.total) + Text( + text = stringResource(R.string.store_downloading, done, total), + style = MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.height(8.dp)) + if (install.total > 0) { + LinearProgressIndicator( + progress = { install.bytes.toFloat() / install.total }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + is InstallStep.Installing, + is InstallStep.Confirming -> { + Text( + text = + stringResource( + if (install is InstallStep.Confirming) R.string.store_confirming + else R.string.store_installing + ), + style = MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + is InstallStep.Failed -> { + // Named, not swallowed: an install that fails silently leaves the user with a + // button that appears to do nothing. + Text( + text = + install.reason?.let { + stringResource( + R.string.store_install_failed_reason, + install.packageName, + it, + ) + } ?: stringResource(R.string.store_install_failed, install.packageName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(4.dp)) + // The same body as the resting button below, because this is the same press: + // clearing the failure on its own would only put the Install button back and + // leave the reader to press it again, which is a retry that retries nothing. + TextButton( + onClick = { + onAcknowledge() + onInstall(newest) + } + ) { + Text(stringResource(R.string.retry)) + } + } + else -> { + Button( + onClick = { + onAcknowledge() + onInstall(newest) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.Rounded.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + when { + state.upgradable -> + stringResource( + if (state.sameVersion) R.string.store_badge_reinstall + else R.string.store_badge_update, + state.latest?.versionName.orEmpty(), + ) + state.installed != null -> stringResource(R.string.store_reinstall) + else -> stringResource(R.string.store_install) + } + ) + } + } + } + } + } +} + +@Composable +private fun ReadmeTab( + module: OnlineModule, + fetch: DetailFetch, + onRetry: () -> Unit, + onOpenUrl: (String) -> Unit, +) { + val readme = module.readmeHTML + when { + !readme.isNullOrBlank() -> StoreHtmlPane(html = readme, modifier = Modifier.fillMaxSize(), onOpenUrl = onOpenUrl) + // A spinner while the request is in flight, rather than "no readme" — which would be a + // statement about the module when it is really a statement about the network. + fetch == DetailFetch.Loading -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + fetch == DetailFetch.Unavailable -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RetryMessage(stringResource(R.string.store_detail_partial), onRetry) + } + else -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.store_readme_missing), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ReleasesTab( + state: RepoDetailsState, + listState: LazyListState, + onOpenUrl: (String) -> Unit, + onInstall: (Release) -> Unit, +) { + if (state.releases.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.store_releases_none), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + return + } + + // One expanded set of notes at a time, and the newest starts open: "what changed" is the + // question this tab is opened to answer, while five releases with their notes open is a wall of + // text with no structure and the list stops being skimmable. + // + // The default is keyed by *which* release is newest, not by the list object. The view model's + // combine rebuilds that list on every emission — an installed-version refresh, a channel + // change, the detail fetch landing — and keying on it would re-apply the default and reopen the + // notes under a reader who had just closed them, for reasons that had nothing to do with them. + val newest = state.releases.firstOrNull()?.key(0) + var expanded by remember(newest) { mutableStateOf(newest) } + + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(state.releases, key = { index, release -> release.key(index) }) { index, release + -> + val key = release.key(index) + ReleaseCard( + release = release, + // Compared on the version code, which is what the platform compares. Two + // releases can carry the same name and be different builds. + installed = + release.version != null && state.installed?.versionCode == release.version?.versionCode, + notesOpen = expanded == key, + onToggleNotes = { expanded = if (expanded == key) null else key }, + onOpenUrl = onOpenUrl, + onInstall = { onInstall(release) }, + ) + if (index < state.releases.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + } + } + } +} + +/** + * One release. + * + * Not a card, despite the name. An outlined box around every entry turns a list of five releases + * into five framed panels competing with each other and with the notes inside them; a rule between + * plain rows reads as a list, which is what the rest of the app does. + * + * The two facts that decide anything — is this newer than what I have, and is it a prerelease — are + * badges rather than grey words in a row of other grey words, and installing is a filled button + * rather than one of two identical text buttons. + */ +@Composable +private fun ReleaseCard( + release: Release, + installed: Boolean, + notesOpen: Boolean, + onToggleNotes: () -> Unit, + onOpenUrl: (String) -> Unit, + onInstall: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val locale = currentLocale() + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = release.name ?: release.tagName.orEmpty(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // On the prerelease channel this list is stable and beta merged, ordered by version + // code, so an entry has to say which of the two it is. Unmarked, the only difference + // would be a tag string nobody reads as a channel. + if (release.isPrerelease == true) { + ReleaseBadge( + text = stringResource(R.string.store_badge_prerelease), + container = colors.tertiaryContainer, + content = colors.onTertiaryContainer, + ) + } + if (installed) { + Spacer(Modifier.width(6.dp)) + ReleaseBadge( + text = stringResource(R.string.store_badge_installed), + container = colors.secondaryContainer, + content = colors.onSecondaryContainer, + ) + } + } + + Spacer(Modifier.height(3.dp)) + // The version line doubles as the notes' disclosure. A release's tag, its date and its + // notes are one object, so the line that names it is where you press to see more of it, + // the way every expandable row on the platform behaves — rather than spending a second row + // on "Show the release notes". The chevron turns rather than swapping glyphs, which says + // the same thing without a word to read. + val hasNotes = !release.descriptionHTML.isNullOrBlank() + val chevron by animateFloatAsState(if (notesOpen) 180f else 0f, label = "notesChevron") + val disclose = + stringResource( + if (notesOpen) R.string.store_release_notes_hide else R.string.store_release_notes + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .then( + if (!hasNotes) Modifier + else + Modifier.clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onToggleNotes) + ) + .padding(vertical = 4.dp), + ) { + // The tag, not the name: it carries the version code, which is what actually decides + // whether the platform will accept this over what is installed. + release.tagName?.let { + Text(text = it, style = VectorMono, color = colors.onSurfaceVariant, maxLines = 1) + } + release.publishedAt.asRepositoryDate(locale)?.let { + if (release.tagName != null) { + Text( + text = " · ", + style = MaterialTheme.typography.labelSmall, + color = colors.outlineVariant, + ) + } + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + } + if (hasNotes) { + Spacer(Modifier.weight(1f)) + Icon( + Icons.Rounded.ExpandMore, + contentDescription = disclose, + tint = colors.onSurfaceVariant, + modifier = Modifier.size(20.dp).rotate(chevron), + ) + } + } + + if (hasNotes) { + if (notesOpen) { + Spacer(Modifier.height(8.dp)) + // Plain text at its natural height. The list is the only thing that scrolls on + // this screen, and it stays that way. + val notes = + remember(release.descriptionHTML, colors.primary) { + releaseNotes( + html = release.descriptionHTML, + linkColor = colors.primary, + codeColor = colors.tertiary, + ) + } + Text( + text = notes, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + ) + } + } + + Spacer(Modifier.height(6.dp)) + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + release.url?.let { url -> + TextButton(onClick = { onOpenUrl(url) }) { + Text(stringResource(R.string.store_open_release)) + } + Spacer(Modifier.width(8.dp)) + } + if (release.apks.isNotEmpty()) { + FilledTonalButton(onClick = onInstall) { + Icon( + Icons.Rounded.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.store_install)) + } + } + } + } +} + +/** A fact worth noticing, as a pill rather than another grey word in a row of grey words. */ +@Composable +private fun ReleaseBadge(text: String, container: Color, content: Color) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = content, + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .background(container) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) +} + +@Composable +private fun InformationTab( + module: OnlineModule, + listState: LazyListState, + /** What the copy on this device declares, when the catalogue declares nothing. */ + installedScope: List, + /** Whether that copy is a legacy module, which decides how to read the catalogue's scope. */ + installedIsLegacy: Boolean, + onOpenUrl: (String) -> Unit, +) { + // Hoisted: the rows below are emitted from a LazyListScope, which is not a composable. + val locale = currentLocale() + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + if (!module.summary.isNullOrBlank()) { + item { + InfoRow( + icon = Icons.Rounded.Code, + label = stringResource(R.string.store_info_summary), + value = module.summary, + ) + } + } + item { + // The single most useful fact before installing anything: which apps this reaches + // into. The catalogue first, because it describes the published module. Failing that, + // what the installed copy declares in its own APK — accurate for the build actually on + // this device, and labelled as such so the two are not confused. + // + // The catalogue's list is written in the module's own vocabulary, and a legacy + // module's is the reverse of everything else here: its "android" is the system server + // and its "system" is the ordinary android package. The installed list beside it has + // already been through that swap on its way out of the APK, so without this the same + // module can name the same target two different ways in two adjacent lines. + val published = + module.scope + ?.takeIf { it.isNotEmpty() } + ?.let { + if (installedIsLegacy) ModuleDetection.swapLegacyFrameworkNames(it) else it + } + InfoRow( + icon = Icons.Rounded.TrackChanges, + label = + if (published == null && installedScope.isNotEmpty()) + stringResource(R.string.store_info_scope_installed) + else stringResource(R.string.store_info_scope), + value = + published?.joinToString("\n") + ?: installedScope.takeIf { it.isNotEmpty() }?.joinToString("\n") + ?: stringResource(R.string.store_info_scope_undeclared), + ) + } + module.homepageUrl?.takeIf { it.isNotBlank() }?.let { url -> + item { + InfoRow( + icon = Icons.Rounded.Language, + label = stringResource(R.string.store_info_homepage), + value = url, + onClick = { onOpenUrl(url) }, + ) + } + } + module.sourceUrl?.takeIf { it.isNotBlank() }?.let { url -> + item { + InfoRow( + icon = Icons.Rounded.Code, + label = stringResource(R.string.store_info_source), + value = url, + onClick = { onOpenUrl(url) }, + ) + } + } + module.collaborators?.takeIf { it.isNotEmpty() }?.let { people -> + item { + InfoRow( + icon = Icons.Rounded.Group, + label = stringResource(R.string.store_info_collaborators), + value = + (people.mapNotNull { it.name ?: it.login } + + module.additionalAuthors.orEmpty().mapNotNull { it.name }) + .joinToString(", "), + ) + } + } + module.stargazerCount?.takeIf { it > 0 }?.let { stars -> + item { + InfoRow( + icon = Icons.Rounded.Star, + label = stringResource(R.string.store_info_stars), + value = stars.toString(), + ) + } + } + module.latestReleaseTime.asRepositoryDate(locale)?.let { date -> + item { + InfoRow( + icon = Icons.Rounded.Today, + label = stringResource(R.string.store_info_updated), + value = date, + ) + } + } + module.createdAt.asRepositoryDate(locale)?.let { date -> + item { + InfoRow( + icon = Icons.Rounded.Today, + label = stringResource(R.string.store_info_created), + value = date, + ) + } + } + } +} + +@Composable +private fun InfoRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, + onClick: (() -> Unit)? = null, +) { + ListItem( + modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier, + supportingContent = { Text(value) }, + leadingContent = { Icon(icon, contentDescription = null) }, + ) { Text(label) } + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) +} + +/** Shown only when a release ships more than one APK — an architecture split, usually. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AssetSheet(release: Release, onDismiss: () -> Unit, onPick: (ReleaseAsset) -> Unit) { + val context = LocalContext.current + ModalBottomSheet(onDismissRequest = onDismiss) { +LocalizedOverlay { + + Text( + text = stringResource(R.string.store_choose_asset), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + release.apks.forEach { asset -> + ListItem( + modifier = Modifier.clickable { onPick(asset) }, + supportingContent = { + val size = Formatter.formatShortFileSize(context, asset.size) + val downloads = + asset.downloadCount?.let { + context.resources.getQuantityString( + R.plurals.store_asset_downloads, + it, + it, + ) + } + Text(listOfNotNull(size, downloads).joinToString(" · ")) + }, + colors = sheetRowColors, + ) { Text(asset.name.orEmpty()) } + } + Spacer(Modifier.navigationBarsPadding().height(16.dp)) + } +} +} + +/** + * How much of the width a drag must cover for the tab to change. + * + * Above `PagerDefaults`' half. The problem is not that the wrong tab arrives, it is that one + * arrives at all while someone is reading, so the bar for "yes, they meant this" is set where an + * accidental sideways component cannot reach it. + */ +private const val COMMIT_FRACTION = 0.65f + +@Composable +private fun RetryMessage(message: String, onRetry: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp), + ) { + Text( + text = message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt new file mode 100644 index 000000000..a7805c792 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt @@ -0,0 +1,234 @@ +package org.matrix.vector.manager.ui.screens.repo + +import kotlinx.coroutines.flow.map +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.matrix.vector.manager.di.ServiceLocator +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.matrix.vector.manager.data.model.OnlineModule +import org.matrix.vector.manager.data.model.Release +import org.matrix.vector.manager.data.model.ReleaseAsset +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreInstall +import org.matrix.vector.manager.data.model.versionCodeCompat +import org.matrix.vector.manager.data.repository.InstallStep +import org.matrix.vector.manager.data.repository.ModuleInstaller +import org.matrix.vector.manager.data.repository.RepoRepository +import org.matrix.vector.manager.data.repository.SettingsRepository + +/** How the second, richer fetch is going. The page is readable in all three states. */ +enum class DetailFetch { + Loading, + Loaded, + Unavailable, +} + +/** + * Everything the detail page shows. + * + * [module] is never null once the catalogue is in memory, because the list entry seeds it. That is + * the whole design of this screen: the catalogue already carries the description, the summary, the + * scope, the collaborators and the newest release *with its APK*, so the page can paint — and be + * installed from — before any request is made, and a failed request costs the README rather than + * the page. + */ +data class RepoDetailsState( + val module: OnlineModule? = null, + val releases: List = emptyList(), + val installed: RepoVersion? = null, + val latest: RepoVersion? = null, + val fetch: DetailFetch = DetailFetch.Loading, + val channel: StoreChannel = StoreChannel.Stable, + /** What the Store last installed for this module, if the Store is what installed it. */ + val storeInstall: StoreInstall? = null, +) { + /** + * As `StoreEntry.upgradable`, minus the mute: this page is a module the reader went looking for. + * + * The note is honoured here as well, and has to be. It is the one thing that keeps this badge + * from disagreeing with the list that led to it — see [StoreInstall]. + */ + val upgradable: Boolean + get() = + installed != null && + latest != null && + storeInstall?.satisfies(latest, installed) != true && + latest.upgradableOver(installed.versionCode, installed.versionName) + + /** As `StoreEntry.sameVersion`: what the bar may call the offer, not whether to make it. */ + val sameVersion: Boolean + get() = latest?.sameVersionAs(installed) == true +} + +class RepoDetailsViewModel( + private val packageName: String, + private val repository: RepoRepository, + private val installer: ModuleInstaller, + private val settings: SettingsRepository, + /** Installs outlive this screen; see [install]. */ + private val backgroundScope: CoroutineScope, +) : ViewModel() { + + /** + * What the installed copy of this module says it hooks, read from its own APK. + * + * The catalogue's `scope` is optional metadata and most authors omit it — 510 of the 814 + * entries served today carry none — so the information panel says "not declared" for the + * majority of modules. For a module that is *installed*, though, the authoritative list is + * right there in the APK, in `META-INF/xposed/scope.list` for a modern module or the + * `xposedscope` metadata for a legacy one, and this app already knows how to read it: that is + * how the scope editor knows what a module asked for. + * + * So the catalogue is preferred when it has an answer — it describes the *published* module + * rather than whichever build happens to be installed — and this fills the silence when it + * does not. + */ + private val _installedScope = MutableStateFlow>(emptyList()) + val installedScope: StateFlow> = _installedScope.asStateFlow() + + /** + * Whether the copy on this device is a legacy module, which decides how to read the + * *catalogue's* scope. + * + * The two generations spell the framework differently — see + * `ModuleDetection.swapLegacyFrameworkNames` — and the catalogue entry is written in whichever + * vocabulary its module belongs to. Nothing in the payload says which that is, so the installed + * copy is the only thing that can answer it, and only for a module that is installed at all. + * + * False while the module is absent, which is the honest answer rather than a safe one: with + * nothing on the device to inspect there is no way to know, and guessing would relabel a + * target on the strength of nothing. The consequence is a legacy module whose catalogue names + * `android` reading as `android` until it is installed and as `system` afterwards — visibly + * odd, and less misleading than the alternative, which is asserting one of the two at random. + */ + private val _installedIsLegacy = MutableStateFlow(false) + val installedIsLegacy: StateFlow = _installedIsLegacy.asStateFlow() + + private fun readInstalledScope() { + viewModelScope.launch(Dispatchers.IO) { + val packageManager = ServiceLocator.context.packageManager + val info = + runCatching { packageManager.getPackageInfo(packageName, 0) }.getOrNull() + ?: return@launch + val appInfo = info.applicationInfo ?: return@launch + val manifest = + ServiceLocator.moduleDetection.inspect( + appInfo, + packageManager, + info.versionCodeCompat, + info.lastUpdateTime, + ) + _installedScope.value = manifest.scope + _installedIsLegacy.value = manifest.isLegacy + } + } + + private val _detail = MutableStateFlow(null) + private val _fetch = MutableStateFlow(DetailFetch.Loading) + + val installState: StateFlow = installer.state + + /** Whether this module has been told to stop reporting updates. */ + val updatesMuted: StateFlow = + settings.mutedUpdates + .map { packageName in it } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + fun setUpdatesMuted(muted: Boolean) = settings.setUpdatesMuted(packageName, muted) + + /** + * The two preferences this page reads, as one value. + * + * Paired rather than passed separately because `combine` takes five flows and this page already + * watches five things of its own. + */ + private data class Preferences(val channel: StoreChannel, val storeInstall: StoreInstall?) + + private fun preferences(): Flow = + combine(settings.updateChannel, settings.storeInstalls) { channelPreference, installs -> + Preferences(StoreChannel.of(channelPreference), installs[packageName]) + } + + val state: StateFlow = + combine( + repository.catalog, + _detail, + _fetch, + repository.installedVersions, + preferences(), + ) { catalog, detail, fetch, installed, preferences -> + val seed = catalog.modules.firstOrNull { it.name == packageName } + val module = detail ?: seed + val channel = preferences.channel + RepoDetailsState( + module = module, + releases = releasesFor(module, channel), + installed = installed[packageName], + latest = latestFor(module, channel), + fetch = fetch, + channel = channel, + storeInstall = preferences.storeInstall, + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RepoDetailsState()) + + init { + fetchDetails() + readInstalledScope() + } + + fun fetchDetails() { + viewModelScope.launch { + _fetch.value = DetailFetch.Loading + val fetched = repository.details(packageName) + // A failure is not an error screen. The seeded entry is still on display; all that is + // missing is the README and the older releases, and the page says so quietly. + _detail.value = fetched ?: _detail.value + _fetch.value = if (fetched != null) DetailFetch.Loaded else DetailFetch.Unavailable + } + } + + /** + * Downloads and installs [asset]. + * + * Deliberately **not** on `viewModelScope`. Navigating back would cancel the transfer halfway + * through, and the user has already consented to this install — leaving the screen is not a + * change of mind. The installer's state is a single shared flow, so coming back re-attaches to + * the progress that kept running. + */ + fun install(asset: ReleaseAsset, release: RepoVersion?) { + backgroundScope.launch { + if (!installer.install(packageName, asset)) return@launch + // The version has to come from this read rather than from the platform directly: it is + // the one the offer is compared against. See RepoRepository.readInstalled. + val installed = repository.readInstalled()[packageName] + if (release != null && installed != null) { + settings.noteStoreInstall(packageName, StoreInstall(release, installed)) + } + } + } + + fun acknowledgeInstall() = installer.acknowledge() + + /** + * Which releases belong to the current channel. + * + * Resolved by [releasesOn] rather than here, so that what this tab lists, what the update badge + * in the Store list compares against, and what the install bar downloads are one rule with one + * implementation rather than three that can disagree on the prerelease channel. + */ + private fun releasesFor(module: OnlineModule?, channel: StoreChannel): List = + module?.releasesOn(channel).orEmpty() + + private fun latestFor(module: OnlineModule?, channel: StoreChannel): RepoVersion? = + module?.latestOn(channel) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt new file mode 100644 index 000000000..ecc3cbba6 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt @@ -0,0 +1,500 @@ +package org.matrix.vector.manager.ui.screens.repo + +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.rounded.LowPriority +import androidx.compose.material.icons.rounded.NewReleases +import androidx.compose.material3.FilterChip +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import org.matrix.vector.manager.ui.components.ChoiceRow +import org.matrix.vector.manager.ui.components.SheetHeading +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.SearchOff +import androidx.compose.material.icons.rounded.Upgrade +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.model.StoreCatalog +import org.matrix.vector.manager.data.model.StoreEntry +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.PanelHeader +import org.matrix.vector.manager.ui.theme.currentLocale +import org.matrix.vector.manager.ui.components.SearchField +import org.matrix.vector.manager.ui.theme.VectorMono + +class RepoViewModelFactory : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + RepoViewModel(ServiceLocator.store, ServiceLocator.settings) as T +} + +/** + * The Store: what else there is to install. + * + * Its first job is the same as the Modules list's — say what needs attention — so a module with an + * update waiting sorts above one that is merely interesting, and the header states the number + * before anyone scrolls. Everything after that is browsing. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RepoScreen( + onModuleClick: (packageName: String) -> Unit, + viewModel: RepoViewModel = viewModel(factory = RepoViewModelFactory()), +) { + val entries by viewModel.entries.collectAsState() + val catalog by viewModel.catalog.collectAsState() + val query by viewModel.query.collectAsState() + val refreshing by viewModel.isRefreshing.collectAsState() + val updates by viewModel.upgradableCount.collectAsState() + val sort by viewModel.sort.collectAsState() + val priorities by viewModel.priorities.collectAsState() + val channel by viewModel.channel.collectAsState() + + Scaffold { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + StoreHeader( + catalog = catalog, + updates = updates, + search = { StoreSearch(query, viewModel, sort, priorities, channel) }, + ) + + Spacer(Modifier.height(4.dp)) + + // Nothing has ever loaded and a fetch is running: the one moment a spinner says + // something the list could not say better itself. + if (!catalog.loaded && entries.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Column + } + + PullToRefreshBox(isRefreshing = refreshing, onRefresh = viewModel::refresh) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp), + ) { + if (entries.isEmpty()) { + // Inside the list rather than beside it: an empty Box has nothing to + // scroll, and pull-to-refresh is exactly what the reader wants when the + // reason the list is empty is that the network was down. + item { + EmptyState( + modifier = Modifier.fillParentMaxSize(), + catalog = catalog, + filtered = query.isNotBlank(), + ) + } + } else { + items(entries, key = { it.module.name }) { entry -> + StoreRow(entry = entry, onClick = { onModuleClick(entry.module.name) }) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + } + } + } + } + } + } +} + +@Composable +private fun StoreHeader(catalog: StoreCatalog, updates: Int, search: @Composable () -> Unit) { + val context = LocalContext.current + PanelHeader( + title = stringResource(R.string.nav_store), + description = { + if (catalog.modules.isNotEmpty()) { + val total = + context.resources.getQuantityString( + R.plurals.store_module_count, + catalog.modules.size, + catalog.modules.size, + ) + // "Up to date" is stated in words rather than as a zero, because a zero in a row + // of counts reads as a failure to load rather than as good news. + val state = + if (updates > 0) + context.resources.getQuantityString( + R.plurals.store_update_count, + updates, + updates, + ) + else stringResource(R.string.store_all_current) + Text( + text = "$total · $state", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + search = search, + ) +} + +/** The store search field, as the header's third row. */ +@Composable +private fun StoreSearch( + query: String, + viewModel: RepoViewModel, + sort: StoreSort, + priorities: List, + channel: StoreChannel, +) { + SearchField( + query = query, + onQueryChange = viewModel::setQuery, + placeholder = stringResource(R.string.store_search_hint), + ) { + StoreFilterButton( + sort = sort, + onSortChange = viewModel::setSort, + priorities = priorities, + onTogglePriority = viewModel::togglePriority, + channel = channel, + onChannelChange = viewModel::setChannel, + ) + } +} + +/** + * Sort, priority, channel and DNS — as a sheet, not a menu. + * + * A menu is for a short list of like things. This holds two exclusive groups, one multi-select + * group that ranks its choices, and a network switch, and a menu can only separate them with + * dividers that say nothing about which group is which. It also sizes itself to its widest child, + * so the switch's sentence would wrap, and a leading icon on that one row alone would push its + * label 24dp right of every other. A sheet has room for a heading per group and for the anatomy a + * boolean setting wants: title, the sentence explaining the cost, and a `Switch` at full width, so + * nothing wraps in any language. Marquee was considered for the label and rejected — scrolling text + * hides a choice behind a delay in a list whose purpose is comparing choices, and it fights the + * reduce-motion setting. + * + * DNS-over-HTTPS lives here rather than in a settings screen because this is the panel it exists + * for: it is the workaround for a network that will not resolve the module mirrors, and there is no + * settings screen to put it on. + */ +@Composable +private fun StoreFilterButton( + sort: StoreSort, + onSortChange: (StoreSort) -> Unit, + priorities: List, + onTogglePriority: (StorePriority) -> Unit, + channel: StoreChannel, + onChannelChange: (StoreChannel) -> Unit, +) { + var sheetOpen by remember { mutableStateOf(false) } + val narrowed = + sort != StoreSort.RecentlyUpdated || + channel != StoreChannel.Stable || + priorities != listOf(StorePriority.Updates) + + IconButton(onClick = { sheetOpen = true }) { + BadgedBox(badge = { if (narrowed) Badge(modifier = Modifier.size(6.dp)) }) { + Icon( + Icons.Rounded.FilterList, + contentDescription = stringResource(R.string.store_filter), + tint = + if (narrowed) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (sheetOpen) { + StoreFilterSheet( + sort = sort, + onSortChange = onSortChange, + priorities = priorities, + onTogglePriority = onTogglePriority, + channel = channel, + onChannelChange = onChannelChange, + onDismiss = { sheetOpen = false }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StoreFilterSheet( + sort: StoreSort, + onSortChange: (StoreSort) -> Unit, + priorities: List, + onTogglePriority: (StorePriority) -> Unit, + channel: StoreChannel, + onChannelChange: (StoreChannel) -> Unit, + onDismiss: () -> Unit, +) { + // Every value left enabled rather than dropping PartiallyExpanded, which would remove the + // half-height stop — the only thing a drag on a sheet can do other than dismiss it. Material + // caps that stop at the sheet's own height, so short sheets still open at their own height and + // nothing gains a useless drag. + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + LocalizedOverlay { + Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + SheetHeading(stringResource(R.string.store_group_sort), Icons.AutoMirrored.Rounded.Sort) + ChoiceRow { + StoreSort.entries.forEach { option -> + FilterChip( + selected = option == sort, + onClick = { onSortChange(option) }, + label = { Text(stringResource(option.labelRes())) }, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading( + stringResource(R.string.store_group_priority), + Icons.Rounded.LowPriority, + ) + ChoiceRow { + StorePriority.entries.forEach { priority -> + val rank = priorities.indexOf(priority) + FilterChip( + selected = rank >= 0, + onClick = { onTogglePriority(priority) }, + label = { Text(stringResource(priority.labelRes)) }, + // Several of these can be on at once, so a tick is not enough — the + // one that wins for a module in both groups is the one chosen last, + // and the chip says so rather than leaving it to be inferred. + leadingIcon = + if (rank >= 0 && priorities.size > 1) { + { + Text( + text = "${rank + 1}", + style = MaterialTheme.typography.labelMedium, + ) + } + } else null, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading( + stringResource(R.string.store_group_channel), + Icons.Rounded.NewReleases, + ) + ChoiceRow { + StoreChannel.entries.forEach { option -> + FilterChip( + selected = option == channel, + onClick = { onChannelChange(option) }, + label = { Text(stringResource(option.labelRes())) }, + ) + } + } + + } + } + } +} + +/** + * A module, as a row. + * + * No card, matching the Modules list: what distinguishes rows here is state, and painting each one + * as a block of colour makes state harder to read rather than easier. The two facts the list exists + * to answer — *do I already have this* and *is mine out of date* — are on the last line, so they + * line up down the page and can be skimmed without reading a single description. + */ +@Composable +private fun StoreRow(entry: StoreEntry, onClick: () -> Unit) { + val module = entry.module + val colors = MaterialTheme.colorScheme + + Column( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text( + text = module.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + // An identifier, so monospaced — the type rules exist for exactly this. + text = module.name, + style = VectorMono, + color = colors.onSurfaceVariant, + ) + if (!module.summary.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text( + text = module.summary, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + // An icon as well as a colour on both badges: under Material You the wallpaper owns + // the hues, so no state may be distinguishable by colour alone. + when { + entry.upgradable -> + RowBadge( + icon = Icons.Rounded.Upgrade, + text = + stringResource( + if (entry.sameVersion) R.string.store_badge_reinstall + else R.string.store_badge_update, + entry.latest?.versionName.orEmpty(), + ), + tint = colors.primary, + ) + entry.installed != null -> + RowBadge( + icon = Icons.Rounded.Check, + text = stringResource(R.string.store_badge_installed), + tint = colors.onSurfaceVariant, + ) + } + module.latestReleaseTime.asRepositoryDate(currentLocale())?.let { date -> + if (entry.installed != null) Spacer(Modifier.width(10.dp)) + Text( + text = stringResource(R.string.store_updated_on, date), + style = MaterialTheme.typography.labelSmall, + color = colors.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun RowBadge(icon: ImageVector, text: String, tint: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = tint) + Spacer(Modifier.width(4.dp)) + Text(text = text, style = MaterialTheme.typography.labelMedium, color = tint) + } +} + +/** + * The three reasons this list can be empty, which must never render identically. + * + * "Nothing matched your search" and "we could not reach the repository" are completely different + * situations, and only the second one is answered by pulling down to try again. + * + * The reason is decided once and both the icon and the sentence are read off it, so they cannot + * disagree in the case that matters: with a query typed *and* nothing downloaded, an unreachable + * repository is why the list is empty whatever is in the search box, so it wins. Blaming the + * reader's query for a network failure would hide the one thing pull-to-refresh fixes. + */ +private enum class StoreEmptiness { + Unreachable, + NoMatch, + NothingPublished, +} + +@Composable +private fun EmptyState(modifier: Modifier, catalog: StoreCatalog, filtered: Boolean) { + val reason = + when { + catalog.isEmpty -> StoreEmptiness.Unreachable + filtered -> StoreEmptiness.NoMatch + else -> StoreEmptiness.NothingPublished + } + Box(modifier = modifier.padding(32.dp), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + // The icon carries the distinction as much as the sentence does: a struck-out + // cloud for "we could not reach the repository", a struck-out search for "your + // query matched none of the modules we do have". + if (reason == StoreEmptiness.NoMatch) Icons.Rounded.SearchOff + else Icons.Rounded.CloudOff, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = + stringResource( + when (reason) { + StoreEmptiness.Unreachable -> R.string.store_unreachable + StoreEmptiness.NoMatch -> R.string.store_no_match + StoreEmptiness.NothingPublished -> R.string.store_empty + } + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +private fun StoreSort.labelRes(): Int = + when (this) { + StoreSort.RecentlyUpdated -> R.string.store_sort_recent + StoreSort.Name -> R.string.store_sort_name + StoreSort.MostStarred -> R.string.store_sort_stars + } + +private fun StoreChannel.labelRes(): Int = + when (this) { + StoreChannel.Stable -> R.string.store_channel_stable + StoreChannel.Prerelease -> R.string.store_channel_prerelease + } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt new file mode 100644 index 000000000..71100bef9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt @@ -0,0 +1,275 @@ +package org.matrix.vector.manager.ui.screens.repo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import org.matrix.vector.manager.R +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.matrix.vector.manager.data.model.OnlineModule +import org.matrix.vector.manager.data.model.Release +import org.matrix.vector.manager.data.model.RepoVersion +import org.matrix.vector.manager.data.model.StoreCatalog +import org.matrix.vector.manager.data.model.StoreEntry +import org.matrix.vector.manager.data.model.StoreInstall +import org.matrix.vector.manager.data.repository.RepoRepository +import org.matrix.vector.manager.data.repository.SettingsRepository + +/** A group the list can be asked to bring to the front. Several may apply at once. */ +enum class StorePriority(val labelRes: Int) { + Updates(R.string.store_updates_first), + Installed(R.string.store_installed_first); + + fun applies(entry: StoreEntry): Boolean = + when (this) { + Updates -> entry.upgradable + Installed -> entry.installed != null + } +} + +enum class StoreSort { + RecentlyUpdated, + Name, + MostStarred, +} + +/** + * Which releases count. + * + * Two options, not three. Of the 809 modules in the catalogue 14 publish a beta and **none** + * publishes a snapshot, so a snapshot channel would have no data behind it at all — and a control + * that can never change anything is a control that lies. + */ +enum class StoreChannel(val preference: String) { + Stable("stable"), + Prerelease("beta"); + + companion object { + fun of(preference: String): StoreChannel = + entries.firstOrNull { it.preference == preference } ?: Stable + } +} + +/** + * Every release the chosen channel admits, newest first. + * + * **A beta is not a flagged element of `releases`; it is a different array.** In the live catalogue + * not one entry of any module's `releases` carries `isPrerelease`, and each of the 14 modules that + * publish a beta keeps it exclusively in `betaReleases`. Selecting the prerelease channel by + * filtering `releases` on `isPrerelease` therefore matches nothing at all, and the install bar + * takes the newest release with an APK straight off this list. + * + * Merged, the order has to come from the version code rather than from either array's position, + * because the two are sorted independently and a beta is not automatically newer: of today's 14, + * `com.luoshui.paycardeditor` publishes beta code 1 against stable code 8. + */ +internal fun OnlineModule.releasesOn(channel: StoreChannel): List { + val published = releases.orEmpty().filter { it.isDraft != true } + if (channel == StoreChannel.Prerelease) { + val beta = betaReleases.orEmpty().filter { it.isDraft != true } + if (beta.isEmpty()) return published + return (published + beta) + .distinctBy { it.tagName ?: it.id ?: it.name } + .sortedByDescending { it.version?.versionCode ?: Long.MIN_VALUE } + } + // Defensive rather than load-bearing, and it stays because the mirror's shape is not ours to + // promise: a module that has only ever prereleased still has releases worth listing. + val stable = published.filter { it.isPrerelease != true } + return stable.ifEmpty { published } +} + +/** + * The version the channel says is current — which is what every "Update to …" label states. + * + * On the prerelease channel that is whichever of the two channels is genuinely newer, not the beta + * unconditionally. Advertising `latestBetaRelease` on its own offers `paycardeditor`'s readers a + * downgrade from code 8 to code 1 and calls it an update. + */ +internal fun OnlineModule.latestOn(channel: StoreChannel): RepoVersion? { + val stable = RepoVersion.parse(latestRelease) + val best = + if (channel == StoreChannel.Stable) stable + else + listOfNotNull(stable, RepoVersion.parse(latestBetaRelease)).maxByOrNull { + it.versionCode + } + // A detail payload fetched for a module the catalogue has not loaded carries no summary of its + // own, so the newest release's own tag stands in. + return best ?: releasesOn(channel).firstOrNull()?.version +} + +class RepoViewModel( + private val repository: RepoRepository, + private val settings: SettingsRepository, +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _sort = MutableStateFlow(StoreSort.RecentlyUpdated) + val sort: StateFlow = _sort.asStateFlow() + + /** + * Which groups are pulled to the front, most recently chosen first. + * + * A list rather than a set of switches because these compose: turning on both "updates first" + * and "installed first" is a coherent request, and the only thing left to decide is which of + * them wins for a module that is both. Recency answers that — the group you just asked for is + * the one you are looking at — so the list is ordered by when each was switched on, and it + * extends to a third rule without changing anything here. + */ + private val _priorities = MutableStateFlow(listOf(StorePriority.Updates)) + val priorities: StateFlow> = _priorities.asStateFlow() + + val catalog: StateFlow = repository.catalog + val isRefreshing: StateFlow = repository.isRefreshing + + val channel: StateFlow = + settings.updateChannel + .map(StoreChannel::of) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + StoreChannel.of(settings.updateChannel.value), + ) + + /** + * Every catalogue entry paired with what this device has, before any filtering. + * + * Shared rather than recomputed per consumer: both the list and the "n updates" count need it, + * and it walks 809 entries. + */ + private val allEntries: StateFlow> = + combine( + repository.catalog, + repository.installedVersions, + channel, + settings.mutedUpdates, + settings.storeInstalls, + ) { catalog, installed, channel, muted, storeInstalls -> + catalog.modules.map { entryFor(it, installed, channel, muted, storeInstalls) } + } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** For the header. Counted over the whole catalogue, not over whatever the search box left. */ + val upgradableCount: StateFlow = + allEntries + .map { entries -> entries.count { it.upgradable } } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + + val entries: StateFlow> = + combine(allEntries, _query, view()) { entries, query, view -> + entries.filter { it.matches(query) }.sortedWith(comparatorFor(view)) + } + // Off the main thread, for the reason ModulesViewModel records: stateIn on its own + // collects on Dispatchers.Main.immediate, which would put a filter and a sort over 809 + // entries on the UI thread on every keystroke. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + init { + // The catalogue outlives this ViewModel — switching tabs destroys the nav entry — so a + // return to the Store paints from what is already in memory and only fetches when there is + // nothing to paint. + if (!repository.catalog.value.loaded) viewModelScope.launch { repository.refresh() } + repository.refreshInstalled() + } + + fun setQuery(value: String) { + _query.value = value + } + + fun togglePriority(priority: StorePriority) { + _priorities.update { current -> + if (priority in current) current - priority else listOf(priority) + current + } + } + + fun setSort(value: StoreSort) { + _sort.value = value + } + + /** + * DNS over HTTPS, exposed here because the Store is what it exists for. + * + * Changing it takes effect on the next lookup: `VectorDns` reads the flag per lookup rather + * than at client construction, so there is nothing to rebuild and no restart to ask for. + */ + + + /** Persisted: the channel decides what counts as an update everywhere, not just in this list. */ + fun setChannel(value: StoreChannel) { + settings.setUpdateChannel(value.preference) + } + + fun refresh() { + viewModelScope.launch { repository.refresh(force = true) } + } + + /** The three controls that reorder the list, as one value so `combine` stays readable. */ + private data class View( + val sort: StoreSort, + val priorities: List, + val channel: StoreChannel, + ) + + private fun view(): Flow = + combine(_sort, _priorities, channel) { sort, priorities, channel -> + View(sort, priorities, channel) + } + + private fun entryFor( + module: OnlineModule, + installed: Map, + channel: StoreChannel, + muted: Set, + storeInstalls: Map, + ): StoreEntry = + StoreEntry( + module = module, + latest = module.latestOn(channel), + installed = installed[module.name], + updatesMuted = module.name in muted, + storeInstall = storeInstalls[module.name], + ) + + private fun StoreEntry.matches(query: String): Boolean { + if (query.isBlank()) return true + return module.title.contains(query, ignoreCase = true) || + module.name.contains(query, ignoreCase = true) || + module.summary?.contains(query, ignoreCase = true) == true + } + + /** + * Updates come first by default, mirroring the Modules list's "enabled first": a list's first + * job is to say what needs attention. It is a separate toggle rather than a fourth sort order + * because it answers a different question from "in what order do I want to browse". + */ + private fun comparatorFor(view: View): Comparator { + val order: Comparator = + when (view.sort) { + // ISO-8601 in UTC sorts correctly as text, so this needs no date parsing per row. + StoreSort.RecentlyUpdated -> + compareByDescending { it.module.latestReleaseTime.orEmpty() } + StoreSort.Name -> compareBy { it.module.title.lowercase() } + StoreSort.MostStarred -> + compareByDescending { it.module.stargazerCount ?: 0 } + .thenBy { it.module.title.lowercase() } + } + // Applied outermost-last, so the most recently chosen group ends up the primary key. + return view.priorities.reversed().fold(order) { acc, priority -> + compareByDescending { priority.applies(it) }.then(acc) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt new file mode 100644 index 000000000..5d44cd0ed --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt @@ -0,0 +1,20 @@ +package org.matrix.vector.manager.ui.screens.repo + +import java.text.DateFormat +import java.time.Instant +import java.util.Date +import java.util.Locale + +/** + * Repository timestamps are ISO-8601 in UTC. The reader's calendar is neither, so the instant is + * parsed and re-formatted for [locale] rather than sliced out of the machine format, which reads as + * a date only to someone who already writes dates that way. + * + * Returns null when the field is missing or unparseable, so a caller can drop the line rather than + * print a placeholder that says nothing. + */ +internal fun String?.asRepositoryDate(locale: Locale): String? { + val raw = this?.takeIf { it.isNotBlank() } ?: return null + val instant = runCatching { Instant.parse(raw) }.getOrNull() ?: return null + return DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(instant.toEpochMilli())) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt new file mode 100644 index 000000000..055841316 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt @@ -0,0 +1,338 @@ +package org.matrix.vector.manager.ui.screens.repo + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.ViewConfiguration +import kotlin.math.abs +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.viewinterop.AndroidView +import java.io.ByteArrayInputStream +import okhttp3.OkHttpClient +import okhttp3.Request +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.screens.web.forWebView + +/** + * Repository-supplied HTML — a README, or a release's notes — rendered inside Vector. + * + * **Why a WebView and not a Compose renderer.** The field the repository serves is `readmeHTML`: + * HTML that GitHub has already rendered. The raw-markdown `readme` field is absent from every + * response the API returns, so a "markdown renderer" here would in fact have to be an HTML engine. + * Across the READMEs of the fifteen most-starred modules that means 181 ``, 157 `
  • `, 136 + * `

    `, 90 `

    `, 77 ``, 50 ``, 43 `` across 5 ``, plus `` with + * theme-switched sources, nested lists, `
    ` and `` — a tokenizer, an inline-span + * builder, table layout and async image loading, all hand-written because no new dependency is + * allowed, and still worse than WebKit on the long tail. That is the wrong place to spend it, on a + * page most people read once before installing. + * + * **So it has to be sandboxed properly, because the content is hostile in practice and not just in + * theory.** One of the 809 READMEs in the catalogue ships a `googlesyndication` ad `