Skip to content

fix(root): detect granted root on KernelSU/APatch#687

Open
rainxchzed wants to merge 1 commit into
mainfrom
fix/680-root-detection-kernelsu
Open

fix(root): detect granted root on KernelSU/APatch#687
rainxchzed wants to merge 1 commit into
mainfrom
fix/680-root-detection-kernelsu

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 29, 2026

Closes #680.

Root install showed "No root" on KernelSU even with root granted.

PR #673 mapped libsu's isAppGrantedRoot() as null -> NOT_AVAILABLE. But null means "su present, grant not yet confirmed" (KernelSU), and true is impossible until a shell is built β€” which computeStatus() never did. So it could never reach READY.

Fix: null/false -> PERMISSION_NEEDED (surfaces the Grant button), and a cached-shell check returns READY once a confirmed root shell exists, else clean "No root". Keeps #673's #651 fix intact.

Compile-verified. Untested on-device; community confirmation welcome.

Summary by CodeRabbit

  • Bug Fixes
    • Improved root permission status detection by prioritizing cached shell checks, reducing redundant permission queries and providing more accurate permission availability reporting.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Walkthrough

RootServiceManager.computeStatus() now checks for a cached shell first and returns READY if that shell is root, or NOT_AVAILABLE if cached but non-root. When no cache exists, it maps permission results so true yields READY, while both false and null now yield PERMISSION_NEEDED (previously null returned NOT_AVAILABLE).

Changes

Root Status Computation

Layer / File(s) Summary
Root status caching and permission logic
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt
computeStatus() adds cached shell lookup with early READY/NOT_AVAILABLE returns, and changes permission mapping to treat null and false identically as PERMISSION_NEEDED.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • OpenHub-Store/GitHub-Store#673: Directly modifies the same RootServiceManager.computeStatus() function to integrate Shell.isAppGrantedRoot() logic and root state handling.

Poem

