Thor v1.95.2 Release Notes
A dev build on top of the v1.95.1 stable release. Its headline is per-component control —
the Components tab in App Info stops being a list of class names and becomes something you can act
on — and alongside it, the silent-install path that was quietly falling back to the system dialog on
both Shizuku and Dhizuku now works.
✨ Highlights
- 🧩 Per-component control — open an activity, force-open the ones an ordinary launch cannot reach, stop a running service, or switch off an individual component, straight from App Info → Components.
- 📒 A ledger, not a policy — Thor records only what it disabled itself, so Restore all puts those components back the way they shipped and leaves everything else alone. Nothing is re-applied at boot;
PackageManagerstays the source of truth. - 🔒 Honest about privilege — component writes need uid 0, so the screen says why it cannot act (shell-mode Shizuku, Dhizuku) instead of drawing buttons that throw on every press.
- 🔧 Silent install actually silent — the Shizuku and Dhizuku rungs no longer fall through to the system installer confirmation dialog. Three separate defects, one per rung.
- ❄️ The Freezer can no longer take the process down — a watchlist write that fails is reported, not thrown into an app with no exception handler.
- 💬 Readable failures — an error toast shows the reason rather than
DynamicString(value=…)or an R8-shortened class name. - 🌍 Locale-correct backup dates — the archive date follows the in-app language picker, matching the size beside it.
What's Changed
🧩 Per-Component Control in App Info (#435, #436, #439, #440, #442)
The Components tab listed four columns of class names and did nothing else. It now offers what
Activity Launcher and the root component managers give you:
- Open / Force open — an exported, unguarded activity launches with a plain
startActivityand no privilege at all. Everything else routes througham start --user N -n. The predicate is!exported || permission != null, not just!exported: an exported activity sitting behind anandroid:permissionfails exactly the way an unexported one does. - Stop now — stops a running service.
- Disable / Enable / Reset to default — switching a component off asks for consent once, persisted for the session; the read-only verbs never consult it. Re-enabling prefers
default-stateoverenable, becauseenablewrites an override that outlives an app update a real default would not. - Copy class name — for the rows you want to take elsewhere.
- Badges that name the state: Restricted by Thor, Changed elsewhere, Disabled, Not exported, Off by default.
What a privilege mode can actually do. Every privileged verb here collapses onto one fact — does
the transport execute at uid 0 — so capability is keyed on the effective uid, not on the
privilege mode:
setComponentEnabledis uid 0 only.PackageManagerService.setEnabledSettingcarves outProcess.SHELL_UIDon the explicit condition thatclassName == null; with a class name present it throws, and reachingIPackageManagerby reflection lands on the same check with the same calling uid.- Force-launch is uid 0 only.
ActivityManager.canAccessUnexportedComponentswaives the export and permission checks forROOT_UIDandSYSTEM_UIDalone, andSTART_ANY_ACTIVITYissignature-level — the Shell package does not declare it in any release from 9 to 16. - So Root works, Shizuku works only when Shizuku itself was started as root, and Dhizuku is an empty case rather than a partial one — and each says so in its own words. An unreadable Shizuku uid fails closed, because a wrong guess here paints a screen of controls that throw.
Restore all. One button puts every component Thor disabled back the way it shipped —
in this app and in every other, for the current user. restoreToEnabled stores the manifest
default as read at the time, never true, so a component that ships disabled is restored to
disabled rather than to a state the app never had. Two orderings are load-bearing and pinned by
tests: the ledger row is written only after the platform call succeeds, and dropped only after
the restoring call succeeds. A partial run therefore reports "Restored X of Y — try again to
finish the rest": a row can survive either because the component is still off, or because the
platform call succeeded and only Thor's record of it failed to clear.
Two traps in the shell layer, both closed here:
- A component spec is
pkg/cls, andPackageManagerreports an inner class ascom.foo.Widget$Receiver. Interpolated unquoted,$Receiverexpands to nothing and the command lands oncom.foo.Widget— in many apps a different, real component. Every spec is single-quoted throughescapeForShell(). - The exit code cannot answer whether a launch worked, in either direction.
am startprintsSecurity exception:and still exits 0 on most releases, while Android 14 folds the same refusal intoSTART_CLASS_NOT_FOUND. The verdict is read from the output, with the code as a backstop — andWarning: Activity not started, its current task has been brought to the frontis deliberately not a failure, since that is the single most common repeat press in the feature.
Privileged actions are serialised per screen, because two concurrent pm writes to
package-restrictions.xml can lose one. Room migrates 6 → 7 by auto-migration for the new table.
🔧 Silent Install on Shizuku and Dhizuku (#434)
Silent install fell through to the system confirmation dialog on both privileged modes while root
and normal install worked. Three independent defects, one per rung:
- Shell
pm, both modes. The multi-APK path invokedpm install-multiple, a verb that does not exist outsideadb's own client, and the single-APK path handedpm installa path inside Thor'sAndroid/data, which a non-root shell cannot read from API 30 on. Both now use the streaming session shape the root gateway already used —install-create→cat <apk> | install-write -S <size> $SID <name> -→install-commit— so the bytes travel over stdin and no privileged reader has to reach into app-private storage. - Privileged
PackageInstaller, both modes. The session install passed the rawIPackageInstallerSessionalong, so every followingopenWrite/fsync/committransacted from Thor's own app uid and met "Session does not belong to uid N". The session binder is now wrapped beforePackageInstaller.Sessionis built around it. - Dhizuku.
getDhizukuPackageInstaller()obtained itsIPackageInstallerthrough the Shizuku utils, so the Dhizuku rung transacted over a wrapper around a service that is not installed on a Dhizuku-only device. The transport is now chosen from theInstallModein awhenwith noelse, and each transport applies exactly one wrapper: its own. The installer package name also stops being cosmetic once a Dhizuku session really reachessystem_server, so it is set correctly.
❄️ Freezer Bookkeeping Hardening (#437, #438)
Room reports a failed write by throwing, FreezerRepositoryImpl is a bare pass-through, and :app
installs no CoroutineExceptionHandler — so every watchlist call sitting in a bare
viewModelScope.launch was one full disk away from process death. 16 of 21 call sites, across
four view models, and ten of those threw after an irreversible privileged call had already
succeeded.
- Every watchlist call site now runs under
launchGuarded, and the handlers distinguish the two sides of the irreversible step: a throw before it means the tap did nothing, a throw after it means the app really is frozen or thawed and only Thor's record is missing — reported as two messages, because either alone is a half-truth. - The port, not just its callers.
FreezerShortcutManager.pinAppShortcut,refreshAppShortcutandsyncDynamicShortcutsare fire-and-forget: they return once the body is merely scheduled, so no caller-side guard could ever have caught them. They now route throughlaunchSafely. - Ordering. Removing an app from the Freezer greys the launcher shortcut before dropping the row, at all four surfaces. A pinned shortcut can only be disabled, never deleted, so the row is the only end of the pair Thor can take back — greying first means a launcher that refuses stops the delete and leaves a state a retry can act on.
- A post-merge review of #437 turned up five more: an app-info sheet drawing the
frozenchip over an app its own toast had just called unfrozen, and the ordering rationale itself — which was backwards at five code sites and on the public site. A per-app shortcut carriesACTION_LAUNCH, so a stale live shortcut thaws an app; it cannot drive a freeze from the launcher. Greying first is still right, for retryability, and that is the reason now given.
💬 Legible Errors (#438, #439)
- A
UiTextpassed as a format argument reachedString.format, which has no idea what one is and fell back totoString(). Users readError: DynamicString(value=no privileged gateway), orError: …UiText$StringResource@4f2a1c— which minification only shortens. NestedUiTextis resolved before formatting now. Two tests had asserted the garbled shape, one of them named "is shown as that message, not as a bare error"; both now assert what actually reaches the user. toString()onStringResourceandPluralsResource, so the next leak reads as "wrong type here" rather than as a crash artefact.UiTextExceptiongets one too — deliberately astoString, not as amessage, because about a dozen handlers rendere.messageinto a toast and giving this type a message would trade an empty error for a worse one.- The obfuscated success toast is gone.
getSuccessMessage'selsebranch filled a translated sentence withaction.javaClass.simpleName— untranslated in all eight locales, and R8 has no keep rule forAppClickAction, so the toast read "b completed: Instagram". Thewhenis exhaustive now, making a newly routed action a compile error rather than a mystery toast.
🌍 Locale-Correct Archive Dates (#434)
DateFormat.getDateInstance(MEDIUM) reads Locale.getDefault(FORMAT), which below API 33 is the
device's language whatever the in-app picker chose — so a backup card showed a localised size
beside an English date. The date now resolves through AppLocale.localeOf(context), off the same
Configuration the composable resolves its strings with.
🧰 Project & Internal
- Component-control documentation (#439, #440) — the feature is written up on the site, and the design spec corrected: Restore all is cross-app but single-user, not device-wide, and the KDoc now states that the set it leaves behind is wider than "the components still disabled".
SyntheticAccessorpinned in:app(#441, #443) — 14 production declarations widened tointernal, and the follow-up report corrected to name the exact lint id and every source set the change touched, including the test one that neitherlintStoreReleasenor a failing build reports.- Shizu Store listing (#428, converged from
master) — the live schema had mutated in place under the sameschema_version: 1, making the manifest a hard rejection that the store ignores silently; re-vendored, with the developer profile slots filled. - Web layer converged (#428) — IndexNow submission, the downloads hero,
llms.txt/robots.txt, and three documentation corrections, all already live onmaster, now back ondev. - Dependencies (#426, #427, #431) — Gradle wrapper 9.7.0 → 9.7.1,
github/codeql-action, and three web packages. - Store changelog sync (#433) — the Shizu manifest brought up to v1.95.1.
🛠 Reliability & Verification
./gradlew :app:testFossDebugUnitTest --rerun-tasks— 129 suites, 1776 tests, 0 failures (up from 1634 at v1.95.1; 51 of the new ones cover component control, 8 theUiTextargument walk)../gradlew lintFossDebug lintStoreRelease— clean; noMissingTranslationacross any flavour, and the eight in-app locales all carry the reworded partial-restore string.- Room 6 → 7 auto-migration for
component_overrides, with the exported schema checked in.
🛠 Commits Log
Per-component control
f8408b48docs(components): design spec for per-component control in App Info3104018efeat(components): open, force-open, stop and disable individual componentscf19a134fix(components): read stopservice's verdict from stderr, not its exit code06781eacfix(components): rank failure markers by usefulness, not by line ordere7090e42fix(components): a dead transport is not a stopped service6ed95a38fix(components): answer the three review findings on the disable path037aaa3cfix(components): the session-consent box says whether it is ticked60cc4daffix(components): give the consent row back the 48dp the null handler gave up84422d9ddocs(components): document per-component control, and correct the spec's scope73c81259docs(components): Restore all is cross-app, not device-wide8b6382b0docs(components): say which rows a partial Restore all leaves behind6517660dfix(strings): a partial Restore All must not claim the rest are off
Installer & backup
0bd1369efix(installer): make the Shizuku and Dhizuku silent-install rungs actually work583198affix(backup): format the archive date in the app locale4d038c1adocs(installer): correct two overstated claims and one overstated test535e7c9fdocs(installer): say what actually exempts the shell from Android/data
Freezer & errors
e0ed3f76fix(freezer): a watchlist write that fails must not take the processb8acbf0afix(freezer): the review follow-ups PR #437 left behindab029f94fix(uitext): make a leaked UiText legible and kill the obfuscated toast
Project
de4119e3chore(lint): pin SyntheticAccessor in :app, and correct what it is worthdfd1a8b0docs(follow-ups): exact lint id, and every source set the :app fix touched09153c8echore: reconverge master's web-layer commits into dev61ab1ec6chore(shizu): sync store changelog to v1.95.10541d937chore(deps): bump gradle-wrapper from 9.7.0 to 9.7.1 in the maven group91f5f784chore(deps): bump github/codeql-action in the actions group71b33dd9chore(deps): bump the web group in /web with 3 updates
Full Changelog: v1.95.1...v1.95.2