Upstream - #18
Open
superturtlee wants to merge 41 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`.
Four changes, three of them cosmetic. The monochrome icon was a flat blob traced round the winged victory, so neither the themed launcher icon nor the status notification said what the statue is. It now carries the statue's own line work, punched out of the silhouette with evenOdd so the feathers and the drapery read at launcher size while the shape stays solid at 24dp. The circuit ambience routes its board on a lattice rather than scribbling it: lanes are claimed, so no two traces share a line; a trace that meets copper already laid steps aside and carries on to the edge rather than stopping mid-header; corners are mitred into the two 45° elbows a router leaves behind, with the vias on them. The signal is a swell of current with a proper head — a tapering ribbon drawn down to nothing behind it — and a vertical drag sets its speed, the gesture the rain and the snow already use. The code rain takes its glyph size from how many columns fit across the header — thirty at the largest, a hundred and twenty at the smallest, eighty at rest — rather than a fraction of its height against a fixed column count, which drew fifty-two streams at a size that fit thirty. Lanes are spread by golden ratio so the field stops clumping, and columns are added and removed between frames so a pinch never blanks a stretch of the header. Last, parasitically the "Install as an app" row went green as soon as the package existed, even when that copy was a different build. Version codes cannot tell those apart, since they count commits on master, so the two APKs are compared by digest instead and the row offers a reinstall. A comparison that could not be made — a dead daemon, an unreadable install — leaves the card as it was, and only a completed one is remembered.
…stent (#757) API 102 is built around hot reload: replacing a module's code inside a process that is already running it. Three smaller capabilities make that coherent. A single entry class must be able to withdraw from lifecycle callbacks while its siblings carry on, or a module cannot retire part of itself. A hooker must be replaceable atomically, or every migration leaves a window in which the method is unhooked. And a module targeting this level may no longer reach the legacy API, which is global static state with no notion of generations, so anything reaching it leaves behind what a reload cannot clean up. The motivation is the cost of iteration. Changing a module means killing every process it is injected into, and for one that hooks the system, a reboot — taking with it whatever state was being reproduced. Reloading in place is effectively free, and deliberate state can be carried across the swap. For users the same mechanism means an update can reach processes already running the old code, offered rather than imposed, since a module is asked first and may refuse. The implementation follows from one decision: the framework already knows which module it handed to which process, so a reloadable target is derived from that record rather than registered by the process. This matters because the system server loads its modules before the framework's own bookkeeping exists, and any lookup there would fail silently; for the same reason the channel it calls back through carries no module identity. Reloads are asynchronous under a timeout the framework owns, since the callee runs arbitrary module code and the IPC layer imposes no deadline. A replacement generation is built fully before the previous one is disturbed, and the swap happens under the lock a call already takes, so a call in flight is answered by the generation it began with. Most of this is IPC, so the interfaces now live in this project's own namespace and are documented. That is not tidying: several of their constraints cannot be inferred from the signatures, and violating one fails in a way whose symptom points elsewhere. Hot reload also exposed how loosely the framework held the idea of a user. A module is one package and one binary for the whole device, so its configuration belongs to the package; what varies per user is only whether it is installed and what identity it is given there. Nothing enforced that, so a module present in one user could execute inside another user's applications. The invariant is now explicit — a module runs only in the users that installed it — with the system server the one principled exception, since it belongs to no user and every holder has equal claim. Granting and revoking scope became symmetric, module identity is stored at the granularity it is compared at, and queries answer with what the caller can act on. Co-Authored-By: JingMatrix <jingmatrix@gmail.com>
On a release build, pressing install on a canary opened the newest release. The channel rule that keeps nightlies out of the update card also filtered the list an explicit request was resolved against, so the version code the canary list passed matched nothing and the selection fell back to the channel default. That rule now filters what is offered, not what can be asked for by name. The canary page is rebuilt around the builds themselves. Each row is the build's head commit — subject wrapped, author credited, pull request in a fixed corner that opens the discussion — matched by SHA, falling back to the subject CI writes into the release notes. The header names the issues closed since the running build was cut, read from the issues endpoint filtered to `completed`: the Development panel closes issues without writing to any commit message, and the link itself exists only in GraphQL, which needs an account. The commit rail's marker shows where the running build sits, and a build wearing a canary's number without being it is told so.
Finishes the namespace move #757 started, doing the three things it deferred: the manager's interface, the Utils logger, and the daemon's implementation class names. /data/adb/lspd stays as it is — on-disk state wants a migration, not a rename. ILSPManagerService and its parcelables become IManagerService, IFrameworkInstallReceiver, ScopeEntry and DeviceUser under org.matrix.vector.ipc. org.lsposed.lspd.util.Utils becomes org.matrix.vector.util, with its inner Log promoted to top level. ApplicationService and ModuleService take the names of the interfaces they implement. 45 methods become 39: three were dead, and getUnloadableModules plus getModuleLoadState collapse into one call returning a map, which drops the placeholder reason a lost transaction used to report as a missing APK. The hand-written transaction ids go too — they kept a number stable rather than a meaning, and getProtocolVersion, declared first and so transaction zero in every revision, guards that at the handshake instead. The interface name is the binder descriptor, so a separately installed manager stops working until it is updated. It now says so rather than drawing blank screens. Seven unrelated defects come with it, each its own commit: a discarded insert() result, a health flag latched before the work it reports, an unbounded wait on a binder thread, and four smaller ones.
Restoring the manager after its process had been reaped crashed, which is #871 -- and #834 before it, on Android 13. Two rules are missing, and the second one only became visible once the first was in place. R8 shrank values() out of 105 of the 106 enums in the released manager, because nothing calls it any more: Kotlin compiles `entries` to a separate synthetic field, so the generated method is left without a call site. Enum.valueOf looks that method up by name, so an enum written into a Bundle -- Parcel has no enum case and java.lang.Enum is Serializable, so it goes out as VAL_SERIALIZABLE -- cannot be read back. The navigation suite scaffold state is one such enum, and it sits at the root of every screen. CREATOR is found the same way, by a reflective field lookup R8 cannot see, and it was gone from every Parcelable the manager did not already keep by name. A `mutableStateOf` that survives process death is a ParcelableSnapshotMutableState, so with the enums fixed the same restore threw BadParcelableException instead. Both stanzas are AGP's, from proguard-android-optimize.txt. That file stopped being passed to proguardFiles in #263, five years ago; the legacy manager had copied CREATOR back by hand, the rewrite in #796 did not, and it declared no enums at all, so neither rule was missed until the Compose manager needed them. Verified on a Pixel 6 (Android 17) and a Galaxy A52s (Android 14): open the manager, background it, kill the host process so the icicle comes back through a Parcel, reopen. Before, that crashed every time; after, state restores across repeated cycles, including the Logs tab and an open bottom sheet.
…owhere (#876) The system server is offered as a synthetic row rather than an installed package, so no filter can lead a reader back to it, yet it was subject to the system-apps filter and a module whose declared scope is the framework showed an empty list. Exempt the framework alone, and only when the module asks for it. The empty-scope dialog's dismiss action closed it and returned the reader to the page they were trying to leave, where back raised it again; the only exit was to disable the module. Make that button leave, and offer the module's own recommendation as the primary action where it declares one. Legacy modules name the framework "android" and the android package "system"; modern modules and the daemon use the reverse. The store normalised the installed scope but not the catalogue's, so one module could name one target two ways in adjacent lines. Move the transform into one function both readings pass through. Module-update notifications are always enqueued for user 0 but carried the user whose PACKAGE_REPLACED raised them, so for a module installed in a private space as well as the main user the surviving notice pointed into a locked profile. Prefer user 0 when the module is installed there.
#882) Constructing a Notification on Android 16 reads an aconfig flag of the systemui container, and the daemon cannot serve that read: it holds an ActivityThread but no application record, so the settings provider is refused it and the constructor throws SecurityException. Setting systemui_is_cached before the first read leaves the flags at their compiled defaults and removes the read. That bypass was gated on SDK_INT >= VANILLA_ICE_CREAM, wider than the release it belongs to, because Xiaomi shipped the change on Android 15 without the SDK level; #96 was such a device, four days before Android 16 DP1 was published. #597 rewrote the test as SDK_INT == 36 and lost that case, which #880 now reports on the same vendor and the same version. Android 17 sets those fields unconditionally, so the test spans 35 to 36, the versions in which the field can exist.
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.
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 free
to 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.