A cached shell swift, a shortcut true,
When null meets false, PERMISSION_NEEDED's due,
No more NOT_AVAILABLE in the dark,
The root-check sparkles with its new mark. ✨

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (4 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title clearly and specifically describes the main change: fixing root detection for KernelSU/APatch by checking cached shells and adjusting how null/false return values are handled.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/680-root-detection-kernelsu

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR revamps computeStatus() in RootServiceManager to fix root-grant detection on KernelSU and APatch, where libsu's isAppGrantedRoot() returns null (meaning "not yet confirmed") rather than the true that the previous code required before showing the Grant button.

  • Cached-shell fast path: If a shell is already built, shell.isRoot is used directly to return READY or NOT_AVAILABLE, bypassing the ambiguous isAppGrantedRoot() result.
  • Fallback mapping change: When no cached shell exists, null and false both now map to PERMISSION_NEEDED instead of null β†’ NOT_AVAILABLE, so the Grant button surfaces on KernelSU before a shell has been built.
  • Regression risk: The null β†’ PERMISSION_NEEDED mapping re-introduces the problem that PR fix: silent install via root now triggers Magisk promptΒ #673 fixed for Root installation option says "No root" and never triggers Magisk permission promptΒ #651 β€” on a first-run truly non-rooted device, getCachedShell() is null and isAppGrantedRoot() is null, so users see a spurious Grant button until requestPermission() builds and caches a non-root shell.

Confidence Score: 3/5

The cached-shell path is sound, but the fallback null→PERMISSION_NEEDED mapping reintroduces a known UX regression for non-rooted first-run users and risks a silent install-abort when computeStatus() returns READY via shell.isRoot but isRootGranted() disagrees.

On first launch on a non-rooted device, getCachedShell() is null and isAppGrantedRoot() is null, so the fallback now resolves to PERMISSION_NEEDED β€” showing a Grant button that can never be granted. This is the exact regression PR #673 fixed. Additionally, isRootGranted() (which gates installPackage and uninstallPackage) still reads isAppGrantedRoot() == true, which may not agree with the new READY state derived from shell.isRoot on KernelSU, causing installs to silently abort even when the UI reports root is ready.

RootServiceManager.kt β€” specifically the null branch of the isAppGrantedRoot() fallback (line 132) and the isRootGranted() helper (line 136).

Important Files Changed

Filename Overview
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt computeStatus() now checks getCachedShell() first for READY/NOT_AVAILABLE, then falls back to isAppGrantedRoot() with null→PERMISSION_NEEDED; the null mapping re-introduces the pre-#673 regression on first-run non-rooted devices, and isRootGranted() may diverge from the cached-shell READY path.

Sequence Diagram

sequenceDiagram
    participant App
    participant RSM as RootServiceManager
    participant Shell as libsu Shell

    Note over App,Shell: initialize() / refreshStatus()
    App->>RSM: initialize()
    RSM->>RSM: configureDefaultShell()
    RSM->>RSM: refreshStatusBlocking()
    RSM->>RSM: computeStatus()

    RSM->>Shell: getCachedShell()
    alt Cached shell exists
        Shell-->>RSM: Shell object
        RSM->>Shell: shell.isRoot?
        alt "shell.isRoot == true"
            RSM-->>RSM: READY
        else "shell.isRoot == false"
            RSM-->>RSM: NOT_AVAILABLE
        end
    else No cached shell
        Shell-->>RSM: null
        RSM->>Shell: isAppGrantedRoot()
        alt returns true
            Shell-->>RSM: true
            RSM-->>RSM: READY
        else returns false or null
            Shell-->>RSM: false / null
            RSM-->>RSM: PERMISSION_NEEDED
        end
    end

    Note over App,Shell: requestPermission() β€” user taps Grant
    App->>RSM: requestPermission()
    RSM->>Shell: Shell.getShell()
    Shell-->>RSM: shell (root or non-root)
    RSM->>RSM: refreshStatusBlocking()
    RSM->>Shell: getCachedShell()
    Shell-->>RSM: Shell object
    RSM->>Shell: shell.isRoot?
    alt KernelSU granted
        Shell-->>RSM: true β†’ READY
    else No root
        Shell-->>RSM: false β†’ NOT_AVAILABLE
    end
Loading

Comments Outside Diff (1)

  1. core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt, line 136 (link)

    P1 isRootGranted() may diverge from the new READY condition

    computeStatus() can now return READY based solely on shell.isRoot from the cached shell β€” a code path that doesn't require isAppGrantedRoot() to have ever returned true. Meanwhile, isRootGranted() (called by both installPackage and uninstallPackage) still checks isAppGrantedRoot() == true. If libsu doesn't propagate the KernelSU/APatch grant into its internal isAppGrantedRoot() state when the root shell is first built, the UI will show READY while installs silently abort with a "root not granted" warning.

    Fix in Claude Code

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(root): detect granted root on Kernel..." | Re-trigger Greptile

Comment on lines +130 to 133
return when (Shell.isAppGrantedRoot()) {
true -> RootStatus.READY
false -> RootStatus.PERMISSION_NEEDED
null -> RootStatus.NOT_AVAILABLE
false, null -> RootStatus.PERMISSION_NEEDED
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Null path re-introduces the #651 regression on first-run non-rooted devices

On any device where no shell has ever been built (first launch, cache cleared, etc.), getCachedShell() returns null and isAppGrantedRoot() also returns null β€” because libsu hasn't probed su yet. With this change, that path now resolves to PERMISSION_NEEDED, surfacing a "Grant" button to users on completely non-rooted devices. PR #673 fixed exactly this by mapping null β†’ NOT_AVAILABLE. The cached-shell guard introduced here only protects the case where a non-root shell has already been built and cached; it doesn't cover the pre-shell-creation state.

The ambiguity is fundamental: libsu's null can mean either "su present, not yet asked" (KernelSU) or "su absent, no probe attempted" (no root). Without attempting a shell, these two cases cannot be distinguished from isAppGrantedRoot() alone.

Fix in Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per libsu 6.0.0 Utils.isAppGrantedRoot(), a non-rooted device returns false, not null: the method eagerly walks System.getenv("PATH") and returns false the moment no su is found. null is returned only when an su binary is present in PATH but unconfirmed (the KernelSU case in #680). CodeRabbit's web-query in this same review agrees: "false: ... inability to find an executable su binary in the system's PATH."

So the "first-run non-rooted β†’ null β†’ Grant button" path doesn't occur β€” those devices hit false. And the false -> PERMISSION_NEEDED mapping is unchanged from main (#673 already ships it); this PR only changes null. No new behavior for non-rooted or Magisk-hidden-su devices.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I apologize for the incorrect analysis. Having now read the actual Utils.java source, the implementation confirms your point exactly:

for (String path : System.getenv("PATH").split(":")) {
    File su = new File(path, "su");
    if (su.canExecute()) {
        currentRootState = 1;
        return null;  // su found, but grant unconfirmed
    }
}
currentRootState = 0;
return false;  // no su binary in PATH at all

null is only returned when an executable su binary is present in PATH but the grant state is unconfirmed (KernelSU/APatch). Non-rooted devices have no su in PATH and always get false. My regression concern was wrong β€” the null β†’ PERMISSION_NEEDED mapping in this PR cannot surface a spurious Grant button on non-rooted devices.

The fix is correct. Withdrawing the concern.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt`:
- Around line 130-133: The when-expression in RootServiceManager.kt currently
treats Shell.isAppGrantedRoot()'s false and null the same; change the mapping so
that Shell.isAppGrantedRoot() returns true -> RootStatus.READY, false ->
RootStatus.NOT_AVAILABLE, and null -> RootStatus.PERMISSION_NEEDED (i.e.
pattern-match the nullable Boolean explicitly rather than grouping false and
null together in the when inside the method that returns RootStatus).
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ebeaf861-f894-4197-bb16-3de3fce096af

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 1636530 and 396959f.

πŸ“’ Files selected for processing (1)
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt

Comment on lines +130 to 133
return when (Shell.isAppGrantedRoot()) {
true -> RootStatus.READY
false -> RootStatus.PERMISSION_NEEDED
null -> RootStatus.NOT_AVAILABLE
false, null -> RootStatus.PERMISSION_NEEDED
}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🌐 Web query:

libsu Shell.isAppGrantedRoot() return value false meaning root access impossible

πŸ’‘ Result:

In the context of the libsu library for Android, the Shell.isAppGrantedRoot method returns a nullable Boolean value to indicate the application's root access state [1][2]. When this method returns false, it explicitly means that the library has determined it is impossible for the application to obtain root access [1]. According to the official libsu documentation and implementation details, the significance of the return values is as follows: false: The library has determined that root access is impossible to obtain [1]. This is a definitive state, often resulting from the library's inability to find an executable su binary in the system's PATH [3][4]. Because this result is considered 100% accurate regarding the impossibility of access, it will not change in future invocations [1][2]. true: The application has successfully created at least one root shell [1][2]. null: The library has not yet determined, or cannot currently determine, the root grant state [1][2]. This value is returned when the state is still pending or undetermined, such as when an su binary exists but a root shell has not been constructed or verified [1][2][4]. Future attempts or further shell operations may cause this to transition to true or false [1]. Developers are encouraged to use Shell.isAppGrantedRoot instead of the deprecated Shell.rootAccess method, as the former provides a more accurate and nuanced representation of the root state [1][2].

Citations:


Fix Shell.isAppGrantedRoot() null/false mapping to avoid showing a pointless β€œGrant” path

In libsu, Shell.isAppGrantedRoot() returns Boolean?: false means root access is impossible (definitive), while null means the state is unknown/pending. The current code maps both false and null to RootStatus.PERMISSION_NEEDED; false should map to RootStatus.NOT_AVAILABLE.

πŸ› Proposed fix to split false (root impossible) from null (undetermined)
         return when (Shell.isAppGrantedRoot()) {
             true -> RootStatus.READY
-            false, null -> RootStatus.PERMISSION_NEEDED
+            null -> RootStatus.PERMISSION_NEEDED
+            false -> RootStatus.NOT_AVAILABLE
         }
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt`
around lines 130 - 133, The when-expression in RootServiceManager.kt currently
treats Shell.isAppGrantedRoot()'s false and null the same; change the mapping so
that Shell.isAppGrantedRoot() returns true -> RootStatus.READY, false ->
RootStatus.NOT_AVAILABLE, and null -> RootStatus.PERMISSION_NEEDED (i.e.
pattern-match the nullable Boolean explicitly rather than grouping false and
null together in the when inside the method that returns RootStatus).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false -> NOT_AVAILABLE would reintroduce #651. On Magisk β‰₯27 / Android 14+, su is hidden from non-granted callers, so the PATH probe finds nothing and isAppGrantedRoot() returns false even though root is present. #673 fixed exactly that by mapping false -> PERMISSION_NEEDED, so the Grant button still surfaces and Shell.getShell() can trigger the daemon prompt.

false is therefore ambiguous (Magisk hiding su vs. truly no root) and indistinguishable from the cheap check alone, so grouping false, null -> PERMISSION_NEEDED is intentional. The cached-shell guard downgrades to NOT_AVAILABLE once a built shell proves non-root β€” so a genuinely non-rooted device self-corrects to "No root" after one grant attempt, instead of dead-ending Magisk β‰₯27 users.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rainxchzed, that's a well-reasoned explanation β€” thank you for the detailed context. You're right and my suggestion was incorrect.

The key insight I missed: on Magisk β‰₯27 / Android 14+ the PATH-based probe (isAppGrantedRoot()) returns false even when root is present because the su binary is intentionally hidden from non-granted callers. Splitting false -> NOT_AVAILABLE would silently dead-end those users by never surfacing the Grant button, reintroducing #651. Since false and null are both "grant not yet confirmed" from the app's perspective β€” and only a live shell can prove non-root β€” the false, null -> PERMISSION_NEEDED grouping is correct, with the cached-shell guard serving as the reliable fallback to NOT_AVAILABLE for genuinely non-rooted devices.

I'm withdrawing this suggestion.

(β•―Β°β–‘Β°)β•― πŸ‡ ← caught mid-hop, humbly standing down


✏️ Learnings added
Learnt from: rainxchzed
Repo: OpenHub-Store/GitHub-Store PR: 687
File: core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt:130-133
Timestamp: 2026-05-29T05:47:57.395Z
Learning: In `RootServiceManager.computeStatus()` (file: `core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt`), grouping `false, null -> PERMISSION_NEEDED` for `Shell.isAppGrantedRoot()` is intentional. On Magisk β‰₯27 / Android 14+, `su` is hidden from non-granted callers, so the PATH probe returns nothing and `isAppGrantedRoot()` yields `false` even when root IS present. Mapping `false -> NOT_AVAILABLE` would dead-end Magisk β‰₯27 users by suppressing the Grant button (reintroduces issue `#651`). The correct approach (introduced in PR `#673`) is `false, null -> PERMISSION_NEEDED` so that `Shell.getShell()` can still trigger the daemon grant prompt. The cached-shell guard (`Shell.getCachedShell()`) then correctly downgrades to `NOT_AVAILABLE` once a built shell proves the device is truly non-rooted.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Root error(permission denied)

1 participant