Skip to content

Commit c52c5de

Browse files
committed
add remove system app
1 parent 0095bc6 commit c52c5de

8 files changed

Lines changed: 662 additions & 25 deletions

File tree

app/proguard-rules.pro

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,11 @@
2626
# release `full` build ever regresses to "NOT_ROOTED" while debug works, look here first.
2727
-keep class com.topjohnwu.superuser.** { *; }
2828
-keep class com.topjohnwu.superuser.internal.** { *; }
29-
-dontwarn com.topjohnwu.superuser.**
29+
-dontwarn com.topjohnwu.superuser.**
30+
31+
# Shizuku.newProcess is package-private; we reach it via reflection to run one-shot `pm`
32+
# commands without building a full UserService/AIDL layer. R8 would otherwise rename the
33+
# method and break ShizukuShellExecutor silently in release builds.
34+
-keepclassmembers class rikka.shizuku.Shizuku {
35+
private static *** newProcess(...);
36+
}

app/src/full/java/app/pwhs/universalinstaller/presentation/install/controller/FullInstallerBackendFactory.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,42 @@ class FullInstallerBackendFactory : InstallerBackendFactory {
6969
): BaseInstallController = RootInstallController(
7070
application, packageInstaller, sessionDataRepository, historyDao,
7171
)
72+
73+
/**
74+
* Shells out to `pm` directly. The quoting is trivial because package names match
75+
* `[A-Za-z0-9._]+` — no shell metacharacters can appear. We still verify before
76+
* passing to make refactors obvious if that assumption changes.
77+
*/
78+
override suspend fun uninstallSystemAppViaRoot(
79+
packageName: String,
80+
method: SystemAppMethod,
81+
): Result<String> = withContext(Dispatchers.IO) {
82+
require(packageName.matches(Regex("^[A-Za-z0-9._]+$"))) {
83+
"Refusing to shell out with suspicious package name: $packageName"
84+
}
85+
val cmd = when (method) {
86+
SystemAppMethod.UninstallForUser0 -> "pm uninstall --user 0 $packageName"
87+
SystemAppMethod.Disable -> "pm disable-user --user 0 $packageName"
88+
}
89+
runCatching {
90+
val result = Shell.cmd(cmd).exec()
91+
val stdout = result.out.joinToString("\n")
92+
val stderr = result.err.joinToString("\n")
93+
// `pm` returns non-zero on real failures AND prints a distinctive success string
94+
// per subcommand. We verify both because some ROMs return 0 for soft failures
95+
// like `Failure [NOT_INSTALLED_FOR_USER]`. The string token differs per command:
96+
// `pm uninstall --user 0` → "Success"
97+
// `pm disable-user --user 0` → "new state: disabled-user"
98+
val successToken = when (method) {
99+
SystemAppMethod.UninstallForUser0 -> "Success"
100+
SystemAppMethod.Disable -> "new state: disabled"
101+
}
102+
if (!result.isSuccess || !stdout.contains(successToken, ignoreCase = true)) {
103+
throw RuntimeException(
104+
"pm command failed: ${stdout.ifBlank { stderr }.ifBlank { "no output" }}",
105+
)
106+
}
107+
stdout
108+
}
109+
}
72110
}

