Upstream - #18
Open
superturtlee wants to merge 64 commits into
Open
Conversation
Gradle 9.6 removed the internal API that AGP 8.13 relied on, forcing the AGP 9 upgrade and the surrounding build-script changes. Toolchain: - Gradle wrapper 9.3.1 -> 9.6.1; AGP 8.13.1 -> 9.3.1; apksig follows AGP. - Adopt AGP 9 built-in Kotlin: remove org.jetbrains.kotlin.android from all Android modules; Kotlin stdlib 2.3.10 -> 2.4.10. - compileSdk/targetSdk 36 -> 37, build-tools 37.0.0. Build-script migration: - Root build configures the shared CommonExtension through its getters, since AGP 9 dropped the action-DSL methods on that type. - daemon generates SignInfo through androidComponents.onVariants and a typed task; android.applicationVariants was removed. Resource generators (replace the rikka autoResConfig / materialthemebuilder plugins, whose entry points use removed AGP variant APIs): - buildSrc/GenerateLangListTask scans the translated locales. - buildSrc/GenerateMaterialThemeTask computes the accent-color theme overlays, reusing the materialthemebuilder color library without applying its plugin. Drop android.nonFinalResIds=false: AGP 9 enables optimized resource shrinking by default, and that shrinker requires non-final resource IDs. With isShrinkResources=true the manager's release build now fails :app:minifyReleaseWithR8 with "Optimized resource shrinking requires non-final IDs". AGP offers two remedies: make the IDs non-final, or opt out of optimized shrinking (r8.optimizedResourceShrinking= false). We take the former, because the false setting turns out to be dead weight: - It was added in 348f049 (Aug 2023), an unrelated "show packagename" feature commit, as a one-line drop-in beside the now-removed experimental flags enableAppCompileTimeRClass / enableNewResourceShrinker.preciseShrinking. Those siblings were cleaned up later; this line was simply missed. - Final IDs are only actually required to use R.* as Java switch/case labels. There are zero `case R.*` occurrences in the tree at 348f049 and at every commit since, so the flag never protected anything here. - Non-final IDs are the modern AGP default, so removing the line (rather than writing =true) expresses the intent with no config at all. It also builds smaller: the optimized shrinker trims the release APK from ~3.45 MB to ~3.13 MB (~9%). Verified end to end -- assembleRelease plus a zygisk installKsuAndReboot run that loads the module and starts lspd on device. Formatting task: - Add buildSrc/src/main/kotlin to the format task so the generator sources are formatted with the rest of the Kotlin build logic. - Exclude daemon/**, which is intentionally kept on ktfmt's default (Meta) style; formatting it here fought :daemon:ktfmtFormat and flipped the style back and forth. Dependencies: - AGP/apksig 9.3.1, Kotlin 2.4.10, androidx.core 1.19.0 (dependabot maven group). - coroutines 1.11.0, okhttp 5.4.0, gson 2.14.0, nav 2.9.8, glide 5.0.9, androidx activity/browser/annotation, ktfmt 0.26.0. - Material kept at 1.12.0; 1.13+ removes the colorPrimary/colorError attrs the manager references. - Submodules fmt and commons-lang bumped; CI action versions bumped (actions/checkout 6 -> 7, actions/cache 5 -> 6).
Android 17 (API 37) reshaped the IServiceConnection callback and dropped the old overload instead of keeping both: void connected(in ComponentName name, IBinder service, in @nullable IBinderSession session, boolean dead); ManagerGuard overrode only the three-argument form, so as soon as system_server dispatched the new transaction the Stub landed on an abstract method and the daemon died with AbstractMethodError, taking the manager session down with it. Only the Xiaomi XSpace workaround binds this connection, which is why the crash was reported on HyperOS first; the interface change itself ships in stock Android 17 and is not vendor specific. Declare both overloads in the IServiceConnection stub and override both in ManagerGuard, so every supported release finds the method system_server dispatches. IBinderSession is stubbed as an empty interface, since the type is only referenced by the descriptor of the new overload.
Brings the framework in line with the API version master vendors, checked throughout with test modules that assert the documented behaviour and log pass/fail, on a Pixel 6 running Android 17. hookClassInitializer never worked: it aborted the process, and once that was fixed the hook still could not fire, because resolving <clinit> through JNI runs the initialiser during the lookup. It finds the method from ART's layout now — the gap the reflected members leave behind — and the hooker runs ahead of the class's own initialiser. Also fixed: the interceptor chain could resurrect an exception it had already suppressed; ExceptionMode.DEFAULT ignored module.prop, so passthrough was unreachable; getInvoker() threw NPE on an unhooked method; Constructor .newInstance was hookable; an unhooked constructor dispatched Method.invoke's id against itself; getArgs() was mutable; a late-injected system server dispatched onSystemServerStarting into an empty module set; android:ui was reported to modules as the system package; edit().clear() did nothing, and preference updates reached every Android user's hooked processes; empty scope requests never called back; openRemoteFile threw RemoteException where FileNotFoundException is documented; getScope() repeated a package once per user; module.prop was not parsed as Properties. In the manager, one malformed module.prop could blank the entire module list. The list now shows which API each module targets, and staticScope is enforced rather than parsed and ignored — in the picker, in the daemon, and by dropping stale rows at startup. Two changes modules will notice: - Invoker.invoke reports the target's exception wrapped in InvocationTargetException on every path, as Method#invoke does. A module catching the raw exception will stop catching it. - A module declaring staticScope loses scope entries outside its scope.list. The <clinit> lookup is measured on one device, one Android version and one architecture. Its assumptions are re-checked at runtime and it declines rather than guesses, so an unfamiliar layout degrades to "no static initializer" rather than misbehaving.
Since #648 the `ServiceManager.addService` call that claims the `serial` name sat inside the `SDK_INT >= R` branch that exists only for `registerForNotifications`, so on pre-R nothing claimed it and the Zygisk module aborted the injection. Keep only `registerForNotifications` behind the check. `startActivityAsUserWithFeature` is also R-only, and both call sites used it unconditionally; the `NoSuchMethodError` escaped `onTransact` and killed the daemon. Route them through `startActivityAsUserCompat`, like `registerReceiverCompat`. Closes#773.
An Actions artifact cannot be downloaded without a GitHub account: measured against this repository, `GET /actions/artifacts/<id>/zip` answers 401 to an anonymous caller while a release asset answers 206. Testing a canary is the lowest-friction way for an ordinary user to help, and asking each of them to grant an OAuth app something first — to work around where the zips happen to live — is a real cost for a project whose users are careful about what they install. It also excludes the users who cannot reach GitHub's login page at all, who are exactly the ones a canary programme loses first. Each push to master, and each manual run, now attaches the same two zips to a `canary-<versionCode>` prerelease. Prerelease, so `releases/latest` — which is what update checks read — keeps pointing at the last stable tag. Artifacts stay: they also carry mappings and symbols, which are for us rather than for testers. Five are kept. They are pruned by version code rather than by date, because the version code is the commit count and therefore monotonic, while dates can be disordered by a rerun or a revert. The release is deleted and recreated rather than edited, so re-running the workflow for a commit replaces that build instead of appending a second copy of every asset to it. The job gains `contents: write`, which it did not have; everything else in it only reads.
The parasitic manager lives inside com.android.shell, which has no INTERNET permission before Android 12, so preAppSpecialize appends the INET group to its gid array. That satisfies setgroups() and nothing else: once nativeForkAndSpecialize returns, Zygote#forkAndSpecialize runs setAllowNetworkingForProcess(containsInetGid(gids)) against the array it passed in, not the one we substituted, and turns networking off in libnetd_client. socket() and dns_open_proxy() then return EPERM whatever groups we belong to, which libcore reports as "Permission denied (missing INTERNET permission?)" — an unchecked SecurityException that killed the OkHttp dispatcher and the manager with it, and left the Repository tab empty. So overwrite the first entry of the caller's array too; it has no further use once specialization is done. LSPosed did this from the commit that introduced the parasitic manager, and the line was dropped in the rewrite for the new Zygisk architecture. Android 12 and later were never affected: com.android.shell declares INTERNET there, so the array already carries the group. Fixes#636
`:app` is deleted, not deprecated — 218 files, and no longer in settings.gradle.kts. `:manager` takes its place: 86 Kotlin files, Material 3, one activity, because parasitically every activity has to be tracked by hand by the zygisk hooker. Launching it has not changed: still injected into com.android.shell, still reached through Constants.setBinder. Most of the branch is screens the old manager did not have. The framework updates from inside the app, canaries included. Home shows the project's commit history rather than a status light. The log screen indexes byte offsets and pages a window instead of loading four megabytes of strings into a process whose heap belongs to com.android.shell, and reaches the daemon's rotated parts. The Store runs off the mirrors that still answer. The scope editor collects edits into a draft and writes them once, where the old one rewrote a module's whole scope on every checkbox tap. System status states what is running and copies itself in English whatever the phone's language. Daemon-side, less: ModuleDatabase owns every configuration read and ConfigCache holds no SQL, so "enable this module" and "what is enabled" agree rather than race a rebuild. The AIDL gained the transactions the new screens need, and ROOT_UNKNOWN takes 0 — a binder proxy answers an unimplemented transaction with a default, so ROOT_NONE at 0 meant an old daemon told a rooted user to install the root manager they were already running. The two ship in one zip. Eighteen languages, crowdin.yml repointed from app/ to manager/, right-to-left included. No automated tests, because the repository has none, and CI runs `zipAll`. It was verified on a device, screen by screen, checking each claim against what the daemon had actually stored. That found real bugs late — a comment asserting the daemon force-stops apps on a scope write, which it has never done; a filter that hid the rows a reader had chosen. Expect a few more.
OkHttp 5's Android artifact keeps the public suffix list in assets/, reached through a process-static Context set from androidx.startup. Parasitically the manager's manifest is never installed, so that provider never runs and DnsOverHttps -- which asks the list whether a host is private before opening any socket -- died with "Unable to load PublicSuffixDatabase.list" on the first lookup. Coil was already hand-initialised for this reason; OkHttp was not. Two things made that fatal rather than degrading: VectorDns caught only UnknownHostException, so an IllegalStateException escaped it and the fallback never engaged; and canaryBuilds and frameworkReleases called get() unguarded, with no CoroutineExceptionHandler anywhere in the manager, so the throw landed on the main thread. Coil's own initialisation then moves out of MainActivity to ServiceLocator.attach, for the same reason OkHttp's did: the debug demo host never opens MainActivity, so it had no image loader at all. Separately, for diagnosis: off, bypassed by a proxy, and latched onto the system resolver all looked identical from the sheet, so VectorDns now records what each lookup did and the sheet renders it, with a way to clear the latch. Two fixes fell out: - `direct` was a lazy val, so the proxy check ran once per process and joining a VPN mid-session was invisible. Read per lookup now. - The failure line named the host being resolved, which UnknownHostException already carries as its whole message -- so it printed twice while which way it failed went missing. Finally, a crash record is discarded when another build wrote it. A record outlives the build that made it, so the status card kept showing a crash the running build had already fixed -- which is what #799 was answered with: five traces from the build before the one that fixed them. Fixes#799
Every row on PackageActionSheet dismissed the sheet and then launched its work on rememberCoroutineScope(), so the composition left on the next frame and took the scope with it — the coroutine died at the first withContext hop inside DaemonClient, usually before the binder transaction was made. Nothing logged, onResult never ran, and whether the work beat the frame was a race, which is what made it look flaky. The actions now run on ServiceLocator.appScope with Dispatchers.Main. App info, force stop, re-optimize, uninstall and soft reboot were all affected; the Scope screen's companion button was not, since it goes through viewModelScope. The sheet also did not read as one list. A Material list item paints its container surface while a sheet is drawn on surfaceContainerLow, so every list item on a sheet now takes the transparent sheetRowColors — the mute switch here, and the log settings, batch update, asset picker and framework versions sheets. The mute switch was the only row built from the generic ToggleRow, so it gains the sheet's own shape through ActionRowLayout and keeps announcing itself as a switch; uninstall drops DeleteOutline for Delete to match the filled glyphs around it; and "not in the store" no longer ripples under a thumb. Fixes#810
Four unrelated fixes. The mapping and symbols artifacts have pointed at paths that stopped existing in #796, so both uploaded empty. check_translations.py had no caller; it runs in CI now and catches bare %s and %d, which fixes three defects it found. zygisk/update.json named a zip the release does not attach, so the v2.0 update has always failed to install. Old repository paths go with it. And the daemon translations: 18 machine-translated product names, a handful of real defects, and 39 locales saying "Xposed module" where we say "module".
On a Huawei MatePad 11 the manager never opened. A LoadedApk for the host package is already cached by the time bindApplication arrives, so ActivityThread#getPackageInfo returns that instance and never looks at the ApplicationInfo we just swapped in. Its one repair path, updateApplicationInfo, is gated by isLoadedApkResourceDirsUpToDate, which compares nothing but the resource and overlay directories -- and getManagerPkgInfo copies both from the host, so sourceDir is never picked up. The process then runs with mResDir on the stock Shell.apk, and because its mApplicationInfo is a different object than ours, the identity check in the getClassLoader hook skips the DEX injection and MainActivity cannot be found. Dropping the entry from mPackages and mResourcePackages forces a fresh LoadedApk, built from our own ApplicationInfo, which the identity check then accepts. That check is left as it is: matching on the package name instead, as this patch first did, would also match the stale LoadedApk and the resource-only one out of mResourcePackages. A freshly forked process has an empty cache, so the eviction is a no-op everywhere else, and the warning it logs when it does remove something is the only evidence that the pre-warming is real. Verified by the reporter on the affected tablet, and on a device without it.
A legacy module reports being active by hooking a method in its own app, so it has to be in its own scope before it can say anything at all. The View-era manager added that row on every save and hid it again on read; #796 dropped both halves, and every legacy module has reported itself inactive since. ConfigCache derives that scope during its rebuild rather than storing it, so configurations written by those builds need no repair and nothing that replaces the scope table can drop it again. Legacy is the loader's own verdict, so a module built against API 101 keeps its own process to itself. The scope screen shows the derived row: ticked, exempt from every filter, grouped with what is in force, and closed to the toggle and to both bulk actions. It never enters the draft, so an apply can neither write nor delete a row the scope table does not own. Fixes#816.
Android 17 refuses every reflective write to a static final field: `Field_set` calls `ThrowIAEIfFieldIsNotOverwritable` before it looks at the accessible flag, and `ArtField::IsUnmodifiable` lets one through only for a process targeting SDK 36 or lower. Clearing the reflective copy's ACC_FINAL does not help, an unreflected VarHandle is read-only, and Android's `Unsafe` has no static field accessors -- which left `XposedHelpers.setStatic*Field` dead for every legacy module on 17, `android.os.Build` spoofing included. `HookBridge.makeFieldWritable` clears ACC_FINAL where the check reads it and the setters retry through reflection, so the value, the conversions and the exceptions stay reflection's. ART's own JNI `SetStatic*Field` is deliberately not taken: `EnsureModifiable` is `LOG(FATAL)` for a field it holds unmodifiable, so a write outside the `android.os.Build` carve-out aborts the process. The ArtField's access flags are checked against `Field.getModifiers()` before anything is written, so a runtime that lays them out differently is left alone and the caller keeps the `IllegalAccessError` it already had.
…om (#809) Flash Vector, open it from the root manager, close it, and there is no way back in. The dialer code registered a filter with no action, which matches nothing, so that branch has been dead since #597; it works again and is rebound to 832867, VECTOR on the keypad. Parasitically the manager is not installed, so the launcher has nothing to show either: the pinned shortcut and the standalone install that #796 dropped return, in an "Opening Vector" section and a first-launch prompt, with `getManagerApk()` handing over the APK the host cannot read. Sixteen new strings, translated into all eighteen languages. The version string now names where a build came from rather than calling every canary "dirty", which it did only because the workflow writes signing credentials into the tracked `gradle.properties`. Repository and commit both come from the pull request's head, since GitHub's defaults describe the run and not the code. Closes#815.
#809 gave the stamp a second half and left `divergesFrom` comparing the whole string. A canary reports `JingMatrix-Vector-93d66473`, no release SHA starts with that, so every canary was called "same number, other build" against the release it had just been flashed from, and no row was ever marked installed. The `-dirty` test it still carried had become unreachable in the same commit. The stamp now leads with the commit, `93d66473-JingMatrix-Vector`, so reading it back is a prefix and nothing more. A modified tree is marked `+` rather than `-`, in semver's sense of build metadata: after a `-` is a repository holding this exact commit, after a `+` are changes no repository holds. `buildStamp` takes one apart and `isCommit` compares as a prefix in either direction, since a stamp carries git's short form and a release the full SHA. Where a build was made is not compared — a fork at the same commit builds the same code. A stamp naming no commit, including the shape published between #809 and here, reads as "I cannot tell" rather than as divergence. On the status page the commit keeps the size it is read at and the rest is set smaller and muted; copying the page still yields the whole stamp. In the versions sheet the status has a width of its own, so the clause on a divergent row no longer takes the room the build's name needs and wraps that row alone.
Refuse to hook Object.getClass R8 compiles Kotlin's parameter null checks into `obj.getClass()`, and one of them is the first instruction of `VectorNativeHooker.callback`. The dispatch therefore calls `Object.getClass` entering every hooked method, and a module that hooks it re-enters the dispatch from its own prologue until the stack is gone, before any hooker runs. There is no recovery: lsplant marks a hooked method non-compilable, the framework dex never gets an oat file, and `Throwable.toString` calls `getClass`, so reporting the StackOverflowError raises another one. It is refused at both registration paths, alongside `Method.invoke` and `Constructor.newInstance`, which are refused for the same reason. The `IllegalArgumentException` costs the module that one hook and nothing else. AGP 9 made this certain rather than possible: from the same source the Release framework dex holds 44 `Object.getClass` call sites at 3043 and 246 at 3048, and the one in `callback` moved from a cold branch to instruction zero. The hook was already fatal -- KiminonawaResa/HyperLight#193 is the same launcher loop on 3044. What changed on the module side is libxposed API 101, which offers no `hookAll*` helper; the module that hit this walks `Class#getMethods()`, which always lists the `getClass` inherited from `Object`. The legacy helpers walk `getDeclaredMethods()` and cannot reach it. Verified on a Pixel 7a with a module transcribed from the decompiled one: on master the target dies and restarts with `failed to complete startup`; here the refusal is logged and the process stays up. The messages for the other blocked hooks -- abstract methods, framework-internal methods, `Method.invoke`, `Constructor.newInstance` -- now say why rather than only that it failed. Fixes#798.
Three of these are what borrowing `com.android.shell`'s uid costs the manager. Below API 33 `ContextCompat.registerReceiver` demands `<package>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`, which under the host is nobody's, so every store install threw before `commit()`; both installers register by hand now, under a UUID-named action. `createSessionInternal` skips `INSTALL_REPLACE_EXISTING` for `SHELL_UID`, so a module already on the device failed with `ALREADY_EXISTS` -- that branch is unchanged from API 27 to AOSP main, so a store update has never worked parasitically. And `AwSettings` reads `checkSelfPermission(INTERNET)` in its constructor, which AOSP's Shell did not request until android-12, so every in-app page failed as `ERR_CACHE_MISS` on a device whose networking is fine; the context a WebView is built with now answers that one question, and only where the platform says no. The fourth is that an offer is decided by numbers a git tag states, which nothing obliges to be the ones in the APK's manifest, and no rule over `(code, name)` can bridge that: a module that never changes its tag code is only ever seen to update through the name clause, and one that reuses a versionName across several codes only through the code clause. So the Store stops inferring and records instead -- the release it installed, and the version the device reported once it was on -- read in `StoreEntry.upgradable` so every count, filter and badge agrees. Where an offer names the version already installed it is worded as a reinstall rather than as an update from a version to itself, which is true whether the tag disagrees with the manifest or the release is a genuine rebuild. All eighteen locales are filled in. Fixes#823.
) A module loaded into system_server cannot dlopen a library out of its own APK. Everything under /data/app is apk_data_file, and while system_server may read and map such a file it may not execute it, which AOSP states outright and forbids granting: "Executable files in /data are a persistence vector". Every app domain does hold that permission, so the same module loads the same library without trouble in an ordinary process and fails only here, with the linker refusing the first PROT_EXEC mapping: dlopen failed: couldn't map ".../base.apk!/lib/arm64-v8a/libhmahook.so" segment 2: Permission denied The way past it is not a new rule but the one this module already ships. xposed_data is a type we declare ourselves, outside the data_file_type attribute that neverallow is written against, and the existing `allow * xposed_data {file dir} *` already reaches every domain, system_server included. So the daemon now copies a system_server-bound module's libraries into the misc directory it already owns and labels, and hands the loader that directory to search first. Nothing is staged for any other process: they can execute straight out of /data/app, and staging for them would buy nothing but disk. The copy is keyed to the APK's size, mtime and the framework version, so an updated module is re-extracted rather than left running superseded native code, and directories belonging to modules that are no longer bound for system_server are dropped. Staging deliberately ignores moduleLibraryNames: that list only names the libraries whose native_init we are asked to call, and a module may load its own libraries without declaring any -- the module that prompted this does exactly that. While here, register the declared native entrypoints before the entry classes run instead of after. A module may load its libraries from its constructor or from onModuleLoaded, and an entrypoint recorded afterwards is one the dlopen hook has already missed. The legacy loader has always done it in this order.
…ped (#832) Defects across the daemon and the manager sharing one cause: the daemon writes or announces something, and the manager either never hears it or overwrites it. Scope - An empty static scope no longer fixes the scope at "no apps at all". readStaticScope answered an empty set, so setModuleScope refused every write and pruneScopeToClaimed deleted the module's rows on each cache rebuild. - A static scope fixes which apps may be listed, not which of them are chosen. The rows are editable and any subset is stored, which the daemon already accepted. - The editor sends the user's diff applied to a fresh read rather than a whole-set replace, and re-reads on resume, so a scope approved elsewhere is no longer deleted. - A row unticked in the list stays in the list. Notifications - One scope prompt per requested package, each independently answerable, bounded at sixteen unanswered per module. Previously they shared a tag and replaced one another, so only the last was ever answerable and the rest were never answered at all. - The delete intent and one-hour timeout dropped in 714e6c8 are restored, so a dismissed prompt answers the module instead of leaving it waiting indefinitely. - "Never ask again" withdraws the module's other prompts, and uninstalling undoes it. - The "not activated yet" notice is cancelled once the module is activated. Manager - module:// launch intents open that module's scope editor again, lost in #796, and a rebuilt activity no longer replays them. - Package events in other users reach the manager, via the daemon's own broadcast. - The "no way back in" prompt waits until the answer is known. - A framework flash survives leaving the screen; a download can still be abandoned.
Source patterns. Within a path node the CLI compiles `*` to `.+` and applies the result as an anchored match, so a `*` must consume at least one character. A pattern of the form `strings*.xml` therefore selects only names that carry something between the stem and the extension, never the bare `strings.xml`. A file that no pattern selects never enters the candidate set, so the upload neither includes it nor reports it as missing: the failure is silent by construction. Neither `**` nor `?` closes the gap — `**` is special only as a whole path node, and `?` adds a further required character. Naming each source file is the only formulation without such a hole. Locale placeholders. %android_code% is region-qualified for every language, whereas an Android resource folder carries a region only where one is needed to disambiguate a variant. Aligning folders with it would require one mapping entry per language to strip the region back off — an obligation that grows with every language added and degrades silently when forgotten, since translation upload derives one expected path per target language and skips the ones it cannot find. %two_letters_code% inverts the default: it already agrees with the unqualified folders, so only the genuinely region-qualified locales, plus the codes Android retained after the ISO renames, need an entry. Those are now written down in the repository rather than left implicit in project state that no checkout can see. The mapping is repeated per file because it is a file-level key; a YAML anchor keeps the four entries identical by construction. The `translation` value doubles as the file's server-side export pattern and is expanded there, where the local mapping does not apply, so the same overrides must exist in the project's language settings for downloads to resolve. Nothing in CI observes this, as it does not download translations. The workflow trigger now names the same files instead of globbing them. Its matching rules and the CLI's are defined independently, and a trigger that quietly never fires is the worse failure of the two.
Two unrelated fixes. A process no module has in scope produced three log lines about it, and one called the daemon's ordinary refusal a failure. The duplicate in `ipc_bridge.cpp` goes; the two in `module.cpp` now say what happened. The wording stops short of "out of scope" because the zygisk side cannot tell: `BridgeService.onTransact` also returns false when the daemon binder has not arrived yet, or when the process is already registered. The daemon logs the reason itself. `sendToBridge` leaves the main thread at euid 1000, and the statement after it read the verbose-log preference out of the config database, which sits under a directory only root can enter. That normally works because a binder thread opens and caches the handle during specialization first, but only when the injection succeeded. When it failed nothing had, and the daemon died on the preference read instead of carrying on — the second half of the crash in #744 and #773. Reading it before `sendToBridge` leaves the check where it was and does the open while we still have root.
android.util.Log drops a throwable's trace whenever an UnknownHostException is anywhere in its cause chain, so our DoH, store and GitHub failures logged a message and nothing else. Traces are formatted here now and appended to the message, on the unfiltered Log.println, for the manager, the daemon and the legacy bridge. The log panel then lost half of each one by guessing continuations from indentation. It goes by the writer instead: logcat.cpp puts the prefix in the first iovec, so an unprefixed line under an entry continues it, bar the four the daemon writes raw. The one exception rejoins a message printlns cut in two, on tag, process, thread, level and adjacency. One renderer draws every trace -- our frames marked, the platform's dimmed, Caused by as a divider, each frame tappable to copy. The crash card states what threw, what it said, the nearest frame that is ours and when, with the trace a tap away. Sixteen new strings across eighteen locales; fifteen dead imports gone.
GrapheneOS ships a "Restrict dynamic code loading" exploit-protection setting
that is immutable and enabled for system apps. The parasitic manager runs
inside com.android.shell, a system app, so GrapheneOS forbids it from loading
the manager's DEX and the manager never starts.
GrapheneOS enforces DCL through two channels, both fed by the same per-app
verdict, and each has to be cleared at that shared source rather than at the
symptom:
- The ART DexFile checks (DynCodeLoading.getAppBindFlags) reject the
manager's transplanted /proc/self/fd DEX as "DCL via storage".
- The kernel grapheneos_flags written at zygote specialize keep
DENY_EXECMEM/DENY_EXECMOD set, so LSPlant/Dobby cannot make its inline-hook
trampoline executable and the process takes a SIGSEGV
(TSEC_FLAG_DENY_EXECMEM: op denied). The manager's isolated WebView process
likewise keeps DENY_EXECMEM, disabling Chromium's JIT.
getAppBindFlags and SELinuxFlags.{get,getForWebViewProcess} all read
AswRestrict{Memory,Storage,WebView}DynCodeLoading.get(), which honours a
non-null getImmutableValue. So the hook is on getImmutableValue for all three
switches in system_server, returning false (allowed) for the single host
package and deferring to GrapheneOS's original verdict for every other app. A
non-null result takes precedence over both the user toggle and the default, so
this forces the setting to allowed regardless of the user's configuration.
The manager host ends up an ordinary DCL-allowed app: the DexFile checks are
off and the exec* SELinux flags cleared, while ptrace denial, hardened_malloc
and MTE stay intact. GrapheneOS is detected by the classes being present, so
the hook is a no-op on every other system; verified against the GrapheneOS 14,
16 and 17 branches.
The scope is intentionally narrow: only com.android.shell is affected, and
only in system_server, where the value is computed. Modules are excluded --
a normal app already exposes a user-configurable DCL toggle on GrapheneOS and
can be allowed without a patch.
Supersedes #711 with a smaller footprint: the GRAPHENE_SETTINGS_PACKAGE_NAME
build variable is dropped and the hook resides in its own class. The technique
was originally developed by @Enovale in #711 and in discussion #340.
Co-authored-by: Enovale <17408285+Enovale@users.noreply.github.com>Long-press the navigation container to enter edit mode: drag panels to reorder, tap a badge to hide or restore one. Hidden panels stay registered as destinations, since a saved back stack may still name them; only the container stops drawing them. One panel always remains visible. Works on both the bottom bar and the rail. Add an appearance-sheet option, off by default, that replaces the bar with a draggable ball. The navigation suite type becomes None, so the container is never laid out and the strip it costs every screen returns as content. Long-press the ball to fan the panels into an arc and release on one; a plain tap latches the arc open so each panel is an ordinary target, which is the path a screen reader can use. Drawn inside the app window, never a system overlay -- the parasitic manager runs as com.android.shell and must not request SYSTEM_ALERT_WINDOW. The arrangement persists as one ordered string of route keys, hidden ones flagged, tolerant of unknown or duplicate entries. The ball's edge and height persist likewise; ball and arc are positioned in absolute window coordinates so both render correctly under RTL. Sixteen strings across eighteen locales.
The GitHub sign-in came from a `githubClientId` Gradle property set nowhere -- not in gradle.properties, not in CI -- so every build compiled GITHUB_CLIENT_ID as "". isConfigured was therefore always false: the card returned before drawing anything, signIn() short-circuited to Unavailable, and tokenProvider() always returned null, so no request ever carried an Authorization header. It bought only the rate limit, 60 requests an hour anonymous against 5000 signed in, and nothing depends on having it: the canary screen sources its zips from release assets precisely so that testing a build needs no account. Deleted rather than given a client id, because the flow also has nowhere good to keep a token. Parasitically the manager's SharedPreferences land in com.android.shell's data directory, whose shared_prefs is world-executable, so a token would sit outside our own sandbox. That is worth solving before offering sign-in again, not alongside it. The rest is what #796 left unreferenced. buildSrc held two Gradle task classes no build script registers and whose outputs no longer exist -- the locale list comes from BuildConfig.TRANSLATIONS now. The version catalog still pinned the whole View stack: 26 aliases, the safeargs plugin, and the nav, glide and appcenter versions. hiddenapi/stubs carried 20 files nothing compiles against, with four HiddenApiBridge wrappers and the stub members that existed only to type them. In :manager the four @serializable models for the Actions runs and artifacts API became unreachable when eb07955 moved canary sourcing to release prereleases, and nine symbols -- relativeTime, levelLabel, sectionHeaderStyle, SeedScheme.parseHex, VectorLightColors, VectorDarkColors, ModulesViewModel.selectAll, LogPaneState.atOldest and GOOD_FIRST_ISSUE_URL -- have no call site at all. Seventeen strings go with them, translations deleted alongside their source so the next Crowdin pull does not add them back. Daemon-side, CliHandler.isPackageInstalled, Utils.getZoneId, Utils.isLENOVO and :xposed's trackedApks have no caller; the live tracker is LoadedApkTracker.activeApks. One deletion ships behaviour. Before #550 the loader baked the raw githubusercontent URL of magisk-loader/update/zygisk.json into every module.prop, so Magisk on a pre-v2.0 install polls it and will now get a 404. Effectively everyone is on v2.0 by now, and zygisk/update.json -- what module.prop has pointed at since -- is untouched.
…841) The fmt and commons-lang submodules move to the tip of their upstream branches, core-splashscreen to 1.2.0, navigation3 to 1.1.5 and material3 to 1.5.0-alpha25, which is everything in the catalog that was behind. Both libxposed submodules stay where they are, since their only new commit is the API 102 RFC and that belongs with the API work. In CI, ninja moves to 1.13.2 and crowdin.yml's floating action refs are pinned to majors like everywhere else. The NDK pin moves from 29.0.13113456, which is r29 beta 1, to r29 stable at 29.0.14206865. Its clang 21 rejects the lsplant x86 build over duplicate <emmintrin.h> definitions arriving through two module fragments, so phmap's SSE2 group scan is switched off -- for every native module, because the flag changes phmap's layout and hook_bridge.cpp instantiates the same templates. The rest is the seventy or so lines a build printed when nothing was wrong. :hiddenapi:stubs no longer overrides its Java version to 8. Thirty-three Kotlin deprecations in the manager are migrated: ListItem's headline becomes a trailing content lambda, TabRow becomes PrimaryTabRow, rememberModalBottomSheetState becomes rememberBottomSheetState, and eight icons move to their auto-mirrored versions; the two reads with no replacement are suppressed in place. Vendored and generated Java, along with :legacy, compile with javac's notes off, and lsplant's string literal operator template warning is silenced at its target. The `by extra` delegate and AGP's srcDirs are rewritten, and the apksign plugin is replaced by the signing configuration it was applying, since its last release still calls the deprecated Project.getProperties. Underneath that, lint was reporting calls the minimum cannot make. The manager and the daemon both set API 27 and both reached for later methods, which on 8.1 is a NoSuchMethodError rather than an opinion: getLongVersionCode is API 28, FileObserver's multi-file constructor is 29, and LocalServerSocket only implements Closeable from 28. Each is now gated on the release that introduced it, checked against the platform's own api-versions.xml, and two constants newer than the minimum are spelled out rather than referenced. NewApi and InlinedApi findings go from twelve to none, on both variants. Signing deserved a second look with the plugin gone, and was verified both ways: with a keystore the manager APK and the certificate compiled into SignInfo.kt are the same one, and without, both fall back to the debug key as before. A clean zipAll over debug and release now prints only its task list, and the release zip runs on a Pixel 6 on Android 17.
Modules loaded but none of their hooks landed in release builds, while debug builds were fine (#847, #848). Some framework types extend super classes that no dex contains: they are generated on the device, so they can inherit from whichever platform classes it provides. Such a type cannot be resolved until that has happened, and a failed resolution is permanent, so the fragility is transitive — every class naming one acquires it. R8 spread it beyond what the source shows. Each lambda becomes a class, and classes of the same shape are merged afterwards, so `XResources`' two lambdas put it inside the shared `Function` synthetic — the one commons-lang's `ClassUtilsX` instantiates from its static initialiser, which `XposedHelpers.findClass` calls. Every `findClass` in system_server failed for the rest of the boot. Both lambdas are now written out long-hand. Keep rules cannot state this invariant, since they govern the classes you write rather than the ones an optimiser invents, so `checkXResourcesIsolationRelease` reads the optimised dex, resolves names through the mapping file, and fails the build if any class outside resource hooking comes to name one of these types. Saved bug reports also get one name, built in one place instead of three. Two archives attached to the same report used to be indistinguishable until opened, so the name now says the build type, and each format records the commit where it can — the log zip in its comment, the module backup in a field of its own. The version code is the commit count on master, so branch builds wear numbers they were never built from.
JNI has a rule that is easy to forget: when a Java method throws, the exception does not become a C++ exception. It stays pending on the thread, and the runtime is entitled to abort at the next transition rather than let you carry on. So every call into Java, and every lookup that can fail, owes an answer to "did that throw" — and six files never asked. The worst of them is `FindAndCall`, which hands a whole process to the framework's Java entry and then inspected nothing. A throwing entry left that process without Xposed, while the line printed immediately afterwards said the framework had been injected. It now reports the failure and returns whether the call arrived, and both callers say which happened. The rest are smaller versions of the same thing: two lookups in `resources_hook` returned `JNI_FALSE` to Java with `NoSuchMethodError` still pending, so a caller that asked for a boolean got a throw instead; `RegisterNatives`, `LogcatMonitor`'s `refreshFd` lookup and `dex2oat`'s string read did the same on their failure paths; and the obfuscation map builder returned null on a failed `FindClass` without clearing, then fed two unchecked method ids to `NewObject`. Most of this is not new code but the lsplant wrappers we already have. They clear the exception, log the Java stack behind it, and hand back scoped references — which incidentally disposes of a local reference the obfuscation map leaked per entry. An explicit check survives only where the caller has to know what happened, because a wrapper clears the exception before anyone can ask. Two decisions worth recording. The stack is rendered with `Log.getStackTraceString` rather than `ExceptionDescribe`, because the latter writes to stderr and a process forked from the zygote has nowhere for stderr to go — the trace would simply vanish. And `SetAllowUnload(false)` stays unconditional: the ART and JNI hooks are installed before the entry runs and their trampolines point into this library, so a failed entry is no reason to let it be unloaded. `hook_bridge` is deliberately untouched. It implements `Method.invoke` semantics and has to leave a target's exception pending so it can wrap it in `InvocationTargetException`.
Scaffold places its bottom slot against the bottom of the window and reserves nothing for it -- the documentation is explicit that topBar and bottomBar are expected to handle insets themselves, which is why NavigationBar and BottomAppBar carry windowInsets of their own. Three of this app's four docked bars are plain Surfaces and reserved nothing, so their contents were drawn under the navigation bar. It bites on every detail screen whatever the navigation style, because the navigation container is hidden away from a panel root and NavigationSuiteScaffold consumes NoWindowInsets while hidden: nothing above the screen has taken the system bars. With three-button navigation that is 48dp, and on the scope editor it left a few pixels of Apply and Discard to aim at, which is #884. Gesture navigation is 24dp and was covered too, the handle drawn across the supporting line. The padding goes inside each bar's Surface rather than on it, so the fill still reaches the bottom edge and the bar reads as one surface. Insets already consumed count for nothing, so the same call adds nothing in the arrangements where a container below has taken them. Home is the same fault from the other direction. It sets contentWindowInsets to zero so the header can run under the status bar, which gave away the bottom as well; with the panels floating there is no container to have taken it, and both the last row of the feed and the scroll controls ended up behind the navigation bar. Take the bottom edge alone, from the Scaffold's own default so a bottom display cutout counts.
Four faults on the daemon side of a module's scope request. The framework could never be granted. "system" names system_server and belongs to no package, but the receiver resolved the requested package before acting on the button, so every framework prompt was answered "Package not found", closed and cancelled with no row written. Accept the framework name without asking the package manager, and do the lookup only under Approve, the one answer that has to name something real; deny and the one-hour timeout no longer report a lookup failure for a package uninstalled while the prompt was up. One request is now one prompt. The interface takes a list and a single IXposedScopeCallback for it, but the daemon put one prompt per package on screen and answered each in its own right, so a module asking for three packages made the user answer three questions and fired that one listener three times. The whole list goes up as one prompt whose Approve answers for all of it, deduplicated and sorted so the same set asked twice replaces its own prompt, and the per-module ceiling bounds calls rather than packages. The notification reuses the string it always did with the packages joined into it, so no translation changes. An approval survives a dead module. A prompt sits for an hour and the app a module runs inside can be killed in it, and the receiver returned on a dead callback binder before claiming the answer, leaving nothing written and the prompt on screen with buttons that did nothing. The decision is recorded whether or not the module is still there to be told, and the only call that can fail against it is caught where it is made. The refusal path is logged. It had no log line at all, so a request that could never be granted left no trace anywhere, which is why the framework case went unnoticed for as long as it did. Name the packages that did not resolve, and the ones that were approved.
Handing a module app its `IXposedService` means starting it: the daemon acquires the module's own `XposedService` provider, which brings the process up, and passes the binder in the reply. That reference was never released, and the platform reads an outstanding external reference as a live client — so `OomAdjuster` held every module app at `FOREGROUND_APP_ADJ` with adj type `ext-provider`, never cached, and a host dying while its provider was still launching was restarted for it. The platform bounds those restarts at three per provider record; acquiring again rebuilds the record with the count at zero, which is how #889 reached fourteen process starts in seventy-six seconds, six of them ours. The release has to be unconditional, because the two returns that matter come back null with the reference already registered. Three consequences of the same path. The delivery no longer runs on the uid observer, where one module delayed every other module's binder by eight seconds. A failed send is no longer recorded as a delivered one, and repeated failures are throttled to one attempt a minute rather than one a second. And a delivery is recorded per process rather than per uid — a uid outlives any one of its processes, so the record could otherwise never clear. Separately, `getContentProviderExternal` gained its `tag` argument in Q by replacing the three-argument form, so the unconditional four-argument call has meant no module received its app-side service on 8.1 or 9 since #597. And the `IUidObserver` stub declared four of the interface's eight methods; the rest stayed abstract at runtime, which `oneway` makes fatal rather than reportable. Module apps are no longer immortal, so a scope answer arriving an hour later may find the app gone. The grant itself is written before the callback.
…895) The status badge is the only route to the System status page, where the settings for opening Vector live, and a tick does not read as a button (#856). While the framework is active the tick now morphs into a gear for ten seconds every thirty; across those ten seconds the gear tosses a coin every two seconds and either turns once or stands still, because a wheel that starts and stops reads as something being operated while one that simply rotates becomes decoration. Every degree it moves was tossed for, including the one hint in thirty-two that does not move at all. Only the tick does this; the other states are reports, two of them urgent. The hint retires after five badge taps in a day and returns the next. A pinned shortcut does not follow the user to a launcher installed later, yet getPinnedShortcuts keeps reporting it, because the pin flag belongs to the shortcut rather than to the pair and only the active launcher may read the per-launcher sets (#883). The launchers that have pinned it are now recorded on this side, and a device running a launcher that is not among them is offered the shortcut again. Where nothing is recorded the current launcher is adopted, so no existing shortcut is declared missing, and a home screen resolving to the chooser or to nothing counts as unknown rather than as a mismatch. Opening Home tossed a coin, and four times in five it showed whatever was on disk — right for returning to Home, wrong for the first Home of a process, when the archive has had longest to go stale. The first now always revalidates; the toss governs only the visits after it.
XposedInterface promises that invocations through an invoker bypass access checks, and ours only did once the executable carried a hook: the unhooked path went out through `Method.invoke` on the module's own reflected object, where ART runs its usual check, while the hooked one escaped only because LSPlant makes its backup accessible. Beside it, `invokeSpecial` and `newInstanceSpecial` never read `Invoker.Type`, so the full chain ran even under `Type.Origin`, and the argument handling bore little relation to the `Method#invoke` the invoker is documented against - any `Number` taken for any numeric parameter and narrowed silently, reference arguments and receivers unchecked, and a static executable or a `Character` unboxed through a `Number` method id taking the process down. Both paths are now one primitive dispatching through JNI, which performs no access control, after applying the rules reflection applies: the widening conversions of JLS 5.1.2 and nothing else, checked before the chain is entered so a refusal of ours arrives unwrapped. The chain boundary also stopped collapsing a level, so a target throwing an `InvocationTargetException` surfaces as `ITE(ITE(x))`. Hooking a method had also turned `invoke` into a non-virtual call, because the backup `ArtMethod::BackupTo` leaves behind is private and ART dispatches it directly, so a `Method` taken from a superclass ran the superclass body where `Method#invoke` runs the override. The override is resolved before dispatch now, top-down so overriding stays transitive, on return type as well as parameters so a covariant bridge is not skipped, and package-private only within the same runtime package. `Type.Origin` deliberately does not resolve, since skipping all hooks cannot mean entering one. Last, `legacyApiPrefixes` refused four prefixes where the interface names one, and API 102 carries no resource API of its own, so a module targeting it could not touch resources at all. Narrowing the guard exposed three defects behind it: the two `ResStringPool::stringAt` overloads bound to each other's signatures, `ResXMLTree` read at Android 9 field offsets, and `XResources.isXmlCached` comparing an asset cookie against a resource id. The conformance module this was checked against is in commit 8768186, and `external/lsplant` is rebased onto upstream.
…or (#902) `uidGone()` drops the in-flight marker rather than wait for a send that may never return, since `provider.call` runs the module's own `onServiceBind` with no deadline, so from that moment a replacement process starts a second send while the first is still blocked in `getContentProviderExternal`. That is the ordinary case for a module app that dies on every launch, which is the one this machinery exists for. Two sends for one uid was survivable; two that both believe they speak for it was not - both are woken by the same publish, both deliver, and `onServiceBind` runs twice. The first one out then removes the marker the second is holding, letting a third start behind it, and a third `getContentProviderExternal` builds a fresh provider record with the platform's restart count back at zero. Whichever finishes last also decides the retry throttle, so a send that spoke to a process the uid has already outlived can clear the failure run that was about to slow the restart loop down. The death recipient had the same problem from the other side: it knew its uid but not its delivery. A death notification is queued when the process dies rather than when we get to it, so the one belonging to the process we served can arrive after a replacement has taken a binder of its own, forgetting a uid that is being served and costing the module a second copy of a service it already holds. A send now carries a token, and only the send whose token is still the one in the map may commit; a recipient checks that the entry it is about to remove is the one it was linked for. The two fields those steps read and write are individually atomic, which is what makes the gap between them easy to miss - a send could pass its ownership test, be overtaken by `uidGone()`, and still add the uid, leaving a replacement started in that window refused at the top of `uidStarts` until another uid edge arrives. The three places that hand a uid over therefore share one lock, and nothing that blocks runs under it. Failures are still counted whether or not the send that saw one still owns the uid, since a module app dying while the platform waits for its provider is both what the throttle is for and what takes the ownership away. Co-authored-by: JingMatrix <jingmatrix@gmail.com>
The fd constructor mmaps the DEX and the destructor unmaps it, which is what a process served its framework over a shared-memory fd needs. An in-process embedder that already holds the framework DEX in a buffer it owns - a JVM byte[] pinned for the load - has nothing to map and nothing to unmap. Give PreloadedDex a non-owning (void*, size_t) view for that case, and track ownership so the move operations and the destructor only unmap what the fd constructor mapped.
buildDummyClassLoader's body becomes BuildDummySuperClassLoader(env, parent, res_super, ta_super), a plain entry the JNI method now delegates to. An injector that builds the framework class loader itself can install this loader as the framework loader's parent at construction, so XResources' synthetic super resolves the first time XResources is defined -- there is no zygote window to generate that super first when the framework is loaded into an already-running app.
makeFieldWritable reads the ArtField from the jfieldID FromReflectedField returns, which is the ArtField pointer only under JniIdType kPointer. A debuggable process runs kIndices, where the jfieldID is a table index and the flag write would dereference a small integer. Take the pointer directly when FromReflectedField yields one (kPointer -- unchanged path), and only when it yields a small index decode the reflected Field to its mirror::Field and read its ArtField (Thread::DecodeJObject + mirror::Field::GetArtField, resolved from libart). The low-address and access-flags checks stay as backstops, and a runtime missing those symbols keeps the old behavior.
getModuleClassLoader(pkg) and loadedModulePackages() expose the generation map read-only. An injector that hands each module its own service -- a libxposed IXposedService delivered without the daemon -- can then find the class loader a module runs in and enumerate what is loaded.
The API 101 onPackageLoaded/onPackageReady callbacks dispatch from the LoadedApk.createAppFactory and createOrUpdateClassLoaderLocked hooks, gated on LoadedApkTracker. A host that builds a LoadedApk by hand, before those hooks exist, never registers with the tracker, so expose VectorStartup.trackLoadedApk (with a Startup facade) to enrol its instance and widen LoadedApkTracker to internal. Once tracked, onPackageLoaded dispatches before the app's AppComponentFactory initializer, as the API requires.
Everything both this manager and a second host would draw -- the module list and rows, the store with its detail and install flow, the logs screen and reader, the hideable nav shell, the ambience header, and the appearance and language sheets -- moves into a new manager-ui library under org.matrix.vector.ui. The app keeps only what is its own and reaches the library through seam interfaces (LogSource, StoreInstallHost, AppearanceSettings, AmbienceSettings, LocaleController, FloatingNavSettings), so another manager can supply its own transport without forking the UI. The manager's split string files move to manager-ui/res, and crowdin.yml and the sync workflow follow. The live-log tail follows the reader's viewport rather than the loaded window, so scrolling up to read history pauses it; and ModuleRow gains an optional reachStart slot, a badge left of the reach band and off by default, for a host that wants a status on that line. The Japanese and Ukrainian translations from #911 are adapted to this layout, since that sync was raised against the old one.
Every submodule that was behind moves to the tip of the branch it tracks: commons-lang by thirty-two commits, fmt by nine, lsplt by two, and ManifestEditor to c238e28. In the catalog okhttp goes to 5.5.0, ktfmt to 0.27.0, webkit to 1.17.0, the Compose BOM to 2026.08.00, material3 to 1.5.0-alpha26 and navigation3 to 1.1.6; everything else has only a prerelease above it. The wrapper takes Gradle 9.7.0 from #920, its jar checked against the published SHA-256. The NDK stays at r29, since sdkmanager's newer offer is r30 beta 2.
Which process wrote a line is not in the line's words, so no search can ask it, but it is the question a reader most often has of a log several applications share. It becomes a filter like the others: the scan counts uids as it already counts tags and levels, the sheet offers them as chips between the levels and the tags, and a chosen one shows as a chip that undoes itself -- with its own avatar, since it sits beside the tag's and says something else. The three read as one question. Within a category the choices are alternatives -- two applications, two levels, and now two tags, which was a single choice before and had no reason to be -- while across categories they are conditions on the same line. The counts follow: each category is counted under the others and never under itself, so choosing an application leaves the sheet listing the tags that application writes, with the counts it wrote them, while the applications beside it stay listed and switchable. Anything the rest of the filter has left with nothing goes, since a chip that selects no line is a choice not worth offering; a chosen one stays regardless, or a filter could not be undone. A uid is not a name, so the host puts one to it through writerLabel, asked once per scan and off the main thread. Both hosts read logs written by installed packages and would answer alike, so the rule is written once, in WriterLabeler: one package holding the uid is named by its label, a uid several share by the platform's name for it. The platform names only what it has a package setting for, and the writers that dominate a privileged daemon's log have none -- root has no setting at all -- so the few fixed assignments a reader actually meets are spelled out. What remains unnamed keeps its number, which still separates one process from another, and a writer outside the first user carries the user it belongs to, since the same application in a second profile is a different writer with the same label. hasVerboseStream, from the same seam: a host reduced to its own process's log has no second stream to unfold into, where both would be the same lines under two names, and says so instead of offering a control that changes nothing.
Asking the daemon for the installed applications is not the cheap lookup its call site reads as. It goes out as getInstalledPackagesFromAllUsers with filterNoProcess set, and the daemon answers that by querying the package manager for the full component list of every package on the device -- activities, services, receivers and providers, several hundred times over, with a four-call fallback each time a binder buffer overflows on the way back. AppRepository caches the answer for exactly that reason, and the splash prefetch pays for it once where nobody is waiting. What the cache did not have was a way to say that a read was already running. It checked a volatile field and, finding it empty, went to the daemon; two callers arriving together both found it empty and both went. The scope editor is two such callers by construction -- its load reads the list, and the module-package set it filters by reads it again -- so on a cold cache it ran two of those enumerations against each other, one racing the other for the same threads, while the screen it was opening waited on the first. Cold is not the rare case either: every install, update and uninstall drops the cache, and a package event arrives twice, once from the platform and once from the daemon's re-broadcast. So the fetch becomes a job the repository holds rather than work each caller starts. A caller that finds one running joins it, and the mutex guarding that field is never held across the fetch itself, only across the decision. The job runs on the application scope, not the caller's: leaving a screen part-way through a read now leaves the answer behind for the next visit instead of throwing away the several hundred queries it had already paid for. A finished job is retired by the next reader rather than by itself, which is what lets a failed read be retried instead of joined for the life of the process -- a successful one has filled the cache and is never consulted again. A generation counter goes in beside it. A read already in flight when a package event lands has by definition missed what that event carried, and the old code cached its answer regardless, holding a list known to be wrong until the next event -- on a device where nothing else changes, forever. The counter is sampled when a read starts and again before it publishes: the caller that asked still gets the answer, because it is the best that read can offer, but nobody else inherits it. forceRefresh goes. It had no callers, and sharing a running job leaves nothing for it to mean.
ScopeUiState starts with loading set, and the state built on the last line of load was the only other thing that ever wrote it. Every exit that skipped that line therefore left the flag standing, and the screen is drawn entirely behind it: a throw from one of the reads, or the reader leaving while they were still running, and the spinner was there for as long as the view model was. There is no second load to correct it -- load runs once, from init. A try around the body and the flag cleared in the finally, only when it is still set, so the success path stays the single writer of the state it builds. What a failure now shows is the empty list, which is wrong but says so, rather than a wait that never ends. The one read in that body that could throw is the manifest inspection, which opens the module's APK. The package info fetched immediately above it is already guarded for the same hazard -- a package removed or replaced between one call and the next -- and this one was not, so an APK that went away mid-load took the whole manager down from a viewModelScope coroutine. It recovers the way its neighbour does, as no recommended scope, which costs the reader a few rows that would have arrived pre-ticked.
The one OkHttp client, the DoH resolver and its status section were the manager app's alone. They move to manager-ui -- a new org.matrix.vector.ui.net package, a NetworkSettings interface in place of the concrete SettingsRepository, and a shared DohSettingSection composing the switch and status the sheet used to build inline -- so a downstream consumer resolves names the same way rather than reimplementing it. The strings move with them, translations and all. Nothing about how Vector resolves or renders changes.
The shared Store screen builds its own header, so a consumer had no way to put anything in its title row. Thread an optional actions slot through to the PanelHeader the header already uses; Vector passes none, so its Store is unchanged. LSPatch hangs a menu button there.
ART resolves the superclass of every type a method mentions when it verifies that method, not when the branch mentioning it runs. The `Build.VERSION.SDK_INT >= R` gate around `registerForNotifications` therefore never got a chance to help: verifying registerProxyService resolved the anonymous callback, that resolved `android.os.IServiceCallback$Stub`, and on Android 9 the daemon logged a NoClassDefFoundError for its very first call even though the guarded code was never executed. Move the callback into a holder object that the gate only touches on R and newer, so the resolution is deferred to that object's class initialization, and pin the holder in the R8 rules so a release build cannot inline it back into the caller and reintroduce the reference. Fixes#925
FRAMEWORK_NAME was baked from this repository's own root project name, so every consumer of the xposed library reported "Vector" to the modules it loaded -- including LSPatch, which ships this code inside a patched app and is a different framework from the Zygisk one. A module that branches on getFrameworkName() was told the name of the library rather than of its loader. It now comes from the root build when there is one, which is the build that decides what the framework is. Vector building itself has no parent, so it stamps its own name as before; the version already resolved this way, being read from the repository the build was invoked in.
The shared library exists to be drawn by two managers, and half of it was drawn by one. The panel bar, the floating ball and the arrangement behind them existed twice over -- this app's own files under ui/navigation, and a copy in the library that only LSPatch consumed -- and so did the language override, its picker, the localised dialog, the clipboard helper and the monospaced identifier styles. The copies were the same code; each was a place for the two to drift; and the language override is where drift costs most, being the mechanism that decides whether a sheet speaks the reader's language or the phone's. The container's fork had a reason once. The library's copy could not name a destination, because the manager that used it navigated by generated route strings and had no route type to hand over, so it took a key, handed a key back, and every surface that drew it mapped keys to routes on its own. That manager now holds its back stack the way this one does, as a list of typed keys. What the library keeps is this app's implementation, typed on NavKey: a panel carries the destination it opens, the bar and the ball are told the current one and hand one back, and the navigator -- the operations over the stack, and the arrangement it owns -- moves there with them. Each host answers for the two things that are its own, where the arrangement is stored and where the ball rests, through ports beside the ones the language override and the ambient header already use. The language mechanism is the library's too, and what stays here is the binding: which languages this app is translated into, where the choice is kept, and who translated what. The picker goes with it. The library's copy had no credits, because the manager that used it had none to show, so the chip moves into the shared row and the map behind it becomes a port with an empty default, and language_translated_by moves into the library's resources with the translations it already had. The words on the row that opens the container's edit mode move the same way: the title was already there, read from a second copy here, while the summary beside it had no shared copy at all, which is what stopped the other manager from offering the row. The matching behind those credits did not work. It promised to tolerate the two spellings of Indonesian and Hebrew -- values-in and values-iw against the tags id and he -- by trying the locale's language beside its tag. Which of the two a Locale answers with is not fixed: Android keeps the retired codes, a desktop JVM normalises them away, and toLanguageTag gives the modern one on both, so an entry keyed the way the folder is named was never found. A credit that fails to match looks exactly like a credit nobody wrote, which is why this went unnoticed. The pairs are written out rather than assumed. The dialog is the library's, which localises through the hook a host installs instead of calling the override itself; the hook is installed once around everything rather than around the one screen that first needed it, because a dialog can open anywhere. The clipboard helper is told the label to put on the clip, since a manager running inside com.android.shell must not name its clips after the process it borrowed. Two things move the other way, having only ever existed here though both managers want them: the snackbar that says by its colour and its icon whether something worked, and the icon cache that rasterises an app icon when it is drawn and bounds what it keeps. Nothing drawn changes.
A pointer asks a row for its menu with the secondary button -- a right click, or a two-finger tap -- and nothing answered it. `combinedClickable` reads any button's press as an ordinary tap and finds a long press only by timing a held finger, so a right click did what a left click does. A `View` answers it by itself -- badly: the menu came from the row under the pointer but acted on the field only the long press wrote, opening against whichever row was held last. Compose changed the symptom, not the cause. So the press is handed to the long press's lambda, watched on the initial pass and consumed so the click never lands -- module rows, apps in a scope, contributors on the home screen. Nothing changes for a finger. Fixes#644
A scope row names a package; the map the injector reads is keyed by uid. Uninstalling a target and installing it again crosses that gap. The app returns under a new uid, the entry under the old one went with ACTION_UID_REMOVED, and the install handler looked a target up by uid in that same cache — so it matched nothing, asked for no rebuild, and the row it is still configured under sat unread. That leaves the configuration correct and inert. The manager reads the table, so it goes on showing the target ticked; draft and saved agree, so no apply bar appears and nothing on that screen can put it right. It takes a scope edit elsewhere, or the next boot. Asking the scope table by name answers it. The uid test stays behind the new one for the rows no table holds: a module in its own scope, and the self-scope derived for a legacy one. Removal asks for its own rebuild too, that cleanup having only ever been incidental to a uid being retired. The lifetime this restores was never written down. The README, the schema, ConfigCache and the scope editor now say it.
The manager APK leaves the module directory as a descriptor, not as bytes: the daemon passes it over binder to the parasitic host, and to an installed manager updating itself. The kernel judges that transfer by the receiver -- selinux_binder_transfer_file checks the file against cred_sid(to) -- so the read is asked of u:r:shell:s0, never of the daemon's root context. The installer leaves the module tree system_file, which every appdomain and coredomain may read. Nothing holds it there: /data/adb is adb_data_file by default, and init's restorecon --recursive --skip-ce /data rewalks the tree whenever the stored digest stops matching -- a system image flashed over a kept /data. Every launch then fails the transfer, and the host dies on an intent it was never given. So the daemon reasserts the label at startup, beside /data/adb/lspd and the dex2oat wrappers, and only when it has drifted.
A repository returns whatever version name the publisher wrote, and a long one took the whole store row with it, pushing the date beside it into a column one character wide. The row's fixed parts are measured first now, and the version takes what is left as a single scrolling line — in the list badge, on the release card, and in the install button, through one shared ScrollingLabel rather than three arrangements of the same idea. That label then replaces the three inline spellings of itself that were already in the tree: the module name in the list, the module name again in the scope screen's title, and the version inside UpdatableVersion. Each had picked its own pause and number of passes, two of them on the same row of the modules list. The motion is one behaviour now, named once, in one file. In the version-history sheet the filled dot marked the installed build, so tapping an older one left the fill against a row the reader had just left. It follows the selection now, the way a column of dots is read everywhere else. Which build is installed is a separate fact, and goes on saying itself in the dot's colour and in the status column. The monochrome icon was a solid silhouette, which at icon size is a blob: the wings and drapery the statue is recognised by were lost inside it. It is line work now, stroked heavily and thinned by region — the outline kept whole, the wing's feathers kept, the skirt's folds mostly dropped — because the status bar draws the whole figure at 24 by 38 pixels.
makeFieldWritable took FromReflectedField's value as the ArtField address and read the access flags four bytes in. That holds only while a jfieldID is the pointer itself, which is all public AOSP has ever handed out: DecodeArtField in runtime/jni/jni_internal.h reinterpret_casts anything IsIndexId says is not an index. The ART on the Android 17 canary train tags it -- DecodeArtFieldInternal tests bit 55 and clears bits 54-55 before using the rest as the address -- so the read lands in unmapped memory. Seen on a Pixel 6, where PixelifyGooglePhotos spoofing Build through XposedHelpers put com.google.android.apps.photos into a crash loop: signal 11 (SIGSEGV), fault addr 0x0080000070d257e4 #3 de.robv.android.xposed.XposedHelpers.setStaticFinalField #7 balti.xposed.pixelifygooglephotos.DeviceSpoofer.handleLoadPackage 0x0080000070d257e0 is the tagged id, and +4 is access_flags_. That tagging is in no published source -- it was read out of the runtime's own disassembly -- so nothing here is built on knowing it. Nothing is decoded. Each reading of the id is offered as a candidate, and a candidate earns the write by answering three questions, ordered so that each is safe to ask before the next: is it above the first page, which is arithmetic on a number; is it mapped, which msync answers from the page tables without touching the address; do the flags at +4 equal what Field.getModifiers() reports, which is the first dereference and only of a page already known to exist. The tag constant therefore shapes which candidate is offered and is never trusted. A wrong constant, or a runtime that shapes ids in some further way, fails one of the three questions, and the caller keeps the IllegalAccessError it had before this existed. A wrong theory about ART costs a module a refusal rather than the app its process, which is what lets this be five lines of guessing. An odd id is not an address under any encoding: JniIdManager::IsIndexId calls a null or odd id an index and encodes one as (index << 1) + 1. It is left to the mirror::Field path that was already there for kIndices, which now returns false where it used to crash, neither ART symbol it needs being exported on this release. The tag constant is built in 64 bits and narrowed to uintptr_t, so it is simply zero on armeabi-v7a instead of a shift wider than the type, which if constexpr would not have excused outside a template.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.