diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..874434b2e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,294 @@ +# CLAUDE.md + +Guidance for Claude Code working in the **Vector Framework** repository — a Zygisk module that +provides an ART hooking framework with Xposed API compatibility (formerly LSPosed, JingMatrix fork). + +## What this project is + +Vector is a root-level Android framework that lets "modules" hook and modify apps and the system in +memory, without touching APK files. It is a **Zygisk module**: it loads inside the Zygote process and +injects a framework into every forked app process that has an enabled module in scope. + +- ART hooking engine: [LSPlant](https://github.com/JingMatrix/LSPlant) (submodule `external/lsplant`) +- Native inline hooking: [Dobby](https://github.com/JingMatrix/Dobby); PLT hooking: [LSPlt](https://github.com/JingMatrix/LSPlt) +- Two module APIs are supported: + - **Modern** [libxposed API](https://github.com/libxposed/api) — implemented in `:xposed` (two submodules: `xposed/libxposed` = module API, `services/libxposed` = service API) + - **Legacy** `de.robv.android.xposed` API — implemented in `:legacy` +- Supports Android 8.1 → 17 Beta. Requires Magisk/KernelSU with Zygisk (e.g. NeoZygisk). +- License: GPL-3.0. + +## Repository layout (Gradle modules) + +`settings.gradle.kts` is the source of truth for the module list. + +| Module | Role | +| :--- | :--- | +| `:zygisk` | The framework zip + injection engine. Native C++ (`src/main/cpp/module.cpp`, `ipc_bridge.cpp`) + Kotlin loader (`org.matrix.vector.core.Main`). Produces `zygisk/release/Vector-v*-{Release,Debug}.zip`. | +| `:daemon` | Root-privileged, standalone `app_process` daemon (started by `zygisk/module/service.sh`). Central coordinator: SQLite state, IPC asset server, dex2oat hijack, logcat monitor, CLI. | +| `:xposed` | Modern libxposed API implementation: hook engine bridge (`VectorHookBuilder`, `VectorNativeHooker`, `VectorChain`), lifecycle interception, in-memory module loading. | +| `:legacy` | Legacy `de.robv.android.xposed` API surface (`XposedBridge`, `XposedHelpers`, `XC_MethodHook`, `XResources`, `XSharedPreferences`), routing execution to the modern engine via `LegacyFrameworkDelegate`. | +| `:native` | Static C++ library (`libnative.a`): `core` (Context/ConfigBridge/native_api), `elf` (symbol resolution incl. `.gnu_debugdata`), `jni` (HookBridge, ResourcesHook, NativeApiBridge). | +| `:dex2oat` | `dex2oat` wrapper + `liboat_hook.so` (LSPlt) that force-disable method inlining system-wide and spoof OAT metadata. | +| `:hiddenapi` | `:hiddenapi:stubs` (compile-time hidden-API stubs) + `:hiddenapi:bridge` (`HiddenApiBridge`, runtime reflection bypass). | +| `:manager` | Compose manager app; runs **parasitically** inside host `com.android.shell` (uid 2000). No privilege of its own — all device changes go through the daemon via Binder. | +| `:manager-ui` | Shared Compose UI library consumed by the manager. | +| `:services` | Pure AIDL/library modules: `:services:manager-service` (`IManagerService`) and `:services:daemon-service` (`IVectorDaemon`, `IFrameworkService`, `IModuleService`, `IProcessChannel`, …). | +| `:external` | Pinned git submodules: `lsplant`, `dobby`, `fmt`, `xz-embedded`, `lsplt`, `apache/commons-lang`, `axml/manifest-editor`. | + +Each module has a detailed `README.md` — read the relevant one before touching that module. + +## How the framework actually works (read this first) + +### Process flow + +1. **Zygisk load**: `VectorModule` (C++, `zygisk/src/main/cpp/module.cpp`) extends both + `zygisk::ModuleBase` and `native::core::Context`. It filters target processes by UID + (isolated/app-zygote/SHARED_RELRO are skipped), then fetches the framework DEX + class + obfuscation map from the daemon and loads it in-memory (`InMemoryDexClassLoader` via + `SharedMemory` FD — **nothing is written to /data**). +2. **Java bootstrap**: `Main.forkCommon` (Kotlin) → `Startup.initXposed` (system server) or + `Startup.bootstrapXposed` (apps), which installs the hookers (LoadedApk, DexTrust, crash dump, + system server process hooks). +3. **IPC without ServiceManager**: nothing registers with `ServiceManager`. Instead, + `ipc_bridge.cpp` uses ART's `SetTableOverride` to replace `CallBooleanMethodV`, intercepting + every `Binder.execTransact`; transactions carrying the `_VEC` code are diverted to Kotlin + `BridgeService.execTransact`. The daemon's primary `IVectorDaemon` binder is pushed into + `system_server` via a raw `ACTION_SEND_BINDER` transaction to the `activity` service. + Apps rendezvous by querying the `activity` service; the daemon approves them against scope state + and hands back an `IFrameworkService` binder. Heartbeat `BBinder` + `DeathRecipient` cleans + up dead processes. +4. **Modules load in-memory**: `VectorModuleClassLoader` (attached to the framework's loader + branch only, invisible to `ClassLoader.getParent()` walking) + `VectorURLStreamHandler` + (bypasses the global JarFile cache). Entry points come from the APK's `assets/xposed_init` + (Java) and `assets/native_init` (native libraries). +5. **Parasitic manager**: the manager APK is injected into `com.android.shell`. Its manifest is + never registered — no ContentProvider/FileProvider/startup — so everything initializes + explicitly from the activity. The daemon intercepts system intents (`ACTION_VIEW` etc.) in + system_server and redirects them to the manager. + +### Key invariants to not break + +- **No disk footprint in target processes** — DEX/APKs are loaded from memory; do not introduce + file writes or installed-package assumptions into injected code. +- **Class-name obfuscation on every boot** — `daemon/.../ObfuscationManager.kt` randomizes + framework class names; native fetches the map over IPC (`kObfuscationMapTransactionCode`). + Never hardcode a framework class name across the native/Java boundary; route through the map. +- **Manager handshake**: framework loads `.Constants` by reflection and calls + `setBinder(IBinder)`. R8 keeps it via `manager/proguard-rules.pro`; renaming breaks the + handshake with **no compile error anywhere**. Order is not fixed — `ServiceLocator.attach()` + is idempotent, `bind()` is a plain `StateFlow` assignment. +- **Scope table semantics** (`daemon`): the `scope` table (PK: mid, app_pkg_name, user_id) is the + configuration; `ConfigCache` resolves it into (process name, uid) keys for the injection path. + Scope rows outlive their target apps and are deleted by exactly four paths — treat any new + deletion path as a design change. Package events decide by name, not uid (a reinstalled target + arrives under an unseen uid). +- **Binder defaults lie**: a proxy returns defaults (0/null/empty) for unimplemented transactions, + and several daemon calls return a `boolean` refusal that callers must check — dropping it turns + a refusal into a silent success. Read the AIDL doc comments before calling. + +## Build & development + +### Prerequisites + +- JDK 21, Android SDK, `ninja` (CI removes Android's own cmake), `ccache` recommended. +- **Submodules are pinned but not checked out in a fresh clone** — run + `git submodule update --init --recursive` before building. Builds compile `external/*` + directly; the libxposed submodules are compiled into `:services:*` and `:xposed`. + +### This machine (local dev environment) + +Development happens against the real device via Android Studio; **the immediate work focus is the +manager UI** (`:manager` + `:manager-ui`). + +- SDK: `/Volumes/SSD/Files/AS` (already set in `local.properties` → `sdk.dir`). +- **Daemon JVM must be JDK 25**: the untracked, auto-generated `gradle/gradle-daemon-jvm.properties` + (toolchainVersion=25) makes Gradle try to auto-provision JDK 25 via foojay, which fails offline. + Android Studio builds fine (it runs its own daemon JVM); a terminal `gradlew` needs `JAVA_HOME` + pointing at the Zulu 25 JDK. JDK 21 at `jdk-21.0.11.jdk` is fine for the *project* toolchain but + does not satisfy the daemon-JVM 25 requirement, so it must not be the `JAVA_HOME` for a terminal + build until that properties file is removed. + - Zulu 25 full path (verified working for a terminal build): + `/Volumes/SSD/Games/Minecraft/Java/zulu25.28.85-ca-jdk25.0.0-macosx_aarch64/zulu-25.jdk/Contents/Home`. +- NDK 29.0.14206865: installed at `/Volumes/SSD/Files/AS/ndk/` — matches + `androidCompileNdkVersion` exactly, no SDK-manager download needed for it. +- cmake 4.4.2 via Homebrew (`/opt/homebrew/bin/cmake`) satisfies the required `3.29.8+`. +- Known gaps to expect on the first build: + - `build-tools;37.0.0` is required (`androidBuildToolsVersion`) but only 34/35/36 are + installed — let Android Studio finish downloading it. + - Platform `android-37` is required (compileSdk 37) but only `android-35` and + `android-37.0` are present; verify AGP accepts the installed one or install `android-37`. + - `ninja` is not installed anywhere (CI installs its own) — install it (e.g. + `brew install ninja`) or the native (C++) parts of the build will fail. + - `android-sdk-license` is accepted already. + +### Commands + +```sh +./gradlew zipAll # the whole framework: release + debug zips → zygisk/release/ +./gradlew :zygisk:zipDebug # just the debug zip (recommended for day-to-day) +./gradlew :zygisk:zipRelease +./gradlew :manager:assembleDebug # the manager APK alone +./gradlew ktfmtFormat # formatting is ktfmt; CI does NOT check it +./gradlew zipAll --offline # if you want to avoid re-resolving deps +``` + +Convenience device tasks also exist per variant in `:zygisk` (see `zygisk/build.gradle.kts`: +`push…Module…`, `install…`, `install…AndReboot`). + +### Versioning & the build stamp + +- Version code = `git rev-list --count refs/remotes/origin/master`; version name = latest `v*` tag. + A branch build and a master build can share a version code. +- Every build carries a stamp — `commit` first, then where it came from: + CI `93d66473-JingMatrix-Vector`; local `93d66473`; local dirty tree `93d66473+hostname`. + Do not change the shape; both the manager's "am I running this build" check and the status page + parse it. Keep `BuildConfig.VERSION_NAME` clean. +- The daemon's `InstallerVerifier` rejects unsigned installs — CI signing uses the + `KEY_STORE*`/secrets; forks build unsigned and publish nothing. + +### Testing + +**There are no test source sets anywhere. CI runs `zipAll` (plus a translations sanity check) +and nothing else.** A green tick means it compiles and packages. Everything else is verified +on-device against a real daemon. Debug zips have far more logging — always reproduce bugs against +the latest debug build before filing an issue. + +## Conventions + +### Manager UI pitfalls (nav3 + the shared Details screen) + +The manager sits on **Navigation 3** (`NavDisplay`), not the NavHost of the WeKit project. Several +behaviors are subtle enough that they have already cost real bugs; keep these in mind before editing +`VectorApp.kt`, `Navigator.kt`, or the store screens. + +- **One `NavDisplay` owns the whole stack; the root is a fixed `TopLevel` container, not a panel.** + `NavDisplay` is composed unconditionally (no `if (atRoot) pager else NavDisplay` swap). The stack is + `[TopLevel, detail, ...]`: `TopLevel` renders the finger-following `HorizontalPager` of panels, and a + detail (scope editor, store detail, browser…) is pushed above it by `Navigator.go`. Because the + display stays mounted instead of being swapped for a pager branch, a *system* back / mouse right-click + pops the detail and plays `popTransitionSpec`'s fade-through with the previous page (the pager) as the + entering scene — the background shows behind the shrinking current one. + - **The current panel is NOT the stack root.** It is `Navigator.currentTab`, a saveable + `MutableState` (saved by the panel route's string key), because the stack root is always + `TopLevel`. `switchTo` only mutates `currentTab` and clears the stack back to `[TopLevel]`; it never + re-seeds the root with a panel. `go` pushes a detail above `TopLevel`; `back` pops only the top. + - `currentTopLevel` = `currentTab` (feeds the bar / floating ball highlight), *not* `backStack.first`. + - `reconcilePanels` fixes `currentTab` (move off a tab that got hidden), not the stack root. + - **Pitfall (hit once):** there must be a *single* `currentTab` state. A `rememberSaveable` value plus + a separate `mutableStateOf` inside `Navigator` are two decoupled sources — `switchTo` mutates only + the latter, so the saved value never updates and the panel you were on is lost across process death + (the old design got that for free by making the panel the stack root). Delegate `currentTab` to the + saveable-backed `MutableState`. + - `rememberNavBackStack` is seeded with `TopLevel` only; a restored stack may already carry a detail + above it (the hooker's per-activity Bundle cache survives process death). `registerRoutes` registers + `entry` (the pager) and the detail entries, and **not** the four `TopLevelRoute` panels — + the pager draws each panel via `TopLevelPanelContent` directly, so the stack never holds a + `TopLevelRoute`. +- **`transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` are separate.** Forward keeps + the official sample's half-width horizontal slide; **both kinds of back** share one AOSP **fade-through** + via a single `fadeThroughBackTransition()` helper: the outgoing page scales 100%→90% and fades out, + the incoming page — which starts at 110% — settles to 100% and fades in, with *no* horizontal travel. + `popTransitionSpec` (system back button + mouse right-click) and `predictivePopTransitionSpec` (gesture) + both call it, so a back-key press and a back drag land the reader in the same place; only the gesture + tracks the finger 1:1. Using `EnterTransition.None` (a "pop-in" reveal) or a half-width slide reads as + page-flipping and was judged wrong on-device. + - **Predictive-back specs must be `LinearEasing`.** nav3 *seeks* the predictive transition to the + finger's progress (`SeekableTransitionState.seekTo(progress, ...)`) instead of playing it, so a + curved easing makes the page lag the hand. This is the same reason WeKitThemeGenerator maps + `backEvent.progress` straight into a `graphicsLayer` alpha (`1f - progress`). +- **The shared store `installState` is a single flow on a singleton installer.** `ServiceLocator.installer` + is one `ModuleInstaller`, and `VectorStoreInstallHost.installState` wraps `installer.state`. Every + opened module's host reads that *same* flow, so a leftover `Failed(A, ...)` paints module B's page. + Each host must **filter by its own `packageName`** (map any step naming another module to `Idle`). + Every non-`Idle` `InstallStep` carries its `packageName`; read it through a `when` helper because + it is declared on the concrete states, not on the sealed `InstallStep` interface. + - Each host is created per-screen via `remember(route.packageName) { VectorStoreInstallHost(...) }`, + so the filter is per-module even though the installer is a process-wide singleton. +- **The install failure bar is reachable and must be re-usable.** A failed install still shows a + centered error + a full-width filled `Button` with a `RestartAlt` icon (not a download icon — that + would imply re-downloading what just failed). Resting state is the full-width filled `Button` with + a `Download` icon. +- **Long-press menus are anchored to the pressed row, not a bottom sheet.** `PackageActionMenu` + (a `ModalBottomSheet`) is the wide/verbose variant, used from the module list and the store. The + scope screen's long press uses `PackageActionMenuItems` + (`Manager → ui/components/PackageActionDropdown.kt`), which reproduces WeKit's + `DropDownMenuWidget` pattern: a Material 3 **`DropdownMenuPopup`** (the Popup primitive below + `DropdownMenu`) wrapping a **`DropdownMenuGroup(shapes = MenuDefaults.groupShapes())`**, whose + items use **`MenuDefaults.itemShape(index, size)`** for the Expressive capsule shapes. It opens + **next to the row that was pressed** because the caller composes it as a sibling of the pressed + `ListItem`, inside the same `Box`: the popup's position provider anchors to the parent layout node + it is declared in, so the anchor is the enclosing `Box`. Composing it at the call site keeps it + bound to this row (a coordinate can't recreate that once a `LazyColumn` reuses rows). Don't swap in + a plain `DropdownMenu` or a custom `Popup` + `Surface` + `Column` shell — those either lose the + Expressive grouped item shapes or the official surface/elevation/animation. `LocalizedOverlay` + wraps only the menu text (a popup is its own window; it otherwise wouldn't inherit the locale). + - **Menu icons use Material Symbols Outlined**, not the legacy `material-icons` set. The manager + consumes `icons-material-symbols-outlined-cmp` (Maven `com.composables`, version + `composablehorizons-symbols`) through the `compose` bundle in `libs.versions.toml`. Reference + icons as `MaterialSymbols.Outlined.X` (e.g. `Info`, `Open_in_new`, `Restart_alt`, `Stop`, + `Bolt`). This matches WeKit and is what makes the menu look M3-Expressive rather than the + heavy, filled look of `Icons.Rounded`. + - The same grouped `DropdownMenuPopup` + `DropdownMenuGroup` pattern is reused for the modules + filter/sort menu (`ModulesScreen.kt` → `ModuleFilterButton`) and the checkbox state mark. For a + single-select group set `selected = option == current` (add a `trailingContent` check icon when + selected, since the `selected` overload tints the item but does not draw a check itself). For a + bottom-sheet toggle row (e.g. "ignore updates" in `PackageActionMenu.kt` → `ActionToggleRow`), + present the state as a trailing `MaterialSymbols.Outlined.Check` when enabled and nothing when + disabled — keep the screen-reader `Role.Switch` via `Modifier.toggleable`. + - **The menu group corner radius must be set explicitly to `RoundedCornerShape(16.dp)`.** WeKit + (material3 `1.5.0-alpha19`) defaults `DropdownMenuGroup`'s container shape to + `SegmentedMenuTokens.ContainerShape` = `CornerLarge` = 16dp. Vector is pinned on `1.5.0-alpha26`, + which re-tokens that container to `CornerExtraSmall` (4dp) — so the plain `MenuDefaults.groupShapes()` + would render a near-square menu. To match WeKit, pass + `MenuDefaults.groupShapes(shape = RoundedCornerShape(16.dp), inactiveShape = RoundedCornerShape(16.dp))` + (items keep `MenuDefaults.itemShape(index, size)`). This is the exact WeKit value, not a guess. + - Long-pressing a module in the **Modules** list (and an app in the scope list) now opens the same + grouped capsule menu beside the row, via `ModuleActionMenuItems` / `PackageActionMenuItems` + (`PackageActionDropdown.kt`). The reader anchors both to the pressed row because the caller + composes them inside a `Box` that also holds the row. The old `PackageActionSheet` + (`ModalBottomSheet`) is still defined (`PackageActionMenu.kt`) for wide/verbose flows (the store + page and repo details), but the module list no longer uses it. + +### Code style +- Kotlin/Java formatted with **ktfmt** (`./gradlew ktfmtFormat`); 4-space indent. +- The codebase is unusually comment-heavy about **why** (see root `build.gradle.kts` and every + module README for the house style). Match it: explain the constraint the code is bending around, + name the bug it prevents, cite the issue/PR number when one exists. +- No R8 on some modules (e.g. `:services:*` are `isMinifyEnabled = false`); the manager, daemon + and zygisk modules minify — check `proguard-rules.pro` before adding reflection entry points. + +### Logging +- Injected/manager code logs under a tag starting with `Vector` (or one of the daemon's filter + tags: `Magisk`, `KernelSU`, `dex2oat`, `LSPosed`, `Vector`). `daemon/src/main/jni/logcat.cpp` + routes those into the daemon's verbose stream, which reaches the manager's Logs screen and the + zip-exported report. A file-local tag lands nowhere. + +### Translations (Crowdin) +- Only **English source strings** are edited in-repo: + `manager/src/main/res/values/strings.xml`, `manager-ui/src/main/res/values/strings_*.xml`, + `daemon/src/main/res/values/strings.xml` (paths are pinned in `.github/workflows/crowdin.yml`). + Never hand-edit locale files. +- Changing what a string *means* requires a **new key** — rewording in place silently desyncs the + 18 translations. +- `manager/build.gradle.kts` merges the daemon's res dir, so a string-name collision across the + two modules is a build error. +- User-visible text must not be hardcoded in composables; identifiers that must not translate carry + `translatable="false"`. + +### Git +- Commit messages: imperative, one descriptive sentence, capitalized, optionally `(#NNN)` + (see `git log`). Meaningful titles like "Ask the scope table by name whether an install matters". +- Release process: cut a commit titled `Release Vector …`, tag it `v*`, push. The CI tag build + publishes the stable release; the master-push build for that commit is skipped so the same code + never also ships as a canary. Canaries are prereleases named `canary-`, only the + five most recent are kept. + +## Where to look when a bug report arrives + +- Logs: manager → Logs screen, or `/data/adb/lspd/log/` (rotating 4MB files) on device; the zip + export attaches them. `logcat` tags: `Vector`, `VectorDaemon`, `LSPosed`. +- Scope/state: daemon DB at `/data/adb/lspd/config/modules_config.db`; CLI socket at + `/data/adb/lspd/.cli_sock` (JSON over a filesystem socket, auth via compiled-in UUID token, + implemented in `daemon/.../ipc/CliHandler.kt` + `daemon/.../env/CliSocketServer.kt`). +- Daemon base path `/data/adb/lspd` keeps the historical name — do not "fix" it; migrations and + user tooling depend on it (there are deliberate legacy `"lspd"` preference/row names too). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4493abb6c..c284626d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,9 @@ coil = "3.5.0" m3 = "1.5.0-alpha26" nav3 = "1.1.6" navigationevent = "1.1.2" +# Material Symbols (the M3 icon set) used by the manager UI, matching WeKit. These are +# Compose Multiplatform artifacts; the `-cmp` Android variant is what we actually resolve. +composablehorizons-symbols = "2.2.1" # Governs every androidx.compose.* artifact below; none of them pin a version. compose-bom = "2026.08.00" @@ -44,6 +47,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u 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" } +composablehorizons-material-symbols-outlined = { module = "com.composables:icons-material-symbols-outlined-cmp", version.ref = "composablehorizons-symbols" } # Navigation 3. Stable since Nov 2025; the back stack is a plain observable list # of NavKey objects rather than route strings. @@ -85,6 +89,7 @@ compose = [ "androidx-compose-material3", "androidx-compose-material3-adaptive-navigation-suite", "androidx-compose-material-icons-extended", + "composablehorizons-material-symbols-outlined", "androidx-lifecycle-viewmodel-compose", "androidx-navigation3-runtime", "androidx-navigation3-ui", diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/ApiBadge.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/ApiBadge.kt index d34ecbe71..04daabbe0 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/ApiBadge.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/ApiBadge.kt @@ -1,11 +1,14 @@ package org.matrix.vector.ui import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -24,17 +27,28 @@ import androidx.compose.ui.unit.sp @Composable fun ApiBadge(label: String, value: String, incompatible: Boolean = false) { val colors = MaterialTheme.colorScheme - Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + // Single line, never wrapping: the badge sits in a fixed-width column, and a wrapping badge + // (e.g. "LSPosed\n102") would break the shared name/description start that the fixed column + // exists to keep. The scale name and number stay on one line however narrow the column is. + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.width(IntrinsicSize.Max), + ) { Text( text = label, style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp), color = colors.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + softWrap = false, ) Text( text = value, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = if (incompatible) colors.error else colors.primary, + maxLines = 1, + softWrap = false, ) } } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/CheckSwitch.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/CheckSwitch.kt new file mode 100644 index 000000000..86c088ae7 --- /dev/null +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/CheckSwitch.kt @@ -0,0 +1,48 @@ +package org.matrix.vector.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * A Material 3 switch that shows a **check when on and a cross when off** inside the thumb. + * + * The ordinary Material 3 `Switch` is a slider whose thumb colour alone says which state it is in; + * a row where the thumb is the only clue is hard to read at a glance, and a module list with a + * switch per row makes "read the colour" the whole interaction. Putting the mark in the thumb keeps + * the switch's Material 3 skeleton (the track, the shape, the motion) while the state is legible in + * the mark itself. + * + * It is a thin wrapper: all of the switch's own parameters are forwarded unchanged, and only + * [thumbContent] is added. The mark colour follows the switch's `iconColor`, so the check and the + * cross pick up the same themed colour the switch would have used for its thumb. + */ +@Composable +fun CheckSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + thumbContent = { + Icon( + imageVector = if (checked) Icons.Rounded.Check else Icons.Rounded.Close, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + }, + ) +} diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/ModuleRow.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/ModuleRow.kt index ff9d1325e..460e051e1 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/ModuleRow.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/ModuleRow.kt @@ -43,6 +43,17 @@ import androidx.compose.ui.unit.dp /** The module's icon, and the slot it is drawn in whether or not it is selected. */ private val ICON_SIZE = 48.dp +/** + * The fixed width of the icon column (the icon plus the API badge under it). + * + * Deliberately wider than the icon: the badge reads "LSPosed 102" on a modern module and "Xposed + * 93" on a legacy one, and if the column wrapped its contents the text column to its right would + * start at a different x for every row — the names and descriptions would stop lining up. Fixing + * the column width wide enough for the widest badge (single-line, so a "LSPosed 102" fits) is what + * keeps the name/description edges shared across the list without the badge truncating. + */ +private val ICON_COLUMN_WIDTH = 66.dp + /** Room for a version and its mark. Anything longer scrolls past instead of pushing. */ private val VERSION_WIDTH = 104.dp @@ -131,18 +142,28 @@ fun ModuleRow( // The icon is the selection handle. Double-tapping it is the host's chance to toggle without // leaving the list; a bare tap only reports state, since a one-tap toggle would fire whenever // a thumb brushed the list. + // + // The column is fixed at the icon's width so the API badge underneath it cannot widen it: a + // wide badge (e.g. "liblsposed 102") would otherwise push the text column right for that row + // alone, and the module names would no longer line up. The badge is laid out at its natural + // width inside the fixed box — if it is wider than the icon it overflows to the right but the + // text column keeps its fixed start, which is the alignment the badge would otherwise break. Column( modifier = - if (onIconClick != null) + (if (onIconClick != null) Modifier.contextClickable(onClick = onIconClick, onLongClick = onIconLongClick) - else Modifier, - // Against the text, not centred over the badge: the badge below is wider than the icon, - // so centring left a gap between the icon and the edge the names all start from. - horizontalAlignment = Alignment.End, + else Modifier) + .width(ICON_COLUMN_WIDTH), + // Left-aligned with the text: the icon's own left edge sits on the same vertical line the + // names start from, so every row's icon and title line up regardless of how the icon was + // drawn (some module icons carry their own padding, which previously pushed them inward + // and made the list look ragged). + horizontalAlignment = Alignment.Start, ) { // Fixed at the icon's size whatever is drawn inside, so selecting a module cannot resize - // its row — a tick larger than the icon would grow this box and reflow the list. - Box(modifier = Modifier.size(ICON_SIZE), contentAlignment = Alignment.Center) { + // its row — a tick larger than the icon would grow this box and reflow the list. Pinned + // to the top-start so the icon, not a centred one, is left-aligned against the edge. + Box(modifier = Modifier.size(ICON_SIZE), contentAlignment = Alignment.TopStart) { icon() if (selected) { Box( @@ -165,7 +186,7 @@ fun ModuleRow( apiBadge() } - Spacer(Modifier.width(16.dp)) + Spacer(Modifier.width(8.dp)) // A Box, not a third column: reserving a column for the version and reach would take width // from every line of the description whether or not anything was there. They overlap the text diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/SheetParts.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/SheetParts.kt index c2ff56d6d..3f2d282d0 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/SheetParts.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/SheetParts.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -118,7 +117,7 @@ fun ToggleRow( ), supportingContent = subtitle?.let { { Text(it) } }, leadingContent = { Icon(icon, contentDescription = null) }, - trailingContent = { Switch(checked = checked, onCheckedChange = null) }, + trailingContent = { CheckSwitch(checked = checked, onCheckedChange = null) }, colors = sheetRowColors, ) { Text(title) } } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogsScreen.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogsScreen.kt index f55b2ed33..ba857deec 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogsScreen.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogsScreen.kt @@ -47,8 +47,8 @@ import androidx.compose.material.icons.rounded.UnfoldLess import androidx.compose.material.icons.rounded.UnfoldMore import androidx.compose.material.icons.automirrored.rounded.Label import androidx.compose.material.icons.rounded.SearchOff -import androidx.compose.material.icons.rounded.VerticalAlignBottom -import androidx.compose.material.icons.rounded.VerticalAlignTop +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowDown +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp import androidx.compose.material.icons.rounded.WarningAmber import androidx.compose.material.icons.automirrored.rounded.WrapText import androidx.compose.material3.CircularProgressIndicator @@ -65,12 +65,11 @@ 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.FilledTonalIconButton import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -107,6 +106,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.launch +import org.matrix.vector.ui.CheckSwitch import org.matrix.vector.ui.LocalDialogLocalizer import org.matrix.vector.ui.PanelHeader import org.matrix.vector.ui.SearchField @@ -355,7 +355,7 @@ private fun LogPane( LaunchedEffect(state.scroll?.token, jumpInset) { val command = state.scroll ?: return@LaunchedEffect if (state.rows.isNotEmpty()) { - listState.scrollToItem(command.position.coerceIn(0, state.rows.lastIndex)) + listState.animateScrollToItem(command.position.coerceIn(0, state.rows.lastIndex)) } } @@ -544,23 +544,32 @@ private fun LogList( // file: hiding one would change the container's height, which is the list's bottom inset, // and so shift the log under the reader as a side effect of scrolling. if (showJump) { - Row( + Column( modifier = Modifier.align(Alignment.BottomEnd) .onSizeChanged { onJumpInset(it.height) } .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - SmallFloatingActionButton(onClick = { viewModel.jumpToOldest(tab) }) { + FilledTonalIconButton( + onClick = { viewModel.jumpToOldest(tab) }, + modifier = Modifier.size(40.dp), + ) { Icon( - Icons.Rounded.VerticalAlignTop, + Icons.Rounded.KeyboardDoubleArrowUp, contentDescription = stringResource(R.string.logs_jump_oldest), + modifier = Modifier.size(20.dp), ) } - SmallFloatingActionButton(onClick = { viewModel.jumpToNewest(tab) }) { + FilledTonalIconButton( + onClick = { viewModel.jumpToNewest(tab) }, + modifier = Modifier.size(40.dp), + ) { Icon( - Icons.Rounded.VerticalAlignBottom, + Icons.Rounded.KeyboardDoubleArrowDown, contentDescription = stringResource(R.string.logs_jump_newest), + modifier = Modifier.size(20.dp), ) } } @@ -790,7 +799,7 @@ private fun LogSettingsSheet( ) }, trailingContent = { - Switch(checked = enabled, onCheckedChange = { viewModel.setVerbose(it) }) + CheckSwitch(checked = enabled, onCheckedChange = { viewModel.setVerbose(it) }) }, colors = sheetRowColors, ) { Text(stringResource(R.string.logs_verbose_switch)) } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/navigation/Navigator.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/navigation/Navigator.kt index 09d95ee60..cf0edc453 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/navigation/Navigator.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/navigation/Navigator.kt @@ -2,12 +2,15 @@ package org.matrix.vector.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -35,8 +38,33 @@ class Navigator( val backStack: NavBackStack, private val panelsState: State, private val store: NavPanelStore, + /** The panel the pager is on, as the saveable MutableState that survives process death. */ + private val currentTabState: MutableState, + /** The fixed foot of the stack: the container a detail is pushed above. See the rootKey param. */ + private val rootKey: NavKey, ) { + /** + * Which panel the pager is on. + * + * Deliberately not the root of [backStack]: the stack's root is the fixed [rootKey] container, + * and a detail (scope editor, store detail, browser…) is pushed above it. Switching tabs is a + * pager gesture and must not touch the stack — otherwise a swipe would truncate the very + * details it is meant to be over. Keeping the panel in its own state is what lets a detail be + * pushed and popped without the current tab changing underneath it, and what lets backing out + * of a detail land back on the same panel. + * + * Delegated to [currentTabState] (rememberSaveable-backed) rather than a private mutableStateOf: + * there must be one source of truth, and it is the one that survives process death — which the + * old code got for free by making the panel the stack root, and a fixed container root no longer + * does. + */ + var currentTab: NavKey + get() = currentTabState.value + set(value) { + currentTabState.value = value + } + /** The reader's panels. A snapshot read, so a composable that touches it recomposes. */ val panels: NavPanels get() = panelsState.value @@ -55,24 +83,26 @@ class Navigator( get() = backStack.lastOrNull() /** - * Which item is highlighted — the root of the stack, or the first visible panel. + * Which item is highlighted — the panel the pager is standing on. * - * The visibility test is not made redundant by [reconcilePanels]: hiding the panel you are - * standing on recomposes the container before the effect that corrects the stack has run, and - * for that frame the root names something the container is no longer drawing. A bar that - * highlights nothing is worse than one highlighting the panel you are about to be moved to. + * The pager is the current tab, not the stack root: the stack root is the fixed container + * (the rootKey param), and the whole point is that a detail above it does not change which + * panel you were on. So this reads [currentTab] rather than [backStack]. Navigation 3's own + * back handling is entirely in terms of the stack, so this only feeds the containers — the bar + * and the floating ball — which are the only things that care about "which panel". */ val currentTopLevel: NavKey - get() { - val root = backStack.firstOrNull() ?: return panels.start.route - return if (panels.isVisible(root)) root else panels.start.route - } + get() = currentTab val canGoBack: Boolean get() = backStack.size > 1 - /** Push a detail destination on top of the current tab. */ + /** Push a detail destination on top of the top-level pager. */ fun go(route: NavKey) { + // A detail always sits above the fixed container root; if the stack is somehow back to empty + // (a restored stack that lacked the root), re-seed it rather than pushing a detail as the + // stack's only entry. + ensureRoot() if (backStack.lastOrNull() != route) backStack.add(route) } @@ -84,14 +114,27 @@ class Navigator( * reader has already answered. */ fun replace(route: NavKey) { - if (backStack.isEmpty()) backStack.add(route) else backStack[backStack.lastIndex] = route + ensureRoot() + if (backStack.size == 1) backStack.add(route) else backStack[backStack.lastIndex] = route } - /** Select a bar item, discarding whatever detail screens were open. */ + /** + * Select a bar item, discarding whatever detail screens were open. + * + * This moves the pager (via [currentTab]) and unwinds the stack back to its fixed container + * root. It does not truncate the stack to a *panel* the way it once did — the root is now + * always the container, so "back to the tab" means clearing everything above it rather than + * re-seeding the stack with the panel. That is what lets backing out of a detail land on the + * panel you were on, and what lets a swipe between panels leave any (now-hidden) detail history + * alone. + */ fun switchTo(tab: NavKey) { - if (backStack.size == 1 && backStack.firstOrNull() == tab) return + currentTab = tab + // Keep only the fixed root; drop any detail that was above it. Clearing on every tab change + // (even one already current) is fine: it is the honest representation of "the user asked for + // this tab", and it is what discards a stale scope-editor draft on a bar tap. backStack.clear() - backStack.add(tab) + backStack.add(rootKey) } /** Returns false when there is nothing left to pop, so the caller can let the system exit. */ @@ -101,6 +144,11 @@ class Navigator( return true } + /** Guarantee the stack's foot is [rootKey], appending it if the stack is empty. */ + private fun ensureRoot() { + if (backStack.isEmpty()) backStack.add(rootKey) + } + /** Hide or restore a panel, and persist it. [key] is a TopLevelDestination.key. */ fun setPanelHidden(key: String, hidden: Boolean) { store.setEncoded(encodeNavPanels(panels.withHidden(key, hidden))) @@ -112,32 +160,27 @@ class Navigator( } /** - * Replace a root that names a panel which is no longer shown. - * - * This is one mechanism serving two stories that look unrelated: hiding the panel you are on - * moves you to the first visible one, and a stack restored from before a panel was hidden — a - * real state, since the stack survives process death both through SavedStateRegistry and - * through the hooker's per-activity Bundle cache — is corrected instead of leaving a container - * that highlights nothing. + * Keep the current panel visibly drawn. * - * `backStack[0] = …` rather than clear() then add(): NavBackStack supports set(index, value), - * and emptying the list even for an instant hands NavDisplay a stack with no entries. + * The stack root is the fixed container and no longer names a panel, so the old + * "replace a root that names a hidden panel" correction becomes "move off a current tab that has + * been hidden". That is the same story as before — hiding the panel you are on puts you on the + * first visible one — but it fixes [currentTab] instead of [backStack], because the tab is where + * the pager is, and the pager is what would otherwise show nothing. */ fun reconcilePanels() { - val root = backStack.firstOrNull() ?: return - // Only a root that names a panel is this method's business. The type test this replaces - // said the same thing; asking the catalogue says it without the shared code having to know - // the host's route types. `all`, not `visible`: a hidden panel is still a panel, and it is - // exactly the root this exists to correct. - if (panels.all.none { it.route == root }) return - if (!panels.isVisible(root)) backStack[0] = panels.start.route + if (!panels.isVisible(currentTab)) currentTab = panels.start.route } } val LocalNavigator = staticCompositionLocalOf { error("No Navigator in composition") } @Composable -fun rememberNavigator(store: NavPanelStore, catalogue: List): Navigator { +fun rememberNavigator( + store: NavPanelStore, + catalogue: List, + rootKey: NavKey, +): Navigator { val stored = store.encoded.collectAsStateWithLifecycle() // Derived rather than decoded on every recomposition: the string changes when a panel is // dragged or hidden and at no other time, while everything that reads the panels reads them @@ -145,11 +188,37 @@ fun rememberNavigator(store: NavPanelStore, catalogue: List val panels = remember(stored, catalogue) { derivedStateOf { decodeNavPanels(stored.value, catalogue) } } // rememberNavBackStack persists across process death via SavedState, which matters here: // parasitically the manager's activity state is hand-managed by the zygisk hooker, so - // anything that relies on the system restoring it needs to survive that path too. The first - // visible panel is the seed and only the seed — a restored stack skips it entirely, which is - // why the correction below is an effect that runs on every arrangement rather than a one-off. - val backStack = rememberNavBackStack(panels.value.start.route) - val navigator = remember(backStack, panels, store) { Navigator(backStack, panels, store) } + // anything that relies on the system restoring it needs to survive that path too. + // + // The seed is the fixed root container, and only it: a restored stack may already carry a + // detail above it (SavedStateRegistry and the hooker's per-activity Bundle cache both survive + // process death), so the seed is never a panel that would have to be reconciled away. + val backStack = rememberNavBackStack(rootKey) + // The current tab also has to survive process death, and it cannot live in the stack (the stack + // root is always the container). It is saved by the panel route's string key, and a route that + // is no longer in the catalogue (a panel deleted since) falls back to the first visible one + // below through reconcilePanels. + // + // The saver is over the MutableState (as rememberSaveable expects when it wraps a mutableStateOf) + // but saves/restores only the inner NavKey's panel key: it is the panel, not the state object, + // that must be serialized, and the panel key is what the catalogue maps back to a route. + val tabSaver = + Saver, String>( + save = { state -> catalogue.firstOrNull { it.route == state.value }?.key ?: "" }, + restore = { key -> + catalogue.firstOrNull { it.key == key }?.route?.let { mutableStateOf(it) } + }, + ) + // The MutableState is the single source of truth: Navigator reads/writes through it (see + // Navigator.currentTab), so a tab change while running updates the very state that is saved. + val currentTabState = + rememberSaveable(saver = tabSaver) { mutableStateOf(panels.value.start.route) } + val navigator = + remember(backStack, panels, store) { + Navigator(backStack, panels, store, currentTabState, rootKey) + } + // Keep the panel and the stack in step: a restored stack's root is the container, and the saved + // tab may have stopped being visible (or existing) while the reader was away. LaunchedEffect(navigator, panels.value) { navigator.reconcilePanels() } return navigator } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoDetailsScreen.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoDetailsScreen.kt index eed084206..783cf180c 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoDetailsScreen.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoDetailsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.rememberBottomSheetState import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.NotificationsOff import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.RestartAlt import android.text.format.Formatter import androidx.compose.foundation.background import androidx.compose.foundation.shape.RoundedCornerShape @@ -434,17 +435,29 @@ private fun InstallBar( } ?: stringResource(UiR.string.store_install_failed, install.packageName), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, ) Spacer(Modifier.height(4.dp)) - // The same body as the resting button below, because this is the same press: - // clearing the failure on its own would only put the Install button back and - // leave the reader to press it again, which is a retry that retries nothing. - TextButton( + // The same press as the resting install button below, and the same look: this is + // the same action, not a separate affordance, and clearing the failure on its own + // would only put the Install button back and leave the reader to press it again, + // which is a retry that retries nothing. The icon is a curved restart loop, the + // standard "try again" gesture, where the download arrow would only repeat the + // failure that just happened. + Button( onClick = { onAcknowledge() onInstall(newest) - } + }, + modifier = Modifier.fillMaxWidth(), ) { + Icon( + Icons.Rounded.RestartAlt, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) Text(stringResource(UiR.string.retry)) } } diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt index ccba13ef4..a8761ea3d 100644 --- a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoActivity.kt @@ -1,7 +1,5 @@ package org.matrix.vector.manager.demo -import kotlinx.coroutines.launch -import androidx.lifecycle.lifecycleScope import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler @@ -28,6 +26,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import org.matrix.vector.ipc.IManagerService import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ui.VectorApp @@ -45,12 +45,12 @@ import org.matrix.vector.manager.ui.theme.VectorTheme * `org.matrix.vector.manager.demo` in a release APK's classes and finding nothing. * * It hosts the app itself rather than launching MainActivity, and that is not a stylistic choice. - * The first version did launch it, and every scenario silently did nothing: `ParasiticManagerHooker` - * intercepts the manager activity starting and hands the *real* binder to `Constants.setBinder`, - * overwriting whatever was bound a moment earlier. Even "no daemon at all" came up reporting a - * healthy framework — the failure mode a test harness can least afford, since it looks like a pass. - * Rendering VectorApp here means no manager activity is ever launched, so nothing re-binds behind - * us. + * The first version did launch it, and every scenario silently did nothing: + * `ParasiticManagerHooker` intercepts the manager activity starting and hands the *real* binder to + * `Constants.setBinder`, overwriting whatever was bound a moment earlier. Even "no daemon at all" + * came up reporting a healthy framework — the failure mode a test harness can least afford, since + * it looks like a pass. Rendering VectorApp here means no manager activity is ever launched, so + * nothing re-binds behind us. */ class DemoActivity : ComponentActivity() { @@ -116,8 +116,7 @@ class DemoActivity : ComponentActivity() { pinned = null return scenario } - pinned = - if (!scenario.connected) null else FakeManagerService(scenario, realService) + pinned = if (!scenario.connected) null else FakeManagerService(scenario, realService) pinning = true ServiceLocator.bind(pinned) return scenario @@ -150,7 +149,9 @@ private fun ScenarioList(onPick: (DemoScenario) -> Unit) { ListItem( modifier = Modifier.clickable { onPick(scenario) }, supportingContent = { Text(scenario.summary) }, - ) { Text(scenario.title) } + ) { + Text(scenario.title) + } } } } diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt index b407e9e26..115878168 100644 --- a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/DemoScenario.kt @@ -64,10 +64,10 @@ data class DemoScenario( * * Reachable through this seam after all, which was not obvious: whether an update *exists* is * decided by comparing the release list against the installed version code — and that version - * comes from the daemon, not from GitHub. Reporting an old one is enough to make a real - * release look like an update, so the whole flow can be exercised without faking any network - * traffic. The release list itself is genuinely GitHub's, which makes this closer to the real - * thing than a canned one would be. + * comes from the daemon, not from GitHub. Reporting an old one is enough to make a real release + * look like an update, so the whole flow can be exercised without faking any network traffic. + * The release list itself is genuinely GitHub's, which makes this closer to the real thing than + * a canned one would be. */ enum class InstallScript { SUCCEEDS, diff --git a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt index a0e7964f0..ef16adf4e 100644 --- a/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt +++ b/manager/src/debug/kotlin/org/matrix/vector/manager/demo/FakeManagerService.kt @@ -10,17 +10,17 @@ import org.matrix.vector.ipc.IFrameworkInstallReceiver import org.matrix.vector.ipc.IManagerService import org.matrix.vector.ipc.ModuleLoadFailure import org.matrix.vector.ipc.ScopeEntry -import rikka.parcelablelist.ParcelableListSlice import org.matrix.vector.manager.data.model.versionCodeCompat +import rikka.parcelablelist.ParcelableListSlice /** * The daemon, as a script. * * The fake sits at the *binder*, which is the boundary between this app and the privileged system, - * and is the reason a demo mode is worth having at all: everything from here inwards — DaemonClient, - * the repositories, the view models, the status derivation that turns three booleans into an issue - * list — runs exactly as it does in production. What is faked is what the device says about itself, - * not what the manager concludes. A bug in the concluding is still visible. + * and is the reason a demo mode is worth having at all: everything from here inwards — + * DaemonClient, the repositories, the view models, the status derivation that turns three booleans + * into an issue list — runs exactly as it does in production. What is faked is what the device says + * about itself, not what the manager concludes. A bug in the concluding is still visible. * * Two other seams were considered and rejected. Faking a repository would have meant the status * derivation never ran, which is the code most likely to be wrong. Faking a view model would have @@ -32,10 +32,10 @@ import org.matrix.vector.manager.data.model.versionCodeCompat * is a lie. With no daemon present the delegating calls return empties rather than throwing, so the * demo is still usable on a device with no Vector installed. * - * Subclassing `Stub()` rather than proxying the interface is deliberate on both counts: DaemonClient - * checks `asBinder().isBinderAlive`, which only a real Binder answers — and a new AIDL method breaks - * this file's compilation, which is the point. A fake that silently kept working while the daemon - * grew a new question would quietly stop covering it. + * Subclassing `Stub()` rather than proxying the interface is deliberate on both counts: + * DaemonClient checks `asBinder().isBinderAlive`, which only a real Binder answers — and a new AIDL + * method breaks this file's compilation, which is the point. A fake that silently kept working + * while the daemon grew a new question would quietly stop covering it. */ class FakeManagerService( private val scenario: DemoScenario, @@ -104,46 +104,43 @@ class FakeManagerService( */ override fun getBuildStamp(): String? = real?.buildStamp - /** * A flash, without a flash. * - * Emits on its own thread and never blocks the caller, because the real one does not either — - * a screen that only works when the lines arrive on the binder thread would pass here and hang - * on a device. + * Emits on its own thread and never blocks the caller, because the real one does not either — a + * screen that only works when the lines arrive on the binder thread would pass here and hang on + * a device. */ override fun installFrameworkZip(zipPath: String?, receiver: IFrameworkInstallReceiver?) { if (receiver == null) return Thread { - fun say(line: String) { - runCatching { receiver.onLine(line) } - Thread.sleep(220) + fun say(line: String) { + runCatching { receiver.onLine(line) } + Thread.sleep(220) + } + when (scenario.install) { + DemoScenario.InstallScript.NO_ROOT -> { + runCatching { receiver.onFinished(IFrameworkInstallReceiver.INSTALL_NO_ROOT) } + } + DemoScenario.InstallScript.SUCCEEDS -> { + say("- Target: $zipPath") + say("- Extracting module files") + say("- Device is arm64-v8a API 36") + say("- Installing Vector") + say("- Setting permissions") + say("- Done. Reboot to apply.") + runCatching { receiver.onFinished(0) } } - when (scenario.install) { - DemoScenario.InstallScript.NO_ROOT -> { - runCatching { - receiver.onFinished(IFrameworkInstallReceiver.INSTALL_NO_ROOT) - } - } - DemoScenario.InstallScript.SUCCEEDS -> { - say("- Target: $zipPath") - say("- Extracting module files") - say("- Device is arm64-v8a API 36") - say("- Installing Vector") - say("- Setting permissions") - say("- Done. Reboot to apply.") - runCatching { receiver.onFinished(0) } - } - DemoScenario.InstallScript.FAILS_PARTWAY -> { - say("- Target: $zipPath") - say("- Extracting module files") - say("- Device is arm64-v8a API 36") - say("- Installing Vector") - say("! Failed to copy zygisk binary: No space left on device") - runCatching { receiver.onFinished(1) } - } + DemoScenario.InstallScript.FAILS_PARTWAY -> { + say("- Target: $zipPath") + say("- Extracting module files") + say("- Device is arm64-v8a API 36") + say("- Installing Vector") + say("! Failed to copy zygisk binary: No space left on device") + runCatching { receiver.onFinished(1) } } } + } .start() } @@ -153,9 +150,9 @@ class FakeManagerService( * The installed package list, optionally rewritten to look old. * * This one call is where "is there an update for this module" is really decided: the catalogue - * says what the newest version is, and the comparison is against what this returns. Reporting - * a low version here is therefore the whole of the "modules are out of date" scenario, and it - * has the property that makes these scenarios worth having — nothing downstream is faked. The + * says what the newest version is, and the comparison is against what this returns. Reporting a + * low version here is therefore the whole of the "modules are out of date" scenario, and it has + * the property that makes these scenarios worth having — nothing downstream is faked. The * catalogue is the real one, the releases are real, the APK that gets installed is real, and so * is the install. * @@ -176,7 +173,8 @@ class FakeManagerService( // daemon's own objects. val rewritten = actual.list.map { info -> - val baseline = baselineVersions.putIfAbsent(info.packageName, info.versionCodeCompat) + val baseline = + baselineVersions.putIfAbsent(info.packageName, info.versionCodeCompat) if (baseline != null && baseline != info.versionCodeCompat) { // This one has genuinely changed under us since the scenario started, which // for a demo means the manager just installed it. Reporting the truth from diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/VectorStoreInstallHost.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/VectorStoreInstallHost.kt index ec4b1c3e8..fcf07ee4d 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/VectorStoreInstallHost.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/VectorStoreInstallHost.kt @@ -3,8 +3,11 @@ package org.matrix.vector.manager.data.repository import android.Manifest import android.content.pm.PackageManager import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.matrix.vector.manager.data.model.versionCodeCompat import org.matrix.vector.manager.di.ServiceLocator @@ -24,7 +27,33 @@ class VectorStoreInstallHost(private val packageName: String) : StoreInstallHost private val installer = ServiceLocator.installer - override val installState: StateFlow = installer.state + /** + * This module's install progress — not the installer's. + * + * [ModuleInstaller] is a singleton, so its [installer.state] is shared across every opened + * Details page. Left as-is, a failure (or a download) for one module would paint over another + * module's resting Install button: install *A*, watch it fail, then open *B* and B would claim + * A's failure. The installer can only run one install at a time, but the result it holds still + * names the module it happened to, and each page must only surface its own. + * + * Every non-[InstallStep.Idle] state carries its [InstallStep.packageName], so this is a + * filter, not a re-derivation: keep the step when it belongs to this host, and treat anything + * naming another module as idle. Done-and-forgotten for this module (acknowledged) is also + * [InstallStep.Idle]. + */ + override val installState: StateFlow = + installer.state + .map { step -> + val owner = step.ownerPackage() + // Idle (owner null) or a step that belongs to this host stays; anything naming + // another module is silently idle for this page. + if (owner == null || owner == packageName) step else InstallStep.Idle + } + .stateIn( + ServiceLocator.appScope, + SharingStarted.Eagerly, + InstallStep.Idle, + ) override val silentInstall: Boolean get() = @@ -77,3 +106,22 @@ class VectorStoreInstallHost(private val packageName: String) : StoreInstallHost override fun acknowledge() = installer.acknowledge() } + +/** + * Which module an install step belongs to. + * + * `InstallStep.packageName` is declared on each concrete state rather than on the sealed + * [InstallStep] interface, so a single property access needs a `when`. Returning null for + * [InstallStep.Idle] keeps the filter in [VectorStoreInstallHost.installState] simple: the empty + * step has no owner, so it is shown as-is, and any step naming a different package is silently + * dropped instead of painting over the page's resting Install button. + */ +private fun InstallStep.ownerPackage(): String? = + when (this) { + is InstallStep.Idle -> null + is InstallStep.Downloading -> packageName + is InstallStep.Installing -> packageName + is InstallStep.Confirming -> packageName + is InstallStep.Done -> packageName + is InstallStep.Failed -> packageName + } 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 index 2d308ef56..01b6ecf17 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/VectorApp.kt @@ -1,18 +1,36 @@ package org.matrix.vector.manager.ui import androidx.activity.compose.BackHandler +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldState +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldValue import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType -import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -33,6 +51,7 @@ 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.TOP_LEVEL_DESTINATIONS +import org.matrix.vector.manager.ui.navigation.TopLevel import org.matrix.vector.manager.ui.navigation.TopLevelRoute import org.matrix.vector.manager.ui.navigation.Troubleshoot import org.matrix.vector.manager.ui.navigation.VectorFloatingNavSettings @@ -60,6 +79,135 @@ import org.matrix.vector.ui.navigation.rememberNavigator import org.matrix.vector.ui.store.RepoDetailsScreen import org.matrix.vector.ui.store.RepoScreen +/** AOSP-native navigation transition duration, in milliseconds. */ +private const val NATIVE_BACK_TRANSITION_MS = 300 + +/** + * AOSP full-screen predictive back is a *fade-through*, not a slide: the current page shrinks to + * 90% and fades out while the previous page — which starts a touch larger at 110% — settles to 100% + * and fades in. No horizontal travel at all; the half-width slide reads as flipping pages. + * + * Both specs use `LinearEasing` on purpose. During the gesture the top screen is not yet at its + * destination: Navigation 3 *seeks* the transition to the finger's progress + * (`SeekableTransitionState.seekTo(progress, ...)`) rather than playing it to completion, so a + * curved easing would read as the page lagging the hand — it would not reach the framebuffer the + * finger is already at. WeKitThemeGenerator drives the same effect by mapping `backEvent.progress` + * straight into a `graphicsLayer` alpha (`1f - progress`); that is linear, 1:1, and is exactly what + * the seeked scale/fade needs to match, or the page won't feel like it is being dragged. + */ +private val PREDICTIVE_BACK_SCALE_SPEC: FiniteAnimationSpec = + tween(NATIVE_BACK_TRANSITION_MS, easing = LinearEasing) + +/** The cross-fade of the previous page during a predictive back gesture. */ +private val PREDICTIVE_BACK_FADE_SPEC: FiniteAnimationSpec = + tween(NATIVE_BACK_TRANSITION_MS, easing = LinearEasing) + +/** + * The AOSP full-screen back transition, shared by predictive-pop and by the plain pop a system back + * button (or a mouse's right-click back) produces. + * + * Navigation 3 applies it to both directions of a pop: the outgoing screen scales 100%→90% and + * fades out while the incoming screen — which started at 110% — settles to 100% and fades in, + * crossing at the middle. Both halves use [PREDICTIVE_BACK_SCALE_SPEC]/[PREDICTIVE_BACK_FADE_SPEC], + * which are linear, so the two drives read the same: predictive back seeks the transition to the + * finger's progress (see the note on those specs), while a button pop plays it to completion on its + * own. + */ +private fun fadeThroughBackTransition(): ContentTransform { + val exit = + scaleOut( + targetScale = 0.9f, + animationSpec = PREDICTIVE_BACK_SCALE_SPEC, + ) + fadeOut(animationSpec = PREDICTIVE_BACK_FADE_SPEC) + val enter = + scaleIn( + initialScale = 1.1f, + animationSpec = PREDICTIVE_BACK_SCALE_SPEC, + ) + fadeIn(animationSpec = PREDICTIVE_BACK_FADE_SPEC) + return enter togetherWith exit +} + +/** + * A [NavigationSuiteScaffoldState] whose bar is animated with the same motion as the destination + * transition, so the bar and the page move as one. + * + * The scaffold's own `rememberNavigationSuiteScaffoldState` hides/shows the bar with a fixed spring + * — crisp, but its own tempo. A back press then animates the bar on a spring while the page it is + * attached to fades through on the 300ms linear curve, so the two read as separate motions: the bar + * snaps into place while the page is still settling. This replaces the spring with the same + * `tween(NATIVE_BACK_TRANSITION_MS, LinearEasing)` the destination transitions use, so the bar + * slides/fades in lockstep with the page rather than on its own clock. + * + * Everything else — [isAnimating], [targetValue], [currentValue], the Saver — mirrors the default + * implementation, because the scaffold reads those to lay out and to decide whether to consume + * insets. Only the animation spec differs. + */ +@Composable +private fun rememberVectorSuiteState(): NavigationSuiteScaffoldState { + return rememberSaveable(saver = rememberVectorSuiteStateSaver()) { VectorSuiteState() } +} + +private fun rememberVectorSuiteStateSaver(): + Saver = + Saver( + save = { it.targetValue }, + restore = { VectorSuiteState(initialValue = it) }, + ) + +private class VectorSuiteState( + initialValue: NavigationSuiteScaffoldValue = NavigationSuiteScaffoldValue.Visible +) : NavigationSuiteScaffoldState { + private val internalValue: Float = + if (initialValue == NavigationSuiteScaffoldValue.Visible) VISIBLE else HIDDEN + private val internalState = Animatable(internalValue, Float.VectorConverter) + private val _currentValue = derivedStateOf { + if (internalState.value == VISIBLE) NavigationSuiteScaffoldValue.Visible + else NavigationSuiteScaffoldValue.Hidden + } + + /** The same curve as the destination fade-through, so the bar tracks the page. */ + private val spec: FiniteAnimationSpec = + tween(NATIVE_BACK_TRANSITION_MS, easing = LinearEasing) + + override val isAnimating: Boolean + get() = internalState.isRunning + + override val targetValue: NavigationSuiteScaffoldValue + get() = + if (internalState.targetValue == VISIBLE) NavigationSuiteScaffoldValue.Visible + else NavigationSuiteScaffoldValue.Hidden + + override val currentValue: NavigationSuiteScaffoldValue + get() = _currentValue.value + + override suspend fun hide() { + internalState.animateTo(targetValue = HIDDEN, animationSpec = spec) + } + + override suspend fun show() { + internalState.animateTo(targetValue = VISIBLE, animationSpec = spec) + } + + override suspend fun toggle() { + internalState.animateTo( + targetValue = + if (targetValue == NavigationSuiteScaffoldValue.Visible) HIDDEN else VISIBLE, + animationSpec = spec, + ) + } + + override suspend fun snapTo(targetValue: NavigationSuiteScaffoldValue) { + internalState.snapTo( + if (targetValue == NavigationSuiteScaffoldValue.Visible) VISIBLE else HIDDEN + ) + } + + private companion object { + const val HIDDEN = 0f + const val VISIBLE = 1f + } +} + /** * The app shell. * @@ -76,7 +224,7 @@ import org.matrix.vector.ui.store.RepoScreen */ @Composable fun VectorApp() { - val navigator = rememberNavigator(VectorNavPanelStore, TOP_LEVEL_DESTINATIONS) + val navigator = rememberNavigator(VectorNavPanelStore, TOP_LEVEL_DESTINATIONS, TopLevel) // Where the launch intent asked to open. The activity has no back stack to act on, so it leaves // the destination here and this is the first place there is one — on a cold start the splash is @@ -112,7 +260,7 @@ fun VectorApp() { // Driving the scaffold's own state rather than dropping the items: hiding the items alone // leaves the container laid out, so a detail screen — the in-app browser especially — // keeps a dead strip of navigation-bar-sized space at the bottom. - val suiteState = rememberNavigationSuiteScaffoldState() + val suiteState = rememberVectorSuiteState() LaunchedEffect(atRoot) { if (atRoot) suiteState.show() else suiteState.hide() } // Computed rather than left to the scaffold's default, for two reasons: the floating style @@ -148,21 +296,48 @@ fun VectorApp() { }, ) { Box(Modifier.fillMaxSize()) { + // One NavDisplay owns the whole stack. The root is the fixed TopLevel container, + // which renders the finger-following pager of panels; a detail (scope editor, store + // detail, browser…) is a second entry pushed above it by Navigator.go. Because the + // display stays mounted instead of being swapped for a separate pager branch, a + // system back press / mouse right-click pops the detail and plays the + // popTransitionSpec fade-through — the previous page shows behind the shrinking + // current one — rather than the whole thing vanishing the instant the stack is back + // to the root. NavDisplay( backStack = navigator.backStack, onBack = { navigator.back() }, // Naming any decorator replaces NavDisplay's default, which is the - // saveable-state one alone, so it is repeated here; the scene-setup decorator - // NavDisplay applies internally is untouched. The ViewModel one is what this - // list is for: it scopes a ViewModelStore per entry, so opening the scope - // editor for a second module builds a second ViewModel instead of reusing the - // first (they would otherwise share one default key under the activity's - // store). + // saveable-state one alone, so it is repeated here; the scene-setup + // decorator NavDisplay applies internally is untouched. The ViewModel one + // is what this list is for: it scopes a ViewModelStore per entry, so + // opening the scope editor for a second module builds a second ViewModel + // instead of reusing the first (they would otherwise share one default key + // under the activity's store). entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), + // Forward is the mirror of back, so it uses the same AOSP fade-through: the + // new page shrinks in from 110% and fades in while the page it covers shrinks + // to 90% and fades out. Push and pop are therefore one symmetric motion rather + // than opposite ones (the official sample's half-width slide is not used here, + // because a slide forward plus a fade-through back would feel like two + // different navigations). + transitionSpec = { fadeThroughBackTransition() }, + popTransitionSpec = { fadeThroughBackTransition() }, + predictivePopTransitionSpec = { _ -> + // AOSP full-screen predictive back, symmetric: the swipe edge is unused + // so the gesture reads the same from either side. + // `fadeThroughBackTransition` + // is shared with the plain button pop above, so a back-key press and a back + // drag land the reader in the same place — the previous page revealed + // behind + // the shrinking current one, the one difference being that the gesture + // tracks the finger 1:1 via seekTo(progress). + fadeThroughBackTransition() + }, entryProvider = entryProvider { registerRoutes(navigator) }, ) // Last child of the Box so it draws over the destination, and inside the app window @@ -195,33 +370,103 @@ fun VectorApp() { * its keys by class, and entryProvider throws for one it was never given, so dropping the * registration of a hidden panel would turn a stale saved stack into a crash. */ -private fun EntryProviderScope.registerRoutes(navigator: Navigator) { - entry { - HomeScreen( - onOpenStatus = { navigator.go(SystemStatus) }, - onOpenUrl = { url -> navigator.go(Web(url)) }, - onOpenCanary = { navigator.go(Canary) }, - onOpenReport = { navigator.go(Troubleshoot) }, - onOpenUpdate = { navigator.go(FrameworkUpdate()) }, - ) + +/** + * The root of the stack: the top-level panels as a finger-following pager, in the reader's own + * order. + * + * The pager owns the gesture; the navigator owns the truth. A swipe that settles on a page turns + * that panel into the current one, and whatever else moves the navigator (a bar tap, a deep link, a + * restored stack) drives the pager to the matching page. The two effects below are the only links, + * and each guards itself so a change it itself caused does not recurse. + * + * A detail is never drawn here: it is pushed *above* this entry by [Navigator.go], and NavDisplay + * keeps this entry mounted underneath it, so backing out of the detail reveals this pager (and the + * panel you were on) instead of remounting it. Both directions are what let the pager's own scroll + * position and each panel's ViewModel survive a detail round-trip. + */ +@Composable +private fun TopLevelContainer(navigator: Navigator) { + val visible = navigator.panels.visible + // Seed the pager at the panel the reader is actually on. [rememberPagerState] reads this on + // first composition only; when the TopLevel entry is remounted after process death it starts + // at the panel the reader was on rather than at page zero. + val initialPage = + visible.indexOfFirst { it.route == navigator.currentTopLevel }.coerceAtLeast(0) + val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { visible.size }) + // The navigator is the authority: wherever the current panel changed, page to it. + LaunchedEffect(navigator.currentTopLevel, visible) { + val page = visible.indexOfFirst { it.route == navigator.currentTopLevel } + if (page >= 0 && page != pagerState.currentPage) { + pagerState.animateScrollToPage(page) + } } - entry { - ModulesScreen( - onModuleClick = { packageName, userId -> navigator.go(Scope(packageName, userId)) }, - onOpenStore = { packageName -> navigator.go(StoreDetail(packageName)) }, - ) + // The gesture is the authority: once a swipe settles, make that panel current. switchTo clears + // the stack back to the root, so a swipe never leaves a stale detail buried under a new tab. + LaunchedEffect(pagerState, visible, navigator) { + snapshotFlow { pagerState.settledPage } + .collect { page -> + val target = visible.getOrNull(page)?.route ?: return@collect + if (navigator.currentTopLevel != target) navigator.switchTo(target) + } } - entry { - RepoScreen( - onModuleClick = { packageName -> navigator.go(StoreDetail(packageName)) }, - dataSource = ServiceLocator.store, - settings = ServiceLocator.settings, - ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + // Every panel in the catalogue routes to a TopLevelRoute; the downcast is how the NavKey + // carried by TopLevelDestination becomes one here. + val route = visible.getOrNull(page)?.route as? TopLevelRoute ?: return@HorizontalPager + Box(Modifier.fillMaxSize()) { TopLevelPanelContent(route, navigator) } } - entry { - val logSource = remember { VectorLogSource() } - LogsScreen(source = logSource, onOpenTrace = { text -> navigator.go(LogTrace(text)) }) +} + +/** + * The four top-level panels, composed for a given route. + * + * The pager draws one page per visible panel, in the reader's own order; the fixed [TopLevel] root + * stores that pager in the back stack, so a detail pushed above it does not disturb which panel is + * on display. A panel's wiring — what a tap on a row opens, which service supplies the data — has + * one home here rather than copies at every place that might draw it. + */ +@Composable +private fun TopLevelPanelContent(route: TopLevelRoute, navigator: Navigator) { + when (route) { + TopLevelRoute.Home -> + HomeScreen( + onOpenStatus = { navigator.go(SystemStatus) }, + onOpenUrl = { url -> navigator.go(Web(url)) }, + onOpenCanary = { navigator.go(Canary) }, + onOpenReport = { navigator.go(Troubleshoot) }, + onOpenUpdate = { navigator.go(FrameworkUpdate()) }, + ) + + TopLevelRoute.Modules -> + ModulesScreen( + onModuleClick = { packageName, userId -> navigator.go(Scope(packageName, userId)) }, + onOpenStore = { packageName -> navigator.go(StoreDetail(packageName)) }, + ) + + TopLevelRoute.Store -> + RepoScreen( + onModuleClick = { packageName -> navigator.go(StoreDetail(packageName)) }, + dataSource = ServiceLocator.store, + settings = ServiceLocator.settings, + ) + + TopLevelRoute.Logs -> { + val logSource = remember { VectorLogSource() } + LogsScreen(source = logSource, onOpenTrace = { text -> navigator.go(LogTrace(text)) }) + } } +} + +private fun EntryProviderScope.registerRoutes(navigator: Navigator) { + // The stack's only ever-present entry: the pager of panels. A detail is pushed above it. The + // four TopLevelRoute entries are not registered as destinations — the pager draws each panel's + // content directly, so the stack never contains a TopLevelRoute and entryProvider never has to + // construct one for a saved stack. + entry { TopLevelContainer(navigator) } entry { route -> ScopeScreen( diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionDropdown.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionDropdown.kt new file mode 100644 index 000000000..e9c832c0d --- /dev/null +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionDropdown.kt @@ -0,0 +1,604 @@ +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.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +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.setValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.unit.dp +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.outlined.Bolt +import com.composables.icons.materialsymbols.outlined.Check +import com.composables.icons.materialsymbols.outlined.Delete +import com.composables.icons.materialsymbols.outlined.Info +import com.composables.icons.materialsymbols.outlined.Notifications_off +import com.composables.icons.materialsymbols.outlined.Open_in_new +import com.composables.icons.materialsymbols.outlined.Restart_alt +import com.composables.icons.materialsymbols.outlined.Stop_circle +import com.composables.icons.materialsymbols.outlined.Storefront +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.logE +import org.matrix.vector.manager.logW +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.ui.R as UiR +import org.matrix.vector.ui.SharedAlertDialog +import org.matrix.vector.ui.SnackbarTone + +/** + * The long-press action menu for one package, drawn as the Material 3 Expressive *grouped* dropdown. + * + * The caller composes this next to the row it belongs to, inside the same [Box]. The menu is a + * [DropdownMenuPopup] — the Popup primitive underneath [DropdownMenu], used here so it can render a + * [DropdownMenuGroup] whose items adopt the Expressive rounded item shapes. This is exactly the + * pattern WeKit's `DropDownMenuWidget` uses (`DropdownMenuPopup` + [DropdownMenuGroup] + + * `MenuDefaults.groupShapes()` + `MenuDefaults.itemShape(index, size)`), which is what gives the + * menu its capsule-shaped items rather than flat rows. + * + * A `DropdownMenuPopup`'s position provider anchors to the parent layout node it is declared in, so + * placing it as a sibling of the pressed row (inside the same `Box`) is what opens it beside that + * row. `LocalizedOverlay` only applies the app's chosen language to the menu text — a popup is its + * own window and would otherwise not inherit the composition's locale; it has no bearing on shape. + * + * It carries the same actions as [PackageActionMenu]'s app half (launch, app info, force stop / + * soft reboot, re-optimize), because this screen's subject is the target. Module-only rows + * (uninstall, store update) are deliberately absent. The soft-reboot confirmation stays a + * [SharedAlertDialog] because it must outlive the menu it was reached from. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun PackageActionMenuItems( + expanded: Boolean, + onDismiss: () -> Unit, + packageName: String, + userId: Int, + appName: String, + applicationInfo: ApplicationInfo, + isModule: Boolean, + onResult: (PackageActionResult) -> Unit, + onOpenStore: ((String) -> Unit)? = null, +) { + val isSystemFramework = packageName == SYSTEM_FRAMEWORK_PACKAGE + + // Asked once, when the menu opens. Most modules have neither a companion nor a launcher entry, + // and a row that exists only to report it has nothing to do is worse than no row. + var openable by remember(packageName, userId) { mutableStateOf(null) } + LaunchedEffect(packageName, userId) { + openable = + ServiceLocator.daemon + .findAppUi(packageName, userId, companionFirst = isModule) + .onFailure { e -> + logW("actions: launch target lookup for $packageName u$userId failed", e) + } + .getOrNull() != null + } + var confirmSoftReboot by remember { mutableStateOf(false) } + + // Every action dismisses the menu before it works (see the `finish` note in PackageActionMenu + // on why the scope must be the process-wide one). The soft-reboot confirmation is the exception: + // it keeps the menu up to confirm first. + val scope = ServiceLocator.appScope + val daemon = ServiceLocator.daemon + + fun finish(block: suspend () -> PackageActionResult) { + onDismiss() + scope.launch(Dispatchers.Main) { onResult(block()) } + } + + fun openAppInfo() { + finish { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val started = daemon.startActivityAsUser(intent, userId, noUserSwitch = false) + val code = started.getOrDefault(-1) + if (code !in 0..99) { + logE( + "actions: opening app info for $packageName as user $userId failed (code $code)", + started.exceptionOrNull(), + ) + } + PackageActionResult(R.string.action_opened_info) + } + } + + // The actions vary by the row: the framework is not an app (no launch/app info) and gets a soft + // reboot instead of force stop; an app gets launch/app info/force stop/optimize. Build them as + // a list so the DropdownMenuGroup can lay each item out with its Expressive capsule shape. + val items = + buildList { + if (!isSystemFramework && openable == true) { + add( + MenuAction( + label = { Text(stringResource(R.string.action_launch)) }, + leadingIcon = { Icon(MaterialSymbols.Outlined.Open_in_new, contentDescription = null) }, + onClick = { + finish { + val result = daemon.openAppUi(packageName, userId, companionFirst = isModule) + if (result.getOrDefault(false)) { + PackageActionResult(R.string.action_launched) + } else { + logE( + "actions: open of $packageName for user $userId did nothing", + result.exceptionOrNull(), + ) + PackageActionResult( + R.string.action_no_launcher, + tone = SnackbarTone.Failure, + ) + } + } + }, + ) + ) + } + + if (!isSystemFramework) { + add( + MenuAction( + label = { Text(stringResource(R.string.action_app_info)) }, + leadingIcon = { Icon(MaterialSymbols.Outlined.Info, contentDescription = null) }, + onClick = { openAppInfo() }, + ) + ) + } + + if (isSystemFramework) { + add( + MenuAction( + label = { + Text( + stringResource(R.string.action_soft_reboot), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + MaterialSymbols.Outlined.Restart_alt, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { confirmSoftReboot = true }, + ) + ) + } else { + add( + MenuAction( + label = { Text(stringResource(R.string.action_force_stop)) }, + leadingIcon = { Icon(MaterialSymbols.Outlined.Stop_circle, contentDescription = null) }, + onClick = { + finish { + val result = + daemon.forceStopPackage(packageName, userId).onFailure { e -> + logE("actions: force stop of $packageName failed", e) + } + val ok = result.isSuccess + PackageActionResult( + if (ok) R.string.action_force_stopped + else R.string.action_force_stop_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + }, + ) + ) + } + + if (!isModule && !isSystemFramework) { + add( + MenuAction( + label = { + // The icon is tinted primary; the label must match so the row reads as + // one control rather than a black word beside a blue glyph. + Text( + stringResource(R.string.action_optimize), + color = MaterialTheme.colorScheme.primary, + ) + }, + leadingIcon = { + Icon( + MaterialSymbols.Outlined.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { + finish { + onResult( + PackageActionResult( + R.string.action_optimizing, + appName, + tone = SnackbarTone.Working, + ) + ) + val ok = + daemon + .optimizePackage(packageName) + .onFailure { e -> + logE("actions: re-optimize of $packageName failed", e) + } + .getOrDefault(false) + PackageActionResult( + if (ok) R.string.action_optimized + else R.string.action_optimize_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + }, + ) + ) + } + } + + DropdownMenuPopup(expanded = expanded, onDismissRequest = onDismiss) { + LocalizedOverlay { + // A single capsule group. The default M3 Expressive group corner (CornerExtraSmall) is + // far too square; WeKit's menu reads as a large-radius rounded group, so set both the + // group container and its items to a clearly rounded shape. + val capsule = RoundedCornerShape(16.dp) + DropdownMenuGroup(shapes = MenuDefaults.groupShapes(shape = capsule, inactiveShape = capsule)) { + items.forEachIndexed { index, action -> + DropdownMenuItem( + selected = action.selected, + onClick = action.onClick, + text = action.label, + shapes = MenuDefaults.itemShape(index, items.size), + // When selected, the leading icon is replaced by a check (KSU-style) rather + // than keeping the glyph and adding a trailing mark. The check is tinted with + // the same content colour as the label, so it reads as part of the item text. + leadingIcon = if (action.selected) null else action.leadingIcon, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + // Tighter than the M3 default so an un-checked item hugs its text instead of + // left-aligning inside a wide empty canvas. + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + ) + } + } + } + } + + if (confirmSoftReboot) { + SharedAlertDialog( + onDismissRequest = { confirmSoftReboot = false }, + icon = { Icon(MaterialSymbols.Outlined.Restart_alt, contentDescription = null) }, + title = { Text(stringResource(R.string.action_soft_reboot)) }, + text = { Text(stringResource(R.string.action_soft_reboot_confirm)) }, + confirmButton = { + TextButton( + onClick = { + confirmSoftReboot = false + onDismiss() + scope.launch(Dispatchers.Main) { + daemon.softReboot().onFailure { logE("actions: soft reboot failed", it) } + } + } + ) { + Text( + stringResource(R.string.action_soft_reboot), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirmSoftReboot = false }) { + Text(stringResource(UiR.string.store_cancel)) + } + }, + ) + } +} + +/** One entry in the menu: its label + leading icon + the click handler, plus an optional */ +/** selected state so a grouped item can draw the KSU-style checkmark for a toggle or a selection. */ +private class MenuAction( + val label: @Composable () -> Unit, + val leadingIcon: @Composable (() -> Unit)?, + val onClick: () -> Unit, + val selected: Boolean = false, +) + +/** + * The long-press menu for a *module* (opened from the Modules list), the module twin of + * [PackageActionMenuItems]. + * + * Same grouped Material 3 Expressive `DropdownMenuPopup` + `DropdownMenuGroup` shape as the scope + * menu, so a long press on a module opens a capsule menu beside the row rather than a bottom sheet. + * A module is not an app you "open", so instead of launch it gets its companion (the screen its + * author wrote to configure it), plus the module-only rows: open in store, and the "ignore updates" + * toggle drawn as the KSU-style checkmark (checked = muted). + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ModuleActionMenuItems( + expanded: Boolean, + onDismiss: () -> Unit, + packageName: String, + userId: Int, + appName: String, + applicationInfo: ApplicationInfo, + onResult: (PackageActionResult) -> Unit, + onOpenStore: ((String) -> Unit)? = null, +) { + val isSystemFramework = packageName == SYSTEM_FRAMEWORK_PACKAGE + + // Asked once, when the menu opens. A module may have no companion and no store page; the rows + // are only offered when there is somewhere to go, so a menu that only reports a dead end is + // not left on screen. + var openable by remember(packageName, userId) { mutableStateOf(null) } + LaunchedEffect(packageName, userId) { + openable = + ServiceLocator.daemon + .findAppUi(packageName, userId, companionFirst = true) + .onFailure { e -> + logW("actions: module companion lookup for $packageName u$userId failed", e) + } + .getOrNull() != null + } + var confirmSoftReboot by remember { mutableStateOf(false) } + + val muted by ServiceLocator.settings.mutedUpdates.collectAsStateWithLifecycle() + + val scope = ServiceLocator.appScope + val daemon = ServiceLocator.daemon + + fun finish(block: suspend () -> PackageActionResult) { + onDismiss() + scope.launch(Dispatchers.Main) { onResult(block()) } + } + + fun openAppInfo() { + finish { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val started = daemon.startActivityAsUser(intent, userId, noUserSwitch = false) + val code = started.getOrDefault(-1) + if (code !in 0..99) { + logE( + "actions: opening app info for $packageName as user $userId failed (code $code)", + started.exceptionOrNull(), + ) + } + PackageActionResult(R.string.action_opened_info) + } + } + + val items = + buildList { + if (!isSystemFramework && openable == true) { + add( + MenuAction( + label = { Text(stringResource(R.string.action_open_companion)) }, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Open_in_new, contentDescription = null) + }, + onClick = { + finish { + val result = + daemon.openAppUi(packageName, userId, companionFirst = true) + if (result.getOrDefault(false)) { + PackageActionResult(R.string.action_launched) + } else { + logE( + "actions: open of $packageName for user $userId did nothing", + result.exceptionOrNull(), + ) + PackageActionResult( + R.string.action_no_launcher, + tone = SnackbarTone.Failure, + ) + } + } + }, + ) + ) + } + + if (onOpenStore != null) { + add( + MenuAction( + label = { Text(stringResource(R.string.action_open_store)) }, + leadingIcon = { Icon(MaterialSymbols.Outlined.Storefront, contentDescription = null) }, + onClick = { + onDismiss() + onOpenStore(packageName) + }, + ) + ) + } + + add( + MenuAction( + label = { Text(stringResource(UiR.string.store_mute_updates)) }, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Notifications_off, contentDescription = null) + }, + onClick = { + ServiceLocator.settings.setUpdatesMuted(packageName, packageName !in muted) + onDismiss() + }, + selected = packageName in muted, + ) + ) + + if (!isSystemFramework) { + add( + MenuAction( + label = { Text(stringResource(R.string.action_app_info)) }, + leadingIcon = { Icon(MaterialSymbols.Outlined.Info, contentDescription = null) }, + onClick = { openAppInfo() }, + ) + ) + } + + if (isSystemFramework) { + add( + MenuAction( + label = { + Text( + stringResource(R.string.action_soft_reboot), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + MaterialSymbols.Outlined.Restart_alt, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { confirmSoftReboot = true }, + ) + ) + } else { + add( + MenuAction( + label = { Text(stringResource(R.string.action_force_stop)) }, + leadingIcon = { Icon(MaterialSymbols.Outlined.Stop_circle, contentDescription = null) }, + onClick = { + finish { + val result = + daemon.forceStopPackage(packageName, userId).onFailure { e -> + logE("actions: force stop of $packageName failed", e) + } + val ok = result.isSuccess + PackageActionResult( + if (ok) R.string.action_force_stopped + else R.string.action_force_stop_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + }, + ) + ) + } + + add( + MenuAction( + label = { + Text( + stringResource(R.string.action_uninstall), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + MaterialSymbols.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + finish { + val result = daemon.uninstallPackage(packageName, userId) + val ok = result.getOrDefault(false) + if (!ok) { + logE( + "actions: uninstall of $packageName for user $userId failed", + result.exceptionOrNull(), + ) + } + PackageActionResult( + if (ok) R.string.action_uninstalled + else R.string.action_uninstall_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } + }, + ) + ) + } + + DropdownMenuPopup(expanded = expanded, onDismissRequest = onDismiss) { + LocalizedOverlay { + // A single capsule group. The default M3 Expressive group corner (CornerExtraSmall) is + // far too square; WeKit's menu reads as a large-radius rounded group, so set both the + // group container and its items to a clearly rounded shape. + val capsule = RoundedCornerShape(16.dp) + DropdownMenuGroup(shapes = MenuDefaults.groupShapes(shape = capsule, inactiveShape = capsule)) { + items.forEachIndexed { index, action -> + DropdownMenuItem( + selected = action.selected, + onClick = action.onClick, + text = action.label, + shapes = MenuDefaults.itemShape(index, items.size), + leadingIcon = if (action.selected) null else action.leadingIcon, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + ) + } + } + } + } + + if (confirmSoftReboot) { + SharedAlertDialog( + onDismissRequest = { confirmSoftReboot = false }, + icon = { Icon(MaterialSymbols.Outlined.Restart_alt, contentDescription = null) }, + title = { Text(stringResource(R.string.action_soft_reboot)) }, + text = { Text(stringResource(R.string.action_soft_reboot_confirm)) }, + confirmButton = { + TextButton( + onClick = { + confirmSoftReboot = false + onDismiss() + scope.launch(Dispatchers.Main) { + daemon.softReboot().onFailure { logE("actions: soft reboot failed", it) } + } + } + ) { + Text( + stringResource(R.string.action_soft_reboot), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirmSoftReboot = false }) { + Text(stringResource(UiR.string.store_cancel)) + } + }, + ) + } +} diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/PackageActionMenu.kt index 6e64fc7be..00057aa8d 100644 --- 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 @@ -1,14 +1,11 @@ package org.matrix.vector.manager.ui.components -import org.matrix.vector.ui.AppIcon -import org.matrix.vector.ui.SnackbarTone -import org.matrix.vector.ui.SharedAlertDialog -import org.matrix.vector.ui.R as UiR import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings +import android.text.format.Formatter import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,56 +16,60 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.rounded.ArrowCircleUp import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material.icons.rounded.Stop +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.outlined.Check import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberBottomSheetState 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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.matrix.vector.manager.R +import org.matrix.vector.manager.data.repository.ModuleUpdateQueue +import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.logE import org.matrix.vector.manager.logW +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE import org.matrix.vector.manager.ui.theme.LocalizedOverlay -import org.matrix.vector.manager.R -import android.text.format.Formatter -import androidx.compose.material.icons.rounded.ArrowCircleUp -import androidx.compose.material.icons.rounded.CloudDownload -import androidx.compose.material.icons.rounded.CloudOff -import androidx.compose.material.icons.rounded.NotificationsOff -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.matrix.vector.ui.ActionDrawerItem +import org.matrix.vector.ui.AppIcon +import org.matrix.vector.ui.R as UiR +import org.matrix.vector.ui.SharedAlertDialog +import org.matrix.vector.ui.SnackbarTone import org.matrix.vector.ui.store.ConfirmInstall import org.matrix.vector.ui.store.ReleaseAsset -import org.matrix.vector.manager.data.repository.ModuleUpdateQueue import org.matrix.vector.ui.store.StoreChannel import org.matrix.vector.ui.store.releasesOn -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material3.TextButton -import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel -import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE -import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.ui.theme.Mono /** What a long press did, and how it went. */ @@ -191,209 +192,236 @@ fun PackageActionSheet( } ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { -LocalizedOverlay { - - Row( - modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - AppIcon(applicationInfo = applicationInfo, contentDescription = null, size = 44.dp) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { - Text( - text = appName, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = ScopeViewModel.displayPackageName(packageName), - style = Mono, - color = colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + LocalizedOverlay { + Row( + modifier = + Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon(applicationInfo = applicationInfo, contentDescription = null, size = 44.dp) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = appName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = ScopeViewModel.displayPackageName(packageName), + style = Mono, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - } - - HorizontalDivider(Modifier.padding(horizontal = 24.dp)) - Spacer(Modifier.height(4.dp)) - if (isModule) { - ModuleUpdateSection( - packageName = packageName, - onOpenStore = onOpenStore, - onDismiss = onDismiss, - onResult = onResult, - ) - } + HorizontalDivider(Modifier.padding(horizontal = 24.dp)) + Spacer(Modifier.height(4.dp)) - // A module is not an app you "open" — most have nothing to look at. What it may have is a - // companion: the screen its author wrote to configure it, which is what the Xposed settings - // category marks. Naming it that way is the difference between a control that looks - // pointless and one that says what it is for. - if (!isSystemFramework && openable == true) - ActionDrawerItem( - icon = Icons.AutoMirrored.Rounded.Launch, - title = - stringResource( - if (isModule) R.string.action_open_companion else R.string.action_launch - ), - subtitle = - if (isModule) stringResource(R.string.action_open_companion_summary) else null, - ) { - finish { - val result = daemon.openAppUi(packageName, userId, companionFirst = isModule) - if (result.getOrDefault(false)) { - PackageActionResult(R.string.action_launched) - } else { - // The row is only drawn once findAppUi resolved a target, so reaching this - // branch contradicts what was rendered. One line for both shapes: a failed - // transaction carries a throwable, a resolve that found nothing does not. - logE( - "actions: open of $packageName for user $userId did nothing, though the " + - "row had resolved a target", - result.exceptionOrNull(), - ) - PackageActionResult(R.string.action_no_launcher, tone = SnackbarTone.Failure) - } + if (isModule) { + ModuleUpdateSection( + packageName = packageName, + onOpenStore = onOpenStore, + onDismiss = onDismiss, + onResult = onResult, + ) } - } - if (!isSystemFramework) - ActionDrawerItem(icon = Icons.Rounded.Info, title = stringResource(R.string.action_app_info)) { - finish { - val intent = - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", packageName, null)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - // `noUserSwitch = false`, so the daemon switches to the target's profile parent - // and locks the screen first. That is right here: this is Settings' own details - // page for a package in that profile, which is not an activity that shows for - // whichever user is current. - val started = daemon.startActivityAsUser(intent, userId, noUserSwitch = false) - // The dominant failure is not an exception: the daemon hands back the activity - // manager's own start code, so a refused user switch or a screen that would not - // start arrives as a number rather than as a throw. Started means 0 to 99 — the - // band `ActivityManager.isStartResultSuccessful` tests, written out because those - // constants are hidden — with -100 to -1 fatal and 100 to 199 non-fatal refusals. - val code = started.getOrDefault(-1) - if (code !in 0..99) { - logE( - "actions: opening app info for $packageName as user $userId failed " + - "(code $code)", - started.exceptionOrNull(), - ) + // A module is not an app you "open" — most have nothing to look at. What it may have is + // a + // companion: the screen its author wrote to configure it, which is what the Xposed + // settings + // category marks. Naming it that way is the difference between a control that looks + // pointless and one that says what it is for. + if (!isSystemFramework && openable == true) + ActionDrawerItem( + icon = Icons.AutoMirrored.Rounded.Launch, + title = + stringResource( + if (isModule) R.string.action_open_companion else R.string.action_launch + ), + subtitle = + if (isModule) stringResource(R.string.action_open_companion_summary) + else null, + ) { + finish { + val result = + daemon.openAppUi(packageName, userId, companionFirst = isModule) + if (result.getOrDefault(false)) { + PackageActionResult(R.string.action_launched) + } else { + // The row is only drawn once findAppUi resolved a target, so reaching + // this + // branch contradicts what was rendered. One line for both shapes: a + // failed + // transaction carries a throwable, a resolve that found nothing does + // not. + logE( + "actions: open of $packageName for user $userId did nothing, though the " + + "row had resolved a target", + result.exceptionOrNull(), + ) + PackageActionResult( + R.string.action_no_launcher, + tone = SnackbarTone.Failure, + ) + } + } } - PackageActionResult(R.string.action_opened_info) - } - } - // Force-stopping the framework is a soft reboot, and calling it anything else would hide - // what the button does: the daemon restarts the primary zygote, so `system_server` and - // every app forked from it go down together. Named and explained accordingly, and - // confirmed first — this is the one action on this sheet that ends what the reader is - // doing everywhere else on the phone. - if (isSystemFramework) { - ActionDrawerItem( - icon = Icons.Rounded.RestartAlt, - title = stringResource(R.string.action_soft_reboot), - subtitle = stringResource(R.string.action_soft_reboot_summary), - tint = colors.error, - ) { - confirmSoftReboot = true - } - } else { - ActionDrawerItem( - icon = Icons.Rounded.Stop, - title = stringResource(R.string.action_force_stop), - ) { - finish { - val result = - daemon.forceStopPackage(packageName, userId).onFailure { e -> - logE("actions: force stop of $packageName (user $userId) failed", e) + if (!isSystemFramework) + ActionDrawerItem( + icon = Icons.Rounded.Info, + title = stringResource(R.string.action_app_info), + ) { + finish { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // `noUserSwitch = false`, so the daemon switches to the target's profile + // parent + // and locks the screen first. That is right here: this is Settings' own + // details + // page for a package in that profile, which is not an activity that shows + // for + // whichever user is current. + val started = + daemon.startActivityAsUser(intent, userId, noUserSwitch = false) + // The dominant failure is not an exception: the daemon hands back the + // activity + // manager's own start code, so a refused user switch or a screen that would + // not + // start arrives as a number rather than as a throw. Started means 0 to 99 — + // the + // band `ActivityManager.isStartResultSuccessful` tests, written out because + // those + // constants are hidden — with -100 to -1 fatal and 100 to 199 non-fatal + // refusals. + val code = started.getOrDefault(-1) + if (code !in 0..99) { + logE( + "actions: opening app info for $packageName as user $userId failed " + + "(code $code)", + started.exceptionOrNull(), + ) } - // Unlike uninstall below there is no boolean to weigh: the call answers with - // Unit, so the Result itself is the verdict — a failure here means the - // transaction never reached a live daemon and nothing was stopped. - val ok = result.isSuccess - PackageActionResult( - if (ok) R.string.action_force_stopped - else R.string.action_force_stop_failed, - appName, - tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, - ) + PackageActionResult(R.string.action_opened_info) + } + } + + // Force-stopping the framework is a soft reboot, and calling it anything else would + // hide + // what the button does: the daemon restarts the primary zygote, so `system_server` and + // every app forked from it go down together. Named and explained accordingly, and + // confirmed first — this is the one action on this sheet that ends what the reader is + // doing everywhere else on the phone. + if (isSystemFramework) { + ActionDrawerItem( + icon = Icons.Rounded.RestartAlt, + title = stringResource(R.string.action_soft_reboot), + subtitle = stringResource(R.string.action_soft_reboot_summary), + tint = colors.error, + ) { + confirmSoftReboot = true + } + } else { + ActionDrawerItem( + icon = Icons.Rounded.Stop, + title = stringResource(R.string.action_force_stop), + ) { + finish { + val result = + daemon.forceStopPackage(packageName, userId).onFailure { e -> + logE("actions: force stop of $packageName (user $userId) failed", e) + } + // Unlike uninstall below there is no boolean to weigh: the call answers + // with + // Unit, so the Result itself is the verdict — a failure here means the + // transaction never reached a live daemon and nothing was stopped. + val ok = result.isSuccess + PackageActionResult( + if (ok) R.string.action_force_stopped + else R.string.action_force_stop_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, + ) + } } } - } - // Only for a hook target. Re-optimizing recompiles an app so that ART stops inlining the - // methods a module wants to hook — which is about the app being hooked, not about the - // module doing the hooking, so on a module it would be an expensive button for nothing. - if (!isModule && !isSystemFramework) { - ActionDrawerItem( - 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( + // Only for a hook target. Re-optimizing recompiles an app so that ART stops inlining + // the + // methods a module wants to hook — which is about the app being hooked, not about the + // module doing the hooking, so on a module it would be an expensive button for nothing. + if (!isModule && !isSystemFramework) { + ActionDrawerItem( + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.action_optimize), + subtitle = stringResource(R.string.action_optimize_summary), + tint = colors.primary, + ) { + finish { + // Slow — this recompiles the app — so the caller is told it started and + // told + // again when it finishes. + onResult( + PackageActionResult( + R.string.action_optimizing, + appName, + tone = SnackbarTone.Working, + ) + ) + val ok = + daemon + .optimizePackage(packageName) + .onFailure { e -> + logE("actions: re-optimize of $packageName failed", e) + } + .getOrDefault(false) PackageActionResult( - R.string.action_optimizing, + if (ok) R.string.action_optimized else R.string.action_optimize_failed, appName, - tone = SnackbarTone.Working, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, ) - ) - val ok = - daemon - .optimizePackage(packageName) - .onFailure { e -> - logE("actions: re-optimize of $packageName failed", e) - } - .getOrDefault(false) - PackageActionResult( - if (ok) R.string.action_optimized else R.string.action_optimize_failed, - appName, - tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, - ) + } } } - } - if (isModule) { - HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) - ActionDrawerItem( - icon = Icons.Rounded.Delete, - title = stringResource(R.string.action_uninstall), - tint = colors.error, - ) { - finish { - val result = daemon.uninstallPackage(packageName, userId) - val ok = result.getOrDefault(false) - // On `!ok`: a device-policy refusal and a missing user come back as a plain - // `false`, which onFailure would never see. - if (!ok) { - logE( - "actions: uninstall of $packageName for user $userId failed", - result.exceptionOrNull(), + if (isModule) { + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + ActionDrawerItem( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.action_uninstall), + tint = colors.error, + ) { + finish { + val result = daemon.uninstallPackage(packageName, userId) + val ok = result.getOrDefault(false) + // On `!ok`: a device-policy refusal and a missing user come back as a plain + // `false`, which onFailure would never see. + if (!ok) { + logE( + "actions: uninstall of $packageName for user $userId failed", + result.exceptionOrNull(), + ) + } + PackageActionResult( + if (ok) R.string.action_uninstalled + else R.string.action_uninstall_failed, + appName, + tone = if (ok) SnackbarTone.Success else SnackbarTone.Failure, ) } - 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)) + Spacer(Modifier.height(24.dp)) + } } } -} /** * One setting, in the same shape — the shared [ActionDrawerItem] disc layout — as the actions it @@ -422,7 +450,17 @@ private fun ActionToggleRow( role = Role.Switch, onValueChange = onCheckedChange, ), - trailing = { Switch(checked = checked, onCheckedChange = null) }, + // The KSU-style state mark: a check when on, nothing when off. The row is still a switch to + // a screen reader (role above), but the visible cue is the check rather than a thumb. + trailing = { + if (checked) { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, ) } 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 index f70e6d1d5..978ec7c53 100644 --- 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 @@ -44,6 +44,23 @@ sealed interface TopLevelRoute : Route { @Serializable data object Logs : TopLevelRoute } +/** + * The fixed foot of the back stack: the top-level panels as a finger-following pager. + * + * Navigation 3 is a single scene stack, and this app's details are *on top of* the tab pages rather + * than alongside them. So the root is one entry that renders the horizontally-swipeable pager of + * the reader's panels, and a detail is a second entry pushed above it. Having a real root entry — + * rather than letting the root *be* whichever panel is current — is what lets [Navigator] keep the + * pager mounted while a detail covers it, so backing out of a detail (system back button, mouse + * right-click, or a predictive swipe) plays [NavDisplay.popTransitionSpec]'s fade-through and the + * previous page shows behind instead of the whole thing just vanishing. + * + * Which panel the pager is standing on lives in Navigator as [Navigator.currentTab], not in the + * stack: switching tabs is a pager gesture that must not disturb the stack, and the fixed + * [TopLevel] root is what makes that possible. + */ +@Serializable data object TopLevel : Route + @Serializable data class Scope(val packageName: String, val userId: Int) : Route @Serializable data class StoreDetail(val packageName: String) : Route 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 index b6d00feb4..f73d21657 100644 --- 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 @@ -3,47 +3,55 @@ package org.matrix.vector.manager.ui.screens.home import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.ExperimentalFoundationApi -import org.matrix.vector.ui.contextClickable import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.CallSplit import androidx.compose.material.icons.automirrored.rounded.AddToHomeScreen -import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Bedtime import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.FilterAlt import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.FilterAlt +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowDown +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -59,66 +67,55 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.material3.InputChip -import androidx.compose.material3.TextButton import androidx.compose.ui.res.pluralStringResource -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.material.icons.rounded.KeyboardDoubleArrowDown -import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.ui.res.stringResource -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import java.text.DateFormat import java.util.Date import kotlinx.coroutines.launch -import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.manager.R -import org.matrix.vector.manager.ui.theme.CROWDIN_URL -import org.matrix.vector.manager.ui.theme.VectorLocaleController -import org.matrix.vector.ui.locale.LanguageSheet -import org.matrix.vector.ui.locale.currentLocale -import org.matrix.vector.manager.di.ServiceLocator -import org.matrix.vector.ui.SharedAlertDialog -import org.matrix.vector.ui.SharedSnackbarHost -import org.matrix.vector.ui.show import org.matrix.vector.manager.data.github.CommunityFeed +import org.matrix.vector.manager.data.github.Contributor 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.di.ServiceLocator 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.ContributorAvatar import org.matrix.vector.manager.ui.components.GapRow import org.matrix.vector.manager.ui.components.HistoryFootRow -import org.matrix.vector.manager.ui.components.ContributorAvatar +import org.matrix.vector.manager.ui.components.InstalledMarkerRow +import org.matrix.vector.manager.ui.components.MonthMarkerRow import org.matrix.vector.manager.ui.components.TakePartSection -import org.matrix.vector.ui.UpdatableVersion import org.matrix.vector.manager.ui.components.VectorAmbienceSettings import org.matrix.vector.manager.ui.components.statusWordRes import org.matrix.vector.manager.ui.components.toTone -import org.matrix.vector.ui.StatusHeader -import org.matrix.vector.ui.ambience.AmbienceKind import org.matrix.vector.manager.ui.screens.splash.WingedVictory +import org.matrix.vector.manager.ui.theme.CROWDIN_URL +import org.matrix.vector.manager.ui.theme.LocalizedOverlay +import org.matrix.vector.manager.ui.theme.VectorLocaleController import org.matrix.vector.ui.RepoStatsRow +import org.matrix.vector.ui.SharedAlertDialog +import org.matrix.vector.ui.SharedSnackbarHost +import org.matrix.vector.ui.StatusHeader +import org.matrix.vector.ui.UpdatableVersion +import org.matrix.vector.ui.ambience.AmbienceKind +import org.matrix.vector.ui.contextClickable +import org.matrix.vector.ui.locale.LanguageSheet +import org.matrix.vector.ui.locale.currentLocale +import org.matrix.vector.ui.show import org.matrix.vector.ui.theme.Mono /** @@ -130,7 +127,8 @@ import org.matrix.vector.ui.theme.Mono * * The activity window is a span of time rather than "the latest N commits" — six months by default, * and the reader's to change from the appearance sheet. In a quiet stretch the page honestly reads - * *7 commits by 4 people*, which is real information about the project; a rolling N would hide that. + * *7 commits by 4 people*, which is real information about the project; a rolling N would hide + * that. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -337,14 +335,14 @@ fun HomeScreen( onOpenLanguage = { showLanguage = true }, onBrandTap = ::onBrandTap, detail = { contentColor -> - val detailText = - buildList { - status.versionLabel?.let { add(it) } - status.apiVersion?.let { add("API $it") } - } - .joinToString(" · ") + val detailText = buildList { + status.versionLabel?.let { add(it) } + status.apiVersion?.let { add("API $it") } + } + .joinToString(" · ") if (detailText.isNotEmpty()) { - // The version line becomes the way in to the update, because it is the thing + // The version line becomes the way in to the update, because it is the + // thing // the mark is attached to. Tappable whether or not there is an update, so // "you are up to date" stays reachable. UpdatableVersion( @@ -425,27 +423,26 @@ fun HomeScreen( properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnClickOutside = true), ) { -LocalizedOverlay { - - Box( - modifier = - Modifier.fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - showSplash = false - } - ) { - WingedVictory() - } - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(2800) - showSplash = false + LocalizedOverlay { + Box( + modifier = + Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + showSplash = false + } + ) { + WingedVictory() + } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(2800) + showSplash = false + } } } -} } } @@ -667,7 +664,8 @@ private fun QuarterHeadline(feed: CommunityFeed, windowChanged: Boolean) { feed.commitCount, feed.commitCount, ) - val by = context.resources.getQuantityString(R.plurals.home_people_count, people, people) + val by = + context.resources.getQuantityString(R.plurals.home_people_count, people, people) val since = DateFormat.getDateInstance(DateFormat.MEDIUM, currentLocale()) .format(Date(feed.windowStartEpochSeconds * 1000)) @@ -760,6 +758,16 @@ private fun ScrollControls(listState: LazyListState, modifier: Modifier = Modifi horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + FilledTonalIconButton( + onClick = { scope.launch { listState.animateScrollToItem(0) } }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Rounded.KeyboardDoubleArrowUp, + contentDescription = stringResource(R.string.home_scroll_top), + modifier = Modifier.size(20.dp), + ) + } AnimatedVisibility(visible = !atEnd, enter = fadeIn(), exit = fadeOut()) { FilledTonalIconButton( onClick = { @@ -780,22 +788,13 @@ private fun ScrollControls(listState: LazyListState, modifier: Modifier = Modifi ) } } - FilledTonalIconButton( - onClick = { scope.launch { listState.animateScrollToItem(0) } }, - modifier = Modifier.size(40.dp), - ) { - Icon( - Icons.Rounded.KeyboardDoubleArrowUp, - contentDescription = stringResource(R.string.home_scroll_top), - modifier = Modifier.size(20.dp), - ) - } } } } /** - * What filter mode looks like: who is being shown, how much of the history that is, and the way out. + * What filter mode looks like: who is being shown, how much of the history that is, and the way + * out. * * It is a bar rather than a badge on the header because it has to carry the exit. A filtered list * that gives no visible way back is the sort of state people escape by force-quitting the app, and 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 index f63f0dd9c..39d91048d 100644 --- 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 @@ -1,34 +1,37 @@ package org.matrix.vector.manager.ui.screens.home -import android.os.Build import android.content.Context +import android.content.res.Configuration +import android.os.Build 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +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.automirrored.rounded.AddToHomeScreen +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.InstallMobile import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.WarningAmber import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider 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 @@ -36,46 +39,43 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.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.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle -import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import java.util.Locale +import kotlinx.coroutines.launch import org.matrix.vector.ipc.IManagerService import org.matrix.vector.manager.BuildConfig -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.ErrorOutline -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -import androidx.compose.foundation.layout.size -import androidx.compose.ui.draw.alpha -import android.content.res.Configuration -import java.util.Locale import org.matrix.vector.manager.R -import org.matrix.vector.ui.R as UiR import org.matrix.vector.manager.data.log.CrashRecorder +import org.matrix.vector.manager.data.log.CrashReport import org.matrix.vector.manager.data.model.ManagerCopy import org.matrix.vector.manager.data.model.XposedApi -import org.matrix.vector.manager.data.log.CrashReport import org.matrix.vector.manager.data.model.buildStamp import org.matrix.vector.manager.data.repository.ManagerInstallStep -import org.matrix.vector.ui.SnackbarTone +import org.matrix.vector.ui.CheckSwitch +import org.matrix.vector.ui.R as UiR import org.matrix.vector.ui.SharedSnackbarHost +import org.matrix.vector.ui.SnackbarTone import org.matrix.vector.ui.copyToClipboard import org.matrix.vector.ui.show -import kotlinx.coroutines.launch import org.matrix.vector.ui.theme.Mono /** @@ -175,7 +175,7 @@ fun SystemStatusScreen( } }, ) - } + }, ) { padding -> LazyColumn( modifier = Modifier.padding(padding), @@ -271,10 +271,10 @@ fun SystemStatusScreen( * * A card rather than more rows, because these are not the settings the rows above are and did not * read as them: a switch is always the same width, so a column of switches lines up, while these - * trailing controls were a long label, a spinner and a button — three different widths that left the - * right-hand edge ragged and squeezed each description into a narrow column with nothing beside it. - * The page already has this shape for "here is a situation, here is what to do about it": IssueCard - * and CrashCard. + * trailing controls were a long label, a spinner and a button — three different widths that left + * the right-hand edge ragged and squeezed each description into a narrow column with nothing beside + * it. The page already has this shape for "here is a situation, here is what to do about it": + * IssueCard and CrashCard. * * One card rather than one per remedy, because the reader's question is not "should I pin a * shortcut" but "which of these do I have" — and each separate card would have to re-explain the @@ -549,11 +549,11 @@ private fun IssueCard(issue: HealthIssue) { * monospace here would be the only thing on the page a reader has to decode rather than read; the * trace has its own screen, one tap away, where it can be a list instead of a paragraph. The four * are chosen as the answers to what a maintainer asks first: what threw, what it said, the nearest - * frame that is ours, and when. "Where" is the one worth having on the card at all — it is the - * fact that decides who picks the report up, and it is buried in the middle of the printed trace. + * frame that is ours, and when. "Where" is the one worth having on the card at all — it is the fact + * that decides who picks the report up, and it is buried in the middle of the printed trace. * - * The card is absent when there have been no crashes, which is the normal state and deserves no - * row of its own. + * The card is absent when there have been no crashes, which is the normal state and deserves no row + * of its own. */ @Composable private fun CrashCard(report: CrashReport, onOpenTrace: () -> Unit, onClear: () -> Unit) { @@ -593,7 +593,9 @@ private fun CrashCard(report: CrashReport, onOpenTrace: () -> Unit, onClear: () TextButton(onClick = onOpenTrace) { Text(stringResource(R.string.crash_open_trace)) } - TextButton(onClick = onClear) { Text(stringResource(R.string.crash_recorded_clear)) } + TextButton(onClick = onClear) { + Text(stringResource(R.string.crash_recorded_clear)) + } } } } @@ -618,8 +620,7 @@ private fun CrashFact( Text( value, style = - if (monospace) Mono.copy(fontSize = 14.sp) - else MaterialTheme.typography.bodyMedium, + if (monospace) Mono.copy(fontSize = 14.sp) else MaterialTheme.typography.bodyMedium, color = if (error) colors.error else colors.onSurface, ) } @@ -661,8 +662,7 @@ private fun InfoRow(row: InfoItem) { buildAnnotatedString { append(row.value) row.detail?.let { detail -> - val muted = - SpanStyle(fontSize = 12.sp, color = colors.onSurfaceVariant) + val muted = SpanStyle(fontSize = 12.sp, color = colors.onSurfaceVariant) withStyle(muted) { append(detail) } } }, @@ -885,7 +885,6 @@ private fun FrameworkToggle( ) } Spacer(Modifier.width(12.dp)) - Switch(checked = checked, onCheckedChange = null, enabled = enabled) + CheckSwitch(checked = checked, onCheckedChange = null, enabled = enabled) } } - 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 index d5e71533a..c46e51272 100644 --- 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 @@ -30,7 +30,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.SettingsBackupRestore import androidx.compose.material.icons.rounded.Block -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Close @@ -39,11 +38,17 @@ import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.SaveAlt +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.outlined.Check +import com.composables.icons.materialsymbols.outlined.Filter_list import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuGroup import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MenuDefaults import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -115,7 +120,7 @@ import org.matrix.vector.ui.R as UiR import org.matrix.vector.manager.data.model.InstalledModule import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.ui.AppIcon -import org.matrix.vector.manager.ui.components.PackageActionSheet +import org.matrix.vector.manager.ui.components.ModuleActionMenuItems import org.matrix.vector.ui.SnackbarTone import org.matrix.vector.ui.SharedSnackbarHost import org.matrix.vector.ui.show @@ -582,6 +587,7 @@ private fun ModulesSearch( } /** The filter menu that lives in the search field's trailing slot. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ModuleFilterButton( filter: ModuleFilter, @@ -602,7 +608,7 @@ private fun ModuleFilterButton( } ) { Icon( - Icons.Rounded.FilterList, + MaterialSymbols.Outlined.Filter_list, contentDescription = stringResource(R.string.modules_filter), tint = if (filtering) MaterialTheme.colorScheme.primary @@ -610,36 +616,79 @@ private fun ModuleFilterButton( ) } } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { -LocalizedOverlay { - - ModuleFilter.entries.forEach { option -> - DropdownMenuItem( - text = { Text(stringResource(option.labelRes())) }, - trailingIcon = { - if (option == filter) Icon(Icons.Rounded.Check, contentDescription = null) - }, - onClick = { - onFilterChange(option) - menuOpen = false - }, - ) - } - HorizontalDivider() - ModuleSort.entries.forEach { option -> - DropdownMenuItem( - text = { Text(stringResource(option.labelRes())) }, - trailingIcon = { - if (option == sort) Icon(Icons.Rounded.Check, contentDescription = null) - }, - onClick = { - onSortChange(option) - menuOpen = false - }, - ) + // The same grouped Material 3 Expressive dropdown that PackageActionMenuItems uses: a + // `DropdownMenuPopup` (the popup primitive below `DropdownMenu`) holding two `DropdownMenuGroup`s + // — one for the filter, one for the sort — so each item adopts the capsule shape from + // `MenuDefaults.itemShape(index, size)` and the selected one gets the checkmark. This is what + // WeKit's `DropDownMenuWidget` does and is why the menu items read as pills rather than flat rows. + DropdownMenuPopup(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + LocalizedOverlay { + // Two separate groups with a real gap between them — the M3 grouped menu and what the + // official sample shows as a blank section break. `Spacer(GroupSpacing)` produces + // the gap; no divider. The outer corners stay a large capsule, but the corners that + // face the gap (bottom of the first group, top of the second) are small, so the two + // capsules don't read as a fat pill against a sliver of background. + DropdownMenuGroup( + shapes = + MenuDefaults.groupShapes( + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 6.dp, bottomEnd = 6.dp), + inactiveShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 6.dp, bottomEnd = 6.dp), + ) + ) { + ModuleFilter.entries.forEachIndexed { index, option -> + DropdownMenuItem( + selected = option == filter, + onClick = { + onFilterChange(option) + menuOpen = false + }, + text = { Text(stringResource(option.labelRes())) }, + shapes = MenuDefaults.itemShape(index, ModuleFilter.entries.size), + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + // The KSU-style selection mark: the selected item's leading slot shows a + // check in the content (text) colour, standing in for the item's glyph. + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + ) + } + } + Spacer(Modifier.height(MenuDefaults.GroupSpacing)) + DropdownMenuGroup( + shapes = + MenuDefaults.groupShapes( + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 16.dp, bottomEnd = 16.dp), + inactiveShape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 16.dp, bottomEnd = 16.dp), + ) + ) { + ModuleSort.entries.forEachIndexed { index, option -> + DropdownMenuItem( + selected = option == sort, + onClick = { + onSortChange(option) + menuOpen = false + }, + text = { Text(stringResource(option.labelRes())) }, + shapes = MenuDefaults.itemShape(index, ModuleSort.entries.size), + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + ) + } + } } } -} } } @@ -956,34 +1005,37 @@ private fun ModuleListItem( var menuOpen by remember { mutableStateOf(false) } val haptics = LocalHapticFeedback.current - ModuleRow( - module = module, - facts = facts, - hasUpdate = hasUpdate, - selected = selected, - onOpenStore = onOpenStore, - // Once anything is selected the whole row joins the selection, because that is what every - // other list on the platform does and aiming at a 48dp icon to add the ninth module would - // be its own small ordeal. - onClick = if (selectionActive) onSelect else onClick, - onIconClick = { - haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) - onSelect() - }, - onLongClick = { - haptics.performHapticFeedback(HapticFeedbackType.ContextClick) - menuOpen = true - }, - ) + // The menu must live in the same Box as the row it belongs to: a `DropdownMenuPopup` anchors to + // the layout node it is declared in, so composing it as a sibling of the ModuleRow is what opens + // the menu beside this row rather than a bottom sheet. + Box { + ModuleRow( + module = module, + facts = facts, + hasUpdate = hasUpdate, + selected = selected, + onOpenStore = onOpenStore, + // Once anything is selected the whole row joins the selection, because that is what every + // other list on the platform does and aiming at a 48dp icon to add the ninth module would + // be its own small ordeal. + onClick = if (selectionActive) onSelect else onClick, + onIconClick = { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onSelect() + }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + menuOpen = true + }, + ) - if (menuOpen) { - PackageActionSheet( + ModuleActionMenuItems( + expanded = menuOpen, + onDismiss = { menuOpen = false }, packageName = module.packageName, userId = module.userId, appName = module.appName, applicationInfo = module.applicationInfo, - isModule = true, - onDismiss = { menuOpen = false }, onResult = onAction, onOpenStore = { onOpenStore() }, ) 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 index 94606d0a9..2c3dcc9cf 100644 --- 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 @@ -1,31 +1,15 @@ package org.matrix.vector.manager.ui.screens.modules -import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE -import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.DoneAll -import androidx.compose.material.icons.automirrored.rounded.PlaylistAdd -import androidx.compose.material.icons.rounded.RemoveDone -import androidx.compose.material.icons.rounded.SettingsBackupRestore -import androidx.compose.material.icons.rounded.SaveAlt -import androidx.compose.material.icons.rounded.SwapVert -import androidx.compose.material3.FilterChip -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberBottomSheetState -import androidx.compose.ui.graphics.vector.ImageVector -import org.matrix.vector.ui.ChoiceRow -import org.matrix.vector.ui.SheetAction -import org.matrix.vector.ui.SheetHeading -import org.matrix.vector.ui.ToggleRow -import androidx.activity.compose.BackHandler +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler 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.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -39,39 +23,53 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.border -import org.matrix.vector.ui.contextClickable +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Launch import androidx.compose.material.icons.rounded.Checklist -import androidx.compose.material.icons.rounded.FilterList import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material3.Button +import com.composables.icons.materialsymbols.MaterialSymbols +import com.composables.icons.materialsymbols.outlined.Auto_awesome +import com.composables.icons.materialsymbols.outlined.Check +import com.composables.icons.materialsymbols.outlined.Done_all +import com.composables.icons.materialsymbols.outlined.Download +import com.composables.icons.materialsymbols.outlined.Filter_list +import com.composables.icons.materialsymbols.outlined.Playlist_add +import com.composables.icons.materialsymbols.outlined.Remove_done +import com.composables.icons.materialsymbols.outlined.Settings_backup_restore +import com.composables.icons.materialsymbols.outlined.Swap_vert import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -83,6 +81,8 @@ 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.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback @@ -99,19 +99,25 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import org.matrix.vector.ui.SharedAlertDialog -import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.manager.R import org.matrix.vector.manager.data.model.AppInfo import org.matrix.vector.manager.di.ServiceLocator +import org.matrix.vector.manager.ui.components.PackageActionMenuItems +import org.matrix.vector.manager.ui.components.PackageActionResult +import org.matrix.vector.manager.ui.screens.modules.ScopeViewModel.Companion.SYSTEM_FRAMEWORK_PACKAGE +import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.ui.AppIcon -import org.matrix.vector.ui.SnackbarTone +import org.matrix.vector.ui.CheckSwitch +import org.matrix.vector.ui.SearchField +import org.matrix.vector.ui.SharedAlertDialog import org.matrix.vector.ui.SharedSnackbarHost +import org.matrix.vector.ui.SnackbarTone +import org.matrix.vector.ui.contextClickable import org.matrix.vector.ui.show -import org.matrix.vector.manager.ui.components.PackageActionResult -import org.matrix.vector.manager.ui.components.PackageActionSheet -import org.matrix.vector.ui.SearchField import org.matrix.vector.ui.theme.Mono class ScopeViewModelFactory(private val packageName: String, private val userId: Int) : @@ -134,9 +140,9 @@ class ScopeViewModelFactory(private val packageName: String, private val userId: * * The screen's shape follows from one fact: **a scope is written whole, never incrementally.** The * daemon deletes every scope row of the module, writes the new set and rebuilds its configuration, - * so sending that on each tap would mean ten rewrites to tick ten apps. Edits are therefore a - * draft the user builds up, and applying is a deliberate act with its size stated — *3 to add, 1 - * to remove*. + * so sending that on each tap would mean ten rewrites to tick ten apps. Edits are therefore a draft + * the user builds up, and applying is a deliberate act with its size stated — *3 to add, 1 to + * remove*. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -149,6 +155,21 @@ fun ScopeScreen( val apps by viewModel.filteredApps.collectAsStateWithLifecycle() val listState = rememberLazyListState() val state by viewModel.uiState.collectAsStateWithLifecycle() + // The filtered list is computed on a background dispatcher and lands a beat after `loading` + // flips false, so the screen can draw one frame of "no matching apps" (or one frame of + // "everything") before the real list arrives. Gate the empty state on a short settle window: + // only once the list is still empty a moment after loading finished is it genuinely empty, + // which keeps a loading flash from reading as an empty module. + var emptySettled by remember(packageName, userId) { mutableStateOf(false) } + LaunchedEffect(state.loading, apps.isEmpty()) { + if (!state.loading && apps.isEmpty()) { + delay(200) + emptySettled = apps.isEmpty() + } else { + emptySettled = false + } + } + val wouldStrand by viewModel.wouldStrand.collectAsStateWithLifecycle() val pending by viewModel.pendingChanges.collectAsStateWithLifecycle() val applying by viewModel.applying.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() @@ -198,6 +219,13 @@ fun ScopeScreen( } val haptics = LocalHapticFeedback.current var confirmStranded by remember { mutableStateOf(false) } + // How far a predictive-back gesture has been pulled, 0..1. Drives the same AOSP fade-through + // the store detail page uses (see the predictive-pop transitionSpec in VectorApp), rendered + // here + // directly because this screen's own back handling owns the gesture: nav3's + // NavigationBackHandler + // is behind this one, so its transitionSpec never plays for this destination. + var backProgress by remember { mutableStateOf(0f) } // Whether the stranding question has already been put this visit, and answered by neither // button. Two slots cannot hold three answers, so when the module has asked for something the // buttons are "give it that" and "switch it off" and there is none that says "leave it exactly @@ -250,11 +278,56 @@ fun ScopeScreen( else onNavigateBack() } - // The gesture leaves this screen exactly as the arrow does, so it asks the same question - // first. Declared here it wins over the navigator's own back handling. - BackHandler { attemptBack() } + // Predictive back is a compromise between the two things this screen has to do at once. + // + // Navigation 3 only plays its predictive-pop animation — the fade-through that *reveals the + // previous page behind this one* — when it owns the back gesture. But this screen also has to + // ask before leaving when the module would be left enabled with nothing to hook + // ([attemptBack]). + // It cannot own the gesture and still get the background, so the choice is made *per gesture*: + // + // - When leaving is safe, [enabled] is false and this handler steps aside. Navigation 3 + // receives + // the gesture, plays the fade-through, and the previous page shows behind. No warning + // needed. + // - When the module would be stranded, [enabled] is true and this handler intercepts the + // gesture + // to ask first. The background is *not* shown in that case — the point of the question is + // exactly that the reader has not decided to leave yet, so a shrinking-away preview would be + // false. It commits to leaving only through the dialog's answer. + // + // [wouldStrand] is live: ticking a target in the list clears the warning even though the module + // opened stranded. Asking once, then believing ([strandWarned]) means a user who dismissed the + // question this visit can leave with the ordinary fade-through from then on. + PredictiveBackHandler(enabled = wouldStrand && !strandWarned) { progress: Flow + -> + try { + progress.collect { backProgress = it.progress } + attemptBack() + } catch (e: CancellationException) { + // The gesture was cancelled: snap back to the resting state. + backProgress = 0f + throw e + } finally { + backProgress = 0f + } + } + // The AOSP fade-through, drawn for the (now rare) case where this handler intercepts the + // gesture: + // nav3 is not playing its own animation then, so this screen fades itself out as the finger + // pulls + // to keep the response alive. When [PredictiveBackHandler] is disabled (the common, safe case) + // [backProgress] stays 0 and this is an identity transform — nav3 is animating instead. Scaffold( + modifier = + Modifier.fillMaxSize().graphicsLayer { + val p = backProgress + val scale = 1f - 0.1f * p + scaleX = scale + scaleY = scale + alpha = 1f - p + }, topBar = { // One line: back, who this is about, and the switch. A large two-line bar would spend // a fifth of the screen restating a name the user has just tapped, on a screen whose @@ -304,7 +377,7 @@ fun ScopeScreen( // list: it is the single most consequential control on the screen. What an // overflow menu would hold here lives in the search field instead, next to the // list it acts on. - Switch( + CheckSwitch( checked = state.isEnabled, onCheckedChange = { enable -> haptics.performHapticFeedback( @@ -327,7 +400,12 @@ fun ScopeScreen( // entry — which is most of them — would otherwise carry a button whose whole function // is to report that it has nothing to do. if (hasCompanion == true) { - FloatingActionButton(onClick = viewModel::openModule) { + FloatingActionButton( + onClick = viewModel::openModule, + // No shadow: the button floats over a list of app rows and the drop it would + // cast is the one thing the flat list design has no room for. + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + ) { Icon( Icons.AutoMirrored.Rounded.Launch, contentDescription = stringResource(R.string.action_open_companion), @@ -378,9 +456,7 @@ fun ScopeScreen( showModules = showModules, hasRecommended = !state.recommended.isEmpty, recommendedOnly = recommendedOnly, - onToggleRecommendedOnly = { - viewModel.setRecommendedOnly(!recommendedOnly) - }, + onToggleRecommendedOnly = { viewModel.setRecommendedOnly(!recommendedOnly) }, locked = state.recommended.staticScope, onLockedClick = { scope.launch { snackbars.show(staticScopeNotice) } }, onToggleSystem = { viewModel.showSystemApps.value = !showSystem }, @@ -413,7 +489,7 @@ fun ScopeScreen( return@Column } - if (apps.isEmpty()) { + if (apps.isEmpty() && emptySettled) { ScopeEmptyState() return@Column } @@ -598,6 +674,7 @@ fun ScopeScreen( * every second row wraps and the menu reads as a paragraph. A sheet has the full width, and it can * carry the leading icons that tell an action from a setting. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ScopeSelectMenu( hasRecommended: Boolean, @@ -610,76 +687,103 @@ private fun ScopeSelectMenu( onRestore: () -> Unit, ) { var open by remember { mutableStateOf(false) } - IconButton(onClick = { open = true }) { - Icon( - Icons.Rounded.Checklist, - contentDescription = stringResource(R.string.scope_select), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (open) { - ScopeSheet( - stringResource(R.string.scope_select), - Icons.Rounded.Checklist, - { open = false }, - ) { - if (hasRecommended) { - SheetAction( - title = stringResource(R.string.scope_use_recommended), - icon = Icons.Rounded.AutoAwesome, - onClick = { - onUseRecommended() - open = false - }, - ) - } - SheetAction( - title = stringResource(R.string.scope_select_visible), - icon = Icons.Rounded.DoneAll, - onClick = { - onSelectAll() - open = false - }, - ) - SheetAction( - title = stringResource(R.string.scope_clear_visible), - icon = Icons.Rounded.RemoveDone, - onClick = { - onSelectNone() - open = false - }, - ) - // The one entry in this sheet that changes the *future* of the scope rather than its - // present, so its label says exactly that and not something narrower. - ToggleRow( - title = stringResource(R.string.scope_include_new_apps), - subtitle = stringResource(R.string.scope_include_new_apps_summary), - icon = Icons.AutoMirrored.Rounded.PlaylistAdd, - checked = includeNewApps, - onCheckedChange = onIncludeNewApps, - ) - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - // This module's scope alone, separate from the whole-list backup on the module - // screen — useful when moving one module's configuration between devices. - SheetAction( - title = stringResource(R.string.scope_backup), - icon = Icons.Rounded.SaveAlt, - onClick = { - onBackup() - open = false - }, - ) - SheetAction( - title = stringResource(R.string.scope_restore), - icon = Icons.Rounded.SettingsBackupRestore, - onClick = { - onRestore() - open = false - }, + // The menu must live in the same Box as the trigger so a DropdownMenuPopup anchors beside it. + Box { + IconButton(onClick = { open = true }) { + Icon( + Icons.Rounded.Checklist, + contentDescription = stringResource(R.string.scope_select), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + DropdownMenuPopup(expanded = open, onDismissRequest = { open = false }) { + LocalizedOverlay { + val capsule = RoundedCornerShape(16.dp) + DropdownMenuGroup(shapes = MenuDefaults.groupShapes(shape = capsule, inactiveShape = capsule)) { + if (hasRecommended) { + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_use_recommended)) }, + shapes = MenuDefaults.itemShapes(), + selected = false, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Auto_awesome, contentDescription = null) + }, + onClick = { + onUseRecommended() + open = false + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_select_visible)) }, + shapes = MenuDefaults.itemShapes(), + selected = false, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Done_all, contentDescription = null) + }, + onClick = { + onSelectAll() + open = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_clear_visible)) }, + shapes = MenuDefaults.itemShapes(), + selected = false, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Remove_done, contentDescription = null) + }, + onClick = { + onSelectNone() + open = false + }, + ) + // The one entry that changes the *future* of the scope rather than its present, + // so it is a toggle drawn as a checkmark rather than an action row. + DropdownMenuItem( + selected = includeNewApps, + text = { Text(stringResource(R.string.scope_include_new_apps)) }, + shapes = MenuDefaults.itemShapes(), + leadingIcon = { + Icon(MaterialSymbols.Outlined.Playlist_add, contentDescription = null) + }, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onIncludeNewApps(!includeNewApps) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_backup)) }, + shapes = MenuDefaults.itemShapes(), + selected = false, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Download, contentDescription = null) + }, + onClick = { + onBackup() + open = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scope_restore)) }, + shapes = MenuDefaults.itemShapes(), + selected = false, + leadingIcon = { + Icon(MaterialSymbols.Outlined.Settings_backup_restore, contentDescription = null) + }, + onClick = { + onRestore() + open = false + }, + ) + } + } + } } } @@ -693,6 +797,7 @@ private fun ScopeSelectMenu( * Chips rather than rows: these are short, all of one kind, and several are on at once — which a * column of ticks states less clearly than a row of filled chips. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ScopeFilterMenu( showSystem: Boolean, @@ -723,69 +828,104 @@ private fun ScopeFilterMenu( // 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 - }, - ) + Box { + IconButton(onClick = { if (locked) onLockedClick() else open = true }) { + BadgedBox(badge = { if (filtering) Badge(modifier = Modifier.size(6.dp)) }) { + Icon( + MaterialSymbols.Outlined.Filter_list, + contentDescription = stringResource(R.string.modules_filter), + tint = + when { + locked -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + filtering -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } - } - if (open) { - ScopeSheet( - stringResource(R.string.modules_filter), - Icons.Rounded.FilterList, - { open = false }, - ) { - if (hasRecommended) { - // The static-scope view, on request. Offered only when the module has actually - // asked for something — otherwise it would narrow the list to nothing. - ChoiceRow { - FilterChip( - selected = recommendedOnly, - onClick = { onToggleRecommendedOnly() }, - label = { Text(stringResource(R.string.scope_recommended_only)) }, + DropdownMenuPopup(expanded = open, onDismissRequest = { open = false }) { + LocalizedOverlay { + val capsule = RoundedCornerShape(16.dp) + DropdownMenuGroup(shapes = MenuDefaults.groupShapes(shape = capsule, inactiveShape = capsule)) { + if (hasRecommended) { + // The static-scope view, on request. Offered only when the module has + // actually asked for something — otherwise it would narrow the list to + // nothing. + DropdownMenuItem( + selected = recommendedOnly, + text = { Text(stringResource(R.string.scope_recommended_only)) }, + shapes = MenuDefaults.itemShapes(), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onToggleRecommendedOnly() }, + ) + } + // Off while the module's own request is what the list is answering. That + // question has one answer — what it asked for, and what it has been given — + // and these three can only subtract from it. + DropdownMenuItem( + selected = showSystem, + enabled = !recommendedOnly, + text = { Text(stringResource(R.string.scope_system_apps)) }, + shapes = MenuDefaults.itemShapes(), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onToggleSystem() }, + ) + DropdownMenuItem( + selected = showGames, + enabled = !recommendedOnly, + text = { Text(stringResource(R.string.scope_games)) }, + shapes = MenuDefaults.itemShapes(), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onToggleGames() }, + ) + DropdownMenuItem( + selected = showModules, + enabled = !recommendedOnly, + text = { Text(stringResource(R.string.scope_modules)) }, + shapes = MenuDefaults.itemShapes(), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onToggleModules() }, ) } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - } - // Off while the module's own request is what the list is answering. That question has - // one answer — what it asked for, and what it has been given — and these three can - // only subtract from it: Chrome is a system app, so a module asking for Chrome would - // show an empty list to anyone who had not also turned system apps on. Greyed rather - // than hidden, so the reader can see the settings are still there and why they are not - // in play. - ChoiceRow { - FilterChip( - selected = showSystem, - enabled = !recommendedOnly, - onClick = { onToggleSystem() }, - label = { Text(stringResource(R.string.scope_system_apps)) }, - ) - FilterChip( - selected = showGames, - enabled = !recommendedOnly, - onClick = { onToggleGames() }, - label = { Text(stringResource(R.string.scope_games)) }, - ) - FilterChip( - selected = showModules, - enabled = !recommendedOnly, - onClick = { onToggleModules() }, - label = { Text(stringResource(R.string.scope_modules)) }, - ) } } } } /** What order it is in: every [ScopeSort], and a reverse toggle over whichever is chosen. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ScopeSortMenu( sort: ScopeSort, @@ -794,62 +934,59 @@ private fun ScopeSortMenu( onReverse: () -> Unit, ) { var open by remember { mutableStateOf(false) } - IconButton(onClick = { open = true }) { - Icon( - Icons.AutoMirrored.Rounded.Sort, - contentDescription = stringResource(R.string.scope_sort), - tint = - if (sort != ScopeSort.Relevance || reversed) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (open) { - ScopeSheet( - stringResource(R.string.scope_sort), - Icons.AutoMirrored.Rounded.Sort, - { open = false }, - ) { - ChoiceRow { - ScopeSort.entries.forEach { option -> - FilterChip( - selected = option == sort, - onClick = { onSort(option) }, - label = { Text(stringResource(option.labelRes())) }, - ) - } - } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - ToggleRow( - title = stringResource(R.string.scope_sort_reverse), - icon = Icons.Rounded.SwapVert, - checked = reversed, - onCheckedChange = { onReverse() }, + Box { + IconButton(onClick = { open = true }) { + Icon( + MaterialSymbols.Outlined.Swap_vert, + contentDescription = stringResource(R.string.scope_sort), + tint = + if (sort != ScopeSort.Relevance || reversed) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, ) } - } -} - -/** The shell all three of this screen's sheets share, so they cannot drift apart. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ScopeSheet( - title: String, - icon: ImageVector, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - LocalizedOverlay { - Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { - SheetHeading(title, icon) - content() + DropdownMenuPopup(expanded = open, onDismissRequest = { open = false }) { + LocalizedOverlay { + val capsule = RoundedCornerShape(16.dp) + val total = ScopeSort.entries.size + 1 + DropdownMenuGroup(shapes = MenuDefaults.groupShapes(shape = capsule, inactiveShape = capsule)) { + ScopeSort.entries.forEachIndexed { index, option -> + DropdownMenuItem( + selected = option == sort, + text = { Text(stringResource(option.labelRes())) }, + shapes = MenuDefaults.itemShape(index, total), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onSort(option) }, + ) + } + DropdownMenuItem( + selected = reversed, + text = { Text(stringResource(R.string.scope_sort_reverse)) }, + shapes = MenuDefaults.itemShape(ScopeSort.entries.size, total), + leadingIcon = null, + selectedLeadingIcon = { + Icon( + MaterialSymbols.Outlined.Check, + contentDescription = null, + tint = LocalContentColor.current, + ) + }, + contentPadding = PaddingValues(start = 4.dp, end = 10.dp), + onClick = { onReverse() }, + ) + } } } } } - /** * Why the list is empty, which the list itself can never say. * @@ -947,8 +1084,8 @@ private fun AppRow( * A sentence under the package name, for a row whose behaviour a label cannot carry. * * One slot rather than one flag per case: the two rows that have something to explain — the - * framework, and a legacy module's own app — are never the same row, and a boolean apiece - * would grow with every one that follows. + * framework, and a legacy module's own app — are never the same row, and a boolean apiece would + * grow with every one that follows. */ note: Int?, onToggle: (Boolean) -> Unit, @@ -958,70 +1095,77 @@ private fun AppRow( val haptics = LocalHapticFeedback.current val ring = origin.color() - ListItem( - modifier = - Modifier.contextClickable( - onClick = { if (enabled) onToggle(!app.isSelectedInScope) }, - onLongClick = { - // The long press is where re-optimize lives, and re-optimize is the fix - // for a hook that silently never fires because ART inlined its target. - haptics.performHapticFeedback(HapticFeedbackType.ContextClick) - menuOpen = true - }, - ) - .semantics { role = Role.Checkbox }, - leadingContent = { - // The ring is drawn outside the icon rather than tinting it: an app icon is the user's - // own landmark for finding a row and recolouring it would destroy that. - AppIcon( - applicationInfo = app.applicationInfo, - contentDescription = null, - size = 36.dp, - modifier = - Modifier.border(width = 2.dp, color = ring, shape = CircleShape).padding(4.dp), - ) - }, - supportingContent = { - Column { - Text( - ScopeViewModel.displayPackageName(app.packageName), - style = Mono, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (origin != ScopeOrigin.Chosen) { - Text( - text = stringResource(origin.labelRes()), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = ring, + // The DropdownMenu must live in the same Box as the row it belongs to: a `DropdownMenu` is a + // Popup whose position provider anchors to the layout node it is declared in, so composing it + // as a sibling of the ListItem is what keeps the menu beside (and tied to) this row. + Box { + ListItem( + modifier = + Modifier.contextClickable( + 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 + }, ) - } - // Why this row does not behave like the rest: the framework is one process shared - // by every user, and a legacy module's own app is in the scope without anyone - // having put it there. Both are things a checkbox cannot say. - if (note != null) { + .semantics { role = Role.Checkbox }, + leadingContent = { + // The ring is drawn outside the icon rather than tinting it: an app icon is the + // user's + // own landmark for finding a row and recolouring it would destroy that. + AppIcon( + applicationInfo = app.applicationInfo, + contentDescription = null, + size = 36.dp, + modifier = + Modifier.border(width = 2.dp, color = ring, shape = CircleShape) + .padding(4.dp), + ) + }, + supportingContent = { + Column { Text( - text = stringResource(note), - style = MaterialTheme.typography.labelSmall, + ScopeViewModel.displayPackageName(app.packageName), + style = Mono, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (origin != ScopeOrigin.Chosen) { + Text( + text = stringResource(origin.labelRes()), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = ring, + ) + } + // Why this row does not behave like the rest: the framework is one process + // shared + // by every user, and a legacy module's own app is in the scope without anyone + // having put it there. Both are things a checkbox cannot say. + if (note != null) { + Text( + text = stringResource(note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - trailingContent = { Checkbox(checked = app.isSelectedInScope, onCheckedChange = null) }, - colors = - ListItemDefaults.colors( - containerColor = Color.Transparent - ), - ) { Text(app.appName) } - if (menuOpen) { - PackageActionSheet( + }, + trailingContent = { Checkbox(checked = app.isSelectedInScope, onCheckedChange = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) { + Text(app.appName) + } + + PackageActionMenuItems( + expanded = menuOpen, + onDismiss = { menuOpen = false }, packageName = app.packageName, userId = app.userId, appName = app.appName, applicationInfo = app.applicationInfo, isModule = false, - onDismiss = { menuOpen = false }, onResult = onAction, ) } 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 index 441276bd2..71fdbddbf 100644 --- 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 @@ -1,4 +1,5 @@ package org.matrix.vector.manager.ui.screens.modules + import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException @@ -14,15 +15,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.vector.ipc.ScopeEntry import org.matrix.vector.manager.data.model.AppInfo -import org.matrix.vector.ui.module.ModuleDetection -import org.matrix.vector.ui.module.RecommendedScope import org.matrix.vector.manager.data.repository.AppRepository import org.matrix.vector.manager.data.repository.ModuleRepository -import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.data.repository.SettingsRepository +import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient import org.matrix.vector.manager.logE import org.matrix.vector.manager.logW +import org.matrix.vector.ui.module.ModuleDetection +import org.matrix.vector.ui.module.RecommendedScope /** * A package/user pair, as a value type so set arithmetic is correct. @@ -60,8 +61,8 @@ data class ScopeUiState( * Whether this device has more than one user at all. * * The framework row explains that it is shared across users, which is only ever news on a - * device that has more than one. On a single-user phone — most of them — it is a sentence - * about a distinction that does not exist, so it is not shown. + * device that has more than one. On a single-user phone — most of them — it is a sentence about + * a distinction that does not exist, so it is not shown. */ val multipleUsers: Boolean = false, /** @@ -69,9 +70,9 @@ data class ScopeUiState( * * A legacy module reports being active by hooking a method in its own app, so it has to be in * its own scope before it can say anything at all, and the daemon derives that one target - * rather than storing it. Nothing comes back from `getModuleScope` to say so — so without - * this, the one row the module certainly hooks is the one row shown unticked, and with the - * module filter at its default it is not shown at all. + * rather than storing it. Nothing comes back from `getModuleScope` to say so — so without this, + * the one row the module certainly hooks is the one row shown unticked, and with the module + * filter at its default it is not shown at all. */ val selfHooked: Boolean = false, ) @@ -83,9 +84,9 @@ class ScopeViewModel( * * Not a second scope. A module is one package and one APK for the whole device and has one * scope set; what varies per user is which apps exist to point at, and the daemon will not - * expand a row for a user that does not hold the module. So this selects the half of the - * device being edited — [apply] merges into the whole stored set rather than replacing it, - * which is what leaves another user's rows alone. + * expand a row for a user that does not hold the module. So this selects the half of the device + * being edited — [apply] merges into the whole stored set rather than replacing it, which is + * what leaves another user's rows alone. */ private val userId: Int, private val daemonClient: DaemonClient, @@ -110,9 +111,9 @@ class ScopeViewModel( * What the user has built up but not yet applied. * * Writing a scope is not incremental — the daemon deletes every scope row of the module and - * writes the new set in one transaction, then asks for a configuration rebuild. Sending that - * on every checkbox tap means ten rewrites and ten rebuilds to tick ten apps, so edits - * accumulate here and go out as one write. + * writes the new set in one transaction, then asks for a configuration rebuild. Sending that on + * every checkbox tap means ten rewrites and ten rebuilds to tick ten apps, so edits accumulate + * here and go out as one write. */ private val draftScope = MutableStateFlow>(emptySet()) @@ -125,10 +126,10 @@ class ScopeViewModel( * out of the list, so only unticking has anything to record here. * * Only the list's own filters read it, and only to keep a row present. Unticking is an edit - * like any other and the row has to survive it — an app that vanishes the moment it is - * unticked cannot be re-ticked, so a slip becomes permanent for as long as the reader does not - * think to go and turn a filter on. It never shrinks: a row put in play stays in play until - * the screen is left, which is the point. + * like any other and the row has to survive it — an app that vanishes the moment it is unticked + * cannot be re-ticked, so a slip becomes permanent for as long as the reader does not think to + * go and turn a filter on. It never shrinks: a row put in play stays in play until the screen + * is left, which is the point. * * Which is also why nothing may go in that was not really changed. A row the reader merely * *saw* would be exempted from the system, game and module filters for the rest of the visit, @@ -170,9 +171,9 @@ class ScopeViewModel( /** * 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. + * They are installed apps like any other and a module *can* legitimately hook one, but that is + * rare enough that the default is off: on a device with two dozen modules, listing them all + * among the hookable apps is two dozen rows of noise for one plausible use. */ val showModules = MutableStateFlow(settings.scopeShowModules.value) @@ -187,8 +188,8 @@ class ScopeViewModel( * Whether this module has a screen to open at all. * * Null until asked, so the control does not flicker into existence on arrival. Most modules - * have no companion and no launcher entry, and offering to open one is offering nothing — - * which is why this is worth a lookup rather than a snackbar after the fact. + * have no companion and no launcher entry, and offering to open one is offering nothing — which + * is why this is worth a lookup rather than a snackbar after the fact. * * Declared above [init] rather than beside the function that fills it, and it has to stay * there. `viewModelScope` dispatches on `Main.immediate`, so [findCompanion] starts inline on @@ -203,9 +204,7 @@ class ScopeViewModel( // Written back as they change rather than on the way out: this screen is left by a back // gesture, by the process being killed, and by the host application deciding it is done — // and only the first of those runs any teardown of ours. - viewModelScope.launch { - showSystemApps.collect { settings.setScopeShowSystemApps(it) } - } + viewModelScope.launch { showSystemApps.collect { settings.setScopeShowSystemApps(it) } } viewModelScope.launch { showGames.collect { settings.setScopeShowGames(it) } } viewModelScope.launch { showModules.collect { settings.setScopeShowModules(it) } } viewModelScope.launch { sort.collect { settings.setScopeSort(it.name.lowercase()) } } @@ -227,7 +226,9 @@ class ScopeViewModel( 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. */ + /** + * 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( @@ -237,6 +238,22 @@ class ScopeViewModel( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PendingChanges()) + /** + * Whether leaving would leave the module enabled with nothing to hook — the state that makes + * the back gesture ask a question before leaving instead of fading out to the previous page. + * + * This is [wouldStrandModule] as an observable flow so the screen can decide *before the + * gesture starts* whether to let Navigation 3 play the predictive-back animation (which needs + * the gesture, and therefore reveals the previous page behind this one) or to intercept it and + * show the confirm dialog instead. Recomputed live: ticking a target in the list clears the + * warning even though the module was stranded the moment it opened. + */ + val wouldStrand: StateFlow = + combine(_uiState, draftScope, savedScope) { ui, draft, saved -> + ui.isEnabled && draft.isEmpty() && saved.isEmpty() + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), wouldStrandModule()) + val filteredApps: StateFlow> = combine(allApps, draftScope, searchQuery, showSystemApps, showGames) { apps, @@ -264,6 +281,15 @@ class ScopeViewModel( View(showMods, modules, order, reverse, state) } ) { filters, view -> + // While the load is running the list must not be published: the app list arrives + // before the saved scope and the module's recommended scope, so publishing the + // unfiltered list for even one frame makes a fixed-scope module flash every app + // before settling on the few it may hook. `loading` is in the same _uiState that + // carries the recommended scope, so gating on it holds the list empty until the + // whole load lands and the first list shown is the correct one. (The UI shows a + // spinner for this same flag, so the empty list here is never drawn as an empty + // state.) + if (view.state.loading) return@combine emptyList() val showMods = view.showModules val modules = view.modulePackages val order = view.sort @@ -397,7 +423,8 @@ class ScopeViewModel( } } // 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 + // 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()) @@ -503,11 +530,11 @@ class ScopeViewModel( val info = withContext(Dispatchers.IO) { runCatching { - packageManager.getApplicationInfo( - modulePackageName, - android.content.pm.PackageManager.GET_META_DATA, - ) - } + packageManager.getApplicationInfo( + modulePackageName, + android.content.pm.PackageManager.GET_META_DATA, + ) + } .onFailure { e -> logW( "scope: package info for $modulePackageName (user $userId) " + @@ -520,25 +547,24 @@ class ScopeViewModel( // One inspection, two answers: what the module asks to hook, and which generation // of module it is. Both come out of the same pass over the APK, and opening it is // the expensive part. - val manifest = - info?.let { - withContext(Dispatchers.IO) { - // Guarded like the package info above it, and for the same reason: this - // opens the module's APK, so a package removed or replaced between that - // lookup and this one throws here. Recovering as "no recommended scope" - // costs the reader a few pre-ticked rows; letting it out of a - // `viewModelScope` coroutine takes the whole manager down. - runCatching { ModuleDetection.inspect(it, packageManager) } - .onFailure { e -> - logW( - "scope: reading the manifest of $modulePackageName " + - "failed, no recommended scope", - e, - ) - } - .getOrNull() - } + val manifest = info?.let { + withContext(Dispatchers.IO) { + // Guarded like the package info above it, and for the same reason: this + // opens the module's APK, so a package removed or replaced between that + // lookup and this one throws here. Recovering as "no recommended scope" + // costs the reader a few pre-ticked rows; letting it out of a + // `viewModelScope` coroutine takes the whole manager down. + runCatching { ModuleDetection.inspect(it, packageManager) } + .onFailure { e -> + logW( + "scope: reading the manifest of $modulePackageName " + + "failed, no recommended scope", + e, + ) + } + .getOrNull() } + } val recommended = manifest?.let { RecommendedScope(it.scope, it.staticScope) } ?: RecommendedScope.NONE @@ -602,11 +628,11 @@ class ScopeViewModel( * Re-reads the stored scope and folds whatever arrived from elsewhere into the draft. * * Called every time the screen comes back to the front, because this editor is not the only - * writer of that table and the other writer is one tap away: the button in the corner opens - * the module, a libxposed module asks for a target while it runs, and the user approves it - * from the notification shade. Nothing here would ever notice — the load runs once, from - * `init` — so the list would go on drawing an empty box beside a target the module is already - * being loaded into, and the apply bar would count a removal nobody asked for. + * writer of that table and the other writer is one tap away: the button in the corner opens the + * module, a libxposed module asks for a target while it runs, and the user approves it from the + * notification shade. Nothing here would ever notice — the load runs once, from `init` — so the + * list would go on drawing an empty box beside a target the module is already being loaded + * into, and the apply bar would count a removal nobody asked for. * * Additive on purpose. What the user has ticked here is theirs and survives untouched; a row * that appeared outside joins both the baseline and the draft, so it reads as in force rather @@ -660,8 +686,7 @@ class ScopeViewModel( val target = ScopeTarget(app.packageName, app.userId) // Before the draft changes, so the row cannot be filtered out by the very edit being made. touched.value = touched.value + target - draftScope.value = - if (selected) draftScope.value + target else draftScope.value - target + draftScope.value = if (selected) draftScope.value + target else draftScope.value - target } // Both skip the derived row for the reason [toggle] gives: it is shown among the visible rows @@ -739,7 +764,7 @@ class ScopeViewModel( } else { logE( "scope: daemon refused include-new-apps=$enabled for " + - modulePackageName, + modulePackageName ) _message.value = ScopeMessage.IncludeNewAppsFailed } @@ -854,8 +879,8 @@ class ScopeViewModel( * reduces the expression below to exactly the draft, which is the behaviour this replaces, and * refusing to apply at all would leave an edit that can never be committed. * - * One write means one configuration rebuild. The new scope reaches an app when its process - * next starts; nothing running is restarted here. + * One write means one configuration rebuild. The new scope reaches an app when its process next + * starts; nothing running is restarted here. * * The daemon enables the module as a side effect of storing a scope. */ @@ -876,13 +901,12 @@ class ScopeViewModel( } val before = current ?: baseline val merged = before + (draft - baseline) - (baseline - draft) - val aidl = - merged.map { target -> - ScopeEntry().apply { - packageName = target.packageName - userId = target.userId - } + val aidl = merged.map { target -> + ScopeEntry().apply { + packageName = target.packageName + userId = target.userId } + } daemonClient .setModuleScope(modulePackageName, aidl) .onSuccess { stored -> @@ -948,25 +972,23 @@ class ScopeViewModel( /** * 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. + * Not gzipped like the whole-list backup: a single scope is small, and a readable file is worth + * more here — it is the kind of thing someone hand-edits or pastes into an issue. */ fun backupScopeTo(uri: android.net.Uri, onDone: (Boolean) -> Unit) { viewModelScope.launch { val ok = withContext(Dispatchers.IO) { runCatching { - val payload = - draftScope.value.joinToString(",\n ") { - """{"packageName":"${it.packageName}","userId":${it.userId}}""" - } - ServiceLocator.context.contentResolver.openOutputStream(uri)?.use { - it.write("[\n $payload\n]".toByteArray()) - } ?: error("could not open the file") - } - .onFailure { e -> - logE("scope: backup of $modulePackageName failed", e) - } + val payload = + draftScope.value.joinToString(",\n ") { + """{"packageName":"${it.packageName}","userId":${it.userId}}""" + } + ServiceLocator.context.contentResolver.openOutputStream(uri)?.use { + it.write("[\n $payload\n]".toByteArray()) + } ?: error("could not open the file") + } + .onFailure { e -> logE("scope: backup of $modulePackageName failed", e) } .isSuccess } onDone(ok) @@ -978,15 +1000,15 @@ class ScopeViewModel( 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() - } + val text = + ServiceLocator.context.contentResolver.openInputStream(uri)?.use { + it.readBytes().decodeToString() + } ?: error("could not open the file") + Regex("\"packageName\"\\s*:\\s*\"([^\"]+)\"[^}]*?\"userId\"\\s*:\\s*(\\d+)") + .findAll(text) + .map { ScopeTarget(it.groupValues[1], it.groupValues[2].toInt()) } + .toSet() + } .onFailure { e -> if (e is CancellationException) throw e logE("scope: restore for $modulePackageName failed", e) @@ -1022,8 +1044,8 @@ class ScopeViewModel( /** * What to *show* for it, which is not what it is stored as. * - * The scope table has said `system` since long before this manager, and the daemon, the - * CLI and every backup file on every device say it too — so the stored name stays. But the + * The scope table has said `system` since long before this manager, and the daemon, the CLI + * and every backup file on every device say it too — so the stored name stays. But the * process it actually means is `system_server`, and a reader looking at a package name * expects the name of the thing. The rename lives here, at the point of display, and * nothing written back to the daemon ever passes through it. @@ -1034,6 +1056,7 @@ class ScopeViewModel( fun displayPackageName(packageName: String): String = if (packageName == SYSTEM_FRAMEWORK_PACKAGE) SYSTEM_FRAMEWORK_DISPLAY_NAME else packageName + const val FRAMEWORK_LABEL = "System Framework" } } diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 5068e3cf5..3cbf940b2 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -84,14 +84,14 @@ 跟随系统 浅色 深色 - 取色自壁纸 - 深色模式使用纯黑 + 壁纸取色 + 深色模式使用纯黑背景 在 OLED 屏幕上更省电。 - 还差两次… - 还差一次… + 再点两次以触发彩蛋… + 再点一次以触发彩蛋… 尚未安装任何模块。 - %2$d 个模块中有 %1$d 个已启用 + %2$d 个模块中有 %1$d 个已加载 没有符合该搜索或筛选条件的模块。 备份模块 恢复模块 @@ -102,13 +102,13 @@ 搜索模块 清除搜索 筛选 - 运行中 - 未运行 + 已加载 + 未加载 +%1$d - 运行中优先 + 已加载 按名称 最近更新 - 作用域最广 + 作用域 全部 已启用 未启用 @@ -124,7 +124,7 @@ 迷宫 电路 代码雨 - 静止 + 导航 悬浮导航 diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index ec54516e1..8e5b14cca 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -179,7 +179,7 @@ %1$d of %2$d active No module matches that search or filter. Xposed - API + LSPosed Back up modules Restore modules Backed up %1$d modules and their scopes.