diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 1f0a55c82..1ea956bf7 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -27,16 +27,39 @@ jobs: submodules: recursive fetch-depth: 0 + # 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 diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index ad1ef4c53..1aacca7da 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -5,7 +5,7 @@ on: push: branches: [ master ] paths: - - app/src/main/res/values/strings.xml + - manager/src/main/res/values/strings*.xml - daemon/src/main/res/values/strings.xml jobs: 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 f87d5a96f..000000000 --- a/app/build.gradle.kts +++ /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) 2021 LSPosed Contributors - */ - -import java.time.Instant - -plugins { - alias(libs.plugins.agp.app) - alias(libs.plugins.nav.safeargs) - 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 -} - -// Generate the translated-locale list and the Material accent-color theme overlays at build time -// from the inputs below. Task implementations live in buildSrc. -androidComponents { - onVariants { variant -> - val capped = variant.name.replaceFirstChar { it.uppercase() } - - val langList = - tasks.register("generate${capped}LangList") { - resDirs.from("src/main/res") - packageName = "org.lsposed.manager.util" - className = "LangList" - firstItem = "SYSTEM" - } - variant.sources.java?.addGeneratedSourceDirectory(langList, GenerateLangListTask::outputDir) - - val materialTheme = - tasks.register("generate${capped}MaterialTheme") { - generatePalette = true - lightThemeFormat = "ThemeOverlay.Light.%s" - darkThemeFormat = "ThemeOverlay.Dark.%s" - seedColors = - mapOf( - "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", - ) - } - variant.sources.res?.addGeneratedSourceDirectory( - materialTheme, - GenerateMaterialThemeTask::outputDir, - ) - } -} - -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 454c5d139..000000000 --- a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java +++ /dev/null @@ -1,733 +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.databinding.ItemScopeFooterBinding; -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; - } - }; - - /** - * staticScope says the module's scope list is the whole of it, so the list above shows only the - * apps it claims and this rules it off. Nothing follows for a module that does not claim one. - */ - public RecyclerView.Adapter footerAdaptor = new RecyclerView.Adapter<>() { - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - return new RecyclerView.ViewHolder(ItemScopeFooterBinding.inflate(activity.getLayoutInflater(), parent, false).getRoot()) { - }; - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { - } - - @Override - public int getItemCount() { - return module.staticScope ? 1 : 0; - } - }; - - 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 (module.staticScope && !tmpChkList.contains(application)) { - // The module says its scope list is the whole of it, so nothing outside that - // list is offered. An app already enabled outside it still has to be shown, or - // it would keep the module loaded from a row nobody can see to switch off. - return; - } 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 e00a0b621..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java +++ /dev/null @@ -1,233 +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); - concatAdapter.addAdapter(scopeAdapter.footerAdaptor); - 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 a44afb6b3..000000000 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java +++ /dev/null @@ -1,878 +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.RelativeSizeSpan; -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.appcompat.widget.TooltipCompat; -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(); - bindApiBadge(holder.apiBadge, item, installXposedVersion); - 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(); - } - - /** - * The API version a module targets, printed under its icon. The word stays in the ordinary - * secondary colour and the version itself carries the colour, so the eye lands on the one - * part that differs between modules. The whole statement goes in the content description - * and the tooltip, so the colour is never the only thing carrying it. - */ - private void bindApiBadge(TextView badge, ModuleUtil.InstalledModule item, int frameworkApi) { - final String label; - final String status; - final String version; - final int color; - - if (item.legacy) { - // A legacy module reports the original Xposed API, which is numbered separately - // from the one this framework implements, so it is never compared against it. - version = item.minVersion > 0 ? String.valueOf(item.minVersion) : null; - label = version != null - ? getString(R.string.module_api_badge_legacy, item.minVersion) - : getString(R.string.module_api_badge_legacy_unknown); - status = getString(R.string.module_api_status_legacy); - color = android.R.attr.textColorSecondary; - } else if (item.minVersion <= 0 || item.targetVersion <= 0 || frameworkApi <= 0) { - // Both keys are required of a module. Reporting the one it did declare would put a - // version on screen for a module that never stated which API it was built for. - version = null; - label = getString(R.string.module_api_badge_unknown); - status = getString(R.string.module_api_status_unknown); - color = android.R.attr.textColorSecondary; - } else { - version = String.valueOf(item.targetVersion); - label = getString(R.string.module_api_badge, item.targetVersion); - if (item.minVersion > frameworkApi) { - // It declares it needs more than we have, whatever it targets. - status = getString(R.string.module_api_status_unsupported, item.minVersion, frameworkApi); - color = com.google.android.material.R.attr.colorError; - } else if (item.targetVersion == frameworkApi) { - status = getString(R.string.module_api_status_current); - color = androidx.appcompat.R.attr.colorPrimary; - } else { - status = getString(item.targetVersion > frameworkApi - ? R.string.module_api_status_newer - : R.string.module_api_status_older, - item.targetVersion, frameworkApi); - color = android.R.attr.textColorSecondary; - } - } - - final int resolved = ResourceUtils.resolveColor(requireActivity().getTheme(), color); - final SpannableStringBuilder text = new SpannableStringBuilder(label); - final int at = version == null ? -1 : label.lastIndexOf(version); - if (at > 0) { - // Small caps: the capitals of the word set smaller than the version beside them, so - // the word reads as a label and the version reads as the value. - text.setSpan(new RelativeSizeSpan(0.78f), 0, at, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - text.setSpan(new StyleSpan(Typeface.BOLD), at, label.length(), - Spannable.SPAN_INCLUSIVE_INCLUSIVE); - text.setSpan(new ForegroundColorSpan(resolved), at, label.length(), - Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - text.setSpan(new RelativeSizeSpan(0.78f), 0, label.length(), - Spannable.SPAN_INCLUSIVE_INCLUSIVE); - text.setSpan(new ForegroundColorSpan(resolved), 0, label.length(), - Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } - - badge.setText(text); - badge.setContentDescription(status); - TooltipCompat.setTooltipText(badge, status); - badge.setVisibility(View.VISIBLE); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - ConstraintLayout root; - ImageView appIcon; - TextView appName; - TextView appDescription; - TextView appVersion; - TextView apiBadge; - 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; - apiBadge = binding.apiBadge; - 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 1fc50843d..000000000 --- a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java +++ /dev/null @@ -1,414 +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) { - // minApiVersion and targetApiVersion are required of a module, but a third-party module - // omitting one must not take down the module list. - if (str == null) return 0; - 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) { - try { - 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"); - } catch (IllegalArgumentException e) { - // Properties.load rejects a malformed unicode escape, and nothing read - // out of the file before that point can be trusted either, so the - // module reports no version rather than the defaults above. - minVersion = 0; - targetVersion = 0; - Log.e(App.TAG, "Malformed module.prop in " + pkg.packageName, e); - } - } - 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 | IllegalArgumentException e) { - // Properties.load is documented to throw IllegalArgumentException for a - // malformed unicode escape. Uncaught, one such module.prop leaves the user - // with an empty module list instead of one unreadable entry. - Log.e(App.TAG, "Error while reading 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 6c0c59f8a..000000000 --- a/app/src/main/res/layout/item_module.xml +++ /dev/null @@ -1,196 +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/item_scope_footer.xml b/app/src/main/res/layout/item_scope_footer.xml deleted file mode 100644 index 4b18cb41a..000000000 --- a/app/src/main/res/layout/item_scope_footer.xml +++ /dev/null @@ -1,52 +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 469a63577..000000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,253 +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. - API %1$d - API ? - Xposed %1$d - Xposed ? - Targets the API this framework implements - Targets API %1$d, older than the API this framework implements (%2$d) - Targets API %1$d, newer than the API this framework implements (%2$d) - Needs API %1$d, more than this framework implements (%2$d) - Uses the original Xposed API through the legacy bridge - The targeted API version is unknown - Static Scope - 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 290706f9c..acb4987d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,11 @@ 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) } @@ -54,13 +59,52 @@ abstract class GitLatestTagValueSource : ValueSource { + @get:Inject abstract val execOperations: ExecOperations + + override fun obtain(): String { + val hash = ByteArrayOutputStream() + val hashResult = execOperations.exec { + commandLine("git", "rev-parse", "--short", "HEAD") + standardOutput = hash + isIgnoreExitValue = true + } + if (hashResult.exitValue != 0 || hash.toString().isBlank()) return "unknown" + + // A dirty tree is the case worth naming: that build corresponds to no commit at all, so + // "which commit is this" has no answer and the version should say so rather than name a + // commit the binary does not match. + val dirty = ByteArrayOutputStream() + val dirtyResult = execOperations.exec { + commandLine("git", "status", "--porcelain", "--untracked-files=no") + standardOutput = dirty + isIgnoreExitValue = true + } + val suffix = + if (dirtyResult.exitValue == 0 && dirty.toString().isNotBlank()) "-dirty" else "" + return hash.toString().trim() + suffix + } +} + // 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 versionHashProvider by extra(providers.of(GitCommitHashValueSource::class.java) {}) val versionNameProvider by extra(providers.of(GitLatestTagValueSource::class.java) {}) val injectedPackageName by extra("com.android.shell") val injectedPackageUid by extra(2000) -val defaultManagerPackageName by extra("org.lsposed.manager") +val defaultManagerPackageName by extra("org.matrix.vector.manager") val androidTargetSdkVersion by extra(37) val androidMinSdkVersion by extra(27) @@ -165,6 +209,7 @@ tasks.register("format") { // :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..ff983868f 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -5,11 +5,12 @@ base_url: 'https://api.crowdin.com' pull_request_title: '[translation] Update translation from Crowdin' preserve_hierarchy: 1 files: - - source: /app/src/main/res/values/strings.xml - translation: /app/src/main/res/values-%two_letters_code%/%original_file_name% + # %android_code% rather than %two_letters_code%: it is the only placeholder that produces the + # region-qualified resource folders Android actually uses — values-zh-rCN, values-pt-rBR — and + # with the two-letter form Crowdin cannot even find the existing ones to upload them. + - source: /manager/src/main/res/values/strings*.xml + translation: /manager/src/main/res/values-%android_code%/%original_file_name% type: android - dest: /app/strings.xml - source: /daemon/src/main/res/values/strings.xml - translation: /daemon/src/main/res/values-%two_letters_code%/%original_file_name% + translation: /daemon/src/main/res/values-%android_code%/%original_file_name% type: android - dest: /daemon/strings.xml diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index 1b8ceda06..da92d3879 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -16,6 +16,7 @@ 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 versionHashProvider: Provider by rootProject.extra plugins { alias(libs.plugins.agp.app) @@ -34,6 +35,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 @@ -107,10 +111,10 @@ androidComponents { val signInfoTask = tasks.register("generate${variantCapped}SignInfo") { - dependsOn(":app:validateSigning${variantCapped}") + dependsOn(":manager:validateSigning${variantCapped}") val sign = rootProject - .project(":app") + .project(":manager") .extensions .getByType(ApplicationExtension::class.java) .buildTypes 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..d1d398c3a 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) @@ -218,6 +223,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 48747e111..ade429684 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -287,8 +287,8 @@ 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 { @@ -340,7 +340,7 @@ 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) @@ -373,7 +373,7 @@ object VectorService : IDaemonService.Stub() { } when (action) { "approve" -> { - val scopes = ConfigCache.getModuleScope(packageName) ?: mutableListOf() + val scopes = ModuleDatabase.getModuleScope(packageName) ?: mutableListOf() if (scopes.none { it.packageName == scopePackageName && it.userId == userId }) { scopes.add( Application().apply { 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 2c34d7d74..43250f2e9 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,6 +12,7 @@ import java.nio.file.attribute.PosixFilePermissions import java.util.UUID import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import org.lsposed.lspd.ILSPManagerService import org.lsposed.lspd.models.Application import org.lsposed.lspd.models.Module import org.matrix.vector.daemon.BuildConfig @@ -32,7 +33,6 @@ 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. @@ -43,10 +43,22 @@ object ConfigCache { 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() @@ -54,39 +66,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() { @@ -101,7 +121,7 @@ object ConfigCache { } else { Paths.get(pathStr) } - state = state.copy(miscPath = path) + synchronized(this) { state = state.copy(miscPath = path) } runCatching { val perms = @@ -123,233 +143,199 @@ 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 newStaticScopes = 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 - } + synchronized(rebuildLock) { + if (packageManager == null) return + + Log.d(TAG, "Executing Cache Update...") + val oldState = state + + val newModules = mutableMapOf() + val newStaticScopes = 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 + } + 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 - } + // 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 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 - // 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 - 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 + // 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 + } - 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 - } + 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] = ILSPManagerService.MODULE_LOAD_NO_APK + return@forEach + } + apkPath = realApkPath + obsoletePaths[pkgName] = realApkPath - FileSystem.readStaticScope(apkPath)?.let { newStaticScopes[pkgName] = it } - - 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) - } + FileSystem.readStaticScope(apkPath)?.let { newStaticScopes[pkgName] = it } + + when (val loaded = FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled)) { + is ModuleLoad.Loaded -> { + val module = + Module().apply { + packageName = pkgName + this.apkPath = apkPath + appId = appInfo.uid + applicationInfo = appInfo + service = oldModule?.service ?: InjectedModuleService(pkgName) + file = 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] = ILSPManagerService.MODULE_LOAD_UNSUPPORTED_API + } + ModuleLoad.Unusable -> { + Log.w(TAG, "Could not load $pkgName; skipping.") + unloadable[pkgName] = ILSPManagerService.MODULE_LOAD_UNUSABLE } } + } - if (packageManager?.asBinder()?.isBinderAlive == true) { - obsoleteModules.forEach { ModuleDatabase.removeModule(it) } - obsoletePaths.forEach { (pkg, path) -> ModuleDatabase.updateModuleApkPath(pkg, path, true) } - } + if (packageManager?.asBinder()?.isBinderAlive == true) { + obsoleteModules.forEach { ModuleDatabase.removeModule(it) } + obsoletePaths.forEach { (pkg, path) -> ModuleDatabase.updateModuleApkPath(pkg, path, true) } + } - staticScopes = newStaticScopes - // 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") + // 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") + } } - } - 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 newScopes = mutableMapOf>() + ModuleDatabase.enabledScopeRows().forEach { scopeRow -> + val appPkg = scopeRow.appPackage + val modPkg = scopeRow.modulePackage + val userId = scopeRow.userId - val pkgInfo = - packageManager?.getPackageInfoWithComponents(appPkg, MATCH_ALL_FLAGS, userId) - if (pkgInfo?.applicationInfo == null) continue + val module = newModules[modPkg] ?: return@forEach - val processNames = pkgInfo.fetchProcesses() - if (processNames.isEmpty()) continue + if (appPkg == "system") { + newScopes.getOrPut(ProcessScope("system_server", 1000)) { mutableListOf() }.add(module) + return@forEach + } - val appUid = pkgInfo.applicationInfo!!.uid + val pkgInfo = packageManager?.getPackageInfoWithComponents(appPkg, MATCH_ALL_FLAGS, userId) + if (pkgInfo?.applicationInfo == null) return@forEach - for (processName in processNames) { - val processScope = ProcessScope(processName, appUid) - newScopes.getOrPut(processScope) { mutableListOf() }.add(module) + val processNames = pkgInfo.fetchProcesses() + if (processNames.isEmpty()) return@forEach - 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) - } - } + val appUid = pkgInfo.applicationInfo!!.uid + + for (processName in processNames) { + val processScope = ProcessScope(processName, appUid) + newScopes.getOrPut(processScope) { mutableListOf() }.add(module) + + 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) } } } } + } - // --- ATOMIC STATE SWAP --- - state = oldState.copy(modules = newModules, scopes = newScopes) - - Log.d(TAG, "Cache Update Complete. Map Swap successful.") - // 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 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) - }) - } - } - 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 + } - 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 - } - } - return isAutoInclude - } + Log.d(TAG, "Cache Update Complete. Map Swap successful.") + // Log.d(TAG, "cached modules:") + // newModules.forEach { (pkg, mod) -> Log.d(TAG, "$pkg ${mod.apkPath}") } - 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) - } - } - return result + // 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 { @@ -374,24 +360,17 @@ 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) { modules.add(cached) - continue + return@forEach } val statPath = FileSystem.toGlobalNamespace("/data/user_de/0/$pkgName").absolutePath @@ -423,7 +402,7 @@ object ConfigCache { uid = module.appId } - FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled)?.let { + FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled).apkOrNull?.let { module.file = it modules.add(module) // We intentionally don't mutate state.modules here. Cache update will catch 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..68107dbe0 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 @@ -19,4 +19,15 @@ data class DaemonState( val miscPath: Path? = null, 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 bcbeb3ecc..c3019d347 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 @@ -39,6 +39,30 @@ 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: PreLoadedApk) : 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: PreLoadedApk? + get() = (this as? ModuleLoad.Loaded)?.apk + object FileSystem { val basePath: Path = Paths.get("/data/adb/lspd") val logDirPath: Path = basePath.resolve("log") @@ -190,9 +214,9 @@ object FileSystem { .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 preLoadedDexes = mutableListOf() @@ -261,12 +285,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 @@ -280,10 +304,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) { @@ -304,7 +328,7 @@ object FileSystem { this.exceptionPassthrough = exceptionPassthrough } - return preLoadedApk + return ModuleLoad.Loaded(preLoadedApk) } /** Safely creates the log directory. If a file exists with the same name, it deletes it first. */ @@ -487,6 +511,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() 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 134c80ea8..6a2c45a87 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 @@ -7,11 +7,187 @@ import org.lsposed.lspd.models.Application 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( + Application().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. @@ -41,7 +217,7 @@ 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 @@ -58,7 +234,7 @@ object ModuleDatabase { } } enableModule(packageName) - val db = ConfigCache.dbHelper.writableDatabase + val db = dbHelper.writableDatabase db.beginTransaction() try { val mid = @@ -69,9 +245,17 @@ object ModuleDatabase { val values = ContentValues().apply { put("mid", mid) } for (app in scope) { - if (app.packageName == "system" && app.userId != 0) continue + // The system server is one process for the whole device, so it is stored against user 0 + // whoever asked for it — 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() @@ -92,7 +276,7 @@ object ModuleDatabase { * @return how many rows went. */ fun pruneScopeToClaimed(packageName: String, claimed: Set): Int { - val db = ConfigCache.dbHelper.writableDatabase + val db = dbHelper.writableDatabase return runCatching { val mid = db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?") @@ -114,7 +298,7 @@ object ModuleDatabase { fun removeModuleScope(packageName: String, scopePackageName: String, userId: Int): Boolean { if (packageName == "lspd" || (scopePackageName == "system" && userId != 0)) return false - val db = ConfigCache.dbHelper.writableDatabase + val db = dbHelper.writableDatabase val mid = db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?") .apply { bindString(1, packageName) } @@ -134,7 +318,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() @@ -157,19 +341,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/ipc/CliHandler.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt index 16341343d..4133f0869 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 @@ -40,7 +40,7 @@ object CliHandler { return mapOf( "Framework Version" to BuildConfig.VERSION_NAME, "Version Code" to BuildConfig.VERSION_CODE, - "Enabled Modules" to ConfigCache.state.modules.size, + "Enabled Modules" to ModuleDatabase.enabledModules().size, "Status Notification" to PreferenceStore.isStatusNotificationEnabled()) } @@ -55,8 +55,10 @@ object CliHandler { 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.enabledModules()`. + val enabledModuleKeys = ModuleDatabase.enabledModules().toSet() // Get all installed modules from the system val installed = ConfigCache.getInstalledModules() @@ -121,14 +123,14 @@ object CliHandler { 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.") rejectBeyondStaticScope(apps) - val scope = ConfigCache.getModuleScope(modulePkg) ?: mutableListOf() + val scope = ModuleDatabase.getModuleScope(modulePkg) ?: mutableListOf() apps.forEach { appStr -> val parts = appStr.split("/") @@ -223,7 +225,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" -> { @@ -234,8 +236,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() @@ -246,8 +248,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") 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 7cc849980..901720985 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,12 @@ import hidden.HiddenApiBridge import io.github.libxposed.service.IXposedService import java.io.File import java.util.concurrent.CountDownLatch +import org.lsposed.lspd.IFrameworkInstallCallback import org.lsposed.lspd.ILSPManagerService import org.lsposed.lspd.models.Application import org.lsposed.lspd.models.UserInfo 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 @@ -35,6 +38,7 @@ 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.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 @@ -43,6 +47,10 @@ private const val TAG = "VectorManagerService" object ManagerService : ILSPManagerService.Stub() { + /** AOSP's switch for the synthesised launcher entries Android 10 introduced. */ + private const val SHOW_HIDDEN_ICON_APPS = "show_hidden_icon_apps_enabled" + + private var managerPid = -1 private var pendingManager = false @@ -147,7 +155,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) } @@ -214,6 +222,8 @@ object ManagerService : ILSPManagerService.Stub() { override fun getXposedVersionName() = BuildConfig.VERSION_NAME + override fun getFrameworkCommit(): String? = BuildConfig.VERSION_HASH.takeIf { it.isNotBlank() } + override fun getInstalledPackagesFromAllUsers( flags: Int, filterNoProcess: Boolean @@ -222,7 +232,12 @@ object ManagerService : ILSPManagerService.Stub() { packageManager?.getInstalledPackagesFromAllUsers(flags, filterNoProcess) ?: emptyList()) } - override fun enabledModules() = ConfigCache.state.modules.keys.toTypedArray() + override fun enabledModules() = ModuleDatabase.enabledModules() + + override fun getUnloadableModules() = ConfigCache.state.unloadable.keys.toTypedArray() + + override fun getModuleLoadState(packageName: String) = + ConfigCache.state.unloadable[packageName] ?: ILSPManagerService.MODULE_LOAD_OK override fun enableModule(packageName: String) = ModuleDatabase.enableModule(packageName) @@ -231,15 +246,27 @@ object ManagerService : ILSPManagerService.Stub() { 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 isVerboseLog() = PreferenceStore.isVerboseLogEnabled() override fun setVerboseLog(enabled: Boolean) { PreferenceStore.setVerboseLog(enabled) if (isVerboseLog()) LogcatMonitor.startVerbose() else LogcatMonitor.stopVerbose() } + 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 getVerboseLog() = LogcatMonitor.getVerboseLog()?.let { ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY) @@ -264,6 +291,8 @@ object ManagerService : ILSPManagerService.Stub() { activityManager?.forceStopPackage(packageName, userId) } + override fun softReboot() = VectorDaemon.softReboot() + override fun reboot() { powerManager?.reboot(false, null, false) } @@ -379,20 +408,51 @@ object ManagerService : ILSPManagerService.Stub() { override fun dex2oatFlagsLoaded() = 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 forcedLauncherIcons(): 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 getLogs(zipFd: ParcelFileDescriptor) { @@ -419,8 +479,36 @@ object ManagerService : ILSPManagerService.Stub() { override fun getDex2OatWrapperCompatibility() = 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 getAutoInclude(packageName: String) = ConfigCache.getAutoInclude(packageName) + 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 getRootImplementationVersion() = RootImplementation.version + + override fun installFrameworkZip(zipPath: String, callback: IFrameworkInstallCallback) { + // 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 { callback.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 callback is gone; continuing", it) + } + } + runCatching { callback.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/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index 056580728..5bbeb3798 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -103,7 +103,28 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { override fun getFrameworkName() = ensureModule().let { BuildConfig.FRAMEWORK_NAME } - override fun getFrameworkVersion() = ensureModule().let { BuildConfig.VERSION_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 } @@ -119,7 +140,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { ensureModule() // The scope table has one row per (app, user), so a module enabled for several users saw the // same package repeatedly. A scope is a set of package names. - return ConfigCache.getModuleScope(loadedModule.packageName)?.map { it.packageName }?.distinct() + return ModuleDatabase.getModuleScope(loadedModule.packageName)?.map { it.packageName }?.distinct() ?: emptyList() } 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..f05a89233 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 @@ -85,7 +85,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() { 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..050089e0a --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/RootImplementation.kt @@ -0,0 +1,228 @@ +package org.matrix.vector.daemon.utils + +import android.util.Log +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import org.lsposed.lspd.ILSPManagerService + +private const val TAG = "VectorRootInstaller" + +/** + * Which root implementation is managing this device, and how to flash through it. + * + * The detection mirrors NeoZygisk's `root_impl` module, deliberately: that is the code deciding + * whether Vector loads at all on this device, and a manager that disagreed with it about which root + * is in charge would be reporting on a different device than the one it is running on. Where a + * version floor can be read at all it is NeoZygisk's own, so "too old to flash through" means the + * same thing in both places. + * + * Detection is by binary rather than by NeoZygisk's ioctl/prctl route, which needs a JNI hop for a + * question asked once — and the binary has to exist anyway to do the flashing. The cost of that + * choice is visible in [detectKernelSu], which cannot read a version at all. + * + * A binary that exists but *fails* is not an implementation: this device carries 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 { + + /** + * NeoZygisk's floors, from its root build.gradle.kts. Below these it will not load. + * + * There is deliberately no KernelSU floor here: its version code is not reachable from a shell, + * and [detectKernelSu] explains why not checking it is correct rather than merely convenient. + */ + private const val MIN_MAGISK = 26402 + private const val MIN_APATCH = 10762 + + /** + * 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 + + val version: String? + get() = detected.version + + 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(ILSPManagerService.ROOT_MULTIPLE, found.joinToString { it.version ?: "?" }) + } + + val only = found.firstOrNull() ?: return Detection(ILSPManagerService.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() + val supported = code >= MIN_MAGISK + return Detection( + if (supported) ILSPManagerService.ROOT_MAGISK else ILSPManagerService.ROOT_TOO_OLD, + "Magisk ${name ?: code}", + binary, + ) + } + + /** + * KernelSU, which cannot be version-checked from a shell. + * + * `ksud -V` prints a *build hash*, not a version code — measured on a KernelSU device it answers + * `ksud 64e3761d`. An earlier version of this took the first run of digits out of that, read + * `64`, compared it against the 10940 floor and declared the device too old to flash on — which + * would have disabled the entire feature on exactly the devices it works on. The version code + * lives behind KernelSU's prctl/ioctl interface, which is why NeoZygisk reaches for it and why + * this cannot. + * + * So presence is the whole test, and that is sound rather than a shrug: NeoZygisk refuses to load + * on a KernelSU older than its floor, so a daemon that is running at all is running under one new + * enough. The check this cannot perform has already been performed, one layer down. + */ + private fun detectKernelSu(): Detection? { + val (binary, raw) = run(KSUD_PATHS, "-V") ?: return null + val build = raw.trim().substringAfter("ksud ").trim() + return Detection(ILSPManagerService.ROOT_KERNELSU, "KernelSU ($build)", binary) + } + + /** + * APatch. `apd -V` prints "apd ", so the second field is the version — NeoZygisk's parse. + * + * When that field is not a number, this reports the implementation as present and usable rather + * than absent. Refusing to flash because *our parser* did not recognise a version string would be + * refusing on the evidence of our own code rather than on the state of the device — which is the + * mistake the KernelSU branch above was making. + */ + 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 when { + code == null -> Detection(ILSPManagerService.ROOT_APATCH, "APatch ($output)", binary) + code >= MIN_APATCH -> Detection(ILSPManagerService.ROOT_APATCH, "APatch $code", binary) + else -> Detection(ILSPManagerService.ROOT_TOO_OLD, "APatch $code", 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) { + ILSPManagerService.ROOT_MAGISK -> listOf(binary, "--install-module", zipPath) + ILSPManagerService.ROOT_KERNELSU -> listOf(binary, "module", "install", zipPath) + ILSPManagerService.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 ILSPManagerService.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 ILSPManagerService.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}") + ILSPManagerService.INSTALL_NOT_EXECUTED + } + } +} 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..b81f9114d --- /dev/null +++ b/daemon/src/main/res/drawable/ic_statue_monochrome.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/gradle.properties b/gradle.properties index 8d8bd1808..b31ced96f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,3 +12,7 @@ # org.gradle.parallel=true android.useAndroidX=true + +# 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 7c4c6ef37..e41b210af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,16 +7,61 @@ glide = "5.0.9" 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-alpha24" +nav3 = "1.1.4" +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" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } nav-safeargs = { id = "androidx.navigation.safeargs", version.ref = "nav" } ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } lsplugin-apksign = { id = "org.lsposed.lsplugin.apksign", version = "1.4" } [libraries] +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.0.1" } +# 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-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" } @@ -56,3 +101,19 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " 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/manager/README.md b/manager/README.md new file mode 100644 index 000000000..2be524467 --- /dev/null +++ b/manager/README.md @@ -0,0 +1,102 @@ +# 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/lsposed/lspd/ILSPManagerService.aidl`. + +Two properties of Binder shape most of the mistakes made here. 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 — which is why `ROOT_UNKNOWN` is `0` and why an older daemon +must never be able to answer a question by accident. 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` carries the commit, marked `-dirty` when the tree was not +clean, which is often the only way to tell two builds apart on a device. + +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..975cddb14 --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,142 @@ +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) + alias(libs.plugins.lsplugin.apksign) +} + +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-alpha24. + // 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. +apksign { + storeFileProperty = "androidStoreFile" + storePasswordProperty = "androidStorePassword" + keyAliasProperty = "androidKeyAlias" + keyPasswordProperty = "androidKeyPassword" +} + +val defaultManagerPackageName: String by rootProject.extra +val injectedPackageName: String by rootProject.extra +val versionHashProvider: Provider by rootProject.extra + +android { + namespace = defaultManagerPackageName + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + applicationId = defaultManagerPackageName + // The commit this manager was built from, short, with a marker when the tree was dirty. + // 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\"") + + // OAuth client id for the optional GitHub device-flow sign-in on Home. It is a Gradle + // property, so set `githubClientId` in ~/.gradle/gradle.properties or pass + // -PgithubClientId=... — local.properties is read by AGP for the SDK location and never + // reaches `providers.gradleProperty`. Left empty the app hides sign-in entirely rather + // than offering something broken. + val githubClientId = providers.gradleProperty("githubClientId").getOrElse("") + buildConfigField("String", "GITHUB_CLIENT_ID", "\"$githubClientId\"") + + // 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.srcDir("../daemon/src/main/res") } } + + packaging { + resources { + excludes += "META-INF/**" + excludes += "okhttp3/**" + excludes += "kotlin/**" + excludes += "**.properties" + excludes += "**.bin" + } + } + + dependenciesInfo.includeInApk = false + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles("proguard-rules.pro") + } + } +} + +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..978023685 --- /dev/null +++ b/manager/proguard-rules.pro @@ -0,0 +1,38 @@ +# 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.lsposed.lspd.** { *; } +-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.** { ; } + +# 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..a0281c434 --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt @@ -0,0 +1,158 @@ +package org.matrix.vector.manager.demo + +import org.lsposed.lspd.ILSPManagerService +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.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: ILSPManagerService? = 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) }, + headlineContent = { Text(scenario.title) }, + supportingContent = { Text(scenario.summary) }, + ) + } + } + } +} 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..7ee890d63 --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt @@ -0,0 +1,212 @@ +package org.matrix.vector.manager.demo + +import org.lsposed.lspd.ILSPManagerService + +/** + * 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 systemServerRequested: Boolean = true, + val dex2oatFlagsLoaded: Boolean = true, + val dex2oatCompatibility: Int = ILSPManagerService.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 xposedApiVersion: Int = -1, + val xposedVersionCode: Long = -1, + val rootImplementation: Int = ILSPManagerService.ROOT_MAGISK, + val rootVersion: String? = "28.1", + 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.", + systemServerRequested = false, + ), + DemoScenario( + id = "dex2oat", + title = "Dex optimizer wrapper unavailable", + summary = "Degraded, one cause. Needs system properties removed or changed.", + dex2oatFlagsLoaded = false, + dex2oatCompatibility = ILSPManagerService.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, + systemServerRequested = false, + dex2oatFlagsLoaded = false, + dex2oatCompatibility = ILSPManagerService.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.", + xposedApiVersion = 82, + ), + DemoScenario( + id = "root-none", + title = "No root implementation", + summary = "Nothing to flash through. The install path must refuse, not fail.", + rootImplementation = ILSPManagerService.ROOT_NONE, + rootVersion = null, + 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 = ILSPManagerService.ROOT_MULTIPLE, + rootVersion = null, + install = DemoScenario.InstallScript.NO_ROOT, + ), + DemoScenario( + id = "root-too-old", + title = "Root implementation too old", + summary = "Installed but not usable. Distinct from having none.", + rootImplementation = ILSPManagerService.ROOT_TOO_OLD, + rootVersion = "20.4", + install = DemoScenario.InstallScript.NO_ROOT, + ), + DemoScenario( + id = "root-ksu", + title = "KernelSU", + summary = "The install path quotes the implementation it found.", + rootImplementation = ILSPManagerService.ROOT_KERNELSU, + rootVersion = "12045", + ), + DemoScenario( + id = "root-apatch", + title = "APatch", + summary = "As above, third implementation.", + rootImplementation = ILSPManagerService.ROOT_APATCH, + rootVersion = "10763", + ), + DemoScenario( + id = "update-available", + title = "An update is available", + summary = "Reports version 1, so a real release becomes an update. Shows the picker.", + xposedVersionCode = 1, + ), + DemoScenario( + id = "install-fails", + title = "Flash that dies halfway", + summary = "Output already streamed, then a non-zero exit. The case that bites.", + xposedVersionCode = 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..2c5958c99 --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt @@ -0,0 +1,284 @@ +package org.matrix.vector.manager.demo + +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.ResolveInfo +import android.os.ParcelFileDescriptor +import org.lsposed.lspd.IFrameworkInstallCallback +import org.lsposed.lspd.ILSPManagerService +import org.lsposed.lspd.models.Application +import org.lsposed.lspd.models.UserInfo +import rikka.parcelablelist.ParcelableListSlice + +/** + * 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: ILSPManagerService?, +) : ILSPManagerService.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) + } + + // ---- what the scenario exists to lie about ------------------------------------------------ + + override fun isSepolicyLoaded(): Boolean { + stall() + return scenario.sepolicyLoaded + } + + override fun systemServerRequested(): Boolean { + stall() + return scenario.systemServerRequested + } + + override fun dex2oatFlagsLoaded(): Boolean { + stall() + return scenario.dex2oatFlagsLoaded + } + + override fun getDex2OatWrapperCompatibility(): Int = scenario.dex2oatCompatibility + + override fun getXposedApiVersion(): Int = + scenario.xposedApiVersion.takeIf { it != DemoScenario.PASS_THROUGH } + ?: real?.xposedApiVersion + ?: 0 + + override fun getXposedVersionCode(): Long = + scenario.xposedVersionCode.takeIf { it != DemoScenario.PASS_THROUGH.toLong() } + ?: real?.xposedVersionCode + ?: 0L + + override fun getRootImplementation(): Int = scenario.rootImplementation + + /** + * Passed through, because a scenario that lied about the commit 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 getFrameworkCommit(): String? = real?.frameworkCommit + + override fun getRootImplementationVersion(): String? = scenario.rootVersion + + /** + * 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?, callback: IFrameworkInstallCallback?) { + if (callback == null) return + Thread { + fun say(line: String) { + runCatching { callback.onLine(line) } + Thread.sleep(220) + } + when (scenario.install) { + DemoScenario.InstallScript.NO_ROOT -> { + runCatching { callback.onFinished(ILSPManagerService.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 { callback.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 { callback.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.longVersionCode) + if (baseline != null && baseline != info.longVersionCode) { + // 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.longVersionCode = 1 + it.versionName = "0.1-demo" + } + } + return ParcelableListSlice(rewritten) + } + + override fun enabledModules(): Array = real?.enabledModules() ?: emptyArray() + + override fun getUnloadableModules(): Array = + real?.unloadableModules ?: emptyArray() + + override fun getModuleLoadState(packageName: String?): Int = + real?.getModuleLoadState(packageName) ?: ILSPManagerService.MODULE_LOAD_OK + + override fun enableModule(packageName: String?): Boolean = + real?.enableModule(packageName) ?: false + + override fun disableModule(packageName: String?): Boolean = + real?.disableModule(packageName) ?: false + + override fun setModuleScope(packageName: String?, scope: MutableList?): Boolean = + real?.setModuleScope(packageName, scope) ?: false + + override fun getModuleScope(packageName: String?): MutableList = + real?.getModuleScope(packageName) ?: mutableListOf() + + override fun isVerboseLog(): Boolean = real?.isVerboseLog ?: false + + override fun setVerboseLog(enabled: Boolean) { + real?.setVerboseLog(enabled) + } + + override fun getVerboseLog(): ParcelFileDescriptor? = real?.verboseLog + + override fun getModulesLog(): ParcelFileDescriptor? = real?.modulesLog + + override fun getLogParts(verbose: Boolean): MutableList = + real?.getLogParts(verbose) ?: mutableListOf() + + override fun getLogPart(verbose: Boolean, name: String?): ParcelFileDescriptor? = + real?.getLogPart(verbose, name) + + override fun getXposedVersionName(): String? = real?.xposedVersionName + + override fun clearLogs(verbose: Boolean): Boolean = real?.clearLogs(verbose) ?: false + + override fun getPackageInfo(packageName: String?, flags: Int, uid: Int): PackageInfo? = + real?.getPackageInfo(packageName, flags, uid) + + 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 installExistingPackageAsUser(packageName: String?, userId: Int): Int = + real?.installExistingPackageAsUser(packageName, userId) ?: 0 + + override fun startActivityAsUserWithFeature(intent: Intent?, userId: Int): Int = + real?.startActivityAsUserWithFeature(intent, userId) ?: 0 + + 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 forcedLauncherIcons(): Boolean = real?.forcedLauncherIcons() ?: true + + override fun setForcedLauncherIcons(force: Boolean) { + real?.setForcedLauncherIcons(force) + } + + override fun getLogs(zipFd: ParcelFileDescriptor?) { + real?.getLogs(zipFd) + } + + override fun restartFor(intent: Intent?) { + real?.restartFor(intent) + } + + override fun optimizePackage(packageName: String?): Boolean = + real?.optimizePackage(packageName) ?: false + + override fun enableStatusNotification(): Boolean = real?.enableStatusNotification() ?: false + + override fun setEnableStatusNotification(enable: Boolean) { + real?.setEnableStatusNotification(enable) + } + + override fun getIncludeNewApps(packageName: String?): Boolean = + real?.getIncludeNewApps(packageName) ?: false + + override fun setIncludeNewApps(packageName: String?, enable: Boolean): Boolean = + real?.setIncludeNewApps(packageName, enable) ?: false +} 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..726558938 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt @@ -0,0 +1,73 @@ +package org.matrix.vector.manager + +import android.os.IBinder +import android.util.Log +import kotlin.system.exitProcess +import org.lsposed.lspd.ILSPManagerService +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. + * + * 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, auth, + * 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, + * and never `Log.getStackTraceString`, which discards the level. Nothing secret is ever + * interpolated: no OAuth token, no SAF `Uri` beyond its authority, no third-party query + * string. + * + * Levels are `e` when something the user asked for did not happen and nothing else will + * explain it, `w` for a degraded path recovered from, and `i` for a one-off milestone worth + * having in a bug report — counts, versions, the endpoint chosen. `d` and `v` are not added, + * because release builds ship them. + * + * A `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. + */ + const val TAG = "VectorManager" + + @JvmStatic + fun setBinder(binder: IBinder): Boolean { + ServiceLocator.bind(ILSPManagerService.Stub.asInterface(binder)) + + 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( + { + Log.w(TAG, "ipc: daemon binder died, manager exiting") + exitProcess(0) + }, + 0, + ) + } catch (e: Exception) { + Log.e( + TAG, + "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/data/github/CommitArchive.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt new file mode 100644 index 000000000..01af530f5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt @@ -0,0 +1,238 @@ +package org.matrix.vector.manager.data.github +import android.util.Log +import org.matrix.vector.manager.Constants + +import java.io.File +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * 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) { + Log.w(Constants.TAG, "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 -> + Log.w( + Constants.TAG, + "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 -> + Log.w(Constants.TAG, "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/GitHubAuth.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt new file mode 100644 index 000000000..9c7b023fd --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt @@ -0,0 +1,215 @@ +package org.matrix.vector.manager.data.github +import android.util.Log +import org.matrix.vector.manager.Constants + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.matrix.vector.manager.BuildConfig + +/** + * Optional GitHub sign-in, over the **device authorisation flow**. + * + * Why this flow and not a browser redirect: it needs no redirect URI, no custom scheme and no + * callback into the app — which matters here because parasitically the manager has no package + * identity of its own to register a scheme against. The user is shown a short code, types it at + * github.com/login/device in any browser on any device, and this polls until it is approved. + * + * **No scopes are requested.** A token with an empty scope set can read public data and nothing + * else, so one leaking out of the host process's data directory cannot be used to act as the user. + * What it buys is the rate limit: 60 requests/hour for an anonymous client, 5000 signed in. + * + * Signing in is never required. Every surface that uses this renders without it, because a large + * part of this project's users cannot reach github.com at all — see [SignInState.Unavailable]. + */ +class GitHubAuth(context: Context, private val client: OkHttpClient) { + + private val prefs = context.getSharedPreferences("vector_github", Context.MODE_PRIVATE) + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + private val _state = MutableStateFlow(initialState()) + val state: StateFlow = _state.asStateFlow() + + val token: String? + get() = prefs.getString(KEY_TOKEN, null) + + /** False when no OAuth client id was compiled in, in which case the UI hides sign-in entirely. */ + val isConfigured: Boolean + get() = BuildConfig.GITHUB_CLIENT_ID.isNotEmpty() + + private fun initialState(): SignInState { + val stored = prefs.getString(KEY_TOKEN, null) ?: return SignInState.SignedOut + return SignInState.SignedIn(login = prefs.getString(KEY_LOGIN, null), token = stored) + } + + /** + * Starts the flow and polls to completion. Emits [SignInState.AwaitingUser] with the code the + * user has to type, so the UI can show it and offer to copy it. + */ + suspend fun signIn() { + if (!isConfigured) { + _state.value = SignInState.Unavailable("no client id") + return + } + withContext(Dispatchers.IO) { + val start = + runCatching { requestDeviceCode() } + .getOrElse { + // Unreachable is the expected case for a lot of users, not an error worth + // shouting about. + Log.w(Constants.TAG, "auth: github device code request failed", it) + _state.value = SignInState.Unavailable(it.message ?: "unreachable") + return@withContext + } + + _state.value = + SignInState.AwaitingUser( + userCode = start.userCode, + verificationUri = start.verificationUri, + ) + + val deadline = System.currentTimeMillis() + start.expiresIn * 1000L + var interval = start.interval.coerceAtLeast(5) + + while (System.currentTimeMillis() < deadline) { + delay(interval * 1000L) + val poll = runCatching { pollForToken(start.deviceCode) }.getOrNull() ?: continue + when { + poll.accessToken != null -> { + val login = runCatching { fetchLogin(poll.accessToken) }.getOrNull() + prefs + .edit() + .putString(KEY_TOKEN, poll.accessToken) + .putString(KEY_LOGIN, login) + .apply() + _state.value = SignInState.SignedIn(login, poll.accessToken) + return@withContext + } + poll.error == "authorization_pending" -> Unit + poll.error == "slow_down" -> interval += 5 + poll.error == "access_denied" -> { + _state.value = SignInState.SignedOut + return@withContext + } + else -> { + Log.w( + Constants.TAG, + "auth: github device flow refused: ${poll.error ?: "unknown"}", + ) + _state.value = SignInState.Unavailable(poll.error ?: "unknown") + return@withContext + } + } + } + _state.value = SignInState.SignedOut + } + } + + fun signOut() { + prefs.edit().remove(KEY_TOKEN).remove(KEY_LOGIN).apply() + _state.value = SignInState.SignedOut + } + + fun cancel() { + if (_state.value is SignInState.AwaitingUser) _state.value = SignInState.SignedOut + } + + private fun requestDeviceCode(): DeviceCodeResponse { + val body = + FormBody.Builder() + .add("client_id", BuildConfig.GITHUB_CLIENT_ID) + // Deliberately empty: read-only access to public data is all this needs. + .add("scope", "") + .build() + val request = + Request.Builder() + .url("https://github.com/login/device/code") + .header("Accept", "application/json") + .post(body) + .build() + client.newCall(request).execute().use { response -> + val text = response.body.string() + return json.decodeFromString(text) + } + } + + private fun pollForToken(deviceCode: String): TokenResponse { + val body = + FormBody.Builder() + .add("client_id", BuildConfig.GITHUB_CLIENT_ID) + .add("device_code", deviceCode) + .add("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .build() + val request = + Request.Builder() + .url("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .post(body) + .build() + client.newCall(request).execute().use { response -> + val text = response.body.string() + return json.decodeFromString(text) + } + } + + private fun fetchLogin(token: String): String? { + val request = + Request.Builder() + .url("https://api.github.com/user") + .header("Authorization", "Bearer $token") + .header("Accept", "application/vnd.github+json") + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + return json.decodeFromString(response.body.string()).login + } + } + + private companion object { + const val KEY_TOKEN = "token" + const val KEY_LOGIN = "login" + } +} + +sealed interface SignInState { + data object SignedOut : SignInState + + data class AwaitingUser(val userCode: String, val verificationUri: String) : SignInState + + data class SignedIn(val login: String?, val token: String) : SignInState + + /** + * No client id was compiled in, GitHub could not be reached, or it refused the grant. Not an + * error state: every surface that offers sign-in renders without it. + */ + data class Unavailable(val reason: String) : SignInState +} + +@Serializable +private data class DeviceCodeResponse( + @SerialName("device_code") val deviceCode: String, + @SerialName("user_code") val userCode: String, + @SerialName("verification_uri") val verificationUri: String, + @SerialName("expires_in") val expiresIn: Int = 900, + val interval: Int = 5, +) + +@Serializable +private data class TokenResponse( + @SerialName("access_token") val accessToken: String? = null, + val error: String? = null, +) 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..7078b9f4e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt @@ -0,0 +1,364 @@ +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, +) + +@Serializable +data class GhWorkflowRuns( + @SerialName("workflow_runs") val runs: List = emptyList() +) + +@Serializable +data class GhWorkflowRun( + val id: Long, + val name: String? = null, + @SerialName("head_branch") val headBranch: String? = null, + @SerialName("head_sha") val headSha: String = "", + @SerialName("display_title") val displayTitle: String? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("html_url") val htmlUrl: String? = null, + val conclusion: String? = null, +) + +@Serializable +data class GhArtifacts(val artifacts: List = emptyList()) + +@Serializable +data class GhArtifact( + val id: Long, + val name: String, + @SerialName("size_in_bytes") val sizeInBytes: Long = 0, + val expired: Boolean = false, + @SerialName("archive_download_url") val downloadUrl: String? = null, +) + +/** A successful CI run, as the canary screen renders it. */ +data class CanaryBuild( + val id: Long, + /** + * The build this is, and the key the installer selects by. + * + * CI tags every canary `canary-`, so the number is in the tag and needs no second + * request. It is what [FrameworkRelease.versionCode] holds for the same release, which is how a + * row here can hand the installer a build rather than a URL. + */ + val versionCode: Long, + val title: String, + val branch: String, + val shortSha: String, + val epochSeconds: Long, + val htmlUrl: String?, + val artifacts: List, +) + +/** + * 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. + */ +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() +} + +/** + * 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 expired: Boolean, + 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..715129e3f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -0,0 +1,999 @@ +package org.matrix.vector.manager.data.github +import android.util.Log +import org.matrix.vector.manager.Constants + +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 + +/** + * 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, + /** + * Supplies the optional sign-in token. Anonymous access is a fully supported mode — this only + * ever raises the rate limit from 60 to 5000 requests an hour. + */ + private val tokenProvider: () -> String? = { null }, + /** 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) { + Log.w( + Constants.TAG, + "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 -> Log.w(Constants.TAG, "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) { + Log.w( + Constants.TAG, + "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") + .apply { tokenProvider()?.let { header("Authorization", "Bearer $it") } } + .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 { tokenProvider()?.let { header("Authorization", "Bearer $it") } } + .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 canary builds, newest first. + * + * These 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 that, so nobody signs in to anything. + * + * Filtered to the canary tag rather than taking every prerelease: a hand-cut release candidate + * is also a prerelease, and it is not a nightly. + */ + suspend fun canaryBuilds(freshness: Freshness = Freshness.Revalidate): List = + withContext(Dispatchers.IO) { + val body = + get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) + ?: return@withContext emptyList() + + runCatching { json.decodeFromString>(body) } + .onFailure { e -> Log.e(Constants.TAG, "update: canary release list unreadable", e) } + .getOrDefault(emptyList()) + .filter { it.prerelease && it.tagName.startsWith(CANARY_TAG_PREFIX) } + .take(CANARY_KEEP) + .map { release -> + CanaryBuild( + id = release.id, + versionCode = release.versionCode() ?: 0, + title = release.name ?: release.tagName, + branch = release.tagName, + shortSha = release.targetCommitish.take(7), + epochSeconds = parseIso8601(release.publishedAt.orEmpty()), + htmlUrl = release.htmlUrl, + artifacts = + release.assets.map { + CanaryArtifact( + id = it.id, + name = it.name, + sizeInBytes = it.size, + expired = false, + downloadUrl = it.downloadUrl, + ) + }, + ) + } + } + + /** + * Every published build, both channels, newest first. + * + * One fetch for both because they come from the same endpoint, and 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 as a canary by being *newer than the newest stable release*, and that + * comparison is impossible with only one of the two lists in hand. + */ + suspend fun frameworkReleases(freshness: Freshness = Freshness.Revalidate): + List = + withContext(Dispatchers.IO) { + val body = + get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) + ?: return@withContext emptyList() + + runCatching { json.decodeFromString>(body) } + .onFailure { e -> Log.e(Constants.TAG, "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, + expired = false, + 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" + const val GOOD_FIRST_ISSUE_URL = "$REPO_URL/issues?q=is%3Aopen+label%3A%22good+first+issue%22" + + /** + * The workflow's own run list, filtered to master. + * + * The way out of the app for anyone who wants a build log, or a commit older than the five + * canaries CI keeps published. [canaryBuilds] itself reads prereleases, not this page. + */ + /** + * 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 + private 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..d4d16bb92 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt @@ -0,0 +1,136 @@ +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 + +/** + * 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 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. + * + * 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 + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { record(application, thread, throwable) } + previous?.uncaughtException(thread, throwable) + } + } + + /** Where the daemon's export collects them from. */ + fun directory(context: Context): File = File(context.cacheDir, DIR_NAME) + + /** 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() + + /** How many crashes are on file. Cheap enough to ask on every visit to a screen. */ + fun count(context: Context): Int = files(context).size + + fun clear(context: Context) { + runCatching { files(context).forEach { it.delete() } } + } + + /** 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(android.util.Log.getStackTraceString(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. + */ + 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))}\n" + + "manager ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) " + + "${BuildConfig.VERSION_HASH} · $host · thread ${thread.name} · " + + "android ${android.os.Build.VERSION.RELEASE} (sdk ${android.os.Build.VERSION.SDK_INT})" + } + + 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/LogFile.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt new file mode 100644 index 000000000..0cd2b1aec --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt @@ -0,0 +1,379 @@ +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 trace: ArrayList? = null + + fun flushTrace() { + val frames = trace + if (traceOwner >= 0 && frames != null) { + rows[traceOwner] = (rows[traceOwner] as LogRow.Entry).copy(trace = frames) + } + trace = null + traceOwner = -1 + } + + forEachLine(index, lines, null) { lineIndex, text, truncated -> + if (traceOwner >= 0 && isContinuationLine(text)) { + // A multi-line message reaches the file as one writev, so its continuation lines + // carry no prefix. They are frames of the entry above, not entries of their own. + (trace ?: ArrayList(8).also { trace = it }).add(text) + } else { + flushTrace() + when (val row = parseLogLine(lineIndex, text, truncated)) { + 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) + } + } + } + flushTrace() + return rows + } + + /** + * Walks back from [line] to the entry that owns it. + * + * Without this a window boundary landing between an entry and its stack trace opens the page + * on orphan frames with nothing to attach them to. Only the first byte of each candidate line + * is read — up to [TRACE_LOOKBACK] single-byte positional reads, all of them page-cache hits. + */ + 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) 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 + + forEachLine(index, null, onProgress) { lineIndex, text, truncated -> + if (isContinuationLine(text)) { + // 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. + if (previousMatched) matches?.add(lineIndex) + return@forEachLine + } + val row = parseLogLine(lineIndex, text, truncated) + if (row is LogRow.Entry) { + tags[row.tag] = (tags[row.tag] ?: 0) + 1 + levels[row.level] = (levels[row.level] ?: 0) + 1 + } + 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 + } + + 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 + + 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..d65049262 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt @@ -0,0 +1,215 @@ +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 — a Java stack trace arrives as one entry followed by N raw lines each + * beginning with a tab. Those frames belong to the entry above them, not to nothing. + * 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. + * + * 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, + /** Continuation lines of a multi-line message — in practice, stack frames. */ + val trace: 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 + +/** A line is a continuation of the entry above it when it starts with whitespace. */ +fun isContinuationLine(text: String): Boolean = + text.isNotEmpty() && (text[0] == ' ' || text[0] == '\t') + +/** 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..658f12506 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt @@ -0,0 +1,26 @@ +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, + 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/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/ModuleDetection.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt new file mode 100644 index 000000000..cf222e160 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt @@ -0,0 +1,235 @@ +package org.matrix.vector.manager.data.model +import android.util.Log +import org.matrix.vector.manager.Constants + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import java.util.Properties +import java.util.zip.ZipFile + +/** + * 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 whole of it and the user cannot widen it. */ + 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 -> + Log.w( + Constants.TAG, + "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() + + 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 -> + Log.w( + Constants.TAG, + "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 { + 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 -> + Log.w( + Constants.TAG, + "modules: ${info.packageName} legacy xposedscope unreadable", + e, + ) + } + .getOrNull() + ?.filter { it.isNotEmpty() } ?: return emptyList() + + // 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. The convention + // is as old as XposedBridge and universal among legacy modules, so the swap is + // unconditional. + return raw.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 only ever applies to [packages] and the user cannot widen it — + * the scope editor shows the list read-only rather than pretending the choice is theirs. + */ +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..ffd50bcbc --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt @@ -0,0 +1,150 @@ +package org.matrix.vector.manager.data.model +import android.util.Log +import org.matrix.vector.manager.Constants + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.util.Base64 +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * 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 -> Log.w(Constants.TAG, "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/RepoModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt new file mode 100644 index 000000000..6b2afbc09 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -0,0 +1,205 @@ +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) { + + fun upgradableOver(installedCode: Long, installedName: String): Boolean = + versionCode > installedCode || + (versionCode == installedCode && installedName.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]) + } + } +} + +/** + * 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, +) { + /** + * There is a newer version *and* the reader wants to hear about it. + * + * 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 && + 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..d65b4bfb8 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt @@ -0,0 +1,124 @@ +package org.matrix.vector.manager.data.repository +import android.util.Log +import org.matrix.vector.manager.Constants +import kotlinx.coroutines.CancellationException + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +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.ipc.DaemonClient + +/** 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) { + Log.w( + Constants.TAG, + "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 + 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.longVersionCode, + 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..464083433 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt @@ -0,0 +1,173 @@ +package org.matrix.vector.manager.data.repository +import android.util.Log +import org.matrix.vector.manager.Constants +import kotlinx.coroutines.CancellationException + +import android.content.Context +import android.net.Uri +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.lsposed.lspd.models.Application +import org.matrix.vector.manager.ipc.DaemonClient + +/** + * 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, + 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 -> + Log.w( + Constants.TAG, + "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 + Log.e( + Constants.TAG, + "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 -> + Log.w( + Constants.TAG, + "restore: enabling ${module.packageName} failed, skipping", + e, + ) + } + .getOrDefault(false) + if (!enabledOk) { + skipped++ + return@forEach + } + if (module.scope.isNotEmpty()) { + val scope = + module.scope.map { target -> + Application().apply { + packageName = target.packageName + userId = target.userId + } + } + val scopeResult = daemon.setModuleScope(module.packageName, scope) + if (!scopeResult.getOrDefault(false)) { + Log.e( + Constants.TAG, + "restore: scope of ${module.packageName} not applied " + + "(${scope.size} targets)", + scopeResult.exceptionOrNull(), + ) + } + } + restored++ + } + RestoreOutcome(restored, skipped) + } + .onFailure { e -> + if (e is CancellationException) throw e + Log.e( + Constants.TAG, + "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/FrameworkInstaller.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt new file mode 100644 index 000000000..c8cd36310 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt @@ -0,0 +1,184 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import android.util.Log +import java.io.File +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.lsposed.lspd.IFrameworkInstallCallback +import org.lsposed.lspd.ILSPManagerService +import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.ipc.DaemonClient + +/** 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 ILSPManagerService.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. + */ +class FrameworkInstaller( + private val context: Context, + private val client: OkHttpClient, + private val daemon: DaemonClient, +) { + + 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. */ + val lines: StateFlow> = _lines.asStateFlow() + + fun acknowledge() { + _state.value = FlashStep.Idle + _lines.value = emptyList() + } + + /** + * Fetches [url] and flashes it. + * + * Returns when the *installer* has exited, not when the download finishes — the caller is a + * screen that stays open across both. + */ + suspend fun flash(url: String, declaredSize: Long, fileName: String) { + _lines.value = emptyList() + val zip = + try { + download(url, declaredSize, fileName) + } catch (e: Exception) { + if (e is CancellationException) { + // Leaving the screen cancels the download, and a cancellation is not a missing + // file. It has to travel on — but the bar goes back to resting first, because + // the progress line has no button on it and nothing else would ever move it. + _state.value = FlashStep.Idle + throw e + } + Log.w(Constants.TAG, "update: download failed", e) + append("Download failed: ${e.message}") + _state.value = FlashStep.Failed(ILSPManagerService.INSTALL_NO_SUCH_FILE) + return + } + + _state.value = FlashStep.Flashing + var cancelled = false + try { + awaitInstall(zip.absolutePath) + } catch (e: CancellationException) { + cancelled = true + throw e + } 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. A cancelled wait is not an exited + // installer — the daemon flashes on regardless, out of this very file. + if (!cancelled) runCatching { zip.delete() } + } + } + + private suspend fun download(url: String, declaredSize: Long, fileName: String): File = + withContext(Dispatchers.IO) { + val target = File(context.cacheDir, fileName) + _state.value = FlashStep.Downloading(0, declaredSize) + + 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 + + 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 + } + + /** + * Runs the daemon-side install and suspends until it reports an exit code. + * + * The installer's output arrives on the callback 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, + * over a deferred whose await is cancellable — leaving the screen stops the wait, and the + * daemon keeps flashing regardless, which is the only safe thing for it to do half way through + * writing a module tree. + */ + private suspend fun awaitInstall(path: String) { + val done = kotlinx.coroutines.CompletableDeferred() + val callback = + object : IFrameworkInstallCallback.Stub() { + override fun onLine(line: String?) { + line?.let(::append) + } + + override fun onFinished(exitCode: Int) { + done.complete(exitCode) + } + } + + val started = daemon.installFrameworkZip(path, callback) + if (started.isFailure) { + val cause = started.exceptionOrNull() + Log.e(Constants.TAG, "update: daemon did not start the install of $path", cause) + append("The daemon refused the install: ${cause?.message}") + _state.value = FlashStep.Failed(ILSPManagerService.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) + } + + 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..b807a3522 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt @@ -0,0 +1,104 @@ +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 + +/** + * 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 only ever offered releases. That is the whole point of the + * distinction: a nightly is not something to be nudged towards. + */ +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 sees whichever is newer; a release reader never sees a canary at all. + val candidates = if (onCanary) releases else releases.filterNot { it.isCanary } + val newest = candidates.maxByOrNull { it.versionCode } + + _state.value = + FrameworkUpdateState( + installedVersionCode = installedVersionCode, + installedCommit = installedCommit, + available = newest?.takeIf { it.versionCode > installedVersionCode }, + // Every release on the channel, not only the newest: 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. + history = candidates.sortedByDescending { it.versionCode }, + ) + } +} + +/** + * 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 commit the running daemon was built from, when it recorded one. */ + val installedCommit: String? = null, + val available: FrameworkRelease? = null, + /** Every release on this channel, newest first — including ones older than the installed one. */ + val history: List = emptyList(), +) { + 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. + */ +fun FrameworkUpdateState.divergesFrom(release: FrameworkRelease?): Boolean { + if (release == null || release.versionCode != installedVersionCode) return false + val mine = installedCommit ?: return false + val theirs = release.commit ?: return false + // A dirty build matches nothing by definition — it was not built from any commit. + if (mine.endsWith("-dirty")) return true + return !theirs.startsWith(mine) +} 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..b98e79458 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -0,0 +1,263 @@ +package org.matrix.vector.manager.data.repository + +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 android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +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.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.matrix.vector.manager.Constants +import org.matrix.vector.manager.data.model.ReleaseAsset + +/** 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. + */ + 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) + } + 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) { + Log.w( + Constants.TAG, + "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 + Log.w(Constants.TAG, "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. + * + * The result arrives as a broadcast, and the receiver is registered at runtime rather than + * declared: parasitically nothing in the manifest exists, so a declared receiver would simply + * never fire. `STATUS_PENDING_USER_ACTION` is not terminal — it means the system is asking the + * user, and the real status follows once they answer. + */ + private suspend fun commit( + session: PackageInstaller.Session, + sessionId: Int, + packageName: String, + ): Pair = suspendCancellableCoroutine { continuation -> + val action = "$RESULT_ACTION.$sessionId" + val receiver = + object : BroadcastReceiver() { + override fun onReceive(received: Context, intent: Intent) { + val status = + intent.getIntExtra( + PackageInstaller.EXTRA_STATUS, + PackageInstaller.STATUS_FAILURE, + ) + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + _state.value = InstallStep.Confirming(packageName) + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java) + ?.let { confirm -> + runCatching { + context.startActivity( + confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + .onFailure { e -> + Log.e( + Constants.TAG, + "store: install prompt for $packageName could not be started", + e, + ) + } + } + return + } + runCatching { context.unregisterReceiver(this) } + if (continuation.isActive) { + continuation.resumeWith( + Result.success( + status to + intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + ) + ) + } + } + } + + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(action), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + continuation.invokeOnCancellation { runCatching { context.unregisterReceiver(receiver) } } + + val flags = + PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE + else 0 + val pending = + PendingIntent.getBroadcast( + context, + sessionId, + Intent(action).setPackage(context.packageName), + flags, + ) + session.commit(pending.intentSender) + } + + private companion object { + const val WRITE_NAME = "module.apk" + const val CHUNK_BYTES = 64 * 1024 + const val PROGRESS_STEP_BYTES = 256L * 1024 + const val RESULT_ACTION = "org.matrix.vector.manager.INSTALL_RESULT" + } +} 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..5abf39455 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt @@ -0,0 +1,113 @@ +package org.matrix.vector.manager.data.repository +import android.util.Log +import org.matrix.vector.manager.Constants + +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 + +/** + * 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 -> + Log.w( + Constants.TAG, + "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) { + Log.e(Constants.TAG, "modules: $verb of $packageName failed", cause) + } else { + Log.e(Constants.TAG, "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..b4be903d0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt @@ -0,0 +1,108 @@ +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 + +/** + * 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 scope: CoroutineScope, +) { + + /** One module to update, resolved before the run starts so nothing is looked up mid-flight. */ + data class Item(val packageName: String, val title: String, val asset: ReleaseAsset) + + 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. + store.refreshInstalled() + // 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() + } + } + + /** + * 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..23f13b048 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -0,0 +1,303 @@ +package org.matrix.vector.manager.data.repository + +import android.content.pm.PackageInfo +import android.os.Build +import android.util.Log +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.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +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.Constants +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.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient + +/** + * 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() } + } + + private suspend fun loadInstalled() { + val packages = + daemon + .getInstalledPackagesFromAllUsers(0, false) + .onFailure { e -> + Log.w(Constants.TAG, "store: installed versions unavailable", e) + } + .getOrNull() ?: return + val versions = HashMap(packages.size) + for (info in packages) { + val version = RepoVersion(info.longVersionCodeCompat(), 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) { + Log.w(Constants.TAG, "store: $url returned HTTP ${response.code}") + } + return null + } + val parsed = parseCatalog(response) + if (parsed.isEmpty()) return null + Log.i(Constants.TAG, "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) { + Log.w(Constants.TAG, "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) { + Log.w(Constants.TAG, "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) Log.w(Constants.TAG, "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() + + @Suppress("DEPRECATION") + private fun PackageInfo.longVersionCodeCompat(): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) longVersionCode + else versionCode.toLong() + + 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..96ee7f654 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt @@ -0,0 +1,315 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * 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 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 + } + + // --- 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 + } + + 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..ec924389c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt @@ -0,0 +1,243 @@ +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 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.launch +import okhttp3.OkHttpClient +import org.lsposed.lspd.ILSPManagerService +import org.matrix.vector.manager.data.github.GitHubAuth +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.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.packageEventsFlow +import org.matrix.vector.manager.net.HttpClientFactory + +/** + * 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() + + val context: Context + get() = + appContext + ?: error("ServiceLocator.attach() must run before the UI touches the context") + + val daemon: DaemonClient by lazy { DaemonClient(service) } + + val http: OkHttpClient by lazy { HttpClientFactory.create(context, settings) } + + 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, + ) { catalog, installed, channelPreference, muted -> + 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, + ) + } + } + .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, appScope) } + + val frameworkInstaller: FrameworkInstaller by lazy { FrameworkInstaller(context, http, daemon) } + + val backup: BackupRepository by lazy { BackupRepository(context, daemon) } + + val githubAuth: GitHubAuth by lazy { GitHubAuth(context, http) } + + val github: GitHubRepository by lazy { + GitHubRepository( + client = http, + cacheDir = context.cacheDir, + tokenProvider = { githubAuth.token }, + 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!!) + observePackageChanges() + } + + /** + * Invalidates the caches when a package is installed, updated or removed. + * + * The only collector of `packageEventsFlow`, and on [appScope] so it lasts as long as the + * process: without it a module installed while the manager is open would never appear. + */ + private fun observePackageChanges() { + appScope.launch { + context.packageEventsFlow().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: ILSPManagerService?) { + _service.value = service + } +} 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..13642791b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -0,0 +1,374 @@ +package org.matrix.vector.manager.ipc + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.lsposed.lspd.IFrameworkInstallCallback +import android.content.Intent +import android.content.pm.ActivityInfo +import android.util.Log +import org.lsposed.lspd.ILSPManagerService +import org.matrix.vector.manager.Constants + +/** + * 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: ILSPManagerService? + 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: (ILSPManagerService) -> 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. + Log.w(Constants.TAG, "ipc: daemon transaction failed", e) + Result.failure(e) + } + } + + suspend fun getXposedApiVersion(): Result = runIpc { it.xposedApiVersion } + + suspend fun getEnabledModules(): Result> = runIpc { it.enabledModules().toList() + } + + /** + * 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. + * + * The `lsp_no_switch_to_user` extra 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 flag on the resolved activity says which case + * this is. + * + * 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) { + Log.e( + Constants.TAG, + "ipc: open resolved no activity for $packageName in user $userId " + + "(companionFirst=$companionFirst)", + resolved.exceptionOrNull(), + ) + return Result.success(false) + } + return runIpc { service -> + val code = + service.startActivityAsUserWithFeature( + Intent(Intent.ACTION_MAIN) + .setClassName(target.packageName, target.name) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra( + "lsp_no_switch_to_user", + (target.flags and FLAG_SHOW_FOR_ALL_USERS) != 0, + ), + userId, + ) + // 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) { + Log.e( + Constants.TAG, + "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. + * + * 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". + */ + suspend fun getUnloadableModules(): Result> = runIpc { + it.unloadableModules.toList() + } + + /** Why a module in that list could not be loaded; `MODULE_LOAD_OK` when it is fine. */ + suspend fun getModuleLoadState(packageName: String): Result = runIpc { + it.getModuleLoadState(packageName) + } + + suspend fun setModuleEnabled(packageName: String, enable: Boolean): Result = runIpc { + if (enable) { + it.enableModule(packageName) + } else { + it.disableModule(packageName) + } + } + + suspend fun getFrameworkCommit(): Result = runIpc { it.frameworkCommit } + + suspend fun getXposedVersionName(): Result = runIpc { it.xposedVersionName } + + suspend fun getXposedVersionCode(): Result = runIpc { it.xposedVersionCode } + + 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) } + + suspend fun getModuleScope( + packageName: String + ): Result> = runIpc { it.getModuleScope(packageName) + } + + suspend fun enableStatusNotification(): Result = runIpc { it.enableStatusNotification() + } + + suspend fun setEnableStatusNotification(enabled: Boolean): Result = runIpc { it.setEnableStatusNotification(enabled) + } + + suspend fun isVerboseLogEnabled(): Result = runIpc { it.isVerboseLog } + + suspend fun setVerboseLogEnabled(enabled: Boolean): Result = runIpc { it.isVerboseLog = 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 getLog(verbose: Boolean): Result = runIpc { + if (verbose) it.verboseLog else it.modulesLog + } + + suspend fun clearLogs(verbose: Boolean): Result = runIpc { it.clearLogs(verbose) + } + + suspend fun getPackageInfo( + packageName: String, + flags: Int, + userId: Int, + ): Result = runIpc { it.getPackageInfo(packageName, flags, userId) + } + + 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 installExistingPackageAsUser(packageName: String, userId: Int): Result = + runIpc { + val INSTALL_SUCCEEDED = 1 + it.installExistingPackageAsUser(packageName, userId) == INSTALL_SUCCEEDED + } + + suspend fun systemServerRequested(): Result = runIpc { it.systemServerRequested() + } + + suspend fun dex2oatFlagsLoaded(): Result = runIpc { it.dex2oatFlagsLoaded() } + + suspend fun getDex2OatWrapperCompatibility(): Result = runIpc { + it.dex2OatWrapperCompatibility + } + + 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 writeLogsTo(zipFd: android.os.ParcelFileDescriptor): Result = runIpc { + it.getLogs(zipFd) + } + + /** Kept for the AIDL's shape: the daemon implements it as a no-op, so this asks for nothing. */ + suspend fun restartFor(intent: android.content.Intent): Result = runIpc { + it.restartFor(intent) + } + + suspend fun startActivityAsUserWithFeature( + intent: android.content.Intent, + userId: Int, + ): Result = runIpc { it.startActivityAsUserWithFeature(intent, userId) } + + suspend fun queryIntentActivitiesAsUser( + intent: android.content.Intent, + flags: Int, + userId: Int, + ): Result> = runIpc { it.queryIntentActivitiesAsUser(intent, flags, userId).list + } + + /** Restarts the framework without rebooting the device. Everything on screen goes with it. */ + suspend fun softReboot(): Result = runIpc { it.softReboot() } + + /** + * 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 forcedLauncherIcons(): Result = runIpc { it.forcedLauncherIcons() } + + 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 } + + suspend fun getRootImplementationVersion(): Result = runIpc { + it.rootImplementationVersion + } + + /** + * 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 [callback] + * over minutes, and a coroutine suspended across a reboot-inducing operation is a coroutine + * that never resumes. The caller keeps the callback alive for as long as it wants the output. + */ + suspend fun installFrameworkZip( + zipPath: String, + callback: IFrameworkInstallCallback, + ): Result = runIpc { it.installFrameworkZip(zipPath, callback) } +} + +/** + * `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/PackageBroadcasts.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt new file mode 100644 index 000000000..ba405a2c4 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt @@ -0,0 +1,80 @@ +package org.matrix.vector.manager.ipc + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +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. + */ +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) } +} + +/** + * `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. + */ +private const val EXTRA_USER_HANDLE = "android.intent.extra.user_handle" 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..a55e3f3ba --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt @@ -0,0 +1,45 @@ +package org.matrix.vector.manager.net + +import android.content.Context +import java.io.File +import java.util.concurrent.TimeUnit +import okhttp3.Cache +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 + + fun create(context: Context, settings: SettingsRepository): OkHttpClient { + 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. + return base.newBuilder().dns(VectorDns(settings, base)).build() + } +} 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..278fa112a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt @@ -0,0 +1,93 @@ +package org.matrix.vector.manager.net +import org.matrix.vector.manager.Constants + +import android.util.Log +import java.net.InetAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import org.matrix.vector.manager.data.repository.SettingsRepository + +/** + * 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. + * + * The setting is 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 it here is what lets the switch 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() + + /** + * 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. */ + private val direct: Boolean by lazy { + runCatching { + ProxySelector.getDefault().select(endpoint.toUri()).firstOrNull() == Proxy.NO_PROXY + } + .getOrDefault(true) + } + + override fun lookup(hostname: String): List { + if (settings.dohEnabled.value && direct && !dohUnavailable) { + try { + return doh.lookup(hostname) + } catch (e: UnknownHostException) { + dohUnavailable = true + Log.w( + Constants.TAG, + "dns: DoH lookup of $hostname failed, using the system resolver for this session", + e, + ) + } + } + return Dns.SYSTEM.lookup(hostname) + } + +} 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..840c5b275 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt @@ -0,0 +1,60 @@ +package org.matrix.vector.manager.ui + +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 coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import org.matrix.vector.manager.di.ServiceLocator +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. + ServiceLocator.attach(this) + + // Coil is configured explicitly rather than through its manifest hooks: parasitically this + // app's manifest is never installed, so nothing that self-registers there ever runs. + // It shares the one OkHttp client, which carries the DoH configuration and the disk cache. + SingletonImageLoader.setSafe { platformContext: PlatformContext -> + ImageLoader.Builder(platformContext) + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { ServiceLocator.http })) + } + .build() + } + + // 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() + + // 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 } + + setContent { LocalizedContent { VectorTheme { SplashGate { VectorApp() } } } } + } +} 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..29168426b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -0,0 +1,155 @@ +package org.matrix.vector.manager.ui + +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +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.ui.res.stringResource +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.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.LocalNavigator +import org.matrix.vector.manager.ui.navigation.Navigator +import org.matrix.vector.manager.ui.navigation.Scope +import org.matrix.vector.manager.ui.navigation.StoreDetail +import org.matrix.vector.manager.ui.navigation.SystemStatus +import org.matrix.vector.manager.ui.navigation.Web +import org.matrix.vector.manager.ui.navigation.TOP_LEVEL_DESTINATIONS +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.SystemStatusScreen +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. + */ +@Composable +fun VectorApp() { + val navigator = rememberNavigator() + + CompositionLocalProvider(LocalNavigator provides navigator) { + // The bar shows only at the root of a tab. On a detail screen none of the four 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() } + + NavigationSuiteScaffold( + state = suiteState, + navigationSuiteItems = { + TOP_LEVEL_DESTINATIONS.forEach { destination -> + item( + selected = navigator.currentTopLevel == destination.route, + onClick = { navigator.switchTo(destination.route) }, + icon = { Icon(destination.icon, contentDescription = null) }, + // The label doubles as the item's accessibility name, so the icon above + // carries no contentDescription of its own — otherwise TalkBack announces + // every selected tab twice. + label = { Text(stringResource(destination.labelRes)) }, + ) + } + }, + ) { + 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) }, + ) + } + } +} + +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() } + + 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() }) } + 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)) }, + ) + } + 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/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..47ae6a11b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt @@ -0,0 +1,589 @@ +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 android.util.Log +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.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.DeleteOutline +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.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.matrix.vector.manager.Constants +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 -> + Log.w( + Constants.TAG, + "actions: launch target lookup for $packageName u$userId failed", + e, + ) + } + .getOrNull() != null + } + var confirmSoftReboot by remember { mutableStateOf(false) } + + val scope = rememberCoroutineScope() + 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 { + daemon.softReboot().onFailure { + Log.e(Constants.TAG, "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 + // Left at the default, without skipPartiallyExpanded: that flag 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. Material adds + // the stop only when the content is actually taller than half the screen, so short sheets + // still open at their own height and nothing gains a useless drag. + val sheetState = rememberModalBottomSheetState() + + fun finish(block: suspend () -> PackageActionResult) { + onDismiss() + scope.launch { 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. + Log.e( + Constants.TAG, + "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) + val started = daemon.startActivityAsUserWithFeature(intent, userId) + // The dominant failure is not an exception: the daemon returns a negative int for + // a null activity manager or a refused user switch. + val code = started.getOrDefault(-1) + if (code < 0) { + Log.e( + Constants.TAG, + "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 -> + Log.e( + Constants.TAG, + "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 -> + Log.e( + Constants.TAG, + "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.DeleteOutline, + 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) { + Log.e( + Constants.TAG, + "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)) + } +} +} + +/** + * One action, with its icon in a tinted disc. + * + * 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. + */ +@Composable +private fun ActionRow( + icon: ImageVector, + title: String, + subtitle: String? = null, + tint: Color? = null, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val accent = tint ?: colors.onSurfaceVariant + + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .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(18.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, + ) + } + } + } +} + +/** + * 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 = {}, + ) + 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(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) + 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() + } + }, + ) + } + + ToggleRow( + 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, + ) + ) + ) + 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/RelativeTime.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/RelativeTime.kt new file mode 100644 index 000000000..0b72c07d1 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/RelativeTime.kt @@ -0,0 +1,129 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import java.util.Locale +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.theme.currentLocale + +/** + * "4 days ago", in the user's language. + * + * Coarse on purpose. The commit feed answers "is this project alive and who is working on it", + * which minutes and hours do not help with, and a coarse label stays correct for longer without + * recomposing. + */ +@Composable +fun relativeTime(epochSeconds: Long): String { + val context = LocalContext.current + val days = ((System.currentTimeMillis() / 1000 - epochSeconds) / 86_400L).toInt() + return when { + // Today is the one case where the exact hour is worth more than the coarse label: it is + // how someone watching a fix land tells "just now" from "this morning". Rendered in the + // device's own locale and 12/24-hour preference. + days <= 0 -> + android.text.format.DateFormat.getTimeFormat(context) + .format(java.util.Date(epochSeconds * 1000)) + days == 1 -> stringResource(R.string.time_yesterday) + days < 7 -> context.resources.getQuantityString(R.plurals.time_days_ago, days, days) + days < 31 -> + (days / 7).let { context.resources.getQuantityString(R.plurals.time_weeks_ago, it, it) } + else -> + (days / 30).let { + context.resources.getQuantityString(R.plurals.time_months_ago, it, it) + } + } +} + +/** + * 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/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..2c51b915d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt @@ -0,0 +1,131 @@ +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.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.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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. + */ +@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, + ), + headlineContent = { Text(title) }, + supportingContent = subtitle?.let { { Text(it) } }, + leadingContent = { Icon(icon, contentDescription = null) }, + trailingContent = { Switch(checked = checked, onCheckedChange = null) }, + ) +} + +/** + * 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), + headlineContent = { Text(title) }, + supportingContent = subtitle?.let { { Text(it) } }, + leadingContent = { + Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) + }, + ) +} 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..237a595c2 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt @@ -0,0 +1,363 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.animation.animateColorAsState +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.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.getValue +import androidx.compose.runtime.remember +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.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 org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.components.ambience.AmbienceKind +import org.matrix.vector.manager.ui.components.ambience.AmbientSurface + +/** The three states the framework can be in, plus the moment before we know. */ +enum class FrameworkState { + Checking, + Active, + Degraded, + Inactive, +} + +/** + * 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, + 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.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.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.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, + 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. + */ +@Composable +private fun StatusIndicator( + state: FrameworkState, + tint: Color, + 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 + FrameworkState.Checking -> null + } + + Box( + modifier = + Modifier.size(52.dp) + .scale(if (state == FrameworkState.Active) pulse else 1f) + .clip(RoundedCornerShape(percent = corner.toInt())) + .background(tint.copy(alpha = 0.15f)) + .clickable(onClick = onClick) + .semantics { this.contentDescription = contentDescription }, + contentAlignment = Alignment.Center, + ) { + 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)) + } + } +} + +/** 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 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..f259d7c49 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TakePart.kt @@ -0,0 +1,267 @@ +package org.matrix.vector.manager.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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.MergeType +import androidx.compose.material.icons.rounded.RateReview +import androidx.compose.material.icons.rounded.Science +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +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.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.unit.dp +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.github.SignInState +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * 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 and explains what signing in would buy. + 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) + } + } +} + +/** + * Optional sign-in, rendered only when it can actually do something. + * + * The card is hidden entirely when no OAuth client id was compiled in, and when GitHub turns out + * to be unreachable it collapses to a single quiet line rather than an error. A large share of this + * project's users cannot reach github.com at all, and for them the rest of Home must still be a + * complete, working screen — so sign-in is never a gate, only an upgrade. + */ +@Composable +fun GitHubSignInCard( + state: SignInState, + isConfigured: Boolean, + onSignIn: () -> Unit, + onSignOut: () -> Unit, + onCancel: () -> Unit, + onOpen: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (!isConfigured) return + val context = LocalContext.current + + when (state) { + is SignInState.SignedOut -> + OutlinedCard(modifier = modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text( + stringResource(R.string.github_sign_in), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.github_sign_in_why), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + FilledTonalButton(onClick = onSignIn) { + Text(stringResource(R.string.github_sign_in)) + } + } + } + + is SignInState.AwaitingUser -> + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ), + ) { + Column(Modifier.padding(16.dp)) { + Text( + stringResource(R.string.github_sign_in_code, state.verificationUri), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(10.dp)) + // The code is the whole point of this state, so it is set large and + // monospaced — it is meant to be read off a screen and typed on another. + Text( + text = state.userCode, + style = VectorMono.copy(fontSize = MaterialTheme.typography.headlineSmall.fontSize), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = { onOpen(state.verificationUri) }) { + Text(stringResource(R.string.github_open_browser)) + } + TextButton(onClick = { copyCode(context, state.userCode) }) { + Text(stringResource(R.string.github_copy_code)) + } + TextButton(onClick = onCancel) { + Text(stringResource(R.string.github_cancel)) + } + } + } + } + + is SignInState.SignedIn -> + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.MergeType, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.github_signed_in_as, state.login ?: "GitHub"), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onSignOut) { + Text(stringResource(R.string.github_sign_out)) + } + } + + is SignInState.Unavailable -> + Text( + text = stringResource(R.string.github_unreachable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.fillMaxWidth(), + ) + } +} + +private fun copyCode(context: Context, code: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip( + // Android 13 and later show this label in the clipboard preview, so it is user-visible. + ClipData.newPlainText(context.getString(R.string.github_device_code), code) + ) +} 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..ee424d360 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt @@ -0,0 +1,142 @@ +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 to re-route. + * + * 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 and the circuit pulses + * move on their own schedule, so they ignore it. + */ + 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..131038755 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt @@ -0,0 +1,304 @@ +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 kotlin.math.hypot +import kotlin.math.roundToInt +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 orthogonal traces, and a + * touch that sends a **pulse travelling down the nearest one**, lighting the trace ahead of it and + * leaving it dark behind. + * + * Traces are laid out with right angles only, the way a board is routed. Diagonals would read as + * decoration; right angles read as a circuit. + */ +class CircuitRenderer : AmbienceRenderer { + + private companion object { + const val MIN_SCALE = 0.4f + const val MAX_SCALE = 3f + + /** 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 + } + + /** A polyline of right-angled segments, 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) + + /** 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() + } + } + + private class Pulse(val trace: Trace, var distance: Float, val speed: Float) { + /** How lit the trace behind the pulse still is, per junction it has passed. */ + val litJunctions = mutableMapOf() + } + + private var traces: List = emptyList() + + /** + * How densely the board is laid out. + * + * Zooming out routes 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) + } + 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 + + /** 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. */ + private fun route(size: Size) { + val random = Random(0xB0A2D + layoutSeed * 7919) + traces = + List(((6 + random.nextInt(4)) / scale).roundToInt().coerceIn(2, 26)) { + val startY = size.height * (0.12f + random.nextFloat() * 0.76f) + val points = mutableListOf(Offset(0f, startY)) + var x = 0f + var y = startY + // Walk rightwards, stepping vertically now and then — routed, not drawn. + while (x < size.width) { + val run = size.width * (0.10f + random.nextFloat() * 0.22f) * scale + x = (x + run).coerceAtMost(size.width) + points += Offset(x, y) + if (x < size.width && random.nextFloat() < 0.72f) { + val rise = size.height * (0.08f + random.nextFloat() * 0.20f) + y = (y + if (random.nextBoolean()) rise else -rise) + .coerceIn(size.height * 0.08f, size.height * 0.92f) + points += Offset(x, y) + } + } + Trace(points) + } + } + + 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) + + // 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, size) + } + + // 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) + } + + pulses.forEach { pulse -> + val before = pulse.distance + pulse.distance += pulse.speed * seconds + + // Light every junction the pulse just crossed, so the board reacts to the signal + // passing rather than only showing the signal itself. + var travelled = 0f + pulse.trace.lengths.forEachIndexed { index, length -> + travelled += length + if (travelled in before..pulse.distance) pulse.litJunctions[index + 1] = 1f + } + pulse.litJunctions.keys.toList().forEach { key -> + val decayed = (pulse.litJunctions.getValue(key) - dt / 700f) + if (decayed <= 0f) pulse.litJunctions.remove(key) + else pulse.litJunctions[key] = decayed + } + } + pulses.removeAll { it.distance > it.trace.total && it.litJunctions.isEmpty() } + } + + /** + * A swipe re-routes the board. + * + * 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. + */ + private var dragged = 0f + + override fun onDrag(pan: Offset, at: Offset, size: Size) { + if (size.height <= 0f) return + dragged += hypot(pan.x, pan.y) + if (dragged < size.height * 0.25f) return + dragged = 0f + reroute(size) + nextRouteMs = ROUTE_INTERVAL_MS + } + + private fun reroute(size: Size) { + layoutSeed++ + pulses.clear() + route(size) + reveal = 0f + } + + private fun fire(trace: Trace, start: Float, size: Size) { + if (pulses.size >= 6) pulses.removeAt(0) + pulses += Pulse(trace, start, size.width * 1.15f) + } + + 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. + val start = + nearest.points + .zip(nearest.points.drop(1)) + .fold(0f to Float.MAX_VALUE) { acc, (a, b) -> + val mid = Offset((a.x + b.x) / 2f, (a.y + b.y) / 2f) + val d = hypot(mid.x - position.x, mid.y - position.y) + if (d < acc.second) { + val travelled = + nearest.points + .take(nearest.points.indexOf(a) + 1) + .zipWithNext { p, q -> hypot(q.x - p.x, q.y - p.y) } + .sum() + travelled to d + } else acc + } + .first + + fire(nearest, start, size) + } + + 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 + } + + // Junction pads, where the route turns. A pad the signal just crossed flares. + trace.points.drop(1).dropLast(1).forEachIndexed { index, point -> + val lit = + pulses + .filter { it.trace === trace } + .maxOfOrNull { it.litJunctions[index + 1] ?: 0f } ?: 0f + drawCircle( + color = tint.copy(alpha = 0.17f + 0.45f * lit), + radius = width * (1.6f + 2.4f * lit), + center = point, + ) + } + } + + pulses.forEach { pulse -> + if (pulse.distance > pulse.trace.total) return@forEach + // A short lit stretch behind the head, so the signal reads as moving rather than as a + // dot that happens to be somewhere. + val tailSteps = 14 + val tailLength = size.width * 0.22f + for (i in 0 until tailSteps) { + val back = tailLength * i / tailSteps + val d = pulse.distance - back + if (d < 0f) break + val fade = 1f - i / tailSteps.toFloat() + drawCircle( + color = tint.copy(alpha = 0.42f * fade * fade), + radius = width * (1.5f * fade + 0.4f), + center = pulse.trace.pointAt(d), + ) + } + // The head: a solid core inside a soft halo, so it reads as something energetic + // rather than as a ring travelling along a line. + val head = pulse.trace.pointAt(pulse.distance) + drawCircle(color = tint.copy(alpha = 0.10f), radius = width * 5.5f, center = head) + drawCircle(color = tint.copy(alpha = 0.22f), radius = width * 3.2f, center = head) + drawCircle(color = tint.copy(alpha = 0.75f), radius = width * 1.5f, 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..5a2846a2c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MatrixRenderer.kt @@ -0,0 +1,348 @@ +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 androidx.compose.ui.unit.sp +import kotlin.math.abs +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. + * + * Clamped rather than unbounded: below the floor the glyphs stop being glyphs, and above the + * ceiling three columns fill the header and it stops being rain. + */ + 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 + + private var cellHeight = 0f + + + 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 + // About a sixth of the header's height per cell: small enough that a column reads as a + // stream of characters rather than as a headline, and the pinch is there for anyone who + // wants them bigger. + cellHeight = size.height * 0.155f + columns.clear() + // More columns than are separable at rest. Their lanes are fixed, so as the glyphs shrink + // the columns come apart and zooming out reveals a finer drizzle rather than empty space. + repeat(52) { columns += newColumn(size) } + } + + private fun newColumn(size: Size, atLane: Float? = null, atHead: Float? = null): Column { + val length = 3 + random.nextInt(6) + return Column( + lane = atLane ?: random.nextFloat(), + head = atHead ?: (-random.nextFloat() * size.height * 1.6f), + weight = 0.45f + random.nextFloat() * 0.85f, + length = length, + glyphs = MutableList(length + 1) { randomGlyph() }, + ) + } + + /** The height of one glyph cell at the current zoom. */ + private fun cell(): Float = cellHeight * scale + + override fun update(dt: Float, size: Size) { + if (size.width <= 0f || size.height <= 0f) return + seed(size) + clock += dt + + // Ease into and out of the freeze. + val target = if (frozen) 0f else 1f + motion += (target - 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 (columns.size >= 90) return + // Seeded right where the finger went down, so it is plainly the one you just made. + 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(52) { 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 (cellHeight < 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 * 0.82f + 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 { + const val MIN_SCALE = 0.45f + const val MAX_SCALE = 3.5f + /** 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/Navigator.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt new file mode 100644 index 000000000..cce619e51 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt @@ -0,0 +1,62 @@ +package org.matrix.vector.manager.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack + +/** + * 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. + */ +@Stable +class Navigator(val backStack: NavBackStack) { + + val current: NavKey? + get() = backStack.lastOrNull() + + /** Which bar item is highlighted — always the root of the stack. */ + val currentTopLevel: TopLevelRoute + get() = backStack.firstOrNull() as? TopLevelRoute ?: TopLevelRoute.Home + + 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 + } +} + +val LocalNavigator = staticCompositionLocalOf { error("No Navigator in composition") } + +@Composable +fun rememberNavigator(): Navigator { + // 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. + val backStack = rememberNavBackStack(TopLevelRoute.Home) + return remember(backStack) { Navigator(backStack) } +} 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..78792191d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -0,0 +1,72 @@ +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.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 + +/** The four destinations in the navigation bar. Order here is the order on screen. */ +@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 + +/** 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. */ +data class TopLevelDestination( + val route: TopLevelRoute, + val icon: ImageVector, + val labelRes: Int, +) + +val TOP_LEVEL_DESTINATIONS: List = + listOf( + TopLevelDestination(TopLevelRoute.Home, Icons.Rounded.Home, R.string.nav_home), + TopLevelDestination(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(TopLevelRoute.Store, Icons.Rounded.CloudDownload, R.string.nav_store), + TopLevelDestination(TopLevelRoute.Logs, Icons.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..9bd189355 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt @@ -0,0 +1,262 @@ +package org.matrix.vector.manager.ui.screens.canary + +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.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Science +import androidx.compose.material3.Button +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.FilledTonalButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +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.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.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.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.github.CanaryBuild +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.data.github.SignInState +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * Canary builds: what CI has produced since the last release, and how to try one. + * + * **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 instead, and this lists those. + * + * The Actions page is one tap away for anyone who wants the build log or a commit older than the + * five canaries CI keeps, filtered the way the project README filters it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CanaryScreen( + onNavigateBack: () -> Unit, + onOpenUrl: (String) -> Unit, + onInstall: (Long) -> Unit, +) { + var builds by remember { mutableStateOf?>(null) } + LaunchedEffect(Unit) { builds = ServiceLocator.github.canaryBuilds() } + + 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 = { + IconButton(onClick = { onOpenUrl(GitHubRepository.CANARY_URL) }) { + Icon( + Icons.Rounded.OpenInNew, + contentDescription = stringResource(R.string.canary_open_actions), + ) + } + }, + ) + } + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + val list = builds + when { + list == null -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + list.isEmpty() -> CanaryEmpty(onOpenUrl = onOpenUrl) + else -> + LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) { + items(list, key = { it.id }) { build -> + BuildRow(build = build, onOpenUrl = onOpenUrl, onInstall = onInstall) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + } + item { + // The raw runs, for anyone who wants the build log or a commit older + // than the five CI keeps. + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedButton( + onClick = { onOpenUrl(GitHubRepository.CANARY_URL) } + ) { + Icon( + Icons.Rounded.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.canary_open_actions)) + } + } + } + } + } + } + } +} + +/** + * 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)) + } + } +} + +@Composable +private fun BuildRow(build: CanaryBuild, onOpenUrl: (String) -> Unit, onInstall: (Long) -> Unit) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp)) { + Text( + text = build.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(3.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(build.shortSha, style = VectorMono, color = colors.onSurfaceVariant) + Text(" · ", style = MaterialTheme.typography.labelSmall, color = colors.outlineVariant) + Text( + build.branch, + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + } + + build.artifacts.filterNot { it.expired }.forEach { artifact -> + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + artifact.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + formatSize(artifact.sizeInBytes), + style = MaterialTheme.typography.labelSmall, + color = colors.onSurfaceVariant, + ) + } + + } + } + + if (build.artifacts.none { !it.expired }) { + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.canary_expired), + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + } + + // One action per build rather than one per zip. Choosing between the Release and the Debug + // zip belongs on the installer, which already offers it, states the size of each and + // remembers which was picked last; here it would be a choice made before the reader has + // been told what either one is for. + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + build.htmlUrl?.let { url -> + TextButton(onClick = { onOpenUrl(url) }) { + Text(stringResource(R.string.canary_open_run)) + } + } + Spacer(Modifier.weight(1f)) + if (build.versionCode > 0 && build.artifacts.any { !it.expired }) { + FilledTonalButton(onClick = { onInstall(build.versionCode) }) { + Icon( + Icons.Rounded.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.canary_install)) + } + } + } + } +} + +private fun formatSize(bytes: Long): String = + when { + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1024 -> "%.0f kB".format(bytes / 1024.0) + else -> "$bytes B" + } 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..fd4d6e5c8 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt @@ -0,0 +1,473 @@ +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.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.Check +import androidx.compose.material.icons.rounded.Colorize +import androidx.compose.material.icons.rounded.DarkMode +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.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.ListItem +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.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.text.font.FontWeight +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.SheetHeading +import org.matrix.vector.manager.ui.components.ToggleRow +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.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. + * + * 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 + 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 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() + + // The default state, deliberately. `skipPartiallyExpanded` 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 + // adds the stop only when the content is actually taller than half the screen, so short sheets + // still open at their own height and nothing gains a useless drag. + val sheetState = rememberModalBottomSheetState() + + 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)) + + // 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, + ) + + 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..1e95a626c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt @@ -0,0 +1,889 @@ +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.WindowInsets +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.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.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.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.GitHubSignInCard +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 signIn by viewModel.signInState.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 context = LocalContext.current + var showSplash by rememberSaveable { mutableStateOf(false) } + var showAppearance by rememberSaveable { mutableStateOf(false) } + var showLanguage by rememberSaveable { mutableStateOf(false) } + + // 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. + contentWindowInsets = WindowInsets(0), + 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)) + GitHubSignInCard( + state = signIn, + isConfigured = viewModel.isSignInConfigured, + onSignIn = viewModel::signIn, + onSignOut = viewModel::signOut, + onCancel = viewModel::cancelSignIn, + onOpen = ::open, + ) + 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), + onOpenStatus = 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 }) + } + + // 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 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..08986ada0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,430 @@ +package org.matrix.vector.manager.ui.screens.home +import android.util.Log +import org.matrix.vector.manager.Constants +import kotlinx.coroutines.CancellationException + +import org.matrix.vector.manager.data.repository.FrameworkUpdateState +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.stateIn +import kotlinx.coroutines.flow.update +import kotlin.random.Random +import kotlinx.coroutines.launch +import org.lsposed.lspd.ILSPManagerService +import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.GitHubAuth +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient +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 dex2oatCompatibility: Int = ILSPManagerService.DEX2OAT_OK, + val sepolicyLoaded: Boolean = false, + val systemServerInjected: Boolean = false, + /** + * The commit the running framework was built from, short, `-dirty` when its tree was not clean. + * + * 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. + */ + val commit: String? = null, +) { + val versionLabel: String? + get() = versionName?.let { if (versionCode > 0) "$it ($versionCode)" else it } +} + +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, + private val auth: GitHubAuth, +) : ViewModel() { + + val signInState: StateFlow = auth.state + + val openLinksExternally: StateFlow = ServiceLocator.settings.openLinksExternally + + val headerAmbience: StateFlow = ServiceLocator.settings.headerAmbience + + val isSignInConfigured: Boolean + get() = auth.isConfigured + + fun signIn() { + viewModelScope.launch { auth.signIn() } + } + + fun signOut() { + auth.signOut() + // The rate limit changes with the token, so the feed is worth re-reading. + refreshFeed(GitHubRepository.Freshness.Force) + } + + fun cancelSignIn() = auth.cancel() + + 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() + + init { + // 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 { + ServiceLocator.service.collect { service -> + refreshStatus(service) + // Both switches on the status page hold the daemon's state rather than ours, and + // this is the moment there is a daemon to ask. + if (service != null) refreshToggles() + } + } + // Opening 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. + val checkNow = Random.nextFloat() < REVALIDATE_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: ILSPManagerService?) { + if (service == null || !daemon.isAlive) { + _status.value = FrameworkStatus(state = FrameworkState.Inactive) + return + } + + val versionName = daemon.getXposedVersionName().getOrNull() + val commit = daemon.getFrameworkCommit().getOrNull() + val versionCode = + daemon + .getXposedVersionCode() + .onFailure { e -> + Log.w( + Constants.TAG, + "status: framework version code unavailable, update check skipped", + e, + ) + } + .getOrDefault(0L) + val api = daemon.getXposedApiVersion().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.systemServerRequested() + val healthFailure = sepolicyResult.exceptionOrNull() ?: systemServerResult.exceptionOrNull() + if (healthFailure != null && healthFailure !is CancellationException) { + Log.w( + Constants.TAG, + "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.getDex2OatWrapperCompatibility().getOrDefault(ILSPManagerService.DEX2OAT_OK) + val dex2oatFlags = daemon.dex2oatFlagsLoaded().getOrDefault(true) + + val issues = buildList { + if (!sepolicy) add(HealthIssue.SepolicyNotLoaded) + if (!systemServer) add(HealthIssue.SystemServerNotInjected) + // The wrapper and the property are alternatives, 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 flag did not load either. + if (dex2oat != ILSPManagerService.DEX2OAT_OK && !dex2oatFlags) { + 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, + dex2oatCompatibility = 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()) + + // --- framework toggles ------------------------------------------------------------------ + // Properties of the *framework* rather than of the app, so they belong on the screen that + // reports the framework's state rather than in a settings screen of their own. + + private val _statusNotification = MutableStateFlow(false) + val statusNotification: StateFlow = _statusNotification.asStateFlow() + + /** + * 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 + + // 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() + + private suspend fun refreshToggles() { + _statusNotification.value = + daemon + .enableStatusNotification() + .onFailure { e -> + Log.w(Constants.TAG, "status: notification toggle unreadable, showing off", e) + } + .getOrDefault(false) + // 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. + _hiddenIcon.value = + daemon + .forcedLauncherIcons() + .onFailure { e -> + Log.w(Constants.TAG, "status: launcher-icon toggle unreadable, showing on", e) + } + .getOrDefault(true) + } + + fun setStatusNotification(enabled: Boolean) { + viewModelScope.launch { + daemon + .setEnableStatusNotification(enabled) + .onSuccess { _statusNotification.value = enabled } + .onFailure { e -> + Log.e( + Constants.TAG, + "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.forcedLauncherIcons().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 opening Home actually goes and checks GitHub. */ + private const val REVALIDATE_PROBABILITY = 0.2f + + val Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + HomeViewModel( + ServiceLocator.daemon, + ServiceLocator.github, + ServiceLocator.githubAuth, + ) + 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..6692eb5a0 --- /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.ListItem +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.rememberModalBottomSheetState +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() } + // The default state, deliberately. `skipPartiallyExpanded` 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 + // adds the stop only when the content is actually taller than half the screen, so short sheets + // still open at their own height and nothing gains a useless drag. + val sheetState = rememberModalBottomSheetState() + + 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..61dd471e0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt @@ -0,0 +1,523 @@ +package org.matrix.vector.manager.ui.screens.home + +import android.content.ClipData +import android.content.ClipboardManager +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.rounded.ContentCopy +import androidx.compose.material.icons.rounded.WarningAmber +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.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.style.TextOverflow +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.lsposed.lspd.ILSPManagerService +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.ui.components.FrameworkState +import org.matrix.vector.manager.data.log.CrashRecorder +import org.matrix.vector.manager.data.model.XposedApi +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 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, + 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 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 crashes by remember { mutableStateOf(CrashRecorder.read(context)) } + // The two switches below belong to the framework, so they are only live while it is. + val daemonAlive = status.state != FrameworkState.Inactive + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.copied) + + 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. + copy( + context, + englishSections.joinToString("\n\n") { (heading, items) -> + heading + + items.joinToString("") { "\n ${it.label}: ${it.value}" } + }, + ) + 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)) } + } + if (crashes != null) { + item(key = "crashes") { + CrashCard( + report = crashes!!, + onCopy = { + copy(context, crashes!!) + scope.launch { snackbars.show(copied, SnackbarTone.Success) } + }, + onClear = { + CrashRecorder.clear(context) + crashes = 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, + ) + } + } + } +} + +@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 previews the newest trace only + * — the older ones are on file and travel with the copy — since the question being asked is "what + * just happened", not "what has ever happened". + * + * 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: String, onCopy: () -> Unit, onClear: () -> Unit) { + val colors = MaterialTheme.colorScheme + val newest = remember(report) { report.trim().lines().take(CRASH_PREVIEW_LINES) } + 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(8.dp)) + Text( + newest.joinToString("\n"), + style = VectorMono.copy(fontSize = 12.sp), + color = colors.onSurfaceVariant, + maxLines = CRASH_PREVIEW_LINES, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + Row { + TextButton(onClick = onCopy) { Text(stringResource(R.string.action_copy_all)) } + TextButton(onClick = onClear) { Text(stringResource(R.string.crash_recorded_clear)) } + } + } + } +} + +/** + * 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. + */ +@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 = row.value, + 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, + val health: Health = Health.Neutral, + /** True where the value is an identifier to be compared character by character. */ + val monospace: Boolean = true, +) + +/** 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( + InfoItem( + str(R.string.info_framework_version), + buildString { + append(status.versionLabel ?: unknown) + // The exact build, not just its number. Two builds share a version code + // whenever they sit at the same depth on different branches, and a build + // from a tree with uncommitted changes says so with `-dirty` — which is + // what a bug report needs and what the number alone cannot give. + status.commit?.takeIf { it.isNotBlank() }?.let { append(" · ").append(it) } + }, + ), + // 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. + InfoItem( + 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 + listOf( + 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, + ), + InfoItem( + str(R.string.info_dex2oat), + dex2oatLabel(context, status.dex2oatCompatibility), + health = + if (status.dex2oatCompatibility == ILSPManagerService.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, compatibility: Int): String = + context.getString( + when (compatibility) { + ILSPManagerService.DEX2OAT_OK -> R.string.info_supported + ILSPManagerService.DEX2OAT_CRASHED -> R.string.info_dex2oat_crashed + ILSPManagerService.DEX2OAT_MOUNT_FAILED -> R.string.info_dex2oat_mount_failed + ILSPManagerService.DEX2OAT_SELINUX_PERMISSIVE -> + R.string.info_dex2oat_selinux_permissive + ILSPManagerService.DEX2OAT_SEPOLICY_INCORRECT -> + R.string.info_dex2oat_sepolicy_incorrect + else -> R.string.info_unsupported + } + ) + +private fun copy(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(BuildConfig.MANAGER_PACKAGE_NAME, text)) +} + +@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) + } +} + +/** Enough of the newest trace to recognise it; the rest travels in the copy. */ +private const val CRASH_PREVIEW_LINES = 6 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..679e11a59 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -0,0 +1,453 @@ +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.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, + onTagClick: (String) -> Unit, + onCopy: (String) -> Unit, +) { + when (row) { + is LogRow.DayBreak -> DayBreakRow(row) + is LogRow.Marker -> MarkerRow(row, query) + is LogRow.Entry -> EntryRow(row, wordWrap, showTag, pan, query, onTagClick, onCopy) + } +} + +@Composable +private fun EntryRow( + entry: LogRow.Entry, + wordWrap: Boolean, + showTag: Boolean, + pan: LogPan, + query: String, + onTagClick: (String) -> Unit, + onCopy: (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 + val tagBackground = MaterialTheme.colorScheme.secondaryContainer + val tagForeground = MaterialTheme.colorScheme.onSecondaryContainer + 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.trace.isNotEmpty()) { + Text( + pluralStringResource(R.plurals.logs_frames, entry.trace.size, entry.trace.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier.padding(start = 18.dp, top = 2.dp) + .combinedClickable(onClick = { framesOpen = !framesOpen }), + ) + if (framesOpen) { + entry.trace.forEach { frame -> + Text( + frame.trim(), + style = VectorLogLine, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 26.dp), + ) + } + } + } + } +} + +/** + * 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 +} + +/** + * 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 + } + +@Composable +fun levelLabel(level: LogLevel): String = + stringResource( + when (level) { + LogLevel.VERBOSE -> R.string.logs_level_verbose + LogLevel.DEBUG -> R.string.logs_level_debug + LogLevel.INFO -> R.string.logs_level_info + LogLevel.WARN -> R.string.logs_level_warn + LogLevel.ERROR -> R.string.logs_level_error + LogLevel.FATAL -> R.string.logs_level_fatal + else -> R.string.logs_level_other + } + ) + +/** Rebuilds the line exactly as the daemon wrote it, for the clipboard. */ +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.trace.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/LogsScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt new file mode 100644 index 000000000..a7a413b37 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt @@ -0,0 +1,1003 @@ +package org.matrix.vector.manager.ui.screens.logs + +import android.content.ClipData +import android.content.ClipboardManager +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.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.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.rememberModalBottomSheetState +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 java.time.LocalDateTime +import java.time.format.DateTimeFormatter +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.BuildConfig +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.SearchField +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(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) + } + val fileNameTemplate = stringResource(R.string.logs_save_name) + fun launchSave() { + saveLauncher.launch( + String.format(fileNameTemplate, LocalDateTime.now().format(FILE_STAMP)) + ) + } + + 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) + } + } + + 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_failed) + 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) { + 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) }, + ) + } + } + } + +} + +@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, +) { + // 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, + onTagClick = { viewModel.setTag(tab, it) }, + onCopy = onCopy, + ) + } + } + } + } + + // 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.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() + // Left at the default rather than passing skipPartiallyExpanded, which removes the half-height + // stop — the only thing a drag on a sheet can do other than dismiss it. Material adds that stop + // only when the content is taller than half the screen, so short sheets still open at their own + // height and nothing gains a useless drag. + val sheetState = rememberModalBottomSheetState() + + 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) }, + headlineContent = { Text(stringResource(R.string.logs_verbose_switch)) }, + 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) }) + }, + ) + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + + ListItem( + modifier = Modifier.clickable(onClick = onSave), + headlineContent = { Text(stringResource(R.string.logs_save)) }, + supportingContent = { Text(stringResource(R.string.logs_save_summary)) }, + leadingContent = { Icon(Icons.Rounded.Save, contentDescription = null) }, + ) + ListItem( + modifier = Modifier.clickable(onClick = onRotate), + headlineContent = { Text(stringResource(R.string.logs_rotate)) }, + supportingContent = { Text(stringResource(R.string.logs_rotate_summary)) }, + leadingContent = { + Icon( + Icons.Rounded.RestartAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } +} +} + + +/** + * 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 = rememberModalBottomSheetState() + 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 val FILE_STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") + +private fun copyToClipboard(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(BuildConfig.MANAGER_PACKAGE_NAME, text)) +} + +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..145731932 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt @@ -0,0 +1,652 @@ +package org.matrix.vector.manager.ui.screens.logs +import android.util.Log +import org.matrix.vector.manager.Constants + +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 + +/** 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 atOldest: Boolean + get() = windowFirst == 0 + + 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.isVerboseLog()` 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 + + 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) + + /** 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.getLog(verbose) + else daemon.getLogPart(verbose, chosen) + val pfd = + result.getOrElse { + Log.w( + Constants.TAG, + "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 `clearLogs()` 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. + */ + fun rotate(tab: LogTab, onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val result = daemon.clearLogs(tab == LogTab.VERBOSE) + result.onFailure { + Log.e(Constants.TAG, "logs: rotating the ${tab.name.lowercase()} log failed", it) + } + val ok = result.getOrDefault(false) + if (ok) reload(tab, Jump.NEWEST) + onResult(ok) + } + } + + /** + * Writes every log the daemon holds into [uri] as a zip. + * + * 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 + .writeLogsTo(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 { + Log.e(Constants.TAG, "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..62c97dc6f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt @@ -0,0 +1,1432 @@ +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.DeleteOutline +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.TabRow +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.rememberModalBottomSheetState +import org.matrix.vector.manager.data.model.ReleaseAsset +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.screens.repo.StoreChannel +import org.matrix.vector.manager.ui.screens.repo.releasesOn +import org.lsposed.lspd.ILSPManagerService +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) { + TabRow(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.DeleteOutline, 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.DeleteOutline, + 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. + ILSPManagerService.MODULE_LOAD_UNSUPPORTED_API -> + R.string.modules_load_unsupported_api + ILSPManagerService.MODULE_LOAD_UNUSABLE -> + R.string.modules_load_unusable + else -> R.string.modules_load_no_apk + } + ), + 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 = rememberModalBottomSheetState() + 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 asset: ReleaseAsset?, val muted: Boolean) + + val rows = + remember(entries, upgradable, mutedUpgradable, channel) { + (upgradable + mutedUpgradable).mapNotNull { name -> + val entry = entries[name] ?: return@mapNotNull null + val apks = + entry.module + .releasesOn(channel) + .firstOrNull() + ?.releaseAssets + .orEmpty() + .filter { it.isApk } + Row(entry, 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 + }, + ), + headlineContent = { Text(row.entry.module.title) }, + supportingContent = { + Text( + text = + when { + row.asset == null -> + stringResource(R.string.action_update_choose) + 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, + ) + } + }, + ) + } + } + 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!!, + ) + } + ) + 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..6a9e3a611 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt @@ -0,0 +1,579 @@ +package org.matrix.vector.manager.ui.screens.modules +import org.matrix.vector.manager.Constants + +import android.util.Log +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.lsposed.lspd.models.Application +import org.lsposed.lspd.models.UserInfo +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.lsposed.lspd.ILSPManagerService +import org.matrix.vector.manager.data.model.XposedApi +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient + +/** One tab: a user, and the modules installed for them. */ +data class UserModulesState(val user: UserInfo, 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. + * + * Null when it loaded. 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 selectAll(modules: List) { + _selection.update { it + modules.map { m -> ModuleKey(m.packageName, m.userId) } } + } + + 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++ + Log.e( + Constants.TAG, + "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.getXposedApiVersion().getOrDefault(0) + // One call for the whole list. Asking per module would be one binder round trip per + // row for an answer that is empty on almost every device. + val unloadable = + daemonClient + .getUnloadableModules() + .getOrDefault(emptyList()) + .associateWith { ILSPManagerService.MODULE_LOAD_NO_APK } + .toMutableMap() + unloadable.keys.toList().forEach { pkg -> + daemonClient.getModuleLoadState(pkg).getOrNull()?.let { unloadable[pkg] = it } + } + // 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 = unloadable[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 -> + Log.w( + Constants.TAG, + "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 -> + Log.e( + Constants.TAG, + "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.longVersionCode, + 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.longVersionCode, + 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. + Log.i( + Constants.TAG, + "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..cb9037294 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt @@ -0,0 +1,957 @@ +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.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.rememberModalBottomSheetState +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.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.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) } + 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() + } + } + + // Leaving a module enabled with nothing to hook does nothing at all but looks like it works. + fun attemptBack() { + if (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, + enabled = !state.recommended.staticScope, + origin = + when { + state.recommended.staticScope && app.isRecommended -> + ScopeOrigin.Locked + app.isRecommended -> ScopeOrigin.Requested + else -> ScopeOrigin.Chosen + }, + sharedNote = + app.packageName == SYSTEM_FRAMEWORK_PACKAGE && state.multipleUsers, + 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) { + VectorAlertDialog( + onDismissRequest = { confirmStranded = false }, + title = { Text(stringResource(R.string.scope_empty_title)) }, + text = { Text(stringResource(R.string.scope_empty_message)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.setModuleEnabled(false) + confirmStranded = false + onNavigateBack() + } + ) { + Text(stringResource(R.string.scope_empty_disable)) + } + }, + dismissButton = { + TextButton(onClick = { confirmStranded = false }) { + 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.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 = rememberModalBottomSheetState() + 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 — one is fixed forever, one is the module's suggestion, one is only ever what you + * ticked. Rendered as an identical checkbox, a scope the module controls looks exactly like a + * scope the user controls. + * + * 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 fixed this scope; the user cannot change it. */ + Locked, + /** The module asked for it, but it is the user's choice. */ + Requested, + /** Nothing asked for it; it is in the scope because someone ticked it. */ + Chosen, +} + +@Composable +private fun ScopeOrigin.color(): Color = + when (this) { + // Locked reads as "not yours to change", so it borrows the disabled-ish outline rather + // than a colour that invites a tap. + ScopeOrigin.Locked -> MaterialTheme.colorScheme.outline + ScopeOrigin.Requested -> MaterialTheme.colorScheme.primary + ScopeOrigin.Chosen -> Color.Transparent + } + +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 + } + +@Composable +private fun AppRow( + app: AppInfo, + enabled: Boolean, + origin: ScopeOrigin, + sharedNote: Boolean, + 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), + ) + }, + headlineContent = { Text(app.appName) }, + 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, + ) + } + // The one row on this screen that is not an app, and the one row offered + // identically to every 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 device that is a sentence about a distinction that does not exist. + if (sharedNote) { + Text( + text = stringResource(R.string.scope_framework_shared), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + trailingContent = { Checkbox(checked = app.isSelectedInScope, onCheckedChange = null) }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent + ), + ) + 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( + modifier = Modifier.fillMaxWidth().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..ec4b87efc --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt @@ -0,0 +1,822 @@ +package org.matrix.vector.manager.ui.screens.modules +import android.util.Log +import org.matrix.vector.manager.Constants +import kotlinx.coroutines.CancellationException + +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.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.lsposed.lspd.models.Application +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 + +/** A package/user pair, as a value type so set arithmetic is correct. */ +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, +) + +class ScopeViewModel( + private val modulePackageName: String, + /** + * The user the module is installed for. It has to travel with the package name: the same + * module in a work profile is a different copy with a different scope, and resolving it + * against the owner would edit the wrong one. + */ + 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 currently holds. */ + 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()) + + 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) } + // 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 is the module's whole answer, so the list *is* that set. Showing + // the other few hundred apps beneath uncheckable checkboxes offered a choice that + // does not exist. This one stays absolute: there is no choice to preserve. + val locked = view.state.recommended.staticScope + 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) + // An app already in the scope is never filtered away. Otherwise a default + // filter can hide a target the user deliberately chose, and the list then + // disagrees with what the module is actually hooking. + val chosen = ScopeTarget(app.packageName, app.userId) in filters.draft + if (filters.recommendedOnly) { + // Answers one question — what does this module want, and what have I + // given it — and the other filters have no say in it. 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. The screen greys the other three out while this is + // on, and this is the code that makes that honest rather than + // decorative. + return@filter matchesQuery && + (chosen || app.packageName in recommended) + } + // The framework is a system target and is filtered like one. It needs no + // exemption of its own: once it is in the scope the line above puts it + // beyond every filter, and before it is chosen it is simply the most + // system of system apps, so someone who has asked not to see those has + // asked not to see it. + val matchesSys = chosen || filters.showSystem || !app.isSystemApp + val matchesGame = chosen || filters.showGames || !app.isGame + val matchesModule = + chosen || showMods || modules == null || app.packageName !in modules + matchesQuery && matchesSys && matchesGame && matchesModule + } + .map { app -> + app.copy( + isSelectedInScope = + ScopeTarget(app.packageName, app.userId) in filters.draft, + 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. + val (inForce, newlyTicked) = + chosen.partition { 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(), + ) + + 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 } + + val savedResult = daemonClient.getModuleScope(modulePackageName) + // Keyed on the exception, not on a null: a fresh module with nothing ticked is a + // success carrying an empty list and must stay silent. + savedResult.exceptionOrNull()?.let { e -> + Log.e( + Constants.TAG, + "scope: reading the saved scope of $modulePackageName failed, showing none", + e, + ) + } + val saved = + savedResult + .getOrNull() + ?.map { ScopeTarget(it.packageName, it.userId) } + ?.toSet() ?: 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 -> + Log.w( + Constants.TAG, + "scope: package info for $modulePackageName (user $userId) " + + "unavailable, no recommended scope", + e, + ) + } + .getOrNull() + } + val recommended = + info?.let { + withContext(Dispatchers.IO) { + val manifest = ModuleDetection.inspect(it, packageManager) + RecommendedScope(manifest.scope, manifest.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, + ) + } + } + + /** + * 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) { + val target = ScopeTarget(app.packageName, app.userId) + draftScope.value = + if (selected) draftScope.value + target else draftScope.value - target + } + + fun selectAllVisible() { + draftScope.value = + draftScope.value + + filteredApps.value.map { ScopeTarget(it.packageName, it.userId) } + } + + fun clearAllVisible() { + draftScope.value = + draftScope.value - + filteredApps.value.map { ScopeTarget(it.packageName, it.userId) }.toSet() + } + + /** 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 { + Log.e( + Constants.TAG, + "scope: daemon refused include-new-apps=$enabled for " + + modulePackageName, + ) + _message.value = ScopeMessage.IncludeNewAppsFailed + } + } + .onFailure { e -> + Log.e( + Constants.TAG, + "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 -> + Log.e(Constants.TAG, "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 -> + Log.w( + Constants.TAG, + "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 -> + Log.e( + Constants.TAG, + "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 the draft, once. + * + * The whole draft goes out as one `setModuleScope`, which replaces every scope row of the + * module and triggers 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 + val draft = draftScope.value + val aidl = + draft.map { target -> + Application().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 + // draft that reaches beyond a scope the module fixes for itself, and keeping + // the draft on a refusal would show a scope the framework never took. + if (!stored) { + Log.e( + Constants.TAG, + "scope: daemon refused ${draft.size} targets for $modulePackageName", + ) + _message.value = ScopeMessage.ApplyFailed + } else { + // Whether the framework itself just joined or left this scope. Compared + // against what was stored before the write, so it is the change that is + // reported and not the mere presence of the row. + val framework = ScopeTarget(SYSTEM_FRAMEWORK_PACKAGE, 0) + val wasThere = framework in savedScope.value + val isThere = framework in draft + savedScope.value = draft + // 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 -> + Log.e( + Constants.TAG, + "scope: apply of ${draft.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 -> + Log.e(Constants.TAG, "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 + Log.e(Constants.TAG, "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..f7f0d2d21 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt @@ -0,0 +1,914 @@ +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 androidx.compose.material3.rememberModalBottomSheetState +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.TabRow +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.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 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) } + 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 = 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 + } + } + } + + TabRow(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 = assets.first() + else choosing = release + }, + ) + else -> + InformationTab( + module = module, + listState = informationScroll, + installedScope = installedScope, + onOpenUrl = openUrl, + ) + } + } + } + } + + if (optionsOpen) { + val muted by viewModel.updatesMuted.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState() + 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 = asset + }, + ) + } + + confirming?.let { asset -> + ConfirmInstall( + module = state.module, + packageName = packageName, + asset = asset, + onDismiss = { confirming = null }, + onConfirm = { + confirming = null + viewModel.install(asset) + }, + ) + } +} + +/** + * 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( + 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, + 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. + val published = module.scope?.takeIf { it.isNotEmpty() } + 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, + headlineContent = { Text(label) }, + supportingContent = { Text(value) }, + leadingContent = { Icon(icon, contentDescription = null) }, + ) + 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) }, + headlineContent = { Text(asset.name.orEmpty()) }, + 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(" · ")) + }, + ) + } + 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..33a4d3cb8 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt @@ -0,0 +1,179 @@ +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.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.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, +) { + val upgradable: Boolean + get() = + installed != null && + latest != null && + latest.upgradableOver(installed.versionCode, installed.versionName) +} + +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() + + 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.longVersionCode, + info.lastUpdateTime, + ) + _installedScope.value = manifest.scope + } + } + + 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) + + val state: StateFlow = + combine( + repository.catalog, + _detail, + _fetch, + repository.installedVersions, + settings.updateChannel, + ) { catalog, detail, fetch, installed, channelPreference -> + val seed = catalog.modules.firstOrNull { it.name == packageName } + val module = detail ?: seed + val channel = StoreChannel.of(channelPreference) + RepoDetailsState( + module = module, + releases = releasesFor(module, channel), + installed = installed[packageName], + latest = latestFor(module, channel), + fetch = fetch, + channel = channel, + ) + } + .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) { + backgroundScope.launch { + if (installer.install(packageName, asset)) repository.refreshInstalled() + } + } + + 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..95c92f09d --- /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.rememberModalBottomSheetState +import org.matrix.vector.manager.ui.components.ChoiceRow +import org.matrix.vector.manager.ui.components.SheetHeading +import org.matrix.vector.manager.ui.components.ToggleRow +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.Dns +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, +) { + // Left at the default rather than passing skipPartiallyExpanded, which removes the half-height + // stop — the only thing a drag on a sheet can do other than dismiss it. Material adds that stop + // only when the content is taller than half the screen, so short sheets still open at their own + // height and nothing gains a useless drag. + val sheetState = rememberModalBottomSheetState() + + 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( + 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..75626a732 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt @@ -0,0 +1,270 @@ +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.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) { + catalog, + installed, + channel, + muted -> + catalog.modules.map { entryFor(it, installed, channel, muted) } + } + .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, + ): StoreEntry = + StoreEntry( + module = module, + latest = module.latestOn(channel), + installed = installed[module.name], + updatesMuted = module.name in muted, + ) + + 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..83023f316 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt @@ -0,0 +1,348 @@ +package org.matrix.vector.manager.ui.screens.repo + +import android.content.Context +import android.content.res.Configuration +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 + +/** + * 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 `