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..422e8d634 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,51 @@ 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 +208,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..07c739f84 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) @@ -23,35 +24,40 @@ plugins { } android { - defaultConfig { - buildConfigField( - "String", - "DEFAULT_MANAGER_PACKAGE_NAME", - """"$defaultManagerPackageName"""", - ) - buildConfigField("String", "FRAMEWORK_NAME", """"${rootProject.name}"""") - buildConfigField("String", "MANAGER_INJECTED_PKG_NAME", """"$injectedPackageName"""") - buildConfigField("int", "MANAGER_INJECTED_UID", """$injectedPackageUid""") - buildConfigField("String", "VERSION_NAME", """"${versionNameProvider.get()}"""") - buildConfigField("long", "VERSION_CODE", versionCodeProvider.get()) - - val cliToken = UUID.randomUUID() - // Inject the MSB and LSB as Long constants - buildConfigField("Long", "CLI_TOKEN_MSB", "${cliToken.mostSignificantBits}L") - buildConfigField("Long", "CLI_TOKEN_LSB", "${cliToken.leastSignificantBits}L") - } + defaultConfig { + buildConfigField( + "String", + "DEFAULT_MANAGER_PACKAGE_NAME", + """"$defaultManagerPackageName"""", + ) + buildConfigField("String", "FRAMEWORK_NAME", """"${rootProject.name}"""") + buildConfigField("String", "MANAGER_INJECTED_PKG_NAME", """"$injectedPackageName"""") + 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 + buildConfigField("Long", "CLI_TOKEN_MSB", "${cliToken.mostSignificantBits}L") + buildConfigField("Long", "CLI_TOKEN_LSB", "${cliToken.leastSignificantBits}L") + } - buildTypes { - all { externalNativeBuild { cmake { arguments += "-DANDROID_ALLOW_UNDEFINED_SYMBOLS=true" } } } - release { - isMinifyEnabled = true - proguardFiles("proguard-rules.pro") + buildTypes { + all { + externalNativeBuild { cmake { arguments += "-DANDROID_ALLOW_UNDEFINED_SYMBOLS=true" } } + } + release { + isMinifyEnabled = true + proguardFiles("proguard-rules.pro") + } } - } - externalNativeBuild { cmake { path("src/main/jni/CMakeLists.txt") } } + externalNativeBuild { cmake { path("src/main/jni/CMakeLists.txt") } } - namespace = "org.matrix.vector.daemon" + namespace = "org.matrix.vector.daemon" } /** @@ -107,10 +113,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 @@ -132,15 +138,15 @@ androidComponents { } dependencies { - implementation(libs.agp.apksig) - implementation(libs.gson) - implementation(libs.picocli) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.kotlinx.coroutines.core) - implementation(projects.external.apache) - implementation(projects.hiddenapi.bridge) - implementation(projects.services.daemonService) - implementation(projects.services.managerService) - compileOnly(libs.androidx.annotation) - compileOnly(projects.hiddenapi.stubs) + implementation(libs.agp.apksig) + implementation(libs.gson) + implementation(libs.picocli) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) + implementation(projects.external.apache) + implementation(projects.hiddenapi.bridge) + implementation(projects.services.daemonService) + implementation(projects.services.managerService) + compileOnly(libs.androidx.annotation) + compileOnly(projects.hiddenapi.stubs) } 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..08ec3d6b1 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) 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..49fdd6ef7 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 @@ -487,6 +487,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..4d1380ae9 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 @@ -69,9 +69,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() 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..ccfbd0c4e 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 @@ -23,6 +23,7 @@ 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 @@ -35,6 +36,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 @@ -147,7 +149,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 +216,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 @@ -233,13 +237,25 @@ object ManagerService : ILSPManagerService.Stub() { override fun getModuleScope(packageName: String) = ConfigCache.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) @@ -423,4 +439,32 @@ object ManagerService : ILSPManagerService.Stub() { ModuleDatabase.setAutoInclude(packageName, enabled) override fun getAutoInclude(packageName: String) = ConfigCache.getAutoInclude(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/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..31926a564 --- /dev/null +++ b/manager/README.md @@ -0,0 +1,258 @@ +# Vector Manager Application + +## Overview + +The manager is the user-facing surface of the Vector framework: a single-activity Jetpack Compose +application that configures the root daemon over Binder. It is built as `:manager` and replaces the +legacy `:app` module. + +Its architecture is dictated by one constraint. The manager normally runs *parasitically* — the +Zygisk layer transplants its DEX into a host process, usually `com.android.shell` — so its manifest +is never registered with the package manager. Nothing that depends on manifest registration exists +at runtime: no `ContentProvider`, therefore no `androidx.startup`, no `WorkManager`, no guarantee +that a declared `Application` subclass is ever instantiated, and no resource-backed theme applied by +the system before the first frame. The same APK is also installable as an ordinary application for +debugging, and must behave identically in both modes. + +## Directory Structure + +```text +src/main/kotlin/org/matrix/vector/manager/ +├── data/ +│ ├── github/ # Activity feed, contributor resolution, canary releases, device-flow auth +│ ├── log/ # Byte-offset log index and line parser +│ ├── model/ # Module detection, app and repository models +│ └── repository/ # Apps, modules, settings, backup, store catalogue, installer +├── di/ServiceLocator.kt # Hand-rolled service location; no DI framework +├── ipc/ # DaemonClient (suspending Binder wrapper), package broadcasts +├── net/ # OkHttp factory and the DNS resolver +└── ui/ + ├── components/ # Shared surfaces: panel header, search field, snackbar, ambience + ├── navigation/ # Navigation 3 route keys and back stack + ├── screens/ # home, modules, logs, repo (store), canary, update, report, web, splash + └── theme/ # Seeded colour scheme generation, typography, in-composition locale + +src/debug/kotlin/org/matrix/vector/manager/demo/ + # Scripted device states; compiled into debug builds only +``` + +## Process Constraints + +* *Service location, not injection.* `ServiceLocator.attach(context)` and `bind(service)` are + idempotent and order-independent, because in parasitic mode there is no guaranteed initialisation + point and the daemon binder may arrive before or after the first composition. +* *Theme from code.* The window theme is the platform default; all colour comes from + `VectorTheme` at composition time, since a resource theme would require a registered manifest. +* *Process death is routine.* The host process is killed frequently, so every reading preference + (word wrap, header surface, activity window, colour seed) is persisted rather than held in a + `ViewModel`. +* *No `FileProvider`.* Exports go through the Storage Access Framework; the document belongs to + DocumentsUI, which is what makes it shareable at all. + +## Localisation + +The framework's language cannot come from the platform. Per-app language preferences are a manifest +feature, and this manifest is never registered, so `AppCompatDelegate.setApplicationLocales` has +nothing to attach to. The chosen language is instead applied inside the composition: +`ui/theme/AppLocale.kt` provides `LocalConfiguration`, `LocalContext` and `LocalLayoutDirection` +together, which is what makes a right-to-left language flip the whole app rather than only its text. + +Two details are easy to get wrong and were: + +* The overridden context must be a `ContextWrapper` around the activity, not the result of + `createConfigurationContext` alone. The latter is detached from the activity, so anything reached + through `LocalContext` — an activity result launcher, for one — fails to find its owner and the + screen crashes on open rather than on language change. +* Every popup gets its own `AndroidComposeView`, which re-provides the Android composition locals + from the base context. Sheets, dialogs and dropdown menus therefore render in the *system* + language unless they re-apply the override themselves, which `LocalizedOverlay` exists to do. + +Dates and month names are formatted at draw time, never in a model: a name formatted in a repository +is formatted with `Locale.getDefault()`, which in parasitic mode is the host application's. + +## Framework Updates + +The daemon gained an install path (`getRootImplementation`, `installFrameworkZip`) because the +manager cannot run a privileged flash itself. Root is detected by locating the implementation's own binary +and asking it — `magisk -V`, `ksud`, `apd` — rather than by looking for su, and the flash runs +through `ProcessBuilder` with an argument list, never a shell string, so a path can never become a +command. Progress arrives as log lines on an `IFrameworkInstallCallback`, delivered from a thread of the +daemon's own: a flash takes seconds to minutes, and holding a binder thread for it starves every +other call the manager is making meanwhile — including the log reads the install screen is doing to +show what is happening. If the manager goes away mid-flash the install continues, since stopping +would leave the module tree half-written, and the daemon's own log becomes the only record. + +Two builds can share a version code — `git rev-list --count` is identical on a branch and on master +at the same depth — so a release's `target_commitish` is carried through and compared as well. When +the codes match and the hashes do not, the update screen says so instead of claiming the device is +up to date. + +Every release publishes a release zip and a debug zip of roughly three times the size. Which one is +installed is the reader's choice, shown with its size, because the troubleshooting flow elsewhere in +this app asks people for a debug build. + +## Module Updates + +Installing a module APK goes through `PackageInstaller` with the download streamed straight into the +session — no temporary file, and no `FileProvider`, which parasitically does not exist. + +The consent story differs sharply between the two modes, which is why the app has a confirmation +dialog of its own. Inside `com.android.shell` the manager inherits `INSTALL_PACKAGES`, so the commit +installs a third-party APK with no system prompt whatsoever; standalone, the platform asks as usual. +In the mode most people run, Vector's dialog is the only consent gate there is, so it names the +module, the file and its size *before* anything is downloaded. + +Whether a module is out of date is one answer shared by three screens — `ServiceLocator.storeEntries` +joins the catalogue to the installed versions once, and the list's mark, the module's sheet and the +Store's count all read it. Muting is folded into that answer rather than applied at each reader, +because a mute only some of them honoured would be worse than none. The two screens that show a +module *by itself* deliberately ignore it: someone who opened one module's page is asking, not being +nagged. + +Batch updates run one at a time on the application scope. Sequential because four concurrent sessions +contend for the same disk and, without `INSTALL_PACKAGES`, stack four system dialogs in an order +nobody chose; on the application scope because four modules take longer than anyone will hold a +bottom sheet open. + +The panel is told when an install lands rather than waiting to overhear it. A replaced package does +broadcast and the manager does listen, but delivery is the system's business and this process is a +guest in someone else's; the one install path the app performs itself has no reason to learn about it +second-hand. + +## Demo Mode + +Several states worth designing against cannot be produced on a working phone: SELinux policy not +loaded, the system server not injected, a framework below the API level installed modules need, no +root implementation at all, every installed module a version behind. `src/debug` contains a scenario list and an `ILSPManagerService` stub +that scripts the answers it has an opinion about and delegates the rest to the real daemon. + +It is a source set rather than a flag. A demo mode that could be switched on in a release build +would be a way to make the manager report a healthy framework when it is not, which is the one lie +this app must never be able to tell; a reviewer can confirm by finding no `manager/demo` classes in +a release APK. + +The most useful ones lie about a *version*, because that is what every update decision is made +against: the framework's own version code comes from the daemon, and so do the installed modules', so +reporting an old one turns a real release into a real update with nothing else faked — the catalogue, +the release list, the APK and the install are all genuine. The module scenario stops lying about a +package the moment that package actually changes, which is what makes it a test of the refresh rather +than a picture of one. + +The scenario host renders `VectorApp()` itself rather than launching the manager activity. Launching +it lets `ParasiticManagerHooker` hand over the real binder a moment later, which silently undid every +scenario — including "no daemon at all", which came up reporting a healthy framework. + +## Daemon IPC + +`DaemonClient` wraps `ILSPManagerService`. Every call suspends on `Dispatchers.IO` and returns a +`Result`. The binder reference is read *once* per call rather than null-checked and then used, +which was a time-of-check/time-of-use race, and failures are caught as `Exception` rather than +`RemoteException` alone — a daemon built without a given method throws `NoSuchMethodError`, which is +the expected outcome when a newer manager meets an older framework. + +Two calls were added to the AIDL for the log reader: `getLogParts(verbose)` lists the rotated parts +the daemon still holds, and `getLogPart(verbose, name)` opens one. The name arrives from an +unprivileged process and is used to build a path inside a root-only directory, so it is validated by +membership in the listing rather than by pattern-matching for traversal sequences. + +## Log Reader + +The daemon rotates its logcat capture at four megabytes and retains ten parts. A naive reader that +calls `readLines()` retains several megabytes of `String` per stream inside a process whose heap +belongs to the host application. + +`data/log/LogFile.kt` therefore indexes rather than loads. One sequential byte scan records the +start offset of every line into a `LongArray`, allocating no `String`. The pane holds a window of at +most `WINDOW` lines around the viewport and pages outward as the viewport approaches either edge. +Rows are keyed by absolute line number, which is what allows the window to be extended upwards +without the viewport lurching: the list re-resolves its first visible item by key after rows are +inserted above it. + +Filtering builds an `IntArray` of matching line numbers and pages through that instead, so a filter +over a 30,000-line file costs one scan and no re-parse. + +## Colour Generation + +Android exposes no public API that converts a colour into a Material scheme; `dynamicColorScheme` +reads the wallpaper and nothing else. `ui/theme/SeedScheme.kt` generates one in *CIE LCh* — the +same principle as Google's HCT — by holding the seed's hue and chroma and walking L\* across the +Material tone scale. + +The non-obvious part is gamut mapping. Most (lightness, hue) pairs cannot hold the seed's full +chroma in sRGB, so each tone binary-searches the highest chroma that converts in range. Clamping the +channels instead shifts hue as tones darken, which is why naive generators drift blue toward purple +down the ramp. The error ramp is fixed at a red hue regardless of the seed, so destructive actions +do not change meaning with the theme. + +`ui/components/ColorWheel.kt` renders the hue/chroma disc by evaluating every pixel through the same +conversion, once per tone, off the main thread and cached as an `ImageBitmap`. + +## Remote Data + +* *Store mirrors.* The full `modules.json` is served by exactly one host today; the public site + answers it with 403 and two historical mirrors no longer resolve. Per-module detail *is* served by + both, so the mirror lists are deliberately separate — merging them takes the catalogue offline. +* *Freshness is declared per request.* The OkHttp disk cache is the offline story: on total mirror + failure the same request is replayed against the cache alone, so a cold start with no network + renders the last known catalogue instead of an error. +* *DNS-over-HTTPS is a fallback, not a replacement.* `net/VectorDns.kt` attempts DoH, falls + through to the system resolver on failure, latches that failure for the session, and disables + itself entirely when a proxy is configured. The setting is read per lookup, because OkHttp cannot + have its DNS swapped on a live client and rebuilding the shared client would orphan the cache. +* *Activity feed.* `versionCode` equals `git rev-list --count`, so a commit's distance from HEAD is + its version number, and the feed can name exactly which commits an update would bring without an + additional endpoint. The total count comes from the `Link: rel="last"` header and the repository + statistics from a second request, so both are cached in files of their own — they are answers a + cached read cannot reproduce, and writing a failed fetch straight through erased them. +* *Commit archive.* `/commits` returns at most a hundred per request, so a full history has to be + walked backwards and kept. `data/github/CommitArchive.kt` is append-only NDJSON keyed by SHA: + everything below the tip is immutable, so a chunk costs its own length rather than a rewrite, and + the mutable head window is simply appended again with later lines winning on read. + + The walk is cursored on *date*, not page number — page numbers are relative to the tip and shift + under any new commit — and on the **commit** date rather than the author date, because that is + what `until` filters on and the two differ on 39 of the newest hundred commits here. + + A date cursor has one failure mode and this repository has it: 100+ commits share + `2023-02-26T08:48:49Z`, and asking for commits at or before that second returns the same hundred + forever. Inside such a plateau the walk pages by number, which is safe in exactly that position + because the window is anchored by an `until` in the past. Completion is an *empty* page and + nothing weaker; "nothing new" is what the plateau produces on every request. + + Three pages are fetched per visit and the cursor is left on disk. Sixty requests an hour is the + anonymous budget, and a history that assembles over a few sessions is preferable to one that + spends all of it on arrival. +* *Contributor resolution.* GitHub links commits to accounts by email and does not always succeed. + A `@users.noreply.github.com` address encodes the account and needs only parsing. Otherwise the + name is probed against `/users/{name}` *only if it is shaped like a handle* — containing a digit, + hyphen or underscore — because `GET /users/Qing` returns a real and unrelated account, and + crediting a contribution to a stranger is worse than leaving it uncredited. +* *Co-authors.* A `Co-authored-by:` trailer carries an address and no account, and no endpoint turns + one into the other — the users search API refuses to index email, and answers `total_count: 0` for + a noreply address however it is phrased. But every commit GitHub *has* attributed is a verified + email-to-login pair, and the archive is full of them, so trailers are resolved against history + already in hand at no request cost. Names are indexed too, one tier weaker and first-wins. +* *Module detection.* Deciding whether an installed package is a module means opening its APK and + its splits as zips: roughly 550 opens on a 363-package device, once paid on every visit to the + panel. `data/model/ModuleDetectionCache.kt` keys the answer by package, version code and install + time, which is the exact set of things whose change can change the answer. + +## Canary Distribution + +`actions/artifacts//zip` returns 401 to an anonymous caller; a release asset returns 206. CI +therefore attaches each master build to a `canary-` prerelease, and the canary screen +reads `/releases`. No account is required at any point, which matters for users who cannot reach +GitHub's sign-in at all. + +Device-flow sign-in remains available and requests *no scopes*. It only raises the anonymous rate +limit; if `githubClientId` is not supplied as a Gradle property the app hides sign-in entirely rather +than presenting a control that cannot work. + +## Build Notes + +* Material 3 Expressive has not landed in a stable `material3` release, so `material3` is pinned + above the Compose BOM rather than resolved from it. +* Kotlin is declared at the root with `apply false`. AGP 9 otherwise supplies its own, older + version, against which Coil's metadata fails to load. +* `githubClientId` is read from `local.properties` or `~/.gradle/gradle.properties` and defaults to + empty. diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts new file mode 100644 index 000000000..efd20f903 --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,136 @@ +import java.time.Instant + +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 + +android { + namespace = defaultManagerPackageName + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + applicationId = defaultManagerPackageName + buildConfigField("long", "BUILD_TIME", Instant.now().epochSecond.toString()) + 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. Set + // `githubClientId` in local.properties or ~/.gradle/gradle.properties to enable it; + // 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..724db108d --- /dev/null +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt @@ -0,0 +1,271 @@ +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 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 setHiddenIcon(hide: Boolean) { + real?.setHiddenIcon(hide) + } + + 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 getAutoInclude(packageName: String?): Boolean = + real?.getAutoInclude(packageName) ?: false + + override fun setAutoInclude(packageName: String?, enable: Boolean): Boolean = + real?.setAutoInclude(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..0f6c2168b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt @@ -0,0 +1,34 @@ +package org.matrix.vector.manager + +import android.os.IBinder +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 { + 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({ exitProcess(0) }, 0) + } catch (_: Exception) { + 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..67c984717 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/CommitArchive.kt @@ -0,0 +1,157 @@ +package org.matrix.vector.manager.data.github + +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. + * + * The activity feed used to be one request — the newest hundred commits within a date window — and + * "from the start of the project" cannot be answered that way: `/commits` returns at most a hundred + * per request and there is no widening the window past that. The history has to be walked backwards + * a page at a time and remembered, which is what this is. + * + * ## 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 an older, wronger walk is worse than no cursor at all — the one + * that stalled on the plateau above saved `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 four 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() + runCatching { + file.forEachLine { line -> + if (line.isBlank()) return@forEachLine + runCatching { json.decodeFromString(line) } + .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 } + } + } + return byShaLatestWins.values.sortedByDescending { it.commit.author.date } + } + + /** Appends a chunk. Duplicates are expected and are resolved on read, not here. */ + fun append(commits: List) { + if (commits.isEmpty()) return + parsed = null + runCatching { + file.parentFile?.mkdirs() + file.appendText(commits.joinToString("\n", postfix = "\n") { json.encodeToString(it) }) + } + } + + /** + * 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 + val lines = runCatching { file.readLines().count { it.isNotBlank() } }.getOrDefault(0) + val unique = read() + if (lines <= unique.size * 2) return + runCatching { + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(unique.joinToString("\n", postfix = "\n") { json.encodeToString(it) }) + tmp.renameTo(file) + parsed = unique + } + } +} 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..345534734 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/FeedLayout.kt @@ -0,0 +1,202 @@ +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, because a commit row's + * height is set by its text and would otherwise leave the line stopping short of the next + * node — the rail visibly broke 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 + + + + /** Consecutive bot commits, folded. */ + 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. + // + // This used to be a `filter` over every commit at each month boundary, with a fresh + // Calendar per comparison — O(commits × months), which on a six-month window was a few + // thousand cheap operations and on the full archive was 1517 × 55 Calendar allocations and + // a 1.9-second freeze on the thread laying out the feed. Two passes and a map do the same + // work in one sweep. + 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. + * + * This used to be the displayed name, formatted with `Locale.getDefault()` — which is the + * process default, the host app's, and so it stayed French while the rest of the app was in + * Chinese. Worse, it was computed here in the model, where the in-composition language override + * is not visible at all. So the model now 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..c484d382c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt @@ -0,0 +1,205 @@ +package org.matrix.vector.manager.data.github + +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. + _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 -> { + _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 + + /** GitHub could not be reached, or no client id was compiled in. Not an error state. */ + 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..d9c8d2b67 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt @@ -0,0 +1,349 @@ +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 39 of the newest 100 commits here, by up to four days — 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, not + * conventional-commit prefixes, so the leading verb is what gets read. Validated over the last 300 + * commits: `Fix` 56, `Bump`/`Update`/`Upgrade` 88, `Add`/`New`/`Implement` 22, `Remove`/`Delete` + * 14. */ +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 is not the repository owner — highlighted 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. */ + /** 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 telling + * the user "could not reach GitHub" whenever the feed came from disk announced a failure that + * had not happened — the app simply had not 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, +) { + 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, + 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 the single "first zip" this used to keep: each release ships a Release + * and a Debug build, 8 MB against 22 MB, and picking whichever GitHub happened to list first + * meant the reader could neither choose nor tell which one they were about to flash. The app + * asks people for a debug build when they report a problem, so it has to be able to install + * one. + */ + 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..8c02f3711 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -0,0 +1,903 @@ +package org.matrix.vector.manager.data.github + +import java.io.File +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Loads the last quarter of activity on the project's GitHub repository. + * + * Offline-first: [load] returns whatever is on disk immediately if 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. + * + * They used to live inside the feed snapshot, which is why they kept disappearing: the feed is + * rewritten on every successful commit fetch, and the repo comes from a *separate* request that + * fails independently — so one rate-limited hour replaced a perfectly good answer with nothing, + * and every later launch wrote the nothing back. Merging on write fixed that particular loss, + * but it left the answer hostage to a file that something else owns and rewrites constantly. + * + * Kept apart, they can only be replaced by an actual answer to the question they came from. + * Nothing else writes here, so nothing else can lose them. 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 *failure*: it is as worth keeping as a success, because the + * alternative is asking about the same unresolvable name on every load. Persisted so that + * survives the process, which parasitically 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, + /** Normal conditional request; 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 + val previous = + runCatching { json.decodeFromString(snapshotFile.readText()) }.getOrNull() + val fresh = runCatching { fetch(windowStart, freshness) }.getOrNull() + if (fresh != null) { + // Merged with what was already on disk rather than replacing it. The stars, forks + // and licence come from a *second* request, and the two do not fail together: on a + // rate-limited hour the commits arrived and the repo did not, and writing that + // straight through erased a perfectly good cached answer — the footer disappeared + // and stayed disappeared, because every later launch overwrote it again. 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)) + ) + } + return@withContext build( + timeline(fresh.rawCommits, windowStart), + repo, + windowStart, + fromCache = false, + offline = false, + totalCommits = total, + freshness = freshness, + ) + } + val cached = + runCatching { json.decodeFromString(snapshotFile.readText()) } + // 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. + .recoverCatching { + Snapshot( + commits = json.decodeFromString>(snapshotFile.readText()) + ) + } + .getOrNull() + ?: return@withContext CommunityFeed( + fromCache = true, + // Nothing on disk and nothing from the network: whether that counts as + // offline still depends on whether a request was even attempted. + offline = freshness != Freshness.Cached, + loaded = true, + ) + build( + timeline(cached.commits, windowStart), + // Its own file first: the feed snapshot's copy is a historical accident and may + // predate the split, or have been nulled by the bug that prompted it. + readRepo() ?: cached.repo, + windowStart, + fromCache = true, + offline = freshness != Freshness.Cached, + // Kept with the commits rather than recomputed: it comes from the `Link` header of + // a request this path did not make. Without it every cached read lost the commit + // numbering, and "you are here" — which is the whole point of numbering them — + // silently vanished on the four launches out of five that read from disk. + 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 } + 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 — 39 of the newest hundred commits here differ, by up to four days. 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 reading it as the end was precisely the bug; "fewer + * than a hundred" is what a shared boundary second produces legitimately. + * + * ## 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 batch = + runCatching { + get( + "$API/$REPO/commits?until=${iso8601(cursor)}&per_page=100&page=$page", + Freshness.Revalidate, + ) + ?.let { json.decodeFromString>(it) } + } + .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) 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 vanished on every launch that + * deliberately did not fetch, which is most of them. + */ + 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 + } + } + + private fun get(url: String, freshness: Freshness): String? { + 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 -> + if (!response.isSuccessful) return null + return response.body.string() + } + } + + 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 + 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. + .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, + // Two ways the window can be under-served, and both mean "keep walking": an unbounded + // window is never covered until history runs out, and a bounded one is uncovered while + // the oldest commit in hand is still newer than its start — which happens whenever the + // window holds more than the hundred commits a single request returns. + hasMoreHistory = + !archive.state().complete && + !(totalCommits > 0 && archive.read().size >= totalCommits) && + (windowStart <= 0 || + (commits.minOfOrNull { it.epochSeconds } ?: 0L) > 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 meant 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) } + .getOrDefault(emptyList()) + .filter { it.prerelease && it.tagName.startsWith(CANARY_TAG_PREFIX) } + .take(CANARY_KEEP) + .map { release -> + CanaryBuild( + id = release.id, + 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) } + .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. Without this filter the manager offered LSPosed 1.11.0 to a Vector + * 3049 device and called it an update — a cross-product downgrade, flashed with root. Matching + * the 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. Requiring a digit, a hyphen or an + * underscore 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 as a permanent failure — 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 found = + runCatching { + get("$API_ROOT/users/$name", freshness)?.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() + + 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" + + /** + * Canary builds come off CI rather than a release tag, so testing one means going to the + * workflow's run list and taking the artifact from the most recent master build. + */ + const val CANARY_URL = "$REPO_URL/actions/workflows/core.yml?query=branch%3Amaster" + 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" + + /** + * A name shaped like a handle rather than a display name. + * + * A digit, a hyphen or an underscore somewhere in it, and nothing that a GitHub login + * cannot contain. See [resolvePerson] for why the bar is deliberately this high. + */ + 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 + + private const val DAYS_PER_MONTH = 30L + + 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/LogFile.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt new file mode 100644 index 000000000..52691b861 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt @@ -0,0 +1,380 @@ +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. + * + * The screen this feeds used to call `readLines()` and hold the result in a `StateFlow` — for + * *both* tabs, on every refresh, whether or not the tab was visible. That is roughly 13 MB of + * `String` churn per refresh inside a process whose heap belongs to `com.android.shell`. + * + * The honest correction to the usual telling of that story is that a single `getLog()` is capped + * today: `logcat.cpp` rotates at 4 MB per part, and a rotated part on a real device measures + * 4,194,639 bytes. So the old reader was wasteful rather than instantly fatal. It still had to go, + * because the cap is a property of the daemon build that happens to be running — rotation only + * fires inside the logd read loop, so a wedged logd grows the part without bound — 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.** + * + * 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]. The previous code wrapped the raw + * `pfd.fileDescriptor` in a `FileInputStream` *and* closed the `ParcelFileDescriptor` in a + * `finally`, closing 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, which is + * also where [MAX_LINE_CHARS] would have cut it anyway. + */ + 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_CHARS + if (cut) length = MAX_LINE_CHARS + 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 about ten times the largest log the daemon can + * currently produce. 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..0db0e170c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt @@ -0,0 +1,212 @@ +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, and guessing any of them + * wrong is how log parsers rot: + * + * 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 { + /** `kLogChar` in logcat.cpp, in the same order Android's priorities are numbered. */ + 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; `SILENT` and `UNKNOWN` never reach a reader. */ + 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_CHARS] 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. + * + * 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_CHARS = 4096 + +/** The three-character delimiter that ends the prefix. See [parseLogLine]. */ +private const val DELIMITER = " ] " + +/** `"[ "` + 23 characters of timestamp + at least `" 0: 0: 0 V/x ] "`. */ +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..c32c51894 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt @@ -0,0 +1,19 @@ +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 legacy UI sorted on both. */ + val firstInstallTime: Long, + 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..4b2a00b79 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/InstalledModule.kt @@ -0,0 +1,22 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.ApplicationInfo + +/** Pure Kotlin data class representing an installed Xposed module. */ +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 at all, which the old manager flagged. */ + 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, // Kept for Icon loading via Coil/Glide later +) 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..4cd4271c9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt @@ -0,0 +1,202 @@ +package org.matrix.vector.manager.data.model + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import java.util.Properties +import java.util.zip.ZipFile + +/** + * Everything the manager can learn about a package by looking at it. + * + * There are two generations of Xposed module and both must be recognised, which is the bug this + * file exists to fix: the previous implementation tested only the legacy `xposedmodule` / + * `xposedminversion` manifest meta-data, so every module written against API 100 and later — which + * ships no such meta-data, only files inside its APK — was invisible in the module list. + * + * The whole inspection is done in **one pass over the APK**. Opening the zip is the expensive part + * and the list opens it once per module already; doing it three times to answer three questions + * would triple the cost of showing the screen. + */ +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 = "", +) { + val declaresApiVersion: Boolean + get() = minApiVersion > 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" + } + } + + 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(), + ) + } + } + .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`. + * + * Reading the manifest attribute for both generations is why every legacy module in the list + * showed a blank line where its description should be — legacy modules simply do not set it. + * The 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() } + } + } + .getOrNull() + ?.filter { it.isNotEmpty() } ?: return emptyList() + + // Legacy modules name the system server the other way round. The swap dates to a 2015 + // XposedBridge commit and every module written since has followed it. + 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..9e0951265 Binary files /dev/null and b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetectionCache.kt differ 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..f4413b75b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -0,0 +1,204 @@ +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, checked against a live `modules.json` (809 entries) + * rather than against the legacy Java model, which had drifted. 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. + * + * Not a list of names, which is what the field looks like and what cost a crash on the device: the + * 49 entries carrying one hold `{type, name, link}` objects, and Gson threw `Expected a string but + * was BEGIN_OBJECT` on the eleventh module in the catalogue — which took the whole parse, and with + * it the entire Store, down with it. + */ +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` (tags `43-` and `42-`), which is enough to make `LazyColumn` + * throw `IllegalArgumentException` as soon as the Releases tab composes. `id` is unique by + * construction, the tag is the fallback, and the index is the last resort so that a malformed + * payload 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 comparison is the legacy rule kept verbatim, because its second clause 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 there are three + * of them — the header count, the updates-first priority and the row badge — 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 do not read this: the store's + * detail page and the module's own sheet both offer the update whether or not it is muted, and + * the sheet puts the switch right beside it. 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..35e1ab494 --- /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. The lists in the UI derive + * from this one, so nothing else needs touching. + */ + 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..ffb0420e9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt @@ -0,0 +1,96 @@ +package org.matrix.vector.manager.data.repository + +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.ModuleDetection +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, +) { + @Volatile private var cachedApps: List? = null + @Volatile private var cachedModulePackages: Set? = null + + /** + * Drops the cache so the next read goes back to the daemon. + * + * Wired to package install/remove broadcasts. Without it the list went stale for the life of + * the process — a module installed while the manager was open never appeared. + */ + fun invalidate() { + cachedApps = null + cachedModulePackages = null + } + + suspend fun getInstalledApps(forceRefresh: Boolean = false): List = + withContext(Dispatchers.IO) { + if (!forceRefresh && cachedApps != null) { + return@withContext cachedApps!! + } + + // MATCH_UNINSTALLED_PACKAGES | GET_META_DATA + val flags = PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_META_DATA + + val result = + daemonClient.getInstalledPackagesFromAllUsers(flags, filterNoProcess = true) + if (result.isFailure) 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, + applicationInfo = appInfo, + ) + } + + cachedApps = appList + return@withContext appList + } + + /** + * Which installed packages are themselves Xposed modules. + * + * Answering this means opening every installed APK to look for the module markers, 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. + */ + suspend fun modulePackages(): Set = + withContext(Dispatchers.IO) { + cachedModulePackages?.let { + return@withContext it + } + val packages = + getInstalledApps() + .asSequence() + .filter { ModuleDetection.inspect(it.applicationInfo, packageManager).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..94573f0a2 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt @@ -0,0 +1,126 @@ +package org.matrix.vector.manager.data.repository + +import android.content.Context +import android.net.Uri +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import kotlinx.coroutines.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. It was a `Toast` stub that + * showed a file picker and then silently wrote nothing, which is worse than not offering it. + * + * Deliberately *not* backed up: anything the manager can rediscover. The module APKs themselves + * belong to the package manager, and a restore onto a device where a module is not installed + * simply skips it rather than failing 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).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 + } + } + + 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 module that is not installed here is skipped rather than failing the + // restore — a backup is routinely carried between devices. + val enabledOk = + daemon.setModuleEnabled(module.packageName, module.enabled).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 + } + } + daemon.setModuleScope(module.packageName, scope) + } + restored++ + } + RestoreOutcome(restored, skipped) + } + } + + 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..d9cdd15e1 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkInstaller.kt @@ -0,0 +1,168 @@ +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.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; [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) { + 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 + try { + awaitInstall(zip.absolutePath) + } finally { + // The daemon has read it by now; leaving a release zip in the cache costs tens of + // megabytes that nothing will ever clean up. + 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. + * + * A plain suspendCancellableCoroutine would be the obvious shape, and it is wrong here: the + * installer's last act on some root implementations is to restart the device, so the + * continuation may simply never be resumed. The state flow is updated from the callback + * instead, and this returns once the daemon has answered *or* the caller navigates away. + */ + 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) { + append("The daemon refused the install: ${started.exceptionOrNull()?.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 + // only ever shows the tail anyway. + _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..5aac86323 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/FrameworkUpdateRepository.kt @@ -0,0 +1,109 @@ +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 by an app you did not ask for advice. + */ +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( + loaded = true, + onCanaryChannel = onCanary, + installedVersionCode = installedVersionCode, + installedCommit = installedCommit, + available = newest?.takeIf { it.versionCode > installedVersionCode }, + // Kept rather than discarded, which is what this used to do with everything but + // the newest. "No update available" was a dead end, and 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, which + * the UI must not confuse — hence [loaded]. Home renders no update mark until the answer is known, + * rather than flashing "up to date" and then contradicting itself. + */ +data class FrameworkUpdateState( + val loaded: Boolean = false, + val onCanaryChannel: Boolean = false, + 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 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 must not be reported as either of the other two. + */ +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..dc0c2b8d0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt @@ -0,0 +1,244 @@ +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.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, is the point rather than a micro-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. README principle 3. + * + * The session's package name is pinned to the catalogue entry's, which makes the platform reject an + * APK that declares anything else. 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 + _state.value = + if (succeeded) InstallStep.Done(packageName) + else InstallStep.Failed(packageName, result.second) + } + } catch (e: Exception) { + 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) + ) + } + } + 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..1122ecc76 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt @@ -0,0 +1,96 @@ +package org.matrix.vector.manager.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient + +/** + * The single source of truth for which modules are enabled. + * + * It observes the binder rather than being poked once at construction. That ordering was the + * previous bug: the repository fired its first fetch from an `init` block, which ran before any + * daemon connection existed, so it always failed and was never retried — the enabled set stayed + * 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 is known to have changed. + * + * The scope editor is a separate screen with its own view model, so the list behind it had no + * way to learn that the thing it was depicting had just been edited: applying a scope and + * pressing back left the row still showing the old set of app icons until a manual pull to + * refresh. This is a fact, not a guess — the daemon confirmed the change before it is + * announced — so the list can act on it without re-reading everything. + */ + val scopeRevision: StateFlow = _scopeRevision.asStateFlow() + + /** Called after the daemon has *accepted* a scope change, never on an attempt. */ + fun noteScopeChanged() { + _scopeRevision.update { it + 1 } + } + + private val _packageRevision = MutableStateFlow(0) + + /** + * Bumped when a package is installed, updated or removed. + * + * The module list is now cached across visits, which is what made it fast — and what would + * have made it wrong, because a list that is never recomputed never notices a module being + * installed. The daemon already tells us when packages change; this is that signal reaching + * the list, so the rescan happens exactly when something changed rather than on every visit. + * The scan it triggers is cheap: only the package that actually changed is re-inspected. + */ + 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() } } + } + } + + /** + * 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 accepted = daemonClient.setModuleEnabled(packageName, enable).getOrDefault(false) + if (!accepted) 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..0ba76e3ba --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleUpdateQueue.kt @@ -0,0 +1,101 @@ +package org.matrix.vector.manager.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +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) } + _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 the broadcast is the system's to deliver and this process is a + // guest in `com.android.shell`, so a list that only refreshes when the broadcast + // arrives is a list that sometimes does not. This is the one install path the app + // itself performed; there is no reason for it to learn about it second-hand. + modules.notePackagesChanged() + } + } + + /** + * Clears a finished run. + * + * Only when it has finished — there is no cancel here on purpose. An install that has reached + * the platform cannot be recalled, and a stop button that could not stop the thing in front of + * you would be a lie about what the app controls. + */ + fun acknowledge() { + if (_state.value.running) return + _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..0be9b6b09 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -0,0 +1,294 @@ +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 it with a 403, and the blogcdn and + * cloudflare mirrors the previous code listed no longer resolve at all, which left the Store + * permanently empty on a fresh install. 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 again. + * + * **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 — README principle 4 — 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() + + + /** Guards a refresh without the check-then-set race the previous boolean flag had. */ + 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, and `tryLock` closes the window two callers had between check and set. + 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 after an install so the badges settle immediately. */ + fun refreshInstalled() { + scope.launch { loadInstalled() } + } + + private suspend fun loadInstalled() { + val packages = daemon.getInstalledPackagesFromAllUsers(0, false).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` rather than a close on the success branch. The failure path is the one that + // runs whenever a mirror is down, and it was the path leaking the connection. + client.newCall(request(url, cacheControl)).execute().use { response -> + if (!response.isSuccessful) 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 is what the previous code did, and on the + * real payload it threw: `additionalAuthors` holds objects rather than the strings its name + * suggests, and the eleventh module of 809 took the entire Store down with it. This is + * third-party data written by hundreds of authors, so one 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 malformed mirror + * from crashing a `LazyColumn` keyed by package name. Entries with no release at all are + * dropped because there is nothing to install and nothing to say about them; there is exactly + * one such entry today, and the legacy loader dropped it too. + */ + 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: 1,226,889 bytes and 809 + * entries here, a 403 from `modules.lsposed.org`, and no DNS at all for the other two + * mirrors the previous code carried. + */ + 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..1a5ada12b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt @@ -0,0 +1,237 @@ +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 + +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 + private val _updateChannel = + MutableStateFlow(prefs.getString("update_channel", "stable") ?: "stable") + val updateChannel: StateFlow = _updateChannel.asStateFlow() + + private val _dohEnabled = MutableStateFlow(prefs.getBoolean("doh_enabled", false)) + 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 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 fresh set, not the one handed out: SharedPreferences keeps the instance it is given and + // documents that mutating it afterwards is undefined. + 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() + + /** + * How big, 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. + */ + 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 + } + + 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() + + /** + * Matches an `AmbienceKind` key. It used to read "ripple", a surface that was replaced long + * ago — harmless, because an unknown key falls back, but a stored default that names + * nothing is a lie waiting to be believed by 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..212b70087 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt @@ -0,0 +1,238 @@ +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.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 instead of being poked by a setter, which removes the old bug where + * the module list was fetched once, before any binder existed, and then never again. + */ + val service: StateFlow = _service.asStateFlow() + + val context: Context + get() = + appContext + ?: error("ServiceLocator.attach() must run before the UI touches the context") + + /** True once the activity has handed us a context. */ + val isAttached: Boolean + get() = appContext != null + + 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) } + + /** + * 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 550-zip scan on a device with 363 packages. + */ + 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 — + * and keeps doing so. + */ + 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 + observePackageChanges() + } + + /** + * Invalidates the caches when a package is installed, updated or removed. + * + * `packageEventsFlow` existed and had no collectors at all, so every list went stale for the + * life of the process: a module installed while the manager was open simply never appeared. + */ + 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 cannot be reached because + * its prefetch failed would be strictly worse than one that is merely slow. + */ + fun prefetch() { + appScope.launch { runCatching { apps.getInstalledApps() } } + appScope.launch { runCatching { store.refresh() } } + appScope.launch { runCatching { github.load() } } + } + + /** 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..3da5dcb54 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -0,0 +1,211 @@ +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 org.lsposed.lspd.ILSPManagerService + +/** + * Safely wraps synchronous Binder transactions into asynchronous Kotlin Coroutines. Ensures the + * main UI thread is never blocked by IPC delays or daemon deadlocks. + */ +class DaemonClient(private val serviceState: StateFlow) { + + val service: ILSPManagerService? + get() = serviceState.value + + val isAlive: Boolean + get() = service?.asBinder()?.isBinderAlive == true + + /** + * Executes a daemon IPC call on the IO thread pool. Wraps the response in a [Result] to handle + * RemoteExceptions gracefully without crashing. + */ + private suspend fun runIpc(block: (ILSPManagerService) -> T): Result = + withContext(Dispatchers.IO) { + // Read the binder once. Reading it twice invited a TOCTOU where the daemon died + // between the liveness check and the call, and `service!!` threw an NPE that the + // RemoteException-only catch below let escape into the collecting coroutine. + 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 cancels the caller's scope — which in + // a viewModelScope means the process dies. + Result.failure(e) + } + } + + // --- Core Methods --- + + suspend fun getXposedApiVersion(): Result = runIpc { it.xposedApiVersion } + + suspend fun getEnabledModules(): Result> = runIpc { it.enabledModules().toList() + } + + 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 current log part, or `null` when the daemon has not opened one yet. + * + * The AIDL returns a platform type, so the previous `Result` happily + * carried a `null` under a non-null type parameter: `getOrNull()` could not tell "the daemon is + * unreachable" from "there is no log file yet", and `getOrThrow()` would have handed a null back + * as non-null. Those are two different situations and the Logs screen renders them differently, + * so the nullability is admitted here instead of being lost. + */ + /** + * The rotated parts the daemon still holds for one of the two logs, oldest first. + * + * Empty against an older daemon that has no such call — the manager then simply shows the live + * part, which is what it did before this existed. + */ + suspend fun getLogParts(verbose: Boolean): Result> = runIpc { + it.getLogParts(verbose).orEmpty() + } + + suspend fun getLogPart( + verbose: Boolean, + name: String, + ): Result = runIpc { it.getLogPart(verbose, name) } + + 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 a zip of every log the daemon holds into [zipFd], for sharing a bug report. */ + suspend fun writeLogsTo(zipFd: android.os.ParcelFileDescriptor): Result = runIpc { + it.getLogs(zipFd) + } + + /** Restarts the manager after a framework update, re-entering through the given intent. */ + 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 + } + + suspend fun setHiddenIcon(hide: Boolean): Result = runIpc { it.setHiddenIcon(hide) + } + + suspend fun getAutoInclude(packageName: String): Result = runIpc { it.getAutoInclude(packageName) + } + + suspend fun setAutoInclude(packageName: String, enable: Boolean): Result = runIpc { it.setAutoInclude(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) } +} 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..c1c7cd317 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt @@ -0,0 +1,62 @@ +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 + +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() +} + +/** Provides a continuous stream of package events (installs, removals, updates). */ +fun Context.packageEventsFlow(): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val packageName = intent.data?.schemeSpecificPart ?: return + val userId = intent.getIntExtra(Intent.EXTRA_USER, 0) + + when (intent.action) { + // An update to an existing package. It also arrives as ADDED with + // EXTRA_REPLACING, but only after a REMOVED for the old copy — and a listener + // that acts on the REMOVED has already dropped the module from the list by the + // time the ADDED lands. Handling REPLACED directly is one event, in order. + 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) + + // When the flow collection is cancelled (e.g. app goes to background/dies), unregister + // automatically + awaitClose { unregisterReceiver(receiver) } +} 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..13d4c2353 --- /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 in censored networks cannot + * resolve the module repository or GitHub over plain DNS. See [VectorDns] for why it must never + * be the only path: making it one is what leaves the Store permanently empty on a network where + * Cloudflare is itself blocked. + */ +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..df89e4144 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt @@ -0,0 +1,99 @@ +package org.matrix.vector.manager.net + +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. But it used to be **all or nothing** — with the setting on, every lookup went through + * `cloudflare-dns.com` and a failure was final. On a network where Cloudflare itself is blocked, + * which is precisely the kind of network the setting is meant for, that meant the module list, the + * activity feed and every avatar failed together and the app was unusable until the switch was + * found and turned off by hand. This is the single largest cause of an empty Store. + * + * So DoH is now **best-effort**: + * - 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 the switch has to be readable from in here to take + * effect at all. Previously it only applied to clients built after it was toggled, which in + * practice meant "after 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( + TAG, + "DoH resolver unreachable; using the system resolver for the rest of this " + + "session: ${e.message}", + ) + } + } + return Dns.SYSTEM.lookup(hostname) + } + + private companion object { + const val TAG = "VectorManager" + } +} 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..ad53d1f41 --- /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, because `system_server` does not know these spoofed activities + * exist. One activity is therefore not a style preference, it is the shape the injection model + * wants. + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + // Must precede super.onCreate. Handing off from the platform splash removes the frame of + // unthemed window the previous build showed 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..bc163e6a2 --- /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. + * + * It also replaces the hand-built floating pill bar, which announced four unlabelled clickable + * images to TalkBack with no indication of the current destination, applied no horizontal or IME + * insets, and forced every screen to guess a fixed bottom padding to clear 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 + + // Hiding the *items* alone still left the container laid out, so a detail screen — the + // in-app browser especially — kept a dead strip of navigation-bar-sized space at the + // bottom. Driving the scaffold's own state removes the container and animates it away. + 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() }, + // NavDisplay applies its own scene-setup decorator internally; these are the + // two that must be added by hand. The ViewModel one is the important one: it + // scopes a ViewModelStore per entry, so opening the scope editor for a second + // module builds a second ViewModel instead of silently 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)) }, + ) + } + entry { + FrameworkUpdateScreen( + 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..5ae543aa0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/AppIcon.kt @@ -0,0 +1,100 @@ +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 { + + // Roughly 300 icons at 48 dp on an xxhdpi screen. 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..62380ec29 --- /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 single most active contributor of the quarter. + */ +@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..f9b39122a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt @@ -0,0 +1,530 @@ +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) { + val sha = measurer.measure(commit.shortSha, VectorMono).size + val pr = prLabel?.let { measurer.measure(it, VectorMono).size } + // chip padding (10) + border, per chip, plus the gap between them + val width = + sha.width + CHIP_PAD_PX + (pr?.let { it.width + CHIP_PAD_PX + GAP_PX } ?: 0) + val height = maxOf(sha.height, pr?.height ?: 0) + CHIP_PAD_PX + 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(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = + Modifier.fillMaxHeight() + .clip(RoundedCornerShape(4.dp)) + .background(colors.surfaceContainerHigh) + .padding(horizontal = 5.dp), + 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( + 1.dp, + colors.primary.copy(alpha = 0.4f), + RoundedCornerShape(4.dp), + ) + .clickable { + onOpenPullRequest(commit.pullRequest) + } + .padding(horizontal = 5.dp), + 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; crowding them made + // the row read as one dense block. + 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. + * + * 56 of the last 300 commits on this repository are 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. Sizing it to a fixed length instead left it stopping + // short of the next node whenever a commit's text ran to two lines, so the rail visibly broke. + 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. + * + * Colouring by type was tried and dropped: 52 of the last 300 subjects begin with "Fix", so an + * error-coloured node made a perfectly healthy history read as a wall of alarm. It was also + * redundant — this project writes plain imperative subjects, so the first word of the line the + * user is already reading *is* the type. Saying it twice, once in a colour that means "something + * is wrong", was worse than not saying it. + * + * [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" + +/** Horizontal padding inside a chip, and the gap between the two, in raw pixels. */ +private const val CHIP_PAD_PX = 26 +private const val GAP_PX = 12 + +/** + * 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?, + 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) + beginningDate != null -> + stringResource(R.string.home_history_beginning, beginningDate) + 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..40325e412 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt @@ -0,0 +1,453 @@ +package org.matrix.vector.manager.ui.components + +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.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.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.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.CloudOff +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.Storefront +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.model.StoreEntry +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 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. + * + * Everything here needs the daemon, because the manager has no privilege of its own — it runs as + * `com.android.shell` and cannot force-stop or uninstall anything itself. Each action is a Binder + * call, so each reports 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, +) { + val scope = rememberCoroutineScope() + val daemon = ServiceLocator.daemon + val colors = MaterialTheme.colorScheme + // No skipPartiallyExpanded. Passing it removed 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 + // opened at full height and could not be made smaller. Left at the default, 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 = 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, + ) + } + + ActionRow( + icon = Icons.AutoMirrored.Rounded.Launch, + title = stringResource(R.string.action_launch), + ) { + finish { + // The launcher activity has to be resolved *as that user* — the manager's own + // package manager cannot see another profile's activities. + val intent = + Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setPackage(packageName) + val resolved = + daemon.queryIntentActivitiesAsUser(intent, 0, userId).getOrDefault(emptyList()) + val target = resolved.firstOrNull() + if (target == null) { + PackageActionResult(R.string.action_no_launcher, tone = SnackbarTone.Failure) + } else { + val launch = + Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setClassName(target.activityInfo.packageName, target.activityInfo.name) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + daemon.startActivityAsUserWithFeature(launch, userId) + PackageActionResult(R.string.action_launched) + } + } + } + + 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) + daemon.startActivityAsUserWithFeature(intent, userId) + PackageActionResult(R.string.action_opened_info) + } + } + + ActionRow(icon = Icons.Rounded.Stop, title = stringResource(R.string.action_force_stop)) { + finish { + daemon.forceStopPackage(packageName, userId) + PackageActionResult( + R.string.action_force_stopped, + appName, + tone = SnackbarTone.Success, + ) + } + } + + 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).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 ok = daemon.uninstallPackage(packageName, userId).getOrDefault(false) + 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 in the Store, because this is where the reader already is. + * Before this, a module marked out of date in the list offered nothing to press: the route was to + * remember its name, cross to the Store tab, find it again and install from there — and the switch + * that silences a module you have decided not to follow was at the end of that same detour. + * + * 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( + icon = Icons.Rounded.Storefront, + 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..981073b3f --- /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 — and each had grown its own header: two hand-rolled rows and a + * `TopAppBar`, of three different heights. Switching tabs therefore moved the search field, which is + * the one control a thumb learns the position of, and 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..3f0a2fac4 --- /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 was + * being 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. The first version constructed 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 now 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, which was the first version of this: its formatting runs through + // `Locale.getDefault()` regardless of the context handed to it, so the month abbreviation + // stayed 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 actually 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..0cd077713 --- /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 spent 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" took a second line and + // grew the whole bar, breaking the three-row 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..1f7838474 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt @@ -0,0 +1,120 @@ +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.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.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * The three pieces every settings sheet in this app is built from. + * + * They began inside the appearance sheet and were copied outwards, which is how 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. Shared here so that a new sheet inherits the pattern rather than + * re-deriving it, and so that 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. + * + * This scrolled sideways at first, on the theory that a chip which reflows moves every chip after + * it, so an option sits somewhere different in each language. True, but it is the lesser problem: + * 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. + */ +@Composable +fun ToggleRow( + title: String, + icon: ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + subtitle: String? = null, +) { + ListItem( + modifier = Modifier.clickable { onCheckedChange(!checked) }, + 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..1ac52a2d9 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt @@ -0,0 +1,362 @@ +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.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.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 +import org.matrix.vector.manager.ui.theme.VectorMono + +/** + * The framework's state, as the top of the app. + * + * This replaced both a plain "Vector" app bar and a separate status row. The app bar was spending a + * full row restating the app's own name — which the launcher icon, the task switcher and the + * system already say — so it is gone, and the space went 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 title bar that used to say "Vector" is gone, so the brand rides with the state and 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, which made the header swallow the entire 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 surface gets the upper half of the pane to itself; the status settles at the + // bottom, where it sits on the surface rather than floating above a gap. + // Trimmed to pay for the taller status row below, so the pane keeps its height. + 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. Centring it against + // the block put it level with the gap between "Vector Active" and the version + // line, so it read as belonging to neither; 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. Centred against the block they were centred against nothing in + // particular — one floated above the wordmark, the other below the version + // line. Here the stack and the state word share a centre line by + // construction, so the eye reads one row, not 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 48dp each it + // pushed the version line a finger's width away 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/StatusRibbon.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusRibbon.kt new file mode 100644 index 000000000..5f4e4726a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusRibbon.kt @@ -0,0 +1,193 @@ +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.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.matrix.vector.manager.R +import org.matrix.vector.manager.ui.theme.VectorMono + +/** The three states the framework can be in, plus the moment before we know. */ +enum class FrameworkState { + Checking, + Active, + Degraded, + Inactive, +} + +/** + * The status ribbon: one row, the whole width, tappable. + * + * Deliberately a ribbon rather than the legacy manager's 200 dp hero card. Once a user knows the + * framework works, restating it at that size on every launch spends the most valuable screen space + * on the least new information. Everything the hero used to show moved into the system status + * screen this opens. + * + * The leading indicator is the one place in the app where **shape** carries meaning. That matters + * because this app defaults to Material You: the user's wallpaper picks the hues, so colour alone + * can never be trusted to signal state. Shape, icon and label all move together. + */ +@Composable +fun StatusRibbon( + state: FrameworkState, + version: String?, + apiVersion: Int?, + onClick: () -> 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 + }, + label = "statusContainer", + ) + val onContainer by + animateColorAsState( + when (state) { + FrameworkState.Active -> colors.onPrimaryContainer + FrameworkState.Degraded -> colors.onTertiaryContainer + FrameworkState.Inactive -> colors.onErrorContainer + FrameworkState.Checking -> colors.onSurfaceVariant + }, + label = "statusOnContainer", + ) + + val label = + 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 detail = + buildList { + version?.let { add(it) } + apiVersion?.let { add("API $it") } + } + .joinToString(" · ") + + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = container, + contentColor = onContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + StatusIndicator(state = state, tint = onContainer) + Column(Modifier.weight(1f)) { + Text(text = label, style = MaterialTheme.typography.titleMedium) + if (detail.isNotEmpty()) { + Text( + text = detail, + style = VectorMono, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Icon( + Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = stringResource(R.string.status_open_details), + ) + } + } +} + +/** + * The indicator itself. Its corner radius animates between three values — a rounded square when + * active, a softer cookie-ish form when degraded, a full circle when inactive — so the transition + * between states is legible as motion, not just as a colour swap. + * + * When active it breathes: a slow, low-amplitude scale that reads as "running" at a glance and + * stops entirely in the other two states, so stillness itself means something. + */ +@Composable +private fun StatusIndicator(state: FrameworkState, tint: Color) { + val cornerPercent by + animateFloatAsState( + when (state) { + FrameworkState.Active -> 32f + FrameworkState.Degraded -> 42f + else -> 50f + }, + label = "statusCorner", + ) + + val breathing = rememberInfiniteTransition(label = "statusBreath") + val breathScale by + breathing.animateFloat( + initialValue = 1f, + targetValue = 1.06f, + animationSpec = + infiniteRepeatable(tween(durationMillis = 1800), RepeatMode.Reverse), + label = "statusBreathScale", + ) + + 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(40.dp) + .scale(if (state == FrameworkState.Active) breathScale else 1f) + .clip(RoundedCornerShape(percent = cornerPercent.toInt())) + .background(tint.copy(alpha = 0.16f)) + .semantics { contentDescription = "" }, + contentAlignment = Alignment.Center, + ) { + if (icon != null) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp)) + } + } +} 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..b2b8ab971 --- /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, all of which open in a browser and none of which need an account: + * pull requests to review, discussions to join, a canary build to test, and issues to report. + * + * 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), + ) { + // The one door that opens a screen rather than a browser. The Actions page shows an + // anonymous visitor that a build exists and then refuses to hand it over, so sending + // people there was sending them to a dead end; the screen explains the sign-in and + // still lists the builds without one. + 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..303b36e05 --- /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. The plain `AlertDialog` therefore always spoke the *phone's* language + * while the screen behind it spoke 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..8c0cc02cf --- /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, which is exactly the complaint an easter egg's message drew. + * + * 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..a1453a3cd --- /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 in this version of Compose + * reports no gesture boundary, so a remembered origin leaked from one drag into the next — the + * maze rebuilt on a nudge, and the rain 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. Simulating a viewer moving through the field was the + * first attempt and it read as sliding rather than approaching, because none of these have the + * parallax cues that would sell the move. + * + * 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..315dd0cf6 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/AmbientSurface.kt @@ -0,0 +1,139 @@ +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 whenever the renderer reports nothing moving, which is why the + * circuit ambience costs nothing at rest and why [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..f114333a4 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt @@ -0,0 +1,295 @@ +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 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 { + /** Average gap between unprompted pulses. */ + const val MIN_SCALE = 0.4f + const val MAX_SCALE = 3f + 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. + * + * Pinching out routes more traces with shorter runs between turns — a busier board seen from + * further back; pinching 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 + + /** 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 + // A board that only ever reacted looked dead until touched, and a status header is + // mostly looked at rather than played with. It now runs itself, so it is always live. + 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(layoutSeed * 31 + pulses.size).nextFloat() * 0.7f) + fire(traces.random(Random(System.nanoTime())), 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( + // Raised from barely-there: the board is the picture, not a texture, and at + // the old alpha it was invisible on 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..d55348641 --- /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 — + * simulating a viewer moving through the field looked like the columns were sliding sideways + * rather than approaching, because a flat field of text has no parallax cues to sell the move. + * - **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 — nearer-looking 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 any more: the size of a glyph is now 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 + // A quarter of the header was one very large glyph per column; at that size the rain read + // as a headline rather than as code. Small enough that a column is a stream of characters, + // and the pinch is there for anyone who wants them bigger. + cellHeight = size.height * 0.155f + columns.clear() + // Deliberately more columns than fit at rest: the extras live far away as a faint + // drizzle, and they are what there is to *find* when you pull the view closer. + 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: without that, every + // reshuffle also shoved 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 only varies brightness now, so a column reads as nearer or further + // without the field pretending to have 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..58970f6dc --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MazeRenderer.kt @@ -0,0 +1,422 @@ +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 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. A second wanderer + * released while the first was still going turned the header into traffic, and the whole 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 + } + + /** + * 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. + */ + /** + * 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: + * pinching out gives a finer grid with more corridors to solve, pinching in gives 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 + + 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 + get() = true + + /** + * Carves a maze, then loosens it. + * + * The walls used to be an independent coin flip per edge, which does not produce a maze — it + * produces speckle. Random walls leave sealed pockets and open plazas in the same picture, and + * a wanderer in it 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. + */ + /** 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() + } + + 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 not blocked by a wall or the top/bottom. */ + 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 + val stroke = Stroke(width = 1.6f) + + // 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..bd2f55a4c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/SnowRenderer.kt @@ -0,0 +1,277 @@ +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 + get() = true + + /** + * Crystal size, and with it how many there are. + * + * Inversely: pinching out gives a fine dense drizzle, pinching in gives a few large crystals. + * Keeping the *coverage* roughly constant 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..b92f6e6cd --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt @@ -0,0 +1,64 @@ +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, which is why this is fifty lines + * rather than a graph definition. It also removes a whole class of bug the previous layer had: the + * old shell called `popUpTo(startDestination)` against a destination it had already popped, so + * `NavController` ignored it and pushed anyway — every tab visit accumulated an entry and system + * back retraced the entire browsing history instead of exiting. + * + * Tab switching here truncates to a single root entry, so the stack can never grow without bound. + */ +@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..e14ce953f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -0,0 +1,67 @@ +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 cannot be parsed and silently dropped the + * way it was on the previous navigation layer — it is a constructor parameter. + */ +@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 the newest build, and the installer's output while it is flashed. */ +@Serializable data object FrameworkUpdate : 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..555a0ecff --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt @@ -0,0 +1,259 @@ +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.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.** This screen used to list Actions artifacts and ask for a GitHub account + * before it would hand one over, because GitHub gates artifact downloads behind auth even on a + * public repository — `actions/artifacts//zip` answers 401 anonymously where a release asset + * answers 206. That was a trust cost imposed by a storage decision: users of a root-level framework + * were being asked to grant an OAuth app something in order to work around where the zips happened + * to live. CI now attaches the same zips to a rolling `canary-` prerelease, whose + * assets any anonymous caller can fetch, so the ask is gone and so is the block that carried it. + * + * It also works for the users who most need it: the people who cannot reach GitHub's login page are + * exactly the ones a canary programme loses first, and a release asset is served from a different + * host than the login flow. + * + * The Actions page is still one tap away for anyone who wants the build log or a commit older than + * the five CI keeps, filtered the way the project README filters it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CanaryScreen(onNavigateBack: () -> Unit, onOpenUrl: (String) -> 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) + 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 reporting an absence and stopping: 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) { + 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 (artifact.downloadUrl != null) { + // Handed to the browser rather than fetched here: the download URL redirects to + // a signed blob URL, and the browser is already the thing that knows how to + // resume, store and surface a large file the user then installs by hand. + TextButton(onClick = { onOpenUrl(artifact.downloadUrl) }) { + Icon( + Icons.Rounded.Download, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.canary_download)) + } + } + } + } + + if (build.artifacts.none { !it.expired }) { + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.canary_expired), + style = MaterialTheme.typography.labelMedium, + color = colors.onSurfaceVariant, + ) + } + + build.htmlUrl?.let { url -> + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { onOpenUrl(url) }) { + Text(stringResource(R.string.canary_open_run)) + } + } + } + } +} + +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..c9ec2177d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt @@ -0,0 +1,459 @@ +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.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() + + // No skipPartiallyExpanded. Passing it removed 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 + // opened at full height and could not be made smaller. Left at the default, 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)) }, + ) + } + } + 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 pushed the row to 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 + } + + +/** A row of chips that scrolls if it must, so a long option set never clips. */ + + +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..046574a8a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt @@ -0,0 +1,896 @@ +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.foundation.lazy.itemsIndexed +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.Settings +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.VolunteerActivism +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.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 legacy manager spent the whole + * screen restating the first. + * + * The window is a fixed quarter rather than "the latest N commits". In a quiet quarter 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. The count is announced from the second one, because a secret + // nobody can find is not an easter egg — it is dead code. Two taps could be an accident; + // by the second the user is clearly poking at it, so the app plays along. + 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 looked like 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) { + // No browser in the host process is a normal condition parasitically; falling + // back to the built-in viewer beats doing nothing on a link tap. + 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)), + 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 reporting that as + // "could not reach GitHub" accused the network of something the app chose not to do. + // 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 + * header — 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 header, so the pair appears at the moment the top 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 quarter, 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. A quarterly one 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 row is dimmed and inert rather than offering a tap that goes nowhere. They are + // still shown and still counted — the credit is theirs either way. + val hasProfile = !person.profileUrl.isNullOrBlank() + val picked = person.login.lowercase() in selected + val haptics = LocalHapticFeedback.current + // Someone with no GitHub identity still has commits, so they can still be filtered to + // even though there is no profile to open. The long press is therefore offered to + // everyone in the row; only the tap is withheld. + 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..8cff2d5ff --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,346 @@ +package org.matrix.vector.manager.ui.screens.home + +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, +) { + 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. + // Reading it once is why the previous Home could get permanently stuck on "Not Activated". + viewModelScope.launch { + ServiceLocator.service.collect { service -> refreshStatus(service) } + } + // 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.update { 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 versionCode = daemon.getXposedVersionCode().getOrDefault(0L) + val api = daemon.getXposedApiVersion().getOrNull() + + val sepolicy = daemon.isSepolicyLoaded().getOrDefault(false) + val systemServer = daemon.systemServerRequested().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) + // Matches the daemon's own rule: a non-OK wrapper only matters when the flags did + // not load. + if (dex2oat != ILSPManagerService.DEX2OAT_OK && !dex2oatFlags) { + add(HealthIssue.Dex2oatWrapperBroken) + } + } + + _status.value = + FrameworkStatus( + state = if (issues.isEmpty()) FrameworkState.Active else FrameworkState.Degraded, + versionName = versionName, + versionCode = versionCode, + apiVersion = api, + issues = issues, + dex2oatCompatibility = dex2oat, + sepolicyLoaded = sepolicy, + systemServerInjected = systemServer, + ) + + if (versionCode > 0) { + viewModelScope.launch { + ServiceLocator.frameworkUpdates.refresh( + versionCode, + daemon.getFrameworkCommit().getOrNull(), + ) + } + } + } + + // --- 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` on the same repository, so either locates a + // build on the timeline correctly — and without the fallback the marker would + // simply never appear for anyone running the manager standalone. + 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, so laying the rail out — filtering, grouping by + // month, measuring every gap — happened there. At a hundred commits that was invisible. + // At the two thousand the archive now holds, every filter toggle froze the very frame + // that was meant to acknowledge the touch. + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + // --- framework toggles ------------------------------------------------------------------ + // These used to live on a catch-all Settings screen. They are properties of the *framework*, + // so they belong on the screen that reports the framework's state. + + 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 + + private val _hiddenIcon = MutableStateFlow(false) + val hiddenIcon: StateFlow = _hiddenIcon.asStateFlow() + + private suspend fun refreshToggles() { + _statusNotification.value = daemon.enableStatusNotification().getOrDefault(false) + } + + fun setStatusNotification(enabled: Boolean) { + viewModelScope.launch { + daemon.setEnableStatusNotification(enabled).onSuccess { + _statusNotification.value = enabled + } + } + } + + fun setHiddenIcon(hidden: Boolean) { + viewModelScope.launch { + // The old implementation wrote the inverse of what it read — it set the daemon flag + // from one source of truth and read its state back from Settings.Global — so the + // switch could show a state the framework did not hold. It now reports only what it + // successfully wrote. + daemon.setHiddenIcon(hidden).onSuccess { _hiddenIcon.value = hidden } + } + } + + fun refreshFeed(freshness: GitHubRepository.Freshness) { + viewModelScope.launch { + _refreshing.value = true + _feed.update { github.load(freshness) } + _refreshing.value = false + // Anything that actually went to the network settles the debt below. + if (freshness != GitHubRepository.Freshness.Cached) _windowChanged.value = false + } + } + + /** + * True when the window was changed and nothing has been fetched since. + * + * Changing "the last six months" to "since the beginning" used to do nothing visible until the + * next launch: the window is read inside the repository at load time, and nothing reloaded. It + * now 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. The difference + * is what this flag is for: 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 { + 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.update { github.load(GitHubRepository.Freshness.Cached) } + if (added == 0) _exhausted.value = true + _loadingHistory.value = false + } + } + + /** + * True once a 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 for the rest of the session; the foot of the feed stays tappable, so a reader who + * knows they are back online can ask again. + */ + private val _exhausted = MutableStateFlow(false) + val historyStalled: StateFlow = _exhausted.asStateFlow() + + fun retryHistory() { + _exhausted.value = false + loadMoreHistory() + } + + 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..af22d598b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/LanguageSheet.kt @@ -0,0 +1,265 @@ +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.Language +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 is read from the APK's own resources, so a language appears here the moment a translator + * lands its folder; nothing to remember to update. + * + * 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() } + // No skipPartiallyExpanded. Passing it removed 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 + // opened at full height and could not be made smaller. Left at the default, 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 on one line. They were two rows, each carrying the same Translate + // glyph forty dp apart, and the invitation — a two-line list item — outweighed the sheet's + // own title. Worse, at the half-height rest it spent two of the five visible rows before + // the reader reached a single language. One row keeps it permanently visible at any sheet + // height and gives the list its space back. + 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), + ) + } + } +} + +/** The icon that opens it, kept next to the palette because both govern how the app presents itself. */ +val LanguageIcon = Icons.Rounded.Language 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..33683e753 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt @@ -0,0 +1,265 @@ +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.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +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.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 org.matrix.vector.manager.R +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 + +/** + * What the legacy home screen's information card was, done properly. + * + * Every row is copyable, and a row that reports a *problem* carries the 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. + */ +@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 rows = buildRows(status, device, context) + 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 = { + copy(context, rows.joinToString("\n") { "${it.first}: ${it.second}" }) + 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(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (status.issues.isNotEmpty()) { + items(status.issues, key = { it.name }) { issue -> IssueCard(issue) } + item { Spacer(Modifier.height(4.dp)) } + } + items(rows, key = { it.first }) { (label, value) -> InfoRow(label, value) } + + // 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, + onCheckedChange = viewModel::setStatusNotification, + ) + } + item { + FrameworkToggle( + title = stringResource(R.string.hidden_icon), + subtitle = stringResource(R.string.hidden_icon_summary), + checked = hiddenIcon, + onCheckedChange = viewModel::setHiddenIcon, + ) + } + } + } +} + +@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, + ) + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = value, style = VectorMono, color = MaterialTheme.colorScheme.onSurface) + } +} + +@Composable +private fun buildRows( + status: FrameworkStatus, + device: DeviceInfo, + context: Context, +): List> { + val unknown = "—" + return listOf( + stringResource(R.string.info_framework_version) to (status.versionLabel ?: unknown), + // 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. + stringResource( + if (status.apiVersion?.let { XposedApi.isLibxposed(it) } == true) + R.string.info_api_version_libxposed + else R.string.info_api_version + ) to (status.apiVersion?.toString() ?: unknown), + stringResource(R.string.info_manager_package) to context.packageName, + stringResource(R.string.info_selinux) to + stringResource( + if (status.sepolicyLoaded) R.string.info_loaded else R.string.info_not_loaded + ), + stringResource(R.string.info_system_server) to + stringResource( + if (status.systemServerInjected) R.string.info_injected + else R.string.info_not_injected + ), + stringResource(R.string.info_dex2oat) to dex2oatLabel(status.dex2oatCompatibility), + stringResource(R.string.info_android) to + "${device.androidRelease} (API ${device.sdkInt})", + stringResource(R.string.info_device) to device.device, + stringResource(R.string.info_abi) to device.abi, + ) +} + +@Composable +private fun dex2oatLabel(compatibility: Int): String = + when (compatibility) { + ILSPManagerService.DEX2OAT_OK -> stringResource(R.string.info_supported) + ILSPManagerService.DEX2OAT_CRASHED -> "crashed" + ILSPManagerService.DEX2OAT_MOUNT_FAILED -> "mount failed" + ILSPManagerService.DEX2OAT_SELINUX_PERMISSIVE -> "SELinux permissive" + ILSPManagerService.DEX2OAT_SEPOLICY_INCORRECT -> "SEPolicy incorrect" + else -> stringResource(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, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier.fillMaxWidth() + .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = null) + } +} 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..10dbf6942 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -0,0 +1,455 @@ +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. + * + * The bug this replaces was `Modifier.horizontalScroll` applied to the `LazyColumn` itself. A lazy + * list only measures its visible window, so the width it reported to the scroll modifier was the + * width of whichever rows happened to be composed; scrolling vertically brought longer lines into + * the window, the scroll extent was recomputed, and the current offset's clamp moved underneath + * the finger. That is the jump. + * + * Moving a shared `ScrollState` onto the rows would only relocate the same bug: one `ScrollState` + * holds one `maxValue`, and whichever row measured last would win and re-clamp the offset. So the + * offset lives here instead, 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. + * + * Zeroing the width in [reset] looked equivalent and was not: `reset` is called from a + * `LaunchedEffect`, which can land after the rows have already measured for the frame, and + * nothing then re-measures them — so the extent stayed zero and the log could not be panned at + * all. Restarting on the next measurement is correct whichever order those 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 whole payoff of parsing: a rail in the level's colour *and* the level letter, + * because README §6 gives the hue to the user's wallpaper and no state may be distinguishable by + * colour alone; the time of day only, since the date lives on the day separator; the tag, tappable + * to filter to itself; then the message. `uid:pid:tid` are the least-read twenty-two columns of + * every line and are precisely what forces sideways panning, so they hide behind a tap. + * + * All of it is **one** styled `Text` rather than a `Row` of cells, and that is deliberate. Cells + * confine the message to whatever the metadata leaves over — on a phone that was about 40 % of the + * width, so a real verbose line wrapped into a four-line stack beside a mostly empty gutter. As one + * string the message wraps under the metadata and uses the full width, which is what makes the list + * dense enough to skim. The cost is that the tag is no longer 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 text selection now. Copying + // the whole line, metadata included, moved to a 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..cef7354e5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt @@ -0,0 +1,1031 @@ +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.animation.core.animate +import androidx.compose.foundation.gestures.awaitFirstDown +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.shape.CircleShape +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.Notes +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.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.Stable +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.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +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 kotlin.math.abs +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 settled page reads. Opening Logs used to index both files whether or not either was + * visible. + */ +@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 meant two search + // boxes, two filter states and two scroll positions for what is one question — "what does the + // log say" — and the answer often has to be looked for in both. + 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() + // Report the daemon's own words. getLogs() can fail for reasons only it knows — + // a full filesystem, a tombstone it cannot read — and a generic "failed" throws + // that away. + 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 does not + // move when the tab does. It used to be a TopAppBar, which is a different height again. + 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 filled accent with a shadow made a reading + // preference look like the most important control on the screen; a quiet + // neutral container says pressed-in without competing with anything. + 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, + onSelectTab = { currentTab = it }, + ) + } + } + + 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)) }, + // README principle 3: the dangerous action names its consequence — and here the + // consequence is not what a delete icon implies. clearLogs() is LogcatMonitor.refresh(), + // which rotates to a new file rather than truncating. + 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". Rotating no longer puts anything out of reach: the closed part + // stays on disk and is a swipe away, so pressing save on the way past was protecting + // against a loss that does not happen. + dismissButton = { + TextButton(onClick = { confirmRotate = false }) { + Text(stringResource(R.string.logs_cancel)) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogPane( + tab: LogTab, + viewModel: LogsViewModel, + wordWrap: Boolean, + onSelectTab: (LogTab) -> Unit, +) { + 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 + // assumed — README §8 records a hardcoded bottom inset as a bug precisely because a constant + // stops clearing what it was meant to clear the moment anything about it changes. 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: the first layout measures the buttons *after* + // the open-at-the-tail scroll has already run, and 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. That is why the rows no longer take the long press for themselves — see + // LogRows, where copying a whole line moved to a 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 with no jump affordance the newest line is unreachable in + // practice, which 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 and + // 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. A two-segment control spelled out a decision + * that does not need making, and spent half the search field doing it. + * + * 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. They were a dropdown menu, which could hold two verbs + * and nothing that needed 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. + * `ManagerService.isVerboseLog()` ORs the stored preference with `BuildConfig.DEBUG`, so against a + * debug daemon it snaps straight back, and a control that visibly refuses to move with no + * explanation is the same failure as showing one state when another is true. + */ +@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() + // No skipPartiallyExpanded. Passing it removed 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 + // opened at full height and could not be made smaller. Left at the default, 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(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 the override is now only possible against + // an older daemon, since this 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 remain the discoverable way to do it; this is the fast one, and + * it is safe here in a way it was not over the log itself: nothing else in the app bar wants a + * horizontal drag, so there is no arbitration, no threshold tuning against another gesture and no + * band of the screen where it does or does not apply. + * + * 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 was already the only place that says where you are in the file, so it is also where you + * move between files. A sideways swipe was tried first and was the wrong gesture: it competed with + * the row-level pan, it had to be fenced into a corner of the screen and into one scroll position + * to stop it firing by accident, and after all that it was still invisible until it happened. A + * pair of chevrons on the counter is none of those things — it says how many parts there are, which + * one you are on, and it cannot be triggered by a drag meant for something else. + * + * 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 the reader's paging + * strategy, which is nobody's business but the reader's. + */ +@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 four nothing-to-show states, rendered as four different things. + * + * They used to be four strings pushed into the log list itself, so "the daemon is down" arrived + * looking exactly like a line the daemon had written. + */ +@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..d82732fbf --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt @@ -0,0 +1,636 @@ +package org.matrix.vector.manager.ui.screens.logs + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.vector.manager.data.log.LogFacets +import org.matrix.vector.manager.data.log.LogFile +import org.matrix.vector.manager.data.log.LogIndex +import org.matrix.vector.manager.data.log.LogLevel +import org.matrix.vector.manager.data.log.LogQuery +import org.matrix.vector.manager.data.log.LogRow +import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ipc.DaemonClient + +/** 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. + * + * These used to be one `isLoading` boolean and a list of strings, with every failure rendered as a + * fake log line — "Failed to load logs or daemon unreachable.", "No logs available." — so four + * genuinely different situations arrived looking like log content the daemon had written. README + * principle 2: never show one state when another is true. + */ +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 counter reports this, because "which lines am I looking at" is the + * question a line counter is read to answer, and the window's bounds answer a question about + * the reader's implementation instead. + */ + 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. Defaults to the last, which is the one being written. */ + 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 thing worth keeping in mind while changing this: 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. + * + * `ManagerService.isVerboseLog()` is `PreferenceStore.isVerboseLogEnabled() || BuildConfig.DEBUG`, + * so on a debug daemon the switch snaps straight back. A control that visibly refuses to move + * with no explanation is exactly the failure README principle 2 names, so 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 pager page settles. Only the visible stream 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 { + 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. The legacy screen opened at + // the top, so on a thirty-thousand-line file the user's first action was always a + // thirty-thousand-line scroll. + 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 — which is why walking upwards through a 40 MB + * file at constant memory does not fight the reader's finger. + */ + 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: `clearLogs()` is `LogcatMonitor.refresh()`, + * which closes the current part and opens a fresh one. The previous parts stay on disk under a + * ten-part LRU and still travel in the zip export. The dialog on the screen says so; nothing + * here substitutes a synthetic "cleared" line for the file, it simply re-opens and re-indexes. + */ + fun rotate(tab: LogTab, onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = daemon.clearLogs(tab == LogTab.VERBOSE).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. + * + * Both sides own one copy of the descriptor: `use` closes ours, `ZipOutputStream.use` closes + * the daemon's. + */ + 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) + val actual = daemon.isVerboseLogEnabled().getOrDefault(enabled) + _verboseEnabled.value = actual + _verboseEnforced.value = !enabled && actual + if (actual) refresh(LogTab.VERBOSE) + } + } + + override fun 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 much the window walks per step. One seek and one ~75 KB read. */ + 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..c59513b93 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt @@ -0,0 +1,1382 @@ +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.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.PlayArrow +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.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.foundation.text.BasicTextField +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.foundation.layout.fillMaxHeight +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.LaunchedEffect +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.graphics.SolidColor +import androidx.compose.ui.res.pluralStringResource +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.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 androidx.compose.foundation.layout.widthIn +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 and a disabled + * row is visibly dimmed on a plainer surface — the state is 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**: how many apps it is scoped to. That is the fact + * behind most trips into the scope editor, so showing it here saves the trip — and a module that + * is enabled but scoped to nothing, which does nothing at all while looking like it works, is + * called out in the error colour. + */ +@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 toggleFailed by viewModel.toggleFailed.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: how many worked, and how many did not if any did not. */ + fun batchResult(messageRes: Int, changed: Int, failed: Int): PackageActionResult = + if (failed == 0) PackageActionResult(messageRes, changed.toString(), SnackbarTone.Success) + else + PackageActionResult( + R.string.modules_batch_partial, + "$changed/${changed + failed}", + SnackbarTone.Failure, + ) + + // Long-press actions all speak through one snackbar, so a slow one (re-optimize) can report + // twice — 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 failureTemplate = stringResource(R.string.module_toggle_failed) + 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) + } + } + } + } + + LaunchedEffect(toggleFailed) { + toggleFailed?.let { + snackbars.show(String.format(failureTemplate, it), SnackbarTone.Failure) + viewModel.consumeToggleFailure() + } + } + + 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 instead — which is what + // it used to do — left 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) { changed, failed -> + report( + batchResult( + R.string.modules_batch_enabled, + changed, + failed, + ) + ) + } + }, + onDisable = { + viewModel.setSelectedEnabled(false) { changed, failed -> + report( + batchResult( + R.string.modules_batch_disabled, + changed, + failed, + ) + ) + } + }, + 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() }) { + EmptyState(daemonAvailable = daemonAvailable, filtered = query.isNotBlank()) + 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) { + val removed = stringResource(R.string.modules_batch_uninstalled) + 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": what is lost is the module's + // own configuration, which no backup on this screen covers. + text = { + Text( + pluralStringResource( + R.plurals.modules_uninstall_body, + selection.size, + selection.size, + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmUninstall = false + viewModel.uninstallSelected { gone, failed -> + report(batchResult(R.string.modules_batch_uninstalled, gone, failed)) + } + } + ) { + 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. The cloud-and-arrow that was here first is the + // platform's upload glyph, and nothing about this uploads anywhere — the file goes + // wherever the document picker is pointed, usually this device. A box with an arrow in + // and a box with an arrow out fixed the meaning and broke the reading: at 24dp two + // mirror images of the same shape are 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. Naming the *outcome* beats naming the mechanism — "open a file" was accurate + // and still made the reader work out what opening a file would do to their modules. + 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 unable to run — and painting the whole row for each turned the list into stacked blocks + * of colour that fought 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 it + * cannot run at all. One word does the work a whole surface was doing badly. + * + * The icon is left exactly as the module ships it. Wrapping it in a coloured well made every + * module look like it belonged to Vector rather than to its author. + * + * Three columns for the three questions the row answers: what it is (icon, and the API it needs), + * what it does (name and description), and how it is configured — version at the top of the right + * column and reach at the bottom, so both edges of the row are anchored and the counts line up + * down the list. + */ +@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, so that picking a module up cannot resize its row. The tick used to be drawn + // 56dp over a 48dp icon, which grew this box by eight — and with it the icon column, + // the row's intrinsic height, and every row below it. Selecting one module reflowed + // the list under the thumb that selected it. The slot is now the icon's size whatever + // is drawn inside it. + Box(modifier = Modifier.size(ICON_SIZE), contentAlignment = Alignment.Center) { + AppIcon( + applicationInfo = module.applicationInfo, + contentDescription = null, + // 48 rather than 56. Still comfortably a touch target — it is the selection + // handle — but eight density-independent pixels handed back to the column that + // holds the name and the description, which is where the reading happens. + 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. The row used to be one target, so a tap anywhere — + // including the icon someone was aiming at — navigated away. + // + // A Box, not a third column. Reserving a column for the version and the reach took its + // width from *every line* of the description, which is the one piece of prose on this + // screen — and it took it permanently, 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 sits 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 + 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, + module.minVersion, + 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. */ +private val ICON_SIZE = 48.dp + +/** + * Who the module actually touches. + * + * A count answers a question nobody asked; three recognisable icons answer "does this touch + * anything I care about" without opening anything. The remainder collapses to a number, and a + * module that is running with nothing to hook still says so in words, because that state is a + * mistake rather than a fact. + */ +@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" spent a line telling the + // user about an absence, and it said it about 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.minVersion.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. + * + * Swipe-to-toggle used to live here and is gone: 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. Enabling + * eight modules used to be eight round trips through a row, and there was no way at all to remove + * or back up more than one. + */ +@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 reaching for a 56dp 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 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( + modifier = + Modifier.clickable(enabled = selectable) { + chosen = if (name in chosen) 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..1d12d07da --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt @@ -0,0 +1,540 @@ +package org.matrix.vector.manager.ui.screens.modules + +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.ModuleDetection +import org.matrix.vector.manager.data.model.PER_USER_RANGE +import org.matrix.vector.manager.data.repository.ModuleRepository +import org.matrix.vector.manager.data.model.StoreEntry +import org.matrix.vector.manager.data.repository.ModuleUpdateQueue +import org.matrix.vector.manager.data.model.XposedApi +import org.matrix.vector.manager.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, +} + +/** + * 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. + */ +/** Identifies one module row: a package can be installed for several users at once. */ +data class ModuleKey(val packageName: String, val userId: Int) + +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, + /** + * 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" +private const val TAG = "VectorModules" + +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 showing the first when the second is true is a lie the + * previous build told. The UI branches on this. + */ + 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() + + /** Keyed by package name. Filled in after the list appears, so it never delays first paint. */ + /** + * Per module *per user*, not per package. + * + * A module installed in both the owner's space and a private space is two rows with two scopes, + * and keying this by package alone meant the second row was handed the first one's facts — + * so the private space tab depicted the owner's apps. Filtering the scope by user, which is + * also necessary, could not fix that on its own: the entry being filtered already belonged 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: this is the same set the Store counts in + * its header, so the mark on a row and the number on the tab 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() + + private val _frameworkApi = MutableStateFlow(0) + + /** "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) + + /** + * Set when the daemon rejects a toggle, so the UI can say so. Previously a rejected write just + * snapped the switch back with no explanation. + */ + private val _toggleFailed = MutableStateFlow(null) + val toggleFailed: StateFlow = _toggleFailed.asStateFlow() + + /** + * 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, and until now + // nothing on this screen asked for it — the marks appeared only if the splash prefetch had + // happened to succeed. Cheap to repeat: the request is cache-controlled and a concurrent + // caller is a no-op rather than 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 consumeToggleFailure() { + _toggleFailed.value = null + } + + private val _backupMessage = MutableStateFlow(null) + val backupMessage: StateFlow = _backupMessage.asStateFlow() + + fun consumeBackupMessage() { + _backupMessage.value = null + } + + 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) + } + } + + /** + * Enable or disable a module. + * + * The single most important thing a manager for a hooking framework does, and it did not exist + * at all before this: the repository's toggle had no call sites anywhere, and every row + * rendered with a hardcoded `isEnabled = false`. + */ + fun setEnabled(module: InstalledModule, enable: Boolean) { + viewModelScope.launch { + val ok = moduleRepository.toggleModule(module.packageName, enable) + if (!ok) _toggleFailed.value = module.appName + } + } + + + // --- 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 into the daemon that rewrites + * the same config, and firing twenty at once at a single-threaded service buys nothing but a + * harder failure to explain. + */ + fun setSelectedEnabled(enable: Boolean, onResult: (changed: Int, failed: Int) -> Unit) { + val targets = _selection.value.toList() + viewModelScope.launch { + var changed = 0 + var failed = 0 + targets.forEach { key -> + if (moduleRepository.toggleModule(key.packageName, enable)) changed++ else failed++ + } + moduleRepository.refresh() + loadModules() + _selection.value = emptySet() + onResult(changed, failed) + } + } + + /** Uninstalls everything selected. The screen confirms first; this does not. */ + fun uninstallSelected(onResult: (removed: Int, failed: Int) -> Unit) { + val targets = _selection.value.toList() + viewModelScope.launch { + var removed = 0 + var failed = 0 + targets.forEach { key -> + val ok = + daemonClient.uninstallPackage(key.packageName, key.userId).getOrDefault(false) + if (ok) removed++ else failed++ + } + moduleRepository.refresh() + loadModules() + _selection.value = emptySet() + onResult(removed, 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) + _frameworkApi.value = api + // 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 meant a row could depict the wrong + // profile's copy of an app. + val byPackage = + ServiceLocator.apps.getInstalledApps().associateBy { + it.packageName to it.userId + } + + val collected = mutableMapOf() + // Every copy of every module, not one per package: the scope is read once but split + // per user below, and 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 the owner's row was showing the work profile's apps as well — + // 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. It used to count raw scope rows, + // so a module scoped to itself and the framework claimed "2 apps" while + // showing neither of them — the count and the icons disagreed because + // they were derived from different lists. + scopeCount = if (targets == null) -1 else apps.size, + // A module asking for a newer API than the framework provides cannot + // load; saying so here beats letting the user wonder why nothing happens. + incompatible = api > 0 && module.minVersion > api, + apiBrokenSince = + if (api <= 0) null + else XposedApi.brokenSince(module.minVersion, api), + scopeFramework = framework, + scopePreview = + apps + .asSequence() + .mapNotNull { byPackage[it.packageName to it.userId]?.applicationInfo } + .take(SCOPE_PREVIEW_LIMIT) + .toList(), + ) + _facts.value = collected.toMap() + } + } + } + + private suspend fun discover(): List { + val usersResult = daemonClient.getUsers() + _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).getOrNull() + ?: 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 after the first run means the + // cache key is wrong rather than that the device is slow. + Log.i( + TAG, + "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..85dda2a73 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt @@ -0,0 +1,817 @@ +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.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.Extension +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.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.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.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.FilterList +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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.HorizontalDivider +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.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.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 whole shape follows from one fact: **applying a scope makes the daemon force-stop + * every affected app.** So edits are a draft the user builds up, and applying is a deliberate act + * with its cost stated — *3 to add, 1 to remove* — rather than a silent side effect of ticking a + * box. The previous implementation wrote the entire scope on every tap, so choosing ten apps + * killed and restarted them ten times over. + */ +@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 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 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 menuOpen by remember { mutableStateOf(false) } + var confirmStranded by remember { mutableStateOf(false) } + + 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) + + LaunchedEffect(message) { + val text = + when (message) { + ScopeMessage.Applied -> applied + ScopeMessage.ApplyFailed -> applyFailed + ScopeMessage.ToggleFailed, + ScopeMessage.AutoIncludeFailed -> toggleFailed + 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() + } + + Scaffold( + topBar = { + // One line: back, who this is about, and the switch. The large two-line bar spent a + // fifth of the screen restating a name the user had 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 rather than an affordance. 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. It is the single most consequential control + // on the screen and it was previously a card competing with the app list for + // the same attention; an overflow menu in its place held items that now live + // in the search field, next to the list they act 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) }, + bottomBar = { + // Appears only when there is something to apply, so the cost is stated exactly when + // it becomes real. + 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, + autoInclude = state.autoInclude, + onUseRecommended = viewModel::useRecommended, + onSelectAll = viewModel::selectAllVisible, + onSelectNone = viewModel::clearAllVisible, + onAutoInclude = viewModel::setAutoInclude, + 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)) + + LazyColumn( + 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 && state.autoInclude -> ScopeOrigin.AutoIncluded + 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, + ) + } + } + } + } + + 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, for the same reason the catalogue's filters became one: these 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 wrapped and the menu read 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, + autoInclude: Boolean, + onUseRecommended: () -> Unit, + onSelectAll: () -> Unit, + onSelectNone: () -> Unit, + onAutoInclude: (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 + }, + ) + ToggleRow( + title = stringResource(R.string.scope_auto_include), + icon = Icons.Rounded.Extension, + checked = autoInclude, + onCheckedChange = onAutoInclude, + ) + + 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 is narrowing the list, and must not be silent. + val filtering = !locked && (showSystem || !showGames || !showModules || 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 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)) + } + ChoiceRow { + FilterChip( + selected = showSystem, + onClick = { onToggleSystem() }, + label = { Text(stringResource(R.string.scope_system_apps)) }, + ) + FilterChip( + selected = showGames, + onClick = { onToggleGames() }, + label = { Text(stringResource(R.string.scope_games)) }, + ) + FilterChip( + selected = showModules, + onClick = { onToggleModules() }, + label = { Text(stringResource(R.string.scope_modules)) }, + ) + } + } + } +} + +/** What order it is in. Four keys and a reverse, as the legacy manager had. */ +@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() + } + } + } +} + + +/** + * How a row came to be in the scope, which decides the ring around its icon. + * + * Three different mechanisms can put an app in a module's scope and they behave differently when + * the world changes — one is fixed forever, one keeps adding apps behind your back, one is only + * ever what you ticked. Before this they all rendered as an identical checkbox, so a scope the + * module controls looked exactly like a scope the user controls. + */ +private enum class ScopeOrigin { + /** The module fixed this scope; the user cannot change it. */ + Locked, + /** The module asked for it, and auto-include will keep re-adding it. */ + AutoIncluded, + /** 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.AutoIncluded -> MaterialTheme.colorScheme.tertiary + ScopeOrigin.Requested -> MaterialTheme.colorScheme.primary + ScopeOrigin.Chosen -> Color.Transparent + } + +private fun ScopeOrigin.labelRes(): Int = + when (this) { + ScopeOrigin.Locked -> R.string.scope_origin_locked + ScopeOrigin.AutoIncluded -> R.string.scope_origin_auto + 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( + 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 what applying will actually do, 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( + // The consequence, stated before the act. + text = stringResource(R.string.scope_apply_warning), + 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..3434df0f8 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt @@ -0,0 +1,562 @@ +package org.matrix.vector.manager.ui.screens.modules + +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.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. + * + * The legacy manager offered four orderings plus a reverse toggle, and dropping them was a real + * loss: 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 autoInclude: 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. The previous navigation layer parsed this out of the + * route and then threw it away, so a module in a work profile had its scope resolved against + * the wrong user. + */ + private val userId: Int, + private val daemonClient: DaemonClient, + private val appRepository: AppRepository, + private val moduleRepository: ModuleRepository, + private val packageManager: android.content.pm.PackageManager, +) : 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. + * + * The whole reason this is separate: applying a scope makes the daemon force-stop every + * affected app. The previous implementation wrote the entire scope on *every checkbox tap*, so + * ticking ten apps killed and restarted them ten times over. 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(false) + + val showGames = MutableStateFlow(true) + + /** + * Show only what the module asked for. + * + * The same 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. It narrows to the module's own request, not to what is currently ticked, so it stays + * useful for adding the ones that are missing. + */ + 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(false) + + val sort = MutableStateFlow(ScopeSort.Relevance) + val reverseSort = MutableStateFlow(false) + + /** + * Packages that are themselves modules. + * + * Null until known. Deciding this means opening every installed APK, 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) } + // Two typed halves rather than one list of Any. The inputs outnumber the arities + // `combine` provides, and the previous shape carried them positionally through a + // `List` and cast each one back out — a rename or a reorder would have compiled + // and then failed 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. + val locked = view.state.recommended.staticScope || filters.recommendedOnly + 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) + // The framework is always offered: it is the one target a module cannot + // reach any other way, and hiding it behind a filter strands the module. + val isFramework = app.packageName == SYSTEM_FRAMEWORK_PACKAGE + // 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 + val matchesSys = + isFramework || 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 } + // Pinned above the apps, and after the reverse so that reversing the order + // cannot bury it at the bottom. It is not an app and does not belong in the + // same ordering as one: sorted by name it landed under S, by install time it + // landed wherever its borrowed timestamp put it, and either way the one target + // that is not discoverable any other way was somewhere in a list of thousands. + .let { list -> + val framework = list.filter { it.packageName == SYSTEM_FRAMEWORK_PACKAGE } + if (framework.isEmpty()) list + else framework + list.filterNot { it.packageName == SYSTEM_FRAMEWORK_PACKAGE } + } + } + // 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, + ) + + 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. Restricting the row to user 0 left a module in a work profile + // or a private space with no way to ask for the only target it may need (issue #136); + // the daemon never had that restriction, mapping any `system` scope row straight 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 carried into the state built at the + // end of this function rather than copied into the state that exists now — that copy + // was silently discarded when the final ScopeUiState was constructed from scratch. A + // failure to ask means not explaining, never explaining wrongly. + val userCount = + withContext(Dispatchers.IO) { daemonClient.getUsers().getOrNull()?.size ?: 1 } + + val saved = + daemonClient + .getModuleScope(modulePackageName) + .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, + ) + } + .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, + autoInclude = + daemonClient.getAutoInclude(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, computing the module set the first time it is + * needed. + */ + fun setShowModules(show: Boolean) { + showModules.value = show + } + + fun setRecommendedOnly(only: Boolean) { + showRecommendedOnly.value = only + } + + fun setAutoInclude(enabled: Boolean) { + viewModelScope.launch { + daemonClient + .setAutoInclude(modulePackageName, enabled) + .onSuccess { _uiState.value = _uiState.value.copy(autoInclude = enabled) } + .onFailure { _message.value = ScopeMessage.AutoIncludeFailed } + } + } + + 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 daemon force-stops every app whose scope changed, which is why this is an explicit act + * with a visible cost rather than a side effect of ticking a box. + */ + 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 { + savedScope.value = draft + _message.value = ScopeMessage.Applied + // 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 { _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 rather + * than left to discover it. The legacy manager offered to disable the module; so does this. + */ + 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") + } + .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() + } + .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 force-stops anything. + 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 { + /** How the daemon names the system server in a scope list. */ + const val SYSTEM_FRAMEWORK_PACKAGE = "system" + 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, + AutoIncludeFailed, +} 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..2b4057771 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt @@ -0,0 +1,908 @@ +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.content.pm.PackageManager +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.heightIn +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.OutlinedCard +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.components.VectorAlertDialog +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 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. + * + * This is the whole of the fix. 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 — could be split between them: the list scrolled *and* the pager slid partway + * to the next tab. On a screen whose three tabs are all long documents, that happened + * constantly while simply reading. + * + * While a list is scrolling the pager stops accepting drags at all, so 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 consumes its own touches + * and tells the hierarchy not to intercept them, so it was never the one being stolen + * from. + */ + 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. The default settles on + // whichever page is nearer once the finger leaves, so a drag of a third of the + // screen committed; 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, + 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)) + TextButton(onClick = onAcknowledge) { 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. That began as a way to keep a single WebView in the + // list — a renderer per card would have held a renderer process open for every release on the + // page — and the notes are parsed to an AnnotatedString now, so the reason is the reading + // rather than the memory: five releases with their notes open is a wall of text with no + // structure, and the list stops being skimmable. + // + // The newest one starts open, because "what changed" is the question this tab is opened to + // answer and making everyone tap once to reach it was the whole complaint. + // Keyed by *which* release is newest, not by the list object. The list is rebuilt by the view + // model's combine on every emission — an installed-version refresh, a channel change, the + // detail fetch landing — so keying on the list itself re-applied the default and reopened 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 + -> + 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 == release.key(index), + onToggleNotes = { + expanded = if (expanded == release.key(index)) null else release.key(index) + }, + 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. An outlined box around every entry turned a list of five releases into five framed + * panels competing with each other and with the notes inside them; the list reads as a list now, + * separated by a rule, 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. + // + // It used to be a sentence of its own under the notes — "Show the release notes", with a + // chevron, taking a full row to offer what the row above it already implies. 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. The chevron + // turns rather than swapping glyphs, which says the same thing without a second 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, + 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. It is in the payload and no screen was showing it. + InfoRow( + icon = Icons.Rounded.TrackChanges, + label = stringResource(R.string.store_info_scope), + value = + module.scope?.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 the default half. The complaint this answers 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..284fa3e62 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt @@ -0,0 +1,140 @@ +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import 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() { + + 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() + } + + 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 actually downloads are one rule + * with one implementation. They were three, and on the prerelease channel they disagreed. + */ + 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..4a1837c84 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt @@ -0,0 +1,530 @@ +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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.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 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() + val doh by viewModel.doh.collectAsState() + + Scaffold { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + StoreHeader( + catalog = catalog, + updates = updates, + search = { StoreSearch(query, viewModel, sort, priorities, channel, doh) }, + ) + + 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, + doh: Boolean, +) { + 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, + doh = doh, + onDohChange = viewModel::setDoh, + ) + } +} + +/** + * Sort, priority, channel and DNS — as a sheet, not a menu. + * + * This was a dropdown, and it had outgrown one. A menu is for a short list of like things; this + * holds three exclusive groups, one multi-select group that ranks its choices, and a network + * switch, separated only by dividers with nothing to say which group is which. The switch was the + * visible symptom — the only row carrying a leading icon, so its label started 24dp right of every + * other and the menu lost its left edge, and the only label long enough to wrap inside a menu that + * sizes itself to its widest child. + * + * A sheet fixes all of it by having room: each group gets a heading, the switch gets the anatomy a + * boolean setting should have — title, the sentence explaining the cost, and a Switch — and the + * label has the full width, so it does not wrap in any language. Marquee was the alternative + * considered for the label and rejected: scrolling text hides a choice behind a delay in a list + * whose whole 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. When the + * Settings screen was removed the switch lost its home entirely and became unreachable — the + * setting was still read on every lookup, so the feature was live with no way to turn it on. + */ +@Composable +private fun StoreFilterButton( + sort: StoreSort, + onSortChange: (StoreSort) -> Unit, + priorities: List, + onTogglePriority: (StorePriority) -> Unit, + channel: StoreChannel, + onChannelChange: (StoreChannel) -> Unit, + doh: Boolean, + onDohChange: (Boolean) -> Unit, +) { + var sheetOpen by remember { mutableStateOf(false) } + val narrowed = + sort != StoreSort.RecentlyUpdated || + channel != StoreChannel.Stable || + doh || + 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, + doh = doh, + onDohChange = onDohChange, + 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, + doh: Boolean, + onDohChange: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + // No skipPartiallyExpanded. Passing it removed 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 + // opened at full height and could not be made smaller. Left at the default, 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(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())) }, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + SheetHeading(stringResource(R.string.store_group_network), Icons.Rounded.Dns) + ToggleRow( + title = stringResource(R.string.store_doh), + icon = Icons.Rounded.Dns, + subtitle = stringResource(R.string.store_doh_summary), + checked = doh, + onCheckedChange = onDohChange, + ) + } + } + } +} + +/** + * 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 the icon and the sentence are both read off it. They used to be + * two independent conditions that disagreed in exactly the case that matters: with a query typed + * *and* nothing downloaded, the struck-out cloud sat above "no module matches that search", which + * blames the reader's query for a network failure and hides the one thing pull-to-refresh fixes. + * An unreachable repository is why the list is empty whatever is in the search box, so it wins. + */ +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 808 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..bfe51f5e5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt @@ -0,0 +1,274 @@ +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 + +/** How the list is ordered, on top of the independent "updates first" rule. */ +/** 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 rather than the legacy manager's three. Of the 809 modules in the catalogue 14 + * publish a beta and **none** publishes a snapshot — the snapshot channel has 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`. So filtering `releases` by `isPrerelease` + * selected nothing on either channel: choosing "include prereleases" listed the same stable + * releases, and — because the install bar takes the newest release with an APK from this very list + * — downloaded the stable APK under a button labelled with the beta's version number. + * + * 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 + } + + /** Persisted: the channel decides what counts as an update everywhere, not just in this list. */ + /** + * 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. + */ + val doh: StateFlow = settings.dohEnabled + + fun setDoh(enabled: Boolean) = settings.setDohEnabled(enabled) + + 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..1f7e6853f --- /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. + * + * The previous screen printed `latestReleaseTime.take(10)`, which is a raw substring of a machine + * format — it 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..fcd3fd279 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt @@ -0,0 +1,351 @@ +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 `