app/src/main/java/app/pwhs/universalinstaller/presentation/install/controller/InstallerBackendFactory.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,25 @@ interface InstallerBackendFactory {
4242
sessionDataRepository: SessionDataRepository,
4343
historyDao: InstallHistoryDao,
4444
): BaseInstallController?
45+
46+
/**
47+
* Shell-out wrapper for system-app removal. Ackpine's libsu plugin routes through
48+
* `IPackageInstaller.uninstall` which rejects system apps with DELETE_FAILED_INTERNAL_ERROR,
49+
* so for `/system` packages we bypass ackpine and call `pm` directly via the root shell.
50+
*
51+
* Safe to call only when [probeRootState] returned [RootState.READY]; otherwise this
52+
* returns `Result.failure`. Store flavor always returns failure.
53+
*/
54+
suspend fun uninstallSystemAppViaRoot(
55+
packageName: String,
56+
method: SystemAppMethod,
57+
): Result<String>
58+
}
59+
60+
enum class SystemAppMethod {
61+
/** `pm uninstall --user 0 <pkg>` — hides package for user 0; reversible via `cmd package install-existing`. */
62+
UninstallForUser0,
63+
64+
/** `pm disable-user --user 0 <pkg>` — freezes package; reversible via `pm enable`. */
65+
Disable,
4566
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package app.pwhs.universalinstaller.presentation.install.controller
2+
3+
import android.content.pm.PackageManager
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.withContext
6+
import rikka.shizuku.Shizuku
7+
import timber.log.Timber
8+
9+
/**
10+
* Run privileged `pm` commands via Shizuku's `newProcess()` — same shell UID (2000) that
11+
* `adb shell` runs as, so per-user uninstall and disable of system apps work without Root.
12+
*
13+
* Shizuku v13 moved `newProcess` to private visibility to push callers toward the
14+
* UserService/AIDL pattern. A UserService would take ~100 LOC for a one-shot shell wrapper,
15+
* so instead we call via reflection. If a future Shizuku release ever removes the method,
16+
* [isReady] flips to false and the ViewModel routes users to the "Root required" dialog.
17+
*/
18+
object ShizukuShellExecutor {
19+
20+
private val newProcessMethod: java.lang.reflect.Method? = try {
21+
Shizuku::class.java.getDeclaredMethod(
22+
"newProcess",
23+
Array<String>::class.java,
24+
Array<String>::class.java,
25+
String::class.java,
26+
).apply { isAccessible = true }
27+
} catch (t: Throwable) {
28+
Timber.w(t, "Shizuku.newProcess not reachable — Shizuku shell path disabled")
29+
null
30+
}
31+
32+
fun isReady(): Boolean {
33+
if (newProcessMethod == null) return false
34+
return try {
35+
Shizuku.pingBinder() &&
36+
!Shizuku.isPreV11() &&
37+
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
38+
} catch (t: Throwable) {
39+
Timber.w(t, "Shizuku readiness check failed")
40+
false
41+
}
42+
}
43+
44+
/**
45+
* Shells out via Shizuku's remote process. Package name is regex-validated to block
46+
* shell injection — in practice the caller feeds a PackageManager-sourced string that
47+
* can't contain metacharacters, but we verify anyway.
48+
*/
49+
suspend fun uninstallSystemApp(
50+
packageName: String,
51+
method: SystemAppMethod,
52+
): Result<String> = withContext(Dispatchers.IO) {
53+
require(packageName.matches(Regex("^[A-Za-z0-9._]+$"))) {
54+
"Refusing to shell out with suspicious package name: $packageName"
55+
}
56+
val reflectedMethod = newProcessMethod
57+
?: return@withContext Result.failure(
58+
IllegalStateException("Shizuku.newProcess unavailable on this Shizuku build"),
59+
)
60+
val cmd = when (method) {
61+
SystemAppMethod.UninstallForUser0 -> "pm uninstall --user 0 $packageName"
62+
SystemAppMethod.Disable -> "pm disable-user --user 0 $packageName"
63+
}
64+
runCatching {
65+
val process = reflectedMethod.invoke(
66+
null,
67+
arrayOf("sh", "-c", cmd),
68+
null,
69+
null,
70+
) as Process
71+
val stdout = process.inputStream.bufferedReader().use { it.readText() }
72+
val stderr = process.errorStream.bufferedReader().use { it.readText() }
73+
val exitCode = process.waitFor()
74+
// Same token strategy as the Root path — `pm` on some ROMs returns 0 for soft
75+
// failures like `Failure [NOT_INSTALLED_FOR_USER]`, so we verify both.
76+
val successToken = when (method) {
77+
SystemAppMethod.UninstallForUser0 -> "Success"
78+
SystemAppMethod.Disable -> "new state: disabled"
79+
}
80+
if (exitCode != 0 || !stdout.contains(successToken, ignoreCase = true)) {
81+
throw RuntimeException(
82+
"shizuku pm failed (exit=$exitCode): ${stdout.ifBlank { stderr }.ifBlank { "no output" }}",
83+
)
84+
}
85+
stdout
86+
}
87+
}
88+
}

0 commit comments

Comments
 (0)