From 12b09c85fbd15b0fc1d5c2ec23a0a3c71e16473e Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:53:59 +0200 Subject: [PATCH 01/62] Add the :manager module to the build Compose with Material 3 Expressive, which is not in a stable material3 release, so material3 is pinned above the Compose BOM rather than taken from it. Navigation 3 for the back stack. Kotlin is declared at the root with `apply false` because AGP 9 otherwise supplies its own, older, version and Coil's metadata will not load against it. `githubClientId` is read from a gradle property and defaults to empty; the app hides sign-in entirely rather than offering something that cannot work. --- app/build.gradle.kts | 11 +++- build.gradle.kts | 8 ++- daemon/build.gradle.kts | 78 +++++++++++++------------ gradle.properties | 4 ++ gradle/libs.versions.toml | 61 +++++++++++++++++++ manager/build.gradle.kts | 116 +++++++++++++++++++++++++++++++++++++ manager/proguard-rules.pro | 30 ++++++++++ settings.gradle.kts | 1 + zygisk/build.gradle.kts | 6 +- 9 files changed, 270 insertions(+), 45 deletions(-) create mode 100644 manager/build.gradle.kts create mode 100644 manager/proguard-rules.pro diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f87d5a96f..89b00e597 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,7 +32,12 @@ apksign { keyPasswordProperty = "androidKeyPassword" } -val defaultManagerPackageName: String by rootProject.extra +// The legacy manager keeps its own package name rather than following +// rootProject.extra["defaultManagerPackageName"], which now points at the Compose +// rewrite in :manager. Pinning it here keeps :app buildable and separately +// installable alongside :manager for A/B comparison during the transition; its +// Java sources live in org.lsposed.manager, so the namespace must match them. +val legacyManagerPackageName = "org.lsposed.manager" android { buildFeatures { @@ -41,7 +46,7 @@ android { } defaultConfig { - applicationId = defaultManagerPackageName + applicationId = legacyManagerPackageName buildConfigField("long", "BUILD_TIME", Instant.now().epochSecond.toString()) } @@ -67,7 +72,7 @@ android { } sourceSets { named("main") { res { srcDirs("src/common/res") } } } - namespace = defaultManagerPackageName + namespace = legacyManagerPackageName } // Generate the translated-locale list and the Material accent-color theme overlays at build time diff --git a/build.gradle.kts b/build.gradle.kts index 290706f9c..fc5a6baa4 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) } @@ -60,7 +65,7 @@ val versionNameProvider by extra(providers.of(GitLatestTagValueSource::class.jav 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 +170,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/daemon/build.gradle.kts b/daemon/build.gradle.kts index 1b8ceda06..c57c41c7c 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -23,35 +23,37 @@ 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()) + + 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 +109,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 +134,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/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/build.gradle.kts b/manager/build.gradle.kts new file mode 100644 index 000000000..69977a268 --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,116 @@ +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\"") + } + + // 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..9d9cb75f1 --- /dev/null +++ b/manager/proguard-rules.pro @@ -0,0 +1,30 @@ +# 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.** diff --git a/settings.gradle.kts b/settings.gradle.kts index c623fd46f..533d239f5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,7 @@ include( ":hiddenapi:stubs", ":hiddenapi:bridge", ":legacy", + ":manager", ":services:manager-service", ":services:daemon-service", ":xposed", diff --git a/zygisk/build.gradle.kts b/zygisk/build.gradle.kts index 3aff68787..a60cc649a 100644 --- a/zygisk/build.gradle.kts +++ b/zygisk/build.gradle.kts @@ -81,7 +81,7 @@ androidComponents { group = "Vector Module Packaging" dependsOn( "assemble$variantCapped", - ":app:package$variantCapped", + ":manager:package$variantCapped", ":daemon:package$variantCapped", ":dex2oat:externalNativeBuild$variantCapped", ) @@ -110,7 +110,7 @@ androidComponents { mapOf("DEBUG" to if (variantLowered == "debug") "true" else "false") filter("tokens" to tokens) } - from(project(":app").tasks.getByName("package$variantCapped").outputs) { + from(project(":manager").tasks.getByName("package$variantCapped").outputs) { include("*.apk") rename(".*\\.apk", "manager.apk") } @@ -217,6 +217,6 @@ androidComponents { } } -evaluationDependsOn(":app") +evaluationDependsOn(":manager") evaluationDependsOn(":daemon") From 4e3eccf615de7ccfb50b3f005785f606a98da216 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:53:59 +0200 Subject: [PATCH 02/62] Expose the rotated log parts, and report the verbose flag honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getVerboseLog()` only ever handed over the part being written, while the daemon keeps ten — so most of an hour's logging was unreachable from the manager. Two calls list the parts and open one 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 the listing just returned, which rules out traversal by construction rather than by scanning for "..". The listing reads the directory rather than LogcatMonitor's LRU, so a manager opened after a daemon restart still sees the history the LRU has forgotten. `isVerboseLog()` returned the stored preference OR'd with `BuildConfig.DEBUG`, so against a debug daemon it always read true: the manager could never show "off", its switch snapped back on every tap, and the only way to stop that looking broken was to disable the control. The OR was redundant — the preference already defaults to true — so a debug build still logs verbosely out of the box, and a developer can now also turn it off. --- .../matrix/vector/daemon/data/FileSystem.kt | 33 +++++++++++++++++++ .../vector/daemon/ipc/ManagerService.kt | 16 +++++++-- .../org/lsposed/lspd/ILSPManagerService.aidl | 11 +++++++ 3 files changed, 58 insertions(+), 2 deletions(-) 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/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 7cc849980..8f6edf6a8 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 @@ -147,7 +147,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) } @@ -233,13 +233,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) diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl index cc7202dfd..4bf63eaa9 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl @@ -32,6 +32,17 @@ interface ILSPManagerService { ParcelFileDescriptor getModulesLog() = 17; + /** + * The rotated log parts the daemon still holds, oldest first, as bare file names. + * + * getVerboseLog()/getModulesLog() only ever hand over the part being written. The daemon keeps + * ten, so on a device that has been logging for an hour most of the history was unreachable. + */ + List getLogParts(boolean verbose) = 53; + + /** Opens one part by the name getLogParts() returned. Any other name is refused. */ + ParcelFileDescriptor getLogPart(boolean verbose, String name) = 54; + long getXposedVersionCode() = 18; String getXposedVersionName() = 19; From b53a329f1bf0857349abb64cd0ebfb3593eba3bf Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:54:22 +0200 Subject: [PATCH 03/62] Launch the new manager parasitically, and give the notification its own mark The status notification opens `:manager` now. Its icons are the Winged Victory rather than the inherited ones, which is the only place the framework shows a face outside the app itself. --- .../daemon/system/NotificationManager.kt | 2 +- .../res/drawable/ic_statue_monochrome.xml | 11 +++ .../matrix/vector/ParasiticManagerHooker.kt | 32 ++++++-- .../vector/ParasiticManagerSystemHooker.kt | 78 +++++++++++++++++++ .../kotlin/org/matrix/vector/core/Main.kt | 4 +- 5 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 daemon/src/main/res/drawable/ic_statue_monochrome.xml 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/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/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt index 59aec5148..ab322a5c0 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt @@ -34,6 +34,8 @@ object ParasiticManagerHooker { private var managerPkgInfo: PackageInfo? = null private var managerFd: Int = -1 + var isParasitic = false + // Manually track Activity states since the system is unaware of our spoofed activities private val states = ConcurrentHashMap() private val persistentStates = ConcurrentHashMap() @@ -212,8 +214,7 @@ object ParasiticManagerHooker { getManagerPkgInfo(arg.applicationInfo) ?: return@forEachIndexed pkgInfo.activities ?.find { - it.name == - BuildConfig.ManagerPackageName + ".ui.activity.MainActivity" + it.name == BuildConfig.ManagerPackageName + ".ui.MainActivity" } ?.let { it.applicationInfo = pkgInfo.applicationInfo @@ -224,7 +225,7 @@ object ParasiticManagerHooker { arg.component = ComponentName( arg.component!!.packageName, - BuildConfig.ManagerPackageName + ".ui.activity.MainActivity", + BuildConfig.ManagerPackageName + ".ui.MainActivity", ) } } @@ -447,9 +448,30 @@ object ParasiticManagerHooker { val binderList = mutableListOf() return try { VectorServiceClient.requestInjectedManagerBinder(binderList)!!.use { pfd -> - managerFd = pfd.detachFd() val managerService = ILSPManagerService.Stub.asInterface(binderList[0]) - hookForManager(managerService) + + if (isParasitic) { + managerFd = pfd.detachFd() + hookForManager(managerService) + } else { + XposedHelpers.findAndHookMethod( + LoadedApk::class.java, + "getClassLoader", + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam<*>) { + val mAppInfo = + XposedHelpers.getObjectField( + param.thisObject, + "mApplicationInfo", + ) as ApplicationInfo + if (mAppInfo.packageName == BuildConfig.ManagerPackageName) { + val classLoader = param.result as ClassLoader + sendBinderToManager(classLoader, managerService.asBinder()) + } + } + }, + ) + } Utils.logD("Vector manager injected successfully into process.") true } diff --git a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerSystemHooker.kt b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerSystemHooker.kt index 13d347ce1..dd8422db8 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerSystemHooker.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerSystemHooker.kt @@ -3,6 +3,8 @@ package org.matrix.vector import android.annotation.SuppressLint import android.content.Intent import android.content.pm.ActivityInfo +import android.os.Build +import java.lang.reflect.Field import org.lsposed.lspd.util.Utils import org.matrix.vector.impl.hookers.HandleSystemServerProcessHooker import org.matrix.vector.impl.hooks.VectorHookBuilder @@ -28,6 +30,11 @@ class ParasiticManagerSystemHooker : HandleSystemServerProcessHooker.Callback { @SuppressLint("PrivateApi") override fun onSystemServerLoaded(classLoader: ClassLoader) { + hookActivityResolution(classLoader) + hookSplashScreenSuppression(classLoader) + } + + private fun hookActivityResolution(classLoader: ClassLoader) { runCatching { // Android versions change the name of the internal class responsible for activity // tracking. @@ -114,4 +121,75 @@ class ParasiticManagerSystemHooker : HandleSystemServerProcessHooker.Callback { } .onFailure { Utils.logE("Failed to hook system server activity resolution", it) } } + + private fun hookSplashScreenSuppression(classLoader: ClassLoader) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // --------------------------------------------------------- + // Android 12+ (API 31+) - StartingSurfaceController + // --------------------------------------------------------- + runCatching { + val controllerClass = + Class.forName( + "com.android.server.wm.StartingSurfaceController", + false, + classLoader, + ) + val createMethod = + controllerClass.declaredMethods.first { + it.name == "createSplashScreenStartingSurface" + } + + val activityRecordClass = + Class.forName("com.android.server.wm.ActivityRecord", false, classLoader) + + // Cache the field to avoid overhead during hook execution + val infoField: Field = + activityRecordClass.getDeclaredField("info").apply { isAccessible = true } + + VectorHookBuilder(createMethod).intercept { chain -> + val activityRecord = chain.args[0] + val info = infoField.get(activityRecord) as ActivityInfo + + if (info.processName == BuildConfig.ManagerPackageName) { + Utils.logD("Suppressing Android 12+ Splash Screen for Vector Manager.") + return@intercept null + } + chain.proceed() + } + } + .onFailure { Utils.logE("Failed to hook StartingSurfaceController", it) } + } else { + // --------------------------------------------------------- + // Android 8.1 - 11 (API 27 - 30) - ActivityRecord + // --------------------------------------------------------- + runCatching { + val recordClassName = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "com.android.server.wm.ActivityRecord" // API 29-30 + } else { + "com.android.server.am.ActivityRecord" // API 27-28 + } + + val recordClass = Class.forName(recordClassName, false, classLoader) + val showMethod = + recordClass.declaredMethods.first { it.name == "showStartingWindow" } + + // Cache the field + val infoField: Field = + recordClass.getDeclaredField("info").apply { isAccessible = true } + + VectorHookBuilder(showMethod).intercept { chain -> + val activityRecord = chain.thisObject + val info = infoField.get(activityRecord) as ActivityInfo + + if (info.processName == BuildConfig.ManagerPackageName) { + Utils.logD("Suppressing Legacy Starting Window for Vector Manager.") + return@intercept null + } + chain.proceed() + } + } + .onFailure { Utils.logE("Failed to hook Legacy ActivityRecord", it) } + } + } } diff --git a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt index 93b7113f1..f82f5aa30 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt @@ -45,8 +45,8 @@ object Main { // Check if this process is the designated Vector Manager. if (niceName == BuildConfig.ManagerPackageName) { - val type = - if (Process.myUid() == BuildConfig.HostPackageUid) "parasitic" else "user-installed" + ParasiticManagerHooker.isParasitic = Process.myUid() == BuildConfig.HostPackageUid + val type = if (ParasiticManagerHooker.isParasitic) "parasitic" else "user-installed" if (ParasiticManagerHooker.start()) { Utils.logI("Manager ($type) loaded into host, skipping standard bootstrap.") return From 422f5d07833c48f18187944f7f142207e33878d5 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:54:22 +0200 Subject: [PATCH 04/62] Build the manager shell: navigation, theme, service location, IPC Navigation 3 models the back stack as an observable list of typed keys, so an argument like a module's user id is a constructor parameter rather than a string that can be parsed and silently dropped. Everything is shaped by where this runs. Parasitically the manager is injected into `com.android.shell`: its manifest is never installed, so there is no ContentProvider, no `androidx.startup`, and no guarantee that an Application subclass exists. Hence hand-rolled service location whose `attach` and `bind` are idempotent and order-independent, a single activity, and the platform theme. Every daemon call suspends on `Dispatchers.IO` and returns a `Result`; the binder is read once per call rather than checked and then used, which was a race. Messages carry a tone: Material's snackbar said "force stopped" and "could not uninstall" in identical grey. --- manager/src/main/AndroidManifest.xml | 52 ++ .../org/matrix/vector/manager/Constants.kt | 34 ++ .../vector/manager/data/model/AppInfo.kt | 19 + .../manager/data/model/InstalledModule.kt | 22 + .../manager/data/model/ModuleDetection.kt | 202 ++++++++ .../vector/manager/data/model/RepoModels.kt | 188 +++++++ .../manager/data/repository/AppRepository.kt | 96 ++++ .../data/repository/BackupRepository.kt | 126 +++++ .../data/repository/ModuleRepository.kt | 61 +++ .../data/repository/SettingsRepository.kt | 157 ++++++ .../vector/manager/di/ServiceLocator.kt | 120 +++++ .../matrix/vector/manager/ipc/DaemonClient.kt | 190 +++++++ .../vector/manager/ipc/PackageBroadcasts.kt | 56 +++ .../vector/manager/net/HttpClientFactory.kt | 45 ++ .../matrix/vector/manager/net/VectorDns.kt | 99 ++++ .../matrix/vector/manager/ui/MainActivity.kt | 54 ++ .../org/matrix/vector/manager/ui/VectorApp.kt | 145 ++++++ .../vector/manager/ui/components/AppIcon.kt | 100 ++++ .../vector/manager/ui/components/Avatar.kt | 128 +++++ .../manager/ui/components/RelativeTime.kt | 79 +++ .../manager/ui/components/SearchField.kt | 88 ++++ .../manager/ui/components/VectorSnackbar.kt | 161 ++++++ .../vector/manager/ui/navigation/Navigator.kt | 64 +++ .../vector/manager/ui/navigation/Route.kt | 64 +++ .../manager/ui/screens/splash/SplashScreen.kt | 101 ++++ .../matrix/vector/manager/ui/theme/Color.kt | 112 +++++ .../vector/manager/ui/theme/SeedScheme.kt | 301 ++++++++++++ .../matrix/vector/manager/ui/theme/Theme.kt | 62 +++ .../matrix/vector/manager/ui/theme/Type.kt | 37 ++ .../main/res/drawable/ic_winged_victory.xml | 464 ++++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 14 + manager/src/main/res/values-night/colors.xml | 5 + manager/src/main/res/values/colors.xml | 5 + manager/src/main/res/values/themes.xml | 14 + 34 files changed, 3465 insertions(+) create mode 100644 manager/src/main/AndroidManifest.xml create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/Constants.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/model/InstalledModule.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/model/ModuleDetection.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/repository/BackupRepository.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/AppIcon.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Avatar.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/RelativeTime.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SearchField.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorSnackbar.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Navigator.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Color.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/SeedScheme.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Theme.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Type.kt create mode 100644 manager/src/main/res/drawable/ic_winged_victory.xml create mode 100644 manager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 manager/src/main/res/values-night/colors.xml create mode 100644 manager/src/main/res/values/colors.xml create mode 100644 manager/src/main/res/values/themes.xml 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/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/RepoModels.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt new file mode 100644 index 000000000..25bb555da --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/RepoModels.kt @@ -0,0 +1,188 @@ +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?, +) { + val upgradable: Boolean + get() = + 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/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/ModuleRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt new file mode 100644 index 000000000..be5c1b51e --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleRepository.kt @@ -0,0 +1,61 @@ +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() + + 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/SettingsRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt new file mode 100644 index 000000000..92dbf3e44 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/SettingsRepository.kt @@ -0,0 +1,157 @@ +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 + } + + /** 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() + + 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..35c51f52b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt @@ -0,0 +1,120 @@ +package org.matrix.vector.manager.di + +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.ModuleInstaller +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) } + + val store: RepoRepository by lazy { RepoRepository(http, daemon, appScope) } + + val installer: ModuleInstaller by lazy { ModuleInstaller(context, http) } + + 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() + } + } + } + + /** 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..adf38badd --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/DaemonClient.kt @@ -0,0 +1,190 @@ +package org.matrix.vector.manager.ipc + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +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 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) + } +} 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..fec312db0 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ipc/PackageBroadcasts.kt @@ -0,0 +1,56 @@ +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) { + 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_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..7c5ee7cf4 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt @@ -0,0 +1,54 @@ +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.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() + } + + // 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 { 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..b06dd0aaa --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -0,0 +1,145 @@ +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.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) }, + ) + } + entry { + ModulesScreen( + onModuleClick = { packageName, userId -> navigator.go(Scope(packageName, userId)) } + ) + } + 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 { 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..16dc6f0ed --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/Avatar.kt @@ -0,0 +1,128 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.foundation.background +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, +) { + // 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), + 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/RelativeTime.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/RelativeTime.kt new file mode 100644 index 000000000..0c435d605 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/RelativeTime.kt @@ -0,0 +1,79 @@ +package org.matrix.vector.manager.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import org.matrix.vector.manager.R + +/** + * "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". */ +fun compactCount(value: Int): String = + when { + value < 1_000 -> value.toString() + value < 1_000_000 -> String.format(java.util.Locale.getDefault(), "%.1fk", value / 1000f) + else -> String.format(java.util.Locale.getDefault(), "%.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 millis = epochSeconds * 1000 + val time = android.text.format.DateFormat.getTimeFormat(context).format(java.util.Date(millis)) + + val now = java.util.Calendar.getInstance() + val then = java.util.Calendar.getInstance().apply { timeInMillis = millis } + + val sameDay = + now.get(java.util.Calendar.YEAR) == then.get(java.util.Calendar.YEAR) && + now.get(java.util.Calendar.DAY_OF_YEAR) == then.get(java.util.Calendar.DAY_OF_YEAR) + if (sameDay) return time + + val flags = + if (now.get(java.util.Calendar.YEAR) == then.get(java.util.Calendar.YEAR)) { + android.text.format.DateUtils.FORMAT_SHOW_DATE or + android.text.format.DateUtils.FORMAT_ABBREV_MONTH or + android.text.format.DateUtils.FORMAT_NO_YEAR + } else { + android.text.format.DateUtils.FORMAT_SHOW_DATE or + android.text.format.DateUtils.FORMAT_ABBREV_MONTH or + android.text.format.DateUtils.FORMAT_SHOW_YEAR + } + val date = android.text.format.DateUtils.formatDateTime(context, millis, flags) + return "$date $time" +} 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..39c0ff922 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SearchField.kt @@ -0,0 +1,88 @@ +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.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, + ) + } + 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/VectorSnackbar.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorSnackbar.kt new file mode 100644 index 000000000..d52fd2814 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/VectorSnackbar.kt @@ -0,0 +1,161 @@ +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.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, which in an Expressive app reads as + * something left over from another decade. This one keeps the same inverse-surface contrast — a + * message has to stand off the screen it covers — but takes the shape language of everything else, + * 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.inverseSurface + } + val content = + when (tone) { + SnackbarTone.Failure -> colors.onErrorContainer + else -> colors.inverseOnSurface + } + val accent = + when (tone) { + SnackbarTone.Success -> colors.primary + SnackbarTone.Failure -> colors.error + SnackbarTone.Working -> colors.inversePrimary + SnackbarTone.Neutral -> colors.inversePrimary + } + + Snackbar( + modifier = Modifier.padding(horizontal = 12.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/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..d0fc873dc --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/navigation/Route.kt @@ -0,0 +1,64 @@ +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 + +/** 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/splash/SplashScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt new file mode 100644 index 000000000..abc11669d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/splash/SplashScreen.kt @@ -0,0 +1,101 @@ +package org.matrix.vector.manager.ui.screens.splash + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import org.matrix.vector.manager.R +import org.matrix.vector.manager.di.ServiceLocator +import kotlinx.coroutines.flow.first + +/** How long the animation itself needs, so the statue is never cut off mid-fade. */ +private const val ANIMATION_MS = 800L + +/** The longest we wait on the daemon before showing the UI anyway, in its "not activated" state. */ +private const val DAEMON_TIMEOUT_MS = 2_500L + +/** + * The Winged Victory, fading and scaling in exactly as designed — Vector, from *Victoria*. + * + * The behaviour behind it changed in two ways, both of which the fixed 1.5-second delay it + * replaced got wrong: + * - It **waits on the daemon**, not on a timer. The old delay's comment claimed it gave the binder + * time to connect, but it awaited nothing; a fast device paid the full 1.5 s and a slow one + * arrived at Home before the daemon was up and showed "Not Activated" incorrectly. + * - It waits for the animation to finish too, so gating on a binder that resolves instantly does + * not produce a flash of half-drawn artwork. + */ +@Composable +fun SplashGate(content: @Composable () -> Unit) { + var ready by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + // Race the two: the artwork's own duration, and the daemon handshake with a ceiling. + withTimeoutOrNull(DAEMON_TIMEOUT_MS) { ServiceLocator.service.first { it != null } } + delay(ANIMATION_MS) + ready = true + } + + Crossfade(targetState = ready, animationSpec = tween(320), label = "splashHandoff") { done -> + if (done) content() else WingedVictory() + } +} + +/** The splash artwork, also summoned by the header's easter egg. */ +@Composable +fun WingedVictory() { + var started by remember { mutableStateOf(false) } + val alpha by + animateFloatAsState( + targetValue = if (started) 1f else 0f, + animationSpec = tween(durationMillis = ANIMATION_MS.toInt()), + label = "splashAlpha", + ) + val scale by + animateFloatAsState( + targetValue = if (started) 1f else 0.8f, + animationSpec = tween(durationMillis = ANIMATION_MS.toInt()), + label = "splashScale", + ) + + LaunchedEffect(Unit) { started = true } + + Box( + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .semantics { contentDescription = "Vector" }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_winged_victory), + contentDescription = null, + tint = Color.Unspecified, + // The artwork occupies only a slice of its square viewport, so sizing by width gave + // a statue a third the size intended. Sized by the smaller dimension instead, which + // holds on both portrait and landscape without stretching or clipping. + modifier = Modifier.fillMaxSize(0.92f).scale(scale).alpha(alpha), + ) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Color.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Color.kt new file mode 100644 index 000000000..9c5c6ad36 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Color.kt @@ -0,0 +1,112 @@ +package org.matrix.vector.manager.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +/** + * The fallback palette, used below Android 12 and whenever the user turns dynamic colour off. + * + * It is seeded from the Winged Victory's own patina, `#6ABFCF` — the single colour in + * `ic_winged_victory.xml`. Above API 31 the wallpaper decides instead, which is why nothing in this + * app may rely on a specific hue to carry meaning: see `VectorStatusColors` for how status is + * signalled without depending on the palette. + */ +val VectorLightColors: ColorScheme = + lightColorScheme( + primary = Color(0xFF00687A), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFADEDFF), + onPrimaryContainer = Color(0xFF001F27), + inversePrimary = Color(0xFF55D6F2), + secondary = Color(0xFF4B6269), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFCEE7EF), + onSecondaryContainer = Color(0xFF061F25), + tertiary = Color(0xFF5A5B7E), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFE0E0FF), + onTertiaryContainer = Color(0xFF171837), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + background = Color(0xFFF5FAFC), + onBackground = Color(0xFF171C1E), + surface = Color(0xFFF5FAFC), + onSurface = Color(0xFF171C1E), + surfaceVariant = Color(0xFFDBE4E7), + onSurfaceVariant = Color(0xFF3F484B), + surfaceTint = Color(0xFF00687A), + inverseSurface = Color(0xFF2B3134), + inverseOnSurface = Color(0xFFECF1F4), + outline = Color(0xFF6F797B), + outlineVariant = Color(0xFFBFC8CB), + scrim = Color(0xFF000000), + surfaceBright = Color(0xFFF5FAFC), + surfaceDim = Color(0xFFD6DBDE), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFEFF4F7), + surfaceContainer = Color(0xFFE9EEF1), + surfaceContainerHigh = Color(0xFFE3E9EB), + surfaceContainerHighest = Color(0xFFDEE3E6), + ) + +val VectorDarkColors: ColorScheme = + darkColorScheme( + primary = Color(0xFF55D6F2), + onPrimary = Color(0xFF003641), + primaryContainer = Color(0xFF004E5C), + onPrimaryContainer = Color(0xFFADEDFF), + inversePrimary = Color(0xFF00687A), + secondary = Color(0xFFB2CBD3), + onSecondary = Color(0xFF1D343A), + secondaryContainer = Color(0xFF334A51), + onSecondaryContainer = Color(0xFFCEE7EF), + tertiary = Color(0xFFC3C3EB), + onTertiary = Color(0xFF2C2D4D), + tertiaryContainer = Color(0xFF424365), + onTertiaryContainer = Color(0xFFE0E0FF), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF0E1416), + onBackground = Color(0xFFDEE3E6), + surface = Color(0xFF0E1416), + onSurface = Color(0xFFDEE3E6), + surfaceVariant = Color(0xFF3F484B), + onSurfaceVariant = Color(0xFFBFC8CB), + surfaceTint = Color(0xFF55D6F2), + inverseSurface = Color(0xFFDEE3E6), + inverseOnSurface = Color(0xFF2B3134), + outline = Color(0xFF899295), + outlineVariant = Color(0xFF3F484B), + scrim = Color(0xFF000000), + surfaceBright = Color(0xFF343A3C), + surfaceDim = Color(0xFF0E1416), + surfaceContainerLowest = Color(0xFF090F11), + surfaceContainerLow = Color(0xFF171C1E), + surfaceContainer = Color(0xFF1B2022), + surfaceContainerHigh = Color(0xFF252B2D), + surfaceContainerHighest = Color(0xFF303638), + ) + +/** + * Collapses every background role to true black, for OLED panels. + * + * Applied on top of whichever dark scheme is in use — including a dynamic one — so the accent + * colours the user's wallpaper produced survive. + */ +fun ColorScheme.toAmoled(): ColorScheme = + copy( + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = Color(0xFF0A0A0A), + surfaceContainer = Color(0xFF101010), + surfaceContainerHigh = Color(0xFF161616), + surfaceContainerHighest = Color(0xFF1C1C1C), + ) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/SeedScheme.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/SeedScheme.kt new file mode 100644 index 000000000..f005a45fb --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/SeedScheme.kt @@ -0,0 +1,301 @@ +package org.matrix.vector.manager.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import kotlin.math.atan2 +import kotlin.math.cbrt +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin + +/** + * A complete Material 3 colour scheme generated from one colour. + * + * Android's own generator is only reachable through `dynamicColorScheme`, which reads the + * *wallpaper* and nothing else — there is no public API that takes a colour and hands back a + * scheme. So a user who wants Vector in their own colour, or anyone below Android 12 where dynamic + * colour does not exist at all, would otherwise be stuck with the one hand-written palette in + * [VectorLightColors]. This file removes that limit without pulling in a dependency. + * + * The work happens in **CIE LCh**, which is the same idea as Google's HCT: hold the hue and the + * colourfulness of the seed fixed, and walk the *lightness* up and down to build a tonal ramp. That + * is what makes a generated scheme feel like one colour rather than thirteen — every role is the + * same hue at a different tone. Lightness here is L\*, perceptually uniform, so "tone 40" really is + * forty percent of the way from black to white to the eye, which is what the Material contrast + * pairings assume. + * + * The one subtlety is gamut. Most (lightness, hue) pairs cannot hold the seed's full chroma in + * sRGB — a vivid yellow simply does not exist at tone 20 — so each tone binary-searches the most + * chroma it can keep before the conversion falls off the edge of the display. Clipping the channels + * instead, which is the obvious shortcut, shifts the hue and is exactly why naive generators + * produce ramps that drift from blue to purple as they darken. + */ +object SeedScheme { + + /** The Winged Victory's patina, and the app's identity colour when nothing else is chosen. */ + const val DEFAULT_SEED: Int = 0xFF6ABFCF.toInt() + + /** + * Presets worth offering before someone reaches for the wheel. + * + * Chosen to be far apart in hue and all viable as an accent at both ends of the tone scale — + * a palette of near-neighbours would look like a bug. + */ + val PRESETS: List = + listOf( + DEFAULT_SEED, + 0xFF4C6FFF.toInt(), + 0xFF7C4DFF.toInt(), + 0xFFD4467A.toInt(), + 0xFFE0603A.toInt(), + 0xFFD9A227.toInt(), + 0xFF3F9B54.toInt(), + 0xFF5B6570.toInt(), + ) + + /** Builds the scheme a seed implies, for one of the two brightnesses. */ + fun of(seed: Int, dark: Boolean): ColorScheme { + val (_, chroma, hue) = Color(seed).toLch() + + // The standard Material "tonal spot" recipe: the accent is pushed to a floor of chroma so + // that a washed-out seed still yields a usable accent, the supporting roles are + // deliberately quieter, and the neutrals keep a trace of the hue so that surfaces feel + // related to the accent instead of grey next to it. + val primary = Ramp(hue, chroma.coerceAtLeast(48f)) + val secondary = Ramp(hue, 16f) + val tertiary = Ramp(hue + 60f, 24f) + val neutral = Ramp(hue, 4f) + val variant = Ramp(hue, 8f) + // Error stays red whatever the seed. A destructive action must not become negotiable + // because someone picked a green theme. + val error = Ramp(25f, 84f) + + return if (dark) { + darkColorScheme( + primary = primary[80], + onPrimary = primary[20], + primaryContainer = primary[30], + onPrimaryContainer = primary[90], + inversePrimary = primary[40], + secondary = secondary[80], + onSecondary = secondary[20], + secondaryContainer = secondary[30], + onSecondaryContainer = secondary[90], + tertiary = tertiary[80], + onTertiary = tertiary[20], + tertiaryContainer = tertiary[30], + onTertiaryContainer = tertiary[90], + error = error[80], + onError = error[20], + errorContainer = error[30], + onErrorContainer = error[90], + background = neutral[6], + onBackground = neutral[90], + surface = neutral[6], + onSurface = neutral[90], + surfaceVariant = variant[30], + onSurfaceVariant = variant[80], + surfaceTint = primary[80], + inverseSurface = neutral[90], + inverseOnSurface = neutral[20], + outline = variant[60], + outlineVariant = variant[30], + scrim = Color.Black, + surfaceBright = neutral[24], + surfaceDim = neutral[6], + surfaceContainerLowest = neutral[4], + surfaceContainerLow = neutral[10], + surfaceContainer = neutral[12], + surfaceContainerHigh = neutral[17], + surfaceContainerHighest = neutral[22], + ) + } else { + lightColorScheme( + primary = primary[40], + onPrimary = primary[100], + primaryContainer = primary[90], + onPrimaryContainer = primary[10], + inversePrimary = primary[80], + secondary = secondary[40], + onSecondary = secondary[100], + secondaryContainer = secondary[90], + onSecondaryContainer = secondary[10], + tertiary = tertiary[40], + onTertiary = tertiary[100], + tertiaryContainer = tertiary[90], + onTertiaryContainer = tertiary[10], + error = error[40], + onError = error[100], + errorContainer = error[90], + onErrorContainer = error[10], + background = neutral[98], + onBackground = neutral[10], + surface = neutral[98], + onSurface = neutral[10], + surfaceVariant = variant[90], + onSurfaceVariant = variant[30], + surfaceTint = primary[40], + inverseSurface = neutral[20], + inverseOnSurface = neutral[95], + outline = variant[50], + outlineVariant = variant[80], + scrim = Color.Black, + surfaceBright = neutral[98], + surfaceDim = neutral[87], + surfaceContainerLowest = neutral[100], + surfaceContainerLow = neutral[96], + surfaceContainer = neutral[94], + surfaceContainerHigh = neutral[92], + surfaceContainerHighest = neutral[90], + ) + } + } + + /** The tones the palette preview shows, light to dark. */ + val PREVIEW_TONES = intArrayOf(95, 90, 80, 70, 60, 50, 40, 30, 20, 10) + + /** One hue's worth of colour, at every tone. Memoised because a scheme asks for ~40 of them. */ + class Ramp(private val hue: Float, private val chroma: Float) { + private val cache = HashMap(16) + + operator fun get(tone: Int): Color = cache.getOrPut(tone) { lchToColor(tone.toFloat(), chroma, hue) } + } + + /** A colour built directly from a point on the wheel, for the picker's own display. */ + fun wheelColor(hue: Float, chroma: Float, tone: Float = 60f): Color = lchToColor(tone, chroma, hue) + + /** The wheel position a colour occupies: chroma as radius, hue as angle. */ + fun Color.toWheel(): Pair { + val (_, c, h) = toLch() + return c to h + } + + // ---- colour space conversions ------------------------------------------------------------- + // + // sRGB <-> linear RGB <-> CIE XYZ (D65) <-> CIE Lab <-> LCh. Standard formulae; the constants + // are the sRGB primaries and the D65 white point. + + private const val WHITE_X = 95.047f + private const val WHITE_Y = 100.0f + private const val WHITE_Z = 108.883f + + private fun Color.toLch(): Triple { + val r = linearize(red) + val g = linearize(green) + val b = linearize(blue) + + val x = (0.4124f * r + 0.3576f * g + 0.1805f * b) * 100f + val y = (0.2126f * r + 0.7152f * g + 0.0722f * b) * 100f + val z = (0.0193f * r + 0.1192f * g + 0.9505f * b) * 100f + + val fx = labF(x / WHITE_X) + val fy = labF(y / WHITE_Y) + val fz = labF(z / WHITE_Z) + + val l = 116f * fy - 16f + val a = 500f * (fx - fy) + val bb = 200f * (fy - fz) + + val chroma = hypot(a, bb) + var hue = Math.toDegrees(atan2(bb, a).toDouble()).toFloat() + if (hue < 0f) hue += 360f + return Triple(l, chroma, hue) + } + + /** + * The most saturated colour of this hue that sRGB can actually show at this lightness. + * + * Binary search rather than arithmetic because the sRGB gamut is not a nice shape in LCh — its + * boundary depends on all three coordinates at once. Twelve iterations lands within a + * quarter-unit of chroma, well below what an eye resolves, and this runs a few dozen times per + * theme change rather than per frame. + */ + private fun lchToColor(lightness: Float, chroma: Float, hueDegrees: Float): Color { + val l = lightness.coerceIn(0f, 100f) + if (chroma <= 0.01f) return labToColor(l, 0f, 0f) + + val hue = Math.toRadians(hueDegrees.toDouble()) + val cosH = cos(hue).toFloat() + val sinH = sin(hue).toFloat() + + fun fits(c: Float): Boolean = inGamut(l, c * cosH, c * sinH) + + if (fits(chroma)) return labToColor(l, chroma * cosH, chroma * sinH) + + var low = 0f + var high = chroma + repeat(12) { + val mid = (low + high) / 2f + if (fits(mid)) low = mid else high = mid + } + return labToColor(l, low * cosH, low * sinH) + } + + private fun inGamut(l: Float, a: Float, b: Float): Boolean { + val (r, g, bl) = labToLinear(l, a, b) + // A hair of tolerance: the round trip is float maths, and rejecting a colour that is out + // of gamut by one part in ten thousand costs visible chroma for nothing. + return r >= -0.0001f && r <= 1.0001f && + g >= -0.0001f && g <= 1.0001f && + bl >= -0.0001f && bl <= 1.0001f + } + + private fun labToColor(l: Float, a: Float, b: Float): Color { + val (r, g, bl) = labToLinear(l, a, b) + return Color( + red = delinearize(r), + green = delinearize(g), + blue = delinearize(bl), + ) + } + + private fun labToLinear(l: Float, a: Float, b: Float): Triple { + val fy = (l + 16f) / 116f + val fx = fy + a / 500f + val fz = fy - b / 200f + + val x = labInverseF(fx) * WHITE_X / 100f + val y = labInverseF(fy) * WHITE_Y / 100f + val z = labInverseF(fz) * WHITE_Z / 100f + + val r = 3.2406f * x - 1.5372f * y - 0.4986f * z + val g = -0.9689f * x + 1.8758f * y + 0.0415f * z + val bl = 0.0557f * x - 0.2040f * y + 1.0570f * z + return Triple(r, g, bl) + } + + private fun labF(t: Float): Float = + if (t > 0.008856f) cbrt(t.toDouble()).toFloat() else (7.787f * t + 16f / 116f) + + private fun labInverseF(t: Float): Float { + val cubed = t * t * t + return if (cubed > 0.008856f) cubed else (t - 16f / 116f) / 7.787f + } + + private fun linearize(channel: Float): Float = + if (channel <= 0.04045f) channel / 12.92f + else ((channel + 0.055f) / 1.055f).toDouble().pow(2.4).toFloat() + + private fun delinearize(channel: Float): Float { + val c = channel.coerceIn(0f, 1f) + return if (c <= 0.0031308f) c * 12.92f + else (1.055f * c.toDouble().pow(1.0 / 2.4) - 0.055f).toFloat() + } + + /** `#RRGGBB`, for showing the seed as something a user can write down or type back in. */ + fun Color.toHex(): String = + "#%02X%02X%02X" + .format((red * 255).roundToInt(), (green * 255).roundToInt(), (blue * 255).roundToInt()) + + /** Parses `#RRGGBB` or `RRGGBB`, returning null rather than throwing on a half-typed value. */ + fun parseHex(text: String): Int? { + val body = text.trim().removePrefix("#") + if (body.length != 6) return null + val value = body.toLongOrNull(16) ?: return null + return (0xFF000000L or value).toInt() + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Theme.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Theme.kt new file mode 100644 index 000000000..f16675922 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Theme.kt @@ -0,0 +1,62 @@ +package org.matrix.vector.manager.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import org.matrix.vector.manager.di.ServiceLocator + +/** How the app resolves light vs dark. Persisted under the string keys used here. */ +enum class ThemeMode(val key: String) { + System("system"), + Light("light"), + Dark("dark"); + + companion object { + fun from(key: String?): ThemeMode = entries.firstOrNull { it.key == key } ?: System + } +} + +@Composable +fun VectorTheme(content: @Composable () -> Unit) { + val settings = ServiceLocator.settings + val modeKey by settings.themeMode.collectAsState() + val dynamicRequested by settings.dynamicColor.collectAsState() + val amoled by settings.amoledBlack.collectAsState() + val seed by settings.seedColor.collectAsState() + + val dark = + when (ThemeMode.from(modeKey)) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + + val context = LocalContext.current + // Dynamic colour is this app's default; the seed below applies before Android 12, or whenever + // the user would rather choose the colour themselves than inherit their wallpaper's. + val dynamic = dynamicRequested && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + var scheme = + when { + dynamic && dark -> dynamicDarkColorScheme(context) + dynamic -> dynamicLightColorScheme(context) + else -> remember(seed, dark) { SeedScheme.of(seed, dark) } + } + if (dark && amoled) scheme = scheme.toAmoled() + + MaterialExpressiveTheme( + colorScheme = scheme, + // The expressive motion scheme is what makes a state change feel caused rather than + // scheduled. It drives the status indicator's shape morph and the nav transitions. + motionScheme = MotionScheme.expressive(), + content = content, + ) +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Type.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Type.kt new file mode 100644 index 000000000..19a53a38a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/theme/Type.kt @@ -0,0 +1,37 @@ +package org.matrix.vector.manager.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * The platform type scale, with one deliberate deviation: identifiers are monospaced. + * + * Commit SHAs, version codes, package names and log lines are things a user compares character by + * character. Proportional digits make that harder, so [VectorMono] is used for them rather than + * bending the body styles. + */ +val VectorMono: TextStyle = + TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp, letterSpacing = 0.sp) + +/** Log bodies: monospace, tighter line height than the body scale, and small. */ +val VectorLogLine: TextStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp, + letterSpacing = 0.sp, + ) + +/** + * Section headers on Home read as a magazine front page rather than a settings list, so they get + * extra tracking and weight against the default `titleMedium`. + */ +@Composable +fun sectionHeaderStyle(base: Typography): TextStyle = remember(base) { + base.titleSmall.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.8.sp) +} diff --git a/manager/src/main/res/drawable/ic_winged_victory.xml b/manager/src/main/res/drawable/ic_winged_victory.xml new file mode 100644 index 000000000..193431ad9 --- /dev/null +++ b/manager/src/main/res/drawable/ic_winged_victory.xml @@ -0,0 +1,464 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/manager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..935333819 --- /dev/null +++ b/manager/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/manager/src/main/res/values-night/colors.xml b/manager/src/main/res/values-night/colors.xml new file mode 100644 index 000000000..14cdd2acb --- /dev/null +++ b/manager/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #121212 + diff --git a/manager/src/main/res/values/colors.xml b/manager/src/main/res/values/colors.xml new file mode 100644 index 000000000..e6f2ce5cb --- /dev/null +++ b/manager/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #FFFFFF + diff --git a/manager/src/main/res/values/themes.xml b/manager/src/main/res/values/themes.xml new file mode 100644 index 000000000..7e67fa9dd --- /dev/null +++ b/manager/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + + + From 251cf4e366d71385ceabf3789ec05afa2831ce7a Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:54:46 +0200 Subject: [PATCH 05/62] Generate the colour scheme from a seed, and pick it on a wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android exposes no public way to turn a colour into a Material scheme — `dynamicColorScheme` reads the wallpaper and nothing else — so below Android 12, or with dynamic colour off, the only option was one hand-written palette. `SeedScheme` generates one in CIE LCh: hold the seed's hue and chroma, walk L* across the Material tone scale. The part that matters is the gamut search — most (lightness, hue) pairs cannot hold the seed's full chroma in sRGB, so each tone binary-searches the most it can keep. Clipping the channels instead, which is the obvious shortcut, shifts hue as tones darken, and that is why naive generators drift blue to purple down the ramp. Error stays red whatever the seed: a destructive action must not become negotiable because someone picked green. The picker is a hue/chroma wheel rather than an HSV square, because angle and radius are literally the two numbers the generator consumes. It is painted at the tone the accent will occupy, so choosing dark mode visibly lightens it. The sources are one row — wallpaper, presets, wheel — because they are alternatives to each other, and a switch labelled "dynamic colour" above an unrelated list of swatches hid that. Underneath, the tone ramp the choice actually produces: a seed that looks lovely as a dot can still make a muddy container. --- .../manager/ui/components/ColorWheel.kt | 169 ++++++ .../ui/screens/home/HomeAppearanceSheet.kt | 485 ++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ColorWheel.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt 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/screens/home/HomeAppearanceSheet.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt new file mode 100644 index 000000000..401acef42 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt @@ -0,0 +1,485 @@ +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.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.R +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.ColorWheel +import org.matrix.vector.manager.ui.components.ambience.AmbienceKind +import org.matrix.vector.manager.ui.theme.SeedScheme +import org.matrix.vector.manager.ui.theme.ThemeMode + +/** + * How this screen looks, edited from this screen. + * + * Vector deliberately does not gather every switch into one Settings screen. A preference is easier + * to find, and far easier to understand, next to the thing it changes — so what governs Home lives + * behind Home's own button, backup lives on the module list it backs up, and so on. There is no + * catch-all Settings screen at all: a screen that collects unrelated switches is where preferences + * go to be forgotten, and every one of them has a place it actually belongs. + * + * Everything here takes effect immediately behind the sheet, which is the point of a sheet rather + * than a screen: change the surface or the theme and you can watch it happen without losing your + * place. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeAppearanceSheet(onDismiss: () -> Unit) { + val settings = ServiceLocator.settings + val themeMode by settings.themeMode.collectAsStateWithLifecycle() + val dynamicColor by settings.dynamicColor.collectAsStateWithLifecycle() + val amoled by settings.amoledBlack.collectAsStateWithLifecycle() + val seed by settings.seedColor.collectAsStateWithLifecycle() + val ambience by settings.headerAmbience.collectAsStateWithLifecycle() + val contributorOrder by settings.contributorOrder.collectAsStateWithLifecycle() + val resolvedDark = + when (ThemeMode.from(themeMode)) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val windowMonths by settings.activityWindowMonths.collectAsStateWithLifecycle() + val openExternally by settings.openLinksExternally.collectAsStateWithLifecycle() + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.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), + subtitle = stringResource(R.string.appearance_amoled_summary), + icon = Icons.Rounded.DarkMode, + 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 { + listOf(1, 3, 6, 12).forEach { months -> + FilterChip( + selected = windowMonths == months, + onClick = { settings.setActivityWindowMonths(months) }, + label = { + Text( + 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), + subtitle = stringResource(R.string.settings_open_externally_summary), + icon = Icons.Rounded.OpenInBrowser, + 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 + } + +@Composable +private fun SheetHeading(text: String, icon: androidx.compose.ui.graphics.vector.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 chips that scrolls if it must, so a long option set never clips. */ +@Composable +private fun ChoiceRow(content: @Composable () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + content() + } +} + +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + ListItem( + modifier = Modifier.clickable { onCheckedChange(!checked) }, + headlineContent = { Text(title) }, + supportingContent = { Text(subtitle) }, + leadingContent = { Icon(icon, contentDescription = null) }, + trailingContent = { Switch(checked = checked, onCheckedChange = null) }, + ) +} + +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 + } From 84764236d3ba07d03cacdd1868535648364363ac Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:54:46 +0200 Subject: [PATCH 06/62] Give the header's empty space something to be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status header needs breathing room above its status line, and space that exists only because a layout needed it reads as a mistake. Four surfaces fill it, each answering touch: snow that bursts where pressed and grows where it is not, a maze with one wanderer solving it, falling code that freezes under a held finger, and nothing at all for people who want nothing at all. The maze is carved by a randomised depth-first walk and then braided open, because a perfect maze is all dead ends and a wanderer in one mostly reverses; the loops are what make its turns choices. The circuit sits beside it deliberately — they are opposites, a designed path many signals share against an undesigned one a single wanderer has to solve. None of them may compete with the text over them: they draw in the header's own on-container colour at low alpha, and the frame loop parks entirely when nothing is moving. --- .../manager/ui/components/StatusHeader.kt | 288 +++++++++++++ .../manager/ui/components/StatusRibbon.kt | 193 +++++++++ .../ui/components/ambience/Ambience.kt | 86 ++++ .../ui/components/ambience/AmbientSurface.kt | 113 ++++++ .../ui/components/ambience/CircuitRenderer.kt | 272 +++++++++++++ .../ui/components/ambience/MatrixRenderer.kt | 240 +++++++++++ .../ui/components/ambience/MazeRenderer.kt | 384 ++++++++++++++++++ .../ui/components/ambience/SnowRenderer.kt | 218 ++++++++++ 8 files changed, 1794 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusRibbon.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/AmbientSurface.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MatrixRenderer.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MazeRenderer.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/SnowRenderer.kt 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..6a740ee6b --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/StatusHeader.kt @@ -0,0 +1,288 @@ +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.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?, + ambience: AmbienceKind, + onOpenStatus: () -> Unit, + onOpenAppearance: () -> 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. + Spacer(Modifier.height(78.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + // 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. + StatusIndicator( + state = state, + tint = onContainer, + onClick = onOpenStatus, + contentDescription = stringResource(R.string.status_open_details), + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Row(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, + ) + } + val detail = + buildList { + version?.let { add(it) } + apiVersion?.let { add("API $it") } + } + .joinToString(" · ") + if (detail.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text( + text = detail, + style = VectorMono, + color = onContainer.copy(alpha = 0.75f), + ) + } + } + // Not a gear. What it opens governs *this* screen — its theme, its surface, its + // feed — rather than the whole app, and the icon should say so. + IconButton(onClick = onOpenAppearance) { + Icon( + Icons.Rounded.Palette, + contentDescription = stringResource(R.string.appearance_title), + tint = onContainer, + ) + } + } + } + } +} + +/** + * 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, + ) +} 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..869fb41e7 --- /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.rounded.ChevronRight +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.Rounded.ChevronRight, + 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/ambience/Ambience.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt new file mode 100644 index 000000000..4734e5bcd --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/Ambience.kt @@ -0,0 +1,86 @@ +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 across the surface. */ + fun onSwipe(from: Offset, to: Offset, size: Size) {} + + /** A pinch. [factor] is relative to the previous frame's span. */ + fun onZoom(factor: Float) {} + + /** + * 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..14860f376 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/AmbientSurface.kt @@ -0,0 +1,113 @@ +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 + +/** + * 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 renderer = remember(kind) { rendererFor(kind) } ?: 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( + 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. + var dragStart: Offset? = null + detectTransformGestures(panZoomLock = false) { centroid, pan, gestureZoom, _ -> + if (gestureZoom != 1f) renderer.onZoom(gestureZoom) + if (pan != Offset.Zero) { + val start = dragStart ?: centroid.also { dragStart = it } + renderer.onSwipe(start, centroid, size.toSize()) + } + } + } + ) { + 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..a18cc1240 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/CircuitRenderer.kt @@ -0,0 +1,272 @@ +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.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 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() + 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)) { + 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) + 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. + */ + override fun onSwipe(from: Offset, to: Offset, size: Size) { + if (hypot(to.x - from.x, to.y - from.y) < size.height * 0.25f) return + 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 + + 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..bb4c196b8 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MatrixRenderer.kt @@ -0,0 +1,240 @@ +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. + */ + private var scale = 1f + + 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 + + /** + * Half-width katakana, digits and a few latin letters. + * + * 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. + */ + private val alphabet: List = + buildList { + for (c in 'ア'..'ン') add(c) + for (c in '0'..'9') add(c) + addAll("VECTORXPOSED".toList()) + } + + private fun randomGlyph(): Char = alphabet[random.nextInt(alphabet.size)] + + private fun seed(size: Size) { + if (sized == size && columns.isNotEmpty()) return + sized = size + cellHeight = size.height * 0.26f + 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 + + // 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 + } + + override fun onZoom(factor: Float) { + scale = (scale * factor).coerceIn(MIN_SCALE, MAX_SCALE) + } + + /** 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 + } +} 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..3bf1cb463 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/MazeRenderer.kt @@ -0,0 +1,384 @@ +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.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 COLS = 13 + const val ROWS = 5 + /** 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. + */ + private val right = Array(COLS) { BooleanArray(ROWS) } + private val 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. + */ + 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 rows = (0 until ROWS).toMutableList() + rows.shuffle(random) + val doorCount = 2 + random.nextInt(2) + leftDoors += rows.take(doorCount) + rows.shuffle(random) + rightDoors += rows.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. */ + override fun onSwipe(from: Offset, to: Offset, size: Size) { + if (abs(to.x - from.x) < size.width * 0.12f) return + 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..e7985a11c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/ambience/SnowRenderer.kt @@ -0,0 +1,218 @@ +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.hypot +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 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 + + private fun seed(size: Size) { + if (sized == size && flakes.isNotEmpty()) return + sized = size + flakes.clear() + repeat(22) { 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), + // 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) + 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 + val sway = sin(clock / 1000f * flake.swayRate + flake.swayPhase) * size.width * 0.010f + flake.x += sway * seconds + flake.y += flake.fallSpeed * seconds * flake.growth + + 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 -> + shard.age += dt + shard.x += shard.vx * seconds + shard.y += shard.vy * seconds + shard.angle += shard.spin * seconds + } + 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) + } + } + } +} From 3a1090ad2ce426f9b007d6cde92b4cb64f95f5e9 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:55:30 +0200 Subject: [PATCH 07/62] Make Home the project's front page rather than a status light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six months of commits on a rail, with everyone who wrote them credited — co-authors included, because a co-author is a contributor. Contributors can be ordered by volume or by recency: by volume the maintainer leads forever and the row never moves; by recency a first contribution is visible the day it lands. Two things needed care. GitHub links a commit to an account by email and does not always manage it, so a contributor can arrive unlinked. A `@users.noreply.github.com` address *is* the account and needs only parsing; otherwise a handle-shaped name is worth one lookup, and nothing else is — `GET /users/Qing` answers 200 with an unrelated person, so probing every display name would eventually credit a stranger. And `versionCode` is `git rev-list --count`, so a commit's distance from HEAD is exactly its version number: the feed can say which commits an update would bring, by name, with no extra endpoint. A build *past* the head of master says so instead — it was built locally, and then the feed is not the history of what is running. Opening Home is not a reason to talk to GitHub. Most launches read the snapshot on disk and say they are resting; only a request that was actually attempted and failed reports a failure. --- .../vector/manager/data/github/FeedLayout.kt | 173 +++++ .../vector/manager/data/github/GitHubAuth.kt | 205 ++++++ .../manager/data/github/GitHubModels.kt | 246 +++++++ .../manager/data/github/GitHubRepository.kt | 525 +++++++++++++++ .../manager/ui/components/CommitTimeline.kt | 414 ++++++++++++ .../vector/manager/ui/components/TakePart.kt | 243 +++++++ .../manager/ui/screens/home/HomeScreen.kt | 598 ++++++++++++++++++ .../manager/ui/screens/home/HomeViewModel.kt | 220 +++++++ .../ui/screens/home/SystemStatusScreen.kt | 257 ++++++++ .../manager/ui/screens/web/WebScreen.kt | 298 +++++++++ 10 files changed, 3179 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/github/FeedLayout.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubAuth.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TakePart.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt 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..2756c33fe --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/FeedLayout.kt @@ -0,0 +1,173 @@ +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( + val label: String, + 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 = mutableListOf() + + // 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 = monthLabel(commit.epochSeconds) + if (month != lastMonth) { + val inMonth = visible.filter { monthLabel(it.epochSeconds) == month } + items += + FeedItem.MonthMarker( + label = month, + commits = inMonth.size, + people = + inMonth + .flatMap { c -> c.authors.filterNot { it.isBot } } + .distinctBy { it.login.lowercase() } + .size, + ) + 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 + + private fun monthLabel(epochSeconds: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = epochSeconds * 1000 } + val month = + cal.getDisplayName(Calendar.MONTH, Calendar.LONG_STANDALONE, Locale.getDefault()) + ?: cal.get(Calendar.MONTH).toString() + val thisYear = Calendar.getInstance().get(Calendar.YEAR) + val year = cal.get(Calendar.YEAR) + return if (year == thisYear) month else "$month $year" + } +} 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..94dfa4242 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubModels.kt @@ -0,0 +1,246 @@ +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) + +@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, +) { + val commitCount: Int + get() = commits.size + + val isEmpty: Boolean + get() = commits.isEmpty() +} + +// --- 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, +) + +data class CanaryArtifact( + val id: Long, + val name: String, + val sizeInBytes: Long, + val expired: Boolean, + val downloadUrl: String?, +) + 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..68a4d3dbf --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -0,0 +1,525 @@ +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") + + /** + * 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) { + val months = windowMonthsProvider().coerceIn(1, 60) + val windowStart = + System.currentTimeMillis() / 1000 - months * DAYS_PER_MONTH * 24L * 60 * 60 + val fresh = runCatching { fetch(windowStart, freshness) }.getOrNull() + if (fresh != null) { + runCatching { + snapshotFile.writeText( + json.encodeToString(Snapshot(fresh.totalCommits, fresh.rawCommits)) + ) + } + return@withContext build( + fresh.rawCommits, + fresh.repo, + windowStart, + fromCache = false, + offline = false, + totalCommits = fresh.totalCommits, + 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(0L, 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( + cached.commits, + null, + 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, + ) + } + + /** + * 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()) + + 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 { + 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, + windowStartEpochSeconds = windowStart, + repo = repo, + totalCommits = totalCommits, + fromCache = fromCache, + offline = offline, + loaded = true, + ) + } + + /** + * 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 all that is known is the name the person signed with, and they + * are shown under it 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. + * + * Three tiers, cheapest and most certain first: a `@users.noreply.github.com` address *is* the + * account and needs nothing but parsing; otherwise a handle-shaped name is worth one lookup; + * otherwise the person is shown under the name they signed with, uncredited but not dropped. + */ + private fun person(name: String, email: String, freshness: Freshness): CommitPerson { + 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]"), + ) + } + 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, + ) + }, + ) + } + } + + @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-" + + /** 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/ui/components/CommitTimeline.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt new file mode 100644 index 000000000..121e57191 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/CommitTimeline.kt @@ -0,0 +1,414 @@ +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.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.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.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.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 org.matrix.vector.manager.R +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. + */ +@Composable +fun CommitRow( + commit: TimelineCommit, + isFirst: Boolean, + isLast: Boolean, + onOpenCommit: (TimelineCommit) -> Unit, + onOpenPullRequest: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + 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), + ) { + 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, + ) + 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(label: String, commits: Int, people: Int, modifier: Modifier = Modifier) { + 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 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..7ea319e75 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/TakePart.kt @@ -0,0 +1,243 @@ +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.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.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)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Door( + Icons.Rounded.RateReview, + stringResource(R.string.home_review_prs), + Modifier.weight(1f), + ) { + onOpen(GitHubRepository.PULLS_URL) + } + Door( + Icons.Rounded.Forum, + stringResource(R.string.home_discussions), + Modifier.weight(1f), + ) { + onOpen(GitHubRepository.DISCUSSIONS_URL) + } + } + Spacer(Modifier.height(10.dp)) + Row(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), + 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), + onClick = onReport, + ) + } + } +} + +@Composable +private fun Door( + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + OutlinedCard(onClick = onClick, modifier = modifier) { + Row(modifier = Modifier.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(ClipData.newPlainText("GitHub device code", code)) +} 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..be7bbff5c --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeScreen.kt @@ -0,0 +1,598 @@ +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.layout.PaddingValues +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.CloudOff +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.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.R +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.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, + 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 ambienceKey by viewModel.headerAmbience.collectAsStateWithLifecycle() + val context = LocalContext.current + var showSplash by rememberSaveable { mutableStateOf(false) } + var showAppearance 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) + } + } + } + + 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, + 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)) } + } + } + + StatusHeader( + state = status.state, + version = status.versionLabel, + apiVersion = status.apiVersion, + ambience = AmbienceKind.from(ambienceKey), + onOpenStatus = onOpenStatus, + onOpenAppearance = { showAppearance = 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 (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), + ) { + 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, + onOpenCommit: (TimelineCommit) -> Unit, + onOpenPullRequest: (Int) -> Unit, + onOpenProfile: (Contributor) -> Unit, +) { + item { QuarterHeadline(feed) } + + if (feed.contributors.isNotEmpty()) { + item { + ContributorRow(contributors = feed.contributors, onClick = onOpenProfile) + Spacer(Modifier.height(20.dp)) + } + } + + 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, + ) + 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.label, entry.commits, entry.people) + is FeedItem.Bots -> BotBundle(count = entry.count, commits = entry.commits) + } + } +} + +/** 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:$label" + 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) { + 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) + .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. + if (feed.offline || feed.fromCache) { + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (feed.offline) Icons.Rounded.CloudOff else Icons.Rounded.Bedtime, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = + stringResource( + if (feed.offline) R.string.home_offline else R.string.home_resting + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * 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. + */ +@Composable +private fun ContributorRow(contributors: List, onClick: (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() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.then( + if (hasProfile) Modifier.clickable { onClick(person) } else Modifier + ) + .alpha(if (hasProfile) 1f else 0.45f) + .width(72.dp), + ) { + ContributorAvatar( + login = person.login, + avatarUrl = person.avatarUrl, + size = 44.dp, + laurelled = leader, + ) + 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, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FooterStat(Icons.Rounded.Star, compactCount(repo.stars)) + FooterStat(Icons.AutoMirrored.Rounded.CallSplit, compactCount(repo.forks)) + 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..23da9ac3d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,220 @@ +package org.matrix.vector.manager.ui.screens.home + +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.flow.combine +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 + ) + } + + 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, + ) + } + + /** + * 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) { feed, status -> + // 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, installed) + } + .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() + + 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 + } + } + + 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/SystemStatusScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt new file mode 100644 index 000000000..2af422348 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/SystemStatusScreen.kt @@ -0,0 +1,257 @@ +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.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), + stringResource(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/web/WebScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt new file mode 100644 index 000000000..8b427bef5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/web/WebScreen.kt @@ -0,0 +1,298 @@ +package org.matrix.vector.manager.ui.screens.web + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Bitmap +import android.net.Uri +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.Lock +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import org.matrix.vector.manager.R + +/** + * GitHub, inside Vector. + * + * Handing a link to an external browser is a jarring context switch, and doubly so parasitically: + * the app the system knows about is the shell process, so the user leaves something that does not + * look like an app and has no obvious way back. Keeping the page here means reviewing a pull + * request or reading a discussion never costs them their place. + * + * Three things this has to get right, and the first is the one that is easy to get wrong: + * + * **The page must be in the app's theme, not the system's.** A `WebView` decides + * `prefers-color-scheme` from *its own context's* configuration, so a user reading Vector in light + * mode on a dark-themed phone got a black GitHub page under a white app bar. The WebView is + * therefore built against a configuration context whose night bit is forced to match the Compose + * theme, and algorithmic darkening is allowed so sites without a dark mode of their own are + * darkened rather than left glaring. + * + * **The seam must not show.** The bar takes the surface colour, the WebView's own background is + * set to the same colour before the page paints, and the scrim under the status bar matches — so + * there is no white flash on load and no hard line between chrome and content. + * + * **It should get out of the way.** The bar retracts as the page scrolls down and returns on the + * way up, which is what makes a full-screen reading surface feel immersive rather than framed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WebScreen(url: String, onNavigateBack: () -> Unit) { + val context = LocalContext.current + val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() + val surface = MaterialTheme.colorScheme.surface + + var progress by remember { mutableIntStateOf(0) } + var title by remember { mutableStateOf("") } + var host by remember { mutableStateOf(Uri.parse(url).host.orEmpty()) } + var secure by remember { mutableStateOf(url.startsWith("https")) } + var barVisible by remember { mutableStateOf(true) } + + // A WebView reads prefers-color-scheme from the configuration of the context it was built + // with, so forcing the night bit here is what makes the page follow Vector's own theme rather + // than the system's. + val themedContext = + remember(dark) { + val config = + Configuration(context.resources.configuration).apply { + uiMode = + (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (dark) Configuration.UI_MODE_NIGHT_YES + else Configuration.UI_MODE_NIGHT_NO + } + context.createConfigurationContext(config) + } + + val webView = + remember(themedContext) { + WebView(themedContext).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + // Painted before the page arrives, so loading never flashes white on a dark theme. + setBackgroundColor(surface.toArgb()) + + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.builtInZoomControls = true + settings.displayZoomControls = false + + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + // Applies only to sites with no dark mode of their own; GitHub has one and + // keeps using it. + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, true) + } + + // The bar retracts going down and comes back coming up — the page gets the whole + // screen while being read, and the controls are one flick away. + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val delta = scrollY - oldScrollY + if (delta > 12 && scrollY > 120) barVisible = false + else if (delta < -12) barVisible = true + } + + webViewClient = + object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + val target = request.url ?: return false + val scheme = target.scheme?.lowercase() + // Anything that is not web traffic — intent://, market:// — has to + // leave, because a WebView cannot render it. + if (scheme != "http" && scheme != "https") { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, target) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + return true + } + return false + } + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + progress = 5 + barVisible = true + url?.let { + host = Uri.parse(it).host.orEmpty() + secure = it.startsWith("https") + } + } + + override fun onPageFinished(view: WebView, url: String?) { + progress = 100 + title = view.title.orEmpty() + } + } + loadUrl(url) + } + } + + BackHandler(enabled = true) { + if (webView.canGoBack()) webView.goBack() else onNavigateBack() + } + + Scaffold( + containerColor = surface, + topBar = { + AnimatedVisibility( + visible = barVisible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + TopAppBar( + title = { + Column { + Text( + text = title.ifBlank { host }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + // The origin, stated plainly. A browser inside another app should + // never leave any doubt about whose page is being shown. + Row(verticalAlignment = Alignment.CenterVertically) { + if (secure) { + Icon( + Icons.Rounded.Lock, + contentDescription = null, + modifier = Modifier.height(11.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = host, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = { + if (webView.canGoBack()) webView.goBack() else onNavigateBack() + } + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + IconButton( + onClick = { + val current = webView.url ?: url + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(current)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (_: ActivityNotFoundException) { + // No browser in the host process is normal here. + } + } + ) { + Icon( + Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = stringResource(R.string.web_open_external), + ) + } + }, + // Matches the page's own surface so chrome and content read as one plane + // rather than two stacked rectangles. + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = surface, + scrolledContainerColor = surface, + ), + ) + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize().background(surface)) { + // A hairline that only exists while loading; no permanent divider to break the plane. + val shown by animateFloatAsState( + targetValue = if (progress in 1..99) 1f else 0f, + label = "webProgressAlpha", + ) + if (shown > 0f) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(2.dp)) + } + Box(Modifier.fillMaxSize()) { + AndroidView( + factory = { webView }, + modifier = Modifier.fillMaxSize(), + onRelease = { view -> + view.stopLoading() + view.destroy() + }, + ) + } + } + } +} + +/** Whether a surface colour is dark enough that the page should render its own dark mode. */ +private fun androidx.compose.ui.graphics.Color.luminanceIsDark(): Boolean = + (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f + +@Composable +private fun Row( + verticalAlignment: Alignment.Vertical, + content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit, +) = androidx.compose.foundation.layout.Row(verticalAlignment = verticalAlignment, content = content) From 30053ab648b069cead48c09cf8cbeef60ecaca46 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:55:30 +0200 Subject: [PATCH 08/62] Hand over a canary build, and answer the first reply before the issue is filed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub gates artifact downloads behind an account even on a public repository — `actions/artifacts//zip` answers 401 anonymously where a release asset answers 206 — so "test a canary build" used to be an invitation to a dead end. CI publishes the same zips as a `canary-` prerelease now, and this lists them with their sizes. Nobody signs in to anything. "Report a problem" no longer opens the tracker directly. The maintainer's own first reply to a bug report is a checklist, and a screen can do most of it: try the latest canary, update Zygisk, attach logs. The logs matter most, and the two routes are not equivalent — the zip comes from the daemon, and when the daemon is the thing that crashed, root is the only way to reach the files. The manager has none of its own, so it asks for it, and says so when refused. --- .../manager/ui/screens/canary/CanaryScreen.kt | 259 +++++++++++++ .../ui/screens/report/TroubleshootScreen.kt | 358 ++++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/canary/CanaryScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt 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/report/TroubleshootScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt new file mode 100644 index 000000000..9b600e9c1 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/report/TroubleshootScreen.kt @@ -0,0 +1,358 @@ +package org.matrix.vector.manager.ui.screens.report + +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Science +import androidx.compose.material.icons.rounded.Terminal +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.github.GitHubRepository +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.SnackbarTone +import org.matrix.vector.manager.ui.components.VectorSnackbarHost +import org.matrix.vector.manager.ui.components.show + +/** + * What to try, and what to bring, before opening an issue. + * + * "Report a problem" used to go straight to the issue tracker. That is the wrong first step for + * everyone involved: the maintainer's own first reply to a bug report is a checklist — try the + * latest canary, update your Zygisk implementation, and attach logs — and a screen can *do* most of + * that instead of describing it. + * + * The part worth the screen is the logs. A report without them is a conversation that has to start + * over, and the two ways of getting them are not equivalent: + * - the **zip** comes from the daemon, which is the complete and correct export — when the daemon + * is alive; + * - the **log folder** is the fallback for when it is not, which is exactly the case for the bugs + * worth reporting. The manager cannot read `/data/adb/lspd/log` itself — it runs as + * `com.android.shell` with no root of its own — so it hands over the command that can, rather + * than offering a button that would fail precisely when it is needed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TroubleshootScreen( + onNavigateBack: () -> Unit, + onOpenUrl: (String) -> Unit, + onOpenCanary: () -> Unit, +) { + val context = LocalContext.current + val snackbars = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + val savedLabel = stringResource(R.string.logs_saved) + val failedLabel = stringResource(R.string.logs_save_failed) + + val saveLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val ok = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openFileDescriptor(uri, "wt").use { fd -> + fd != null && + ServiceLocator.daemon.writeLogsTo(fd).isSuccess + } + } + .getOrDefault(false) + } + if (ok) snackbars.show(savedLabel, SnackbarTone.Success) + else snackbars.show(failedLabel, SnackbarTone.Failure) + } + } + + Scaffold( + snackbarHost = { VectorSnackbarHost(snackbars) }, + // Docked rather than last in the list. It is where the screen is heading, and a reader who + // has decided to file anyway should not have to scroll past the advice to do it. + bottomBar = { + Surface(tonalElevation = 3.dp) { + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalArrangement = Arrangement.Center, + ) { + Button(onClick = { onOpenUrl(GitHubRepository.ISSUES_URL) }) { + Icon( + Icons.Rounded.BugReport, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.report_open_tracker)) + } + } + } + }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.report_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp), + ) { + item { + Step( + icon = Icons.Rounded.Science, + title = stringResource(R.string.report_step_canary), + body = stringResource(R.string.report_step_canary_body), + ) { + FilledTonalButton(onClick = onOpenCanary) { + Text(stringResource(R.string.home_test_canary)) + } + } + } + + item { + Step( + icon = Icons.Rounded.OpenInNew, + title = stringResource(R.string.report_step_zygisk), + body = stringResource(R.string.report_step_zygisk_body), + titleAction = { + OutlinedButton(onClick = { onOpenUrl(NEO_ZYGISK) }) { Text("NeoZygisk") } + }, + ) + } + + item { + Step( + icon = Icons.Rounded.Save, + title = stringResource(R.string.report_step_logs), + body = stringResource(R.string.report_step_logs_body), + ) { + Button( + onClick = { + saveLauncher.launch( + String.format( + stringResourceOf(context, R.string.logs_save_name), + LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")), + ) + ) + } + ) { + Icon( + Icons.Rounded.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.logs_save)) + } + } + } + + item { + Step( + icon = Icons.Rounded.Folder, + title = stringResource(R.string.report_step_crashed), + body = stringResource(R.string.report_step_crashed_body), + ) { + Column { + // Two audiences, and the phone-only one is not an afterthought: root can + // read the folder the manager cannot, so the export works with no computer + // in the room. The command stays for whoever has one. + Button( + onClick = { + scope.launch { + val result = withContext(Dispatchers.IO) { exportWithRoot() } + result + .onSuccess { + snackbars.show( + context.getString(R.string.report_saved_to, it), + SnackbarTone.Success, + ) + } + .onFailure { failure -> + // Names the fix rather than the symptom: "permission + // denied" tells someone nothing they can act on, and + // being refused is the normal first outcome here. + val detail = + failure.message + ?.takeIf { it.isNotBlank() } + ?.let { ": $it" } + .orEmpty() + snackbars.show( + context.getString( + R.string.report_root_refused, + detail, + ), + SnackbarTone.Failure, + ) + } + } + } + ) { + Icon( + Icons.Rounded.Terminal, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.report_save_with_root)) + } + Spacer(Modifier.height(10.dp)) + // The adb one-liner lived here and is gone: it is four lines of shell that + // only helps someone at a computer, and that reader is better served by the + // thread itself, where it sits in context and stays current. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.report_see_guide), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + OutlinedButton(onClick = { onOpenUrl(GUIDE_ISSUE_URL) }) { + Text("#$GUIDE_ISSUE") + } + } + } + } + } + + } + } +} + +/** One numbered thing to try, with the control that does it. */ +@Composable +private fun Step( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + body: String, + titleAction: (@Composable () -> Unit)? = null, + action: (@Composable () -> Unit)? = null, +) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f, fill = false), + ) + // A step whose whole action is one link does not need a line of its own for it. + titleAction?.let { + Spacer(Modifier.width(8.dp)) + it() + } + } + Spacer(Modifier.height(6.dp)) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + action?.let { + Spacer(Modifier.height(10.dp)) + it() + } + } +} + +private fun stringResourceOf(context: Context, id: Int): String = context.getString(id) + +/** + * Copies the daemon's log folder somewhere the user can attach it, using root. + * + * `/data/adb/lspd/log` is readable by root and nothing else, and this is the only way to get those + * files off a phone with no computer attached — which is the situation this whole section exists + * for. Invoking `su` is also what raises the grant prompt, so the first press is the request. + * + * Whether it works depends on how the manager is running. Parasitically it is inside + * `com.android.shell`, which root managers usually trust already; installed as an ordinary app it + * has its own uid and has to be allowed like anything else. A refusal is therefore an expected + * outcome rather than an error, and it is reported as an instruction — see [rootRefused]. + * + * `tar` rather than a copy: the folder holds several parts of several megabytes, and it is going to + * be attached to an issue. + */ +private fun exportWithRoot(): Result = runCatching { + val stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + val target = "/sdcard/Download/vector-logs-$stamp.tar.gz" + val process = + ProcessBuilder("su", "-c", "tar -czf $target -C /data/adb/lspd log && chmod 644 $target") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() }.trim() + if (process.waitFor() != 0) throw RootRefused(output) + target +} + +/** Root said no, or there is none. Carries whatever `su` had to say, which is often nothing. */ +private class RootRefused(val output: String) : Exception(output) + +private const val NEO_ZYGISK = "https://github.com/JingMatrix/NeoZygisk" +private const val GUIDE_ISSUE = 123 +private const val GUIDE_ISSUE_URL = "https://github.com/JingMatrix/Vector/issues/$GUIDE_ISSUE" + +/** Straight from the maintainer's own troubleshooting reply, so the two cannot drift apart. */ +private const val PULL_LOGS_COMMAND = + "adb shell \"su -c 'cat /data/adb/lspd/log/verbose_* > /data/local/tmp/vector_verbose.log " + + "&& chown shell:shell /data/local/tmp/vector_verbose.log'\" && adb pull " + + "/data/local/tmp/vector_verbose.log" From 3ae4b27b02d7079df38e6f61b3968f35eac77d8f Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:56:07 +0200 Subject: [PATCH 09/62] Rebuild the module list and the scope editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State is the module name's colour, not a control: accent for running, muted for off, error for incompatible, and an inactive row at 45% opacity so the list reads as "what is running" before anything is read at all. Each row shows the *icons* of what it hooks rather than a count, because recognition beats arithmetic — and the framework, which is a scope target with no icon, gets a mark of its own instead of collapsing into a number that matches nothing on screen. Three targets to a row: the middle column opens the scope, the icon selects, a long press opens the action sheet. Selection is what makes the list worth having — enabling eight modules after a restore, or backing up a chosen few, was not possible at all before. The bar that replaces the header is laid over it at the header's own size, so nothing below moves when a module is picked up. In the scope editor, three mechanisms can put an app in a scope and they behave differently when the world changes — the module fixed it, auto-include keeps it, the module asked for it, or someone ticked it. Each gets a ring around its icon; all four used to render as the same checkbox. Anything already in the scope ignores every filter, so a default can never hide a choice that has been made. The long-press sheet carries re-optimize, which is not obvious and is the point: ART inlines small methods into their callers ahead of time, and an inlined method can no longer be hooked, so a module that works on one device silently does nothing on another. It is the first thing to try when a hook "just doesn't fire". --- .../ui/components/PackageActionMenu.kt | 265 +++++ .../ui/screens/modules/ModulesScreen.kt | 992 ++++++++++++++++++ .../ui/screens/modules/ModulesViewModel.kt | 462 ++++++++ .../manager/ui/screens/modules/ScopeScreen.kt | 736 +++++++++++++ .../ui/screens/modules/ScopeViewModel.kt | 524 +++++++++ 5 files changed, 2979 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt 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..11b8fde42 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt @@ -0,0 +1,265 @@ +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.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.R +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, +) { + val scope = rememberCoroutineScope() + val daemon = ServiceLocator.daemon + val colors = MaterialTheme.colorScheme + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + fun finish(block: suspend () -> PackageActionResult) { + onDismiss() + scope.launch { onResult(block()) } + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + 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)) + + 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, + ) + } + } + } +} 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..2340b03f7 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesScreen.kt @@ -0,0 +1,992 @@ +package org.matrix.vector.manager.ui.screens.modules + +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.Backup +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.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.Restore +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.AlertDialog +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.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.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.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, + 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() + var confirmUninstall 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 header always composes, so this band's height never depends on the mode. A + // selection bar that measured itself would be shorter than the header, and everything + // below — search field, tabs, the list itself — would jump the moment a module was + // picked up. Here the bar is laid over a header that has only gone invisible. + Box { + 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("*/*")) }, + modifier = Modifier.alpha(if (selection.isEmpty()) 1f else 0f), + ) + if (selection.isNotEmpty()) { + SelectionBar( + count = selection.size, + modifier = Modifier.matchParentSize(), + 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 }, + ) + } + } + + SearchField( + query = query, + onQueryChange = viewModel::setQuery, + placeholder = stringResource(R.string.modules_search_hint), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + ModuleFilterButton( + filter = filter, + onFilterChange = viewModel::setFilter, + sort = sort, + onSortChange = viewModel::setSort, + ) + } + + // 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), + ) { + 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, onModuleClick, viewModel::toggleSelected, ::report) + } + if (inactive.isNotEmpty()) { + stickyHeader(key = "h:inactive") { + SectionHeader( + stringResource(R.string.modules_section_inactive), + inactive.size, + ) + } + moduleRows(inactive, facts, selection, onModuleClick, viewModel::toggleSelected, ::report) + } + } else { + moduleRows(modules, facts, selection, onModuleClick, viewModel::toggleSelected, ::report) + } + } + } + } + } + } + + if (confirmUninstall) { + val removed = stringResource(R.string.modules_batch_uninstalled) + AlertDialog( + 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.padding(horizontal = 12.dp, vertical = 10.dp), + shape = RoundedCornerShape(26.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + tonalElevation = 3.dp, + ) { + Row( + modifier = Modifier.fillMaxSize().padding(start = 6.dp, end = 6.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.PlayArrow, R.string.modules_batch_enable, onEnable) + SelectionAction(Icons.Rounded.Block, R.string.modules_batch_disable, onDisable) + SelectionAction(Icons.Rounded.Backup, 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(42.dp)) { + Icon( + icon, + contentDescription = stringResource(descriptionRes), + tint = tint ?: LocalContentColor.current, + modifier = Modifier.size(22.dp), + ) + } +} + +/** 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 }) { + 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, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 10.dp, top = 20.dp, bottom = 14.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = stringResource(R.string.nav_modules), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + // Both actions shown rather than hidden behind an overflow. There are exactly two, they + // are opposites, and a menu that holds two items costs a tap to tell you what a glance + // could. The count sits beneath them so the right edge carries one idea — the state of + // the collection, and what you can do with it. + Column(horizontalAlignment = Alignment.End) { + Row { + IconButton(onClick = onRestore) { + Icon( + Icons.Rounded.Restore, + contentDescription = stringResource(R.string.modules_restore), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onBackup) { + Icon( + Icons.Rounded.Backup, + contentDescription = stringResource(R.string.modules_backup), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (total > 0) { + Text( + text = stringResource(R.string.modules_active_of, active, total), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 10.dp), + ) + } + } + } +} + +/** + * 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?, + selected: Boolean, + onClick: () -> Unit, + onIconClick: () -> Unit, + onLongClick: () -> 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), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(contentAlignment = Alignment.Center) { + AppIcon( + applicationInfo = module.applicationInfo, + contentDescription = null, + size = 56.dp, + ) + // 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.size(56.dp) + .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 column opens the scope. The row used to be one target, so a tap anywhere — + // including the icon someone was aiming at — navigated away. + Column( + Modifier.weight(1f) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + ) { + Text( + text = module.appName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = nameColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (incompatible) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.modules_incompatible, module.minVersion), + style = MaterialTheme.typography.bodySmall, + color = colors.error, + ) + } 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 }, + ) + } + } + } + } + + Spacer(Modifier.width(12.dp)) + + Column( + modifier = Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = module.versionName.ifBlank { "" }, + style = VectorMono, + color = colors.onSurfaceVariant, + maxLines = 1, + ) + ScopePreview(module = module, facts = facts) + } + } +} + +/** + * 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?) { + 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(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, + onModuleClick: (String, Int) -> 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)], + selected = ModuleKey(module.packageName, module.userId) in selection, + selectionActive = selection.isNotEmpty(), + onClick = { onModuleClick(module.packageName, module.userId) }, + 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?, + selected: Boolean, + selectionActive: Boolean, + onClick: () -> Unit, + onSelect: () -> Unit, + onAction: (PackageActionResult) -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + + ModuleRow( + module = module, + facts = facts, + selected = selected, + // 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, + ) + } +} + +/** 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, + ) + } + } +} 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..db3f9b8d5 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ModulesViewModel.kt @@ -0,0 +1,462 @@ +package org.matrix.vector.manager.ui.screens.modules + +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.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.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 first few apps in the module's scope, for the row's preview. + * + * A count tells you the *size* of a scope; the icons tell you the scope. "5 apps" answers a + * question nobody asked, where three recognisable icons answer "does this touch anything I + * care about" at a glance. + */ + val scopePreview: List = emptyList(), + /** Whether the module hooks the system framework, which has no icon of its own to show. */ + val scopeFramework: Boolean = false, +) + +/** How the list is ordered. Only [EnabledFirst] groups into sections; the others are flat. */ +enum class ModuleSort { + EnabledFirst, + Name, + RecentlyUpdated, + WidestScope, +} + +/** How many scoped apps a row previews before collapsing the rest into a count. */ +const val SCOPE_PREVIEW_LIMIT = 3 + +/** The framework itself, which the daemon names in a scope like any package but which is not one. */ +private const val SYSTEM_FRAMEWORK = "system" + +class ModulesViewModel( + private val daemonClient: DaemonClient, + private val moduleRepository: ModuleRepository, + private val packageManager: PackageManager, +) : ViewModel() { + + private val _discovered = MutableStateFlow>(emptyList()) + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + /** + * Whether the daemon answered at all. + * + * An empty list means two completely different things — "you have no modules" and "the + * framework is not running" — and 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() + + 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() + } + + 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, + 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() + + val allModules = + packages.mapNotNull { pkg -> + val appInfo = pkg.applicationInfo ?: return@mapNotNull null + val manifest = ModuleDetection.inspect(appInfo, packageManager) + 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, + ) + } + + 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..0dff7db2a --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt @@ -0,0 +1,736 @@ +package org.matrix.vector.manager.ui.screens.modules + +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.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.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 — which is often exactly the + // part that distinguishes two builds of the same module — the text + // scrolls itself when it does not fit. + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + ) + Text( + text = packageName, + style = VectorMono, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + ) + } + }, + 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 + }, + onToggle = { checked -> + haptics.performHapticFeedback( + if (checked) HapticFeedbackType.ToggleOn + else HapticFeedbackType.ToggleOff + ) + viewModel.toggle(app, checked) + }, + onAction = ::report, + ) + } + } + } + } + + if (confirmStranded) { + androidx.compose.material3.AlertDialog( + 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. */ +@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) } + Box { + IconButton(onClick = { open = true }) { + Icon( + Icons.Rounded.Checklist, + contentDescription = stringResource(R.string.scope_select), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + if (hasRecommended) { + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_use_recommended)) }, + onClick = { + onUseRecommended() + open = false + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_select_visible)) }, + onClick = { + onSelectAll() + open = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_clear_visible)) }, + onClick = { + onSelectNone() + open = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_auto_include)) }, + trailingIcon = { + if (autoInclude) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { onAutoInclude(!autoInclude) }, + ) + HorizontalDivider() + // This module's scope alone, separate from the whole-list backup on the module + // screen — useful when moving one module's configuration between devices. + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_backup)) }, + onClick = { + onBackup() + open = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_restore)) }, + 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. + */ +@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) + + Box { + // 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 + }, + ) + } + } + DropdownMenu(expanded = open, onDismissRequest = { 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. + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_recommended_only)) }, + trailingIcon = { + if (recommendedOnly) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { onToggleRecommendedOnly() }, + ) + HorizontalDivider() + } + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_system_apps)) }, + trailingIcon = { + if (showSystem) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { onToggleSystem() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_games)) }, + trailingIcon = { + if (showGames) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { onToggleGames() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_modules)) }, + trailingIcon = { + if (showModules) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { onToggleModules() }, + ) + } + } +} + +/** 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) } + Box { + IconButton(onClick = { open = true }) { + Icon( + Icons.Rounded.Sort, + contentDescription = stringResource(R.string.scope_sort), + tint = + if (sort != ScopeSort.Relevance || reversed) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + ScopeSort.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes())) }, + trailingIcon = { + if (option == sort) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { + onSort(option) + open = false + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_sort_reverse)) }, + trailingIcon = { + if (reversed) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { onReverse() }, + ) + } + } +} + +/** + * 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, + 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, + ) + } + } + }, + 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..0e6c6c5bb --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt @@ -0,0 +1,524 @@ +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, +) + +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 } + } + // 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. + val withFramework = + if (userId == 0) listOf(systemFrameworkEntry(apps)) + apps else apps + allApps.value = withFramework + + 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, + ) + } + } + + /** + * 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 + } + .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 + } + + private 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, +} From c58fdd6e9fc33a42403888b0e0ac928e18acd649 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:56:07 +0200 Subject: [PATCH 10/62] Read the log through a window, and reach every part of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader used to materialise whole files: `readLines()` into a `List` held in a StateFlow, for both tabs, on every refresh — several megabytes of String per tab inside a process whose heap belongs to `com.android.shell`. It indexes and pages now: one sequential byte scan records where every line starts into a `LongArray`, and the pane holds a window around the viewport. One pane, not two. The module log and the verbose log answer the same question at two levels of detail, so the source is a control inside the search field; two tabs meant two search boxes and threw away the query at the moment you wanted to carry it across. The line counter follows the viewport rather than the loaded window, and carries chevrons for the daemon's rotated parts — of which it could previously reach only the one being written. A filter states itself as chips above the list, which is also what lets every row drop its own tag column when filtered to one tag. Text selects the way text selects everywhere else, so copying a whole line moved to a double tap. --- .../matrix/vector/manager/data/log/LogFile.kt | 380 +++++++ .../vector/manager/data/log/LogModel.kt | 212 ++++ .../vector/manager/ui/screens/logs/LogRows.kt | 453 ++++++++ .../manager/ui/screens/logs/LogsScreen.kt | 998 ++++++++++++++++++ .../manager/ui/screens/logs/LogsViewModel.kt | 636 +++++++++++ manager/src/main/res/values/strings_logs.xml | 99 ++ 6 files changed, 2778 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogFile.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/log/LogModel.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsViewModel.kt create mode 100644 manager/src/main/res/values/strings_logs.xml 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/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..3daa85b80 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogRows.kt @@ -0,0 +1,453 @@ +package org.matrix.vector.manager.ui.screens.logs + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.log.LogLevel +import org.matrix.vector.manager.data.log.LogRow +import org.matrix.vector.manager.ui.theme.VectorLogLine + +/** + * Horizontal panning shared by every row of a log. + * + * 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) + val railWidth = with(LocalDensity.current) { 3.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..c70f44a76 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/logs/LogsScreen.kt @@ -0,0 +1,998 @@ +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.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.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.ChevronRight +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.AlertDialog +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.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.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.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.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) }, + topBar = { + TopAppBar( + title = { + // The title block is the one horizontal surface on this screen with no other + // claim on a drag — the list pans, the rows select — so the swipe lives here + // rather than over the log, where it had to be fenced into a corner to stop it + // firing by accident. + Column( + Modifier.partSwipe(currentState) { viewModel.selectPart(currentTab, it) } + ) { + Text(stringResource(R.string.logs_title)) + WindowCounter(currentState) { viewModel.selectPart(currentTab, it) } + } + }, + actions = { + // Selected, not shouted. A filled accent with a shadow made a reading + // preference look like the most important control in the bar; 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), + ) + } + }, + ) + }, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + 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) + AlertDialog( + 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 + var filterOpen by remember { mutableStateOf(false) } + + // 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()) { + SearchField( + query = state.query.text, + onQueryChange = { viewModel.setQuery(tab, it) }, + placeholder = stringResource(R.string.logs_search_hint), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + 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, + ) + } + }, + ) + + // 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) }, + ) + } + } + } + + if (filterOpen) { + LogFilterSheet( + state = state, + onDismiss = { filterOpen = false }, + onToggleLevel = { viewModel.toggleLevel(tab, it) }, + onTag = { viewModel.setTag(tab, it) }, + onClear = { viewModel.clearFilter(tab) }, + ) + } +} + +@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), + ) + } + } + } + } +} + + + +/** + * 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() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.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) } + + val scroll = rememberScrollableState { delta -> + travelled += delta + 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.Rounded.ChevronLeft, + 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.Rounded.ChevronRight, + 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(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.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/res/values/strings_logs.xml b/manager/src/main/res/values/strings_logs.xml new file mode 100644 index 000000000..3b3269195 --- /dev/null +++ b/manager/src/main/res/values/strings_logs.xml @@ -0,0 +1,99 @@ + + + + + Logs + + + Wrap long lines + Save a bug report… + Start a new log… + + + Search this log + Filter by level and tag + Level + Tag + Clear filter + Reading the whole file so the filter can see past what is on screen. + + + Verbose + Debug + Info + Warning + Error + Fatal + Other + + + + Lines %1$d–%2$d of %3$d + + %d matching line + %d matching lines + + This log is longer than the viewer indexes. The oldest %1$d lines are not shown; save a bug report to keep them. + line cut + + %d frame + %d frames + + + + The daemon is not reachable + Nothing is known about the log until Vector is running. This is the same reason the status ribbon on Home is not green. + No log file yet + The daemon is running but has not opened this log. For the verbose log that usually means verbose logging is off. + This log is empty + The file exists and nothing has been written to it. For the module log that is what a quiet system looks like. + Nothing matches + The file has content; the current filter excludes all of it. + Could not read the log + The daemon handed over a file that could not be read: %1$s + Indexing… + + + Verbose logging + The running daemon is forcing this on regardless. + + + Jump to the oldest line + Jump to the newest line + + + uid %1$d · pid %2$d · tid %3$d + + + Start a new log? + Nothing is deleted. The daemon closes the current part and starts a fresh one; the closed part stays on disk — a swipe away — until it ages out of the ten most recent, and a saved bug report still includes it. + Start a new log + A new log has been started + The daemon refused to start a new log + Cancel + + + vector-logs-%1$s.zip + Collecting logs, tombstones and dmesg… + Bug report saved + Could not save the bug report + Could not save the bug report: %1$s + Share + + Clear the tag filter + Log settings + Include the framework\'s own lines + Show module lines only + Turn this on before reporting a bug. Apps can read their own logs, so leaving it on is one way an app can spot Vector. + Writes both logs to a zip you choose + Closes the current part and starts a fresh one + part %1$d/%2$d + Older part + Newer part + From df631e18c572de3c5a0937e375d6bd7ab73ea6aa Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 27 Jul 2026 23:56:07 +0200 Subject: [PATCH 11/62] Build the Store on the mirrors that still answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoints the Store inherited are dead or refusing: modules.lsposed.org answers modules.json with a 403, modules-blogcdn returns 418, modules-cloudflare has no DNS record at all. The list comes from the one host that serves it; per-module detail is served by both, so the public site is a real fallback there and only there. Merging the two lists back into one would quietly take the Store offline again. Freshness is declared per request so the disk cache is actually used, and when every mirror fails the same request is replayed against the cache alone — which is why a cold start with no network renders the last known catalogue rather than an error. DNS-over-HTTPS is a fallback, never the only path. It used to be all-or-nothing, and that is the single largest cause of an empty Store: on a network where Cloudflare is itself blocked — precisely the network the setting is for — every lookup failed. --- .../data/repository/ModuleInstaller.kt | 244 +++++ .../manager/data/repository/RepoRepository.kt | 289 ++++++ .../ui/screens/repo/RepoDetailsScreen.kt | 836 ++++++++++++++++++ .../ui/screens/repo/RepoDetailsViewModel.kt | 131 +++ .../manager/ui/screens/repo/RepoScreen.kt | 473 ++++++++++ .../manager/ui/screens/repo/RepoViewModel.kt | 271 ++++++ .../manager/ui/screens/repo/StoreFormat.kt | 19 + .../manager/ui/screens/repo/StoreHtml.kt | 296 +++++++ .../manager/ui/screens/repo/StoreNotes.kt | 80 ++ manager/src/main/res/values/strings_store.xml | 98 ++ 10 files changed, 2737 insertions(+) create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/repository/ModuleInstaller.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt create mode 100644 manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreNotes.kt create mode 100644 manager/src/main/res/values/strings_store.xml 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/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt new file mode 100644 index 000000000..98c0b2527 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -0,0 +1,289 @@ +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.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/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..e11ac6d57 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsScreen.kt @@ -0,0 +1,836 @@ +package org.matrix.vector.manager.ui.screens.repo + +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.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.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.ExpandLess +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.AlertDialog +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.R +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 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), + ) + } + } + }, + ) + }, + 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() + + 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()) { page -> + when (page) { + 0 -> + ReadmeTab( + module = module, + fetch = state.fetch, + onRetry = viewModel::fetchDetails, + onOpenUrl = openUrl, + ) + 1 -> + ReleasesTab( + state = state, + onOpenUrl = openUrl, + onInstall = { release -> + val assets = release.apks + if (assets.size == 1) confirming = assets.first() + else choosing = release + }, + ) + else -> InformationTab(module = module, onOpenUrl = openUrl) + } + } + } + } + + 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, + 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, so at most one WebView exists in the list — a renderer + // per card would keep a renderer process alive for every release on the page. 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. + var expanded by + remember(state.releases) { + mutableStateOf(state.releases.firstOrNull()?.key(0)) + } + + LazyColumn( + 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 + 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)) + Row(verticalAlignment = Alignment.CenterVertically) { + // 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()?.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 (!release.descriptionHTML.isNullOrBlank()) { + Spacer(Modifier.height(10.dp)) + if (notesOpen) { + // 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), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(onClick = onToggleNotes).padding(vertical = 4.dp), + ) { + Text( + text = + stringResource( + if (notesOpen) R.string.store_release_notes_hide + else R.string.store_release_notes + ), + style = MaterialTheme.typography.labelLarge, + color = colors.primary, + ) + Icon( + if (notesOpen) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = null, + tint = colors.primary, + ) + } + } + + 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, onOpenUrl: (String) -> Unit) { + LazyColumn(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()?.let { date -> + item { + InfoRow( + icon = Icons.Rounded.Today, + label = stringResource(R.string.store_info_updated), + value = date, + ) + } + } + module.createdAt.asRepositoryDate()?.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) { + 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)) + } +} + +/** + * 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 +private 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) + + AlertDialog( + 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)) } + }, + ) +} + +@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..9ffba8ea2 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoDetailsViewModel.kt @@ -0,0 +1,131 @@ +package org.matrix.vector.manager.ui.screens.repo + +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 + + 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..1c33a7f4f --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoScreen.kt @@ -0,0 +1,473 @@ +package org.matrix.vector.manager.ui.screens.repo + +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.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.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) + + SearchField( + query = query, + onQueryChange = viewModel::setQuery, + placeholder = stringResource(R.string.store_search_hint), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + StoreFilterButton( + sort = sort, + onSortChange = viewModel::setSort, + priorities = priorities, + onTogglePriority = viewModel::togglePriority, + channel = channel, + onChannelChange = viewModel::setChannel, + doh = doh, + onDohChange = viewModel::setDoh, + ) + } + + 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) { + val context = LocalContext.current + Column( + modifier = + Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 14.dp) + ) { + Text( + text = stringResource(R.string.nav_store), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + ) + 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 = + if (updates > 0) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (catalog.fromCache) { + // The quiet line Home shows, for the same reason: this is not an error, it is last + // week's catalogue, which is almost all of this week's. + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.CloudOff, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.store_offline), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * Sort, the updates-first rule, the release channel and DNS, in the search field's trailing slot. + * + * 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 menuOpen by remember { mutableStateOf(false) } + val narrowed = + sort != StoreSort.RecentlyUpdated || + channel != StoreChannel.Stable || + doh || + priorities != listOf(StorePriority.Updates) + + Box { + IconButton(onClick = { menuOpen = 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, + ) + } + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + StoreSort.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes())) }, + trailingIcon = { + if (option == sort) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { + onSortChange(option) + menuOpen = false + }, + ) + } + HorizontalDivider() + StorePriority.entries.forEach { priority -> + val rank = priorities.indexOf(priority) + DropdownMenuItem( + text = { Text(stringResource(priority.labelRes)) }, + trailingIcon = { + if (rank >= 0) { + // 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 list says so rather than leaving it to be inferred. + if (priorities.size > 1) { + Text( + text = "${rank + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon(Icons.Rounded.Check, contentDescription = null) + } + } + }, + onClick = { onTogglePriority(priority) }, + ) + } + HorizontalDivider() + StoreChannel.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes())) }, + trailingIcon = { + if (option == channel) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { + onChannelChange(option) + menuOpen = false + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.store_doh)) }, + // Says what it is for rather than what it is. "DNS over HTTPS" tells someone who + // already knows; "when the mirrors will not resolve" tells the person who needs it. + leadingIcon = { Icon(Icons.Rounded.Dns, contentDescription = null) }, + trailingIcon = { + if (doh) Icon(Icons.Rounded.Check, contentDescription = null) + }, + onClick = { + onDohChange(!doh) + menuOpen = false + }, + ) + } + } +} + +/** + * 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()?.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..72ed42728 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/RepoViewModel.kt @@ -0,0 +1,271 @@ +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) { + catalog, + installed, + channel -> + catalog.modules.map { entryFor(it, installed, channel) } + } + .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, + ): StoreEntry = + StoreEntry( + module = module, + latest = module.latestOn(channel), + installed = installed[module.name], + ) + + private fun StoreEntry.matches(query: String): Boolean { + if (query.isBlank()) return true + return module.title.contains(query, ignoreCase = true) || + module.name.contains(query, ignoreCase = true) || + module.summary?.contains(query, ignoreCase = true) == true + } + + /** + * Updates come first by default, mirroring the Modules list's "enabled first": a list's first + * job is to say what needs attention. It is a separate toggle rather than a fourth sort order + * because it answers a different question from "in what order do I want to browse". + */ + private fun comparatorFor(view: View): Comparator { + val order: Comparator = + when (view.sort) { + // ISO-8601 in UTC sorts correctly as text, so this needs no date parsing per row. + StoreSort.RecentlyUpdated -> + compareByDescending { it.module.latestReleaseTime.orEmpty() } + StoreSort.Name -> compareBy { it.module.title.lowercase() } + StoreSort.MostStarred -> + compareByDescending { it.module.stargazerCount ?: 0 } + .thenBy { it.module.title.lowercase() } + } + // Applied outermost-last, so the most recently chosen group ends up the primary key. + return view.priorities.reversed().fold(order) { acc, priority -> + compareByDescending { priority.applies(it) }.then(acc) + } + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt new file mode 100644 index 000000000..f45e0f099 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreFormat.kt @@ -0,0 +1,19 @@ +package org.matrix.vector.manager.ui.screens.repo + +import java.text.DateFormat +import java.time.Instant +import java.util.Date + +/** + * 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(): String? { + val raw = this?.takeIf { it.isNotBlank() } ?: return null + val instant = runCatching { Instant.parse(raw) }.getOrNull() ?: return null + return DateFormat.getDateInstance(DateFormat.MEDIUM).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..c1883fc48 --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/repo/StoreHtml.kt @@ -0,0 +1,296 @@ +package org.matrix.vector.manager.ui.screens.repo + +import android.content.Context +import android.content.res.Configuration +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 `