Skip to content

Commit ab5dcd1

Browse files
committed
Merge pull request #253 from rainxchzed/feat-shizuku-installer
feat(android): Add Shizuku-based silent installation support - Implement `ShizukuInstallerServiceImpl` using Shizuku UserService for privileged package operations (install/uninstall). - Create `ShizukuServiceManager` to manage Shizuku lifecycle, status detection, and permission requests. - Add `ShizukuInstallerWrapper` to delegate installation tasks to Shizuku when enabled and available, falling back to the standard installer on failure. - Update `Profile` screen with a new "Installation" section to allow users to toggle between Default and Shizuku installer types. - Add `InstallerStatusProvider` and `InstallerType` to core domain to handle platform-agnostic installer state. - Update Android manifest and Gradle dependencies to include Shizuku API and HIDL stubs. - Persist installer type preference in `ThemesRepository`.
1 parent c4f30f3 commit ab5dcd1

22 files changed

Lines changed: 1597 additions & 462 deletions

File tree

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@
100100
android:name="android.support.FILE_PROVIDER_PATHS"
101101
android:resource="@xml/filepaths" />
102102
</provider>
103+
104+
<!-- Shizuku provider for optional silent install support -->
105+
<provider
106+
android:name="rikka.shizuku.ShizukuProvider"
107+
android:authorities="${applicationId}.shizuku"
108+
android:multiprocess="false"
109+
android:enabled="true"
110+
android:exported="true"
111+
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
103112
</application>
104113

105114
</manifest>

core/data/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ plugins {
44
alias(libs.plugins.convention.buildkonfig)
55
}
66

7+
android {
8+
buildFeatures {
9+
aidl = true
10+
}
11+
}
12+
713
kotlin {
814
sourceSets {
915
commonMain {
@@ -28,6 +34,9 @@ kotlin {
2834
dependencies {
2935
implementation(libs.ktor.client.okhttp)
3036
implementation(libs.androidx.work.runtime)
37+
implementation(libs.shizuku.api)
38+
implementation(libs.shizuku.provider)
39+
compileOnly(libs.hidden.api.stub)
3140
}
3241
}
3342

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package zed.rainxch.core.data.services.shizuku;
2+
3+
interface IShizukuInstallerService {
4+
int installPackage(String apkPath);
5+
int uninstallPackage(String packageName);
6+
void destroy();
7+
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/di/PlatformModule.android.kt

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package zed.rainxch.core.data.di
22

33
import androidx.datastore.core.DataStore
44
import androidx.datastore.preferences.core.Preferences
5+
import kotlinx.coroutines.CoroutineScope
56
import org.koin.android.ext.koin.androidContext
67
import org.koin.dsl.module
78
import zed.rainxch.core.data.local.data_store.createDataStore
@@ -14,13 +15,17 @@ import zed.rainxch.core.data.services.AndroidInstaller
1415
import zed.rainxch.core.data.services.AndroidLocalizationManager
1516
import zed.rainxch.core.data.services.AndroidPackageMonitor
1617
import zed.rainxch.core.data.services.FileLocationsProvider
17-
import zed.rainxch.core.domain.system.Installer
1818
import zed.rainxch.core.data.services.LocalizationManager
19+
import zed.rainxch.core.data.services.shizuku.AndroidInstallerStatusProvider
20+
import zed.rainxch.core.data.services.shizuku.ShizukuInstallerWrapper
21+
import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager
1922
import zed.rainxch.core.data.utils.AndroidAppLauncher
2023
import zed.rainxch.core.data.utils.AndroidBrowserHelper
2124
import zed.rainxch.core.data.utils.AndroidClipboardHelper
2225
import zed.rainxch.core.data.utils.AndroidShareManager
2326
import zed.rainxch.core.domain.network.Downloader
27+
import zed.rainxch.core.domain.system.Installer
28+
import zed.rainxch.core.domain.system.InstallerStatusProvider
2429
import zed.rainxch.core.domain.system.PackageMonitor
2530
import zed.rainxch.core.domain.utils.AppLauncher
2631
import zed.rainxch.core.domain.utils.BrowserHelper
@@ -36,13 +41,41 @@ actual val corePlatformModule = module {
3641
)
3742
}
3843

39-
single<Installer> {
44+
// AndroidInstaller — registered by class so the wrapper can inject it
45+
single {
4046
AndroidInstaller(
4147
context = get(),
4248
installerInfoExtractor = AndroidInstallerInfoExtractor(androidContext())
4349
)
4450
}
4551

52+
// ShizukuServiceManager — manages Shizuku lifecycle, permissions, service binding
53+
single {
54+
ShizukuServiceManager(
55+
context = androidContext()
56+
).also { it.initialize() }
57+
}
58+
59+
// Installer — the ShizukuInstallerWrapper is the public Installer singleton.
60+
// It delegates to AndroidInstaller by default, intercepting with Shizuku when enabled.
61+
single<Installer> {
62+
ShizukuInstallerWrapper(
63+
androidInstaller = get<AndroidInstaller>(),
64+
shizukuServiceManager = get(),
65+
themesRepository = get()
66+
).also { wrapper ->
67+
wrapper.observeInstallerPreference(get<CoroutineScope>())
68+
}
69+
}
70+
71+
// InstallerStatusProvider — exposes Shizuku availability to the UI layer
72+
single<InstallerStatusProvider> {
73+
AndroidInstallerStatusProvider(
74+
shizukuServiceManager = get(),
75+
scope = get()
76+
)
77+
}
78+
4679
single<FileLocationsProvider> {
4780
AndroidFileLocationsProvider(context = get())
4881
}
@@ -89,4 +122,4 @@ actual val corePlatformModule = module {
89122
)
90123
}
91124

92-
}
125+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package zed.rainxch.core.data.services.shizuku
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.flow.SharingStarted
5+
import kotlinx.coroutines.flow.StateFlow
6+
import kotlinx.coroutines.flow.map
7+
import kotlinx.coroutines.flow.stateIn
8+
import zed.rainxch.core.domain.model.ShizukuAvailability
9+
import zed.rainxch.core.domain.system.InstallerStatusProvider
10+
11+
/**
12+
* Android implementation of [InstallerStatusProvider].
13+
* Maps [ShizukuServiceManager.status] to the platform-agnostic [ShizukuAvailability] enum.
14+
*/
15+
class AndroidInstallerStatusProvider(
16+
private val shizukuServiceManager: ShizukuServiceManager,
17+
scope: CoroutineScope
18+
) : InstallerStatusProvider {
19+
20+
override val shizukuAvailability: StateFlow<ShizukuAvailability> =
21+
shizukuServiceManager.status.map { status ->
22+
when (status) {
23+
ShizukuStatus.NOT_INSTALLED -> ShizukuAvailability.UNAVAILABLE
24+
ShizukuStatus.NOT_RUNNING -> ShizukuAvailability.NOT_RUNNING
25+
ShizukuStatus.PERMISSION_NEEDED -> ShizukuAvailability.PERMISSION_NEEDED
26+
ShizukuStatus.READY -> ShizukuAvailability.READY
27+
}
28+
}.stateIn(
29+
scope = scope,
30+
started = SharingStarted.Eagerly,
31+
initialValue = ShizukuAvailability.UNAVAILABLE
32+
)
33+
34+
override fun requestShizukuPermission() {
35+
shizukuServiceManager.requestPermission()
36+
}
37+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package zed.rainxch.core.data.services.shizuku
2+
3+
import android.content.pm.IPackageInstaller
4+
import android.content.pm.IPackageInstallerSession
5+
import android.content.pm.PackageInstaller
6+
import android.content.pm.PackageManager
7+
import android.content.pm.VersionedPackage
8+
import android.net.LocalServerSocket
9+
import android.net.LocalSocket
10+
import android.net.LocalSocketAddress
11+
import android.os.IBinder
12+
import android.os.ParcelFileDescriptor
13+
import android.os.SystemClock
14+
import rikka.shizuku.SystemServiceHelper
15+
import java.io.DataInputStream
16+
import java.io.DataOutputStream
17+
import java.io.File
18+
import java.io.FileInputStream
19+
import java.util.concurrent.CountDownLatch
20+
import java.util.concurrent.TimeUnit
21+
import java.util.concurrent.atomic.AtomicInteger
22+
23+
/**
24+
* Shizuku UserService implementation that runs in a privileged process (shell/root).
25+
* Provides silent package install/uninstall via the system PackageInstaller API.
26+
*
27+
* This class runs in Shizuku's process, NOT in the app's process.
28+
* It has shell-level (UID 2000) or root-level (UID 0) privileges.
29+
*
30+
* MUST have a default no-arg constructor for Shizuku's UserService framework.
31+
*/
32+
class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() {
33+
34+
companion object {
35+
private const val INSTALL_TIMEOUT_SECONDS = 120L
36+
private const val UNINSTALL_TIMEOUT_SECONDS = 60L
37+
38+
// PackageInstaller status codes
39+
private const val STATUS_SUCCESS = 0
40+
private const val STATUS_FAILURE = -1
41+
private const val STATUS_FAILURE_ABORTED = -2
42+
private const val STATUS_FAILURE_BLOCKED = -3
43+
private const val STATUS_FAILURE_CONFLICT = -4
44+
private const val STATUS_FAILURE_INCOMPATIBLE = -5
45+
private const val STATUS_FAILURE_INVALID = -6
46+
private const val STATUS_FAILURE_STORAGE = -7
47+
private const val STATUS_FAILURE_TIMEOUT = -8
48+
}
49+
50+
private fun getPackageInstaller(): IPackageInstaller {
51+
val binder: IBinder = SystemServiceHelper.getSystemService("package")
52+
val pm = android.content.pm.IPackageManager.Stub.asInterface(binder)
53+
return pm.packageInstaller
54+
}
55+
56+
override fun installPackage(apkPath: String): Int {
57+
val file = File(apkPath)
58+
if (!file.exists()) return STATUS_FAILURE_INVALID
59+
60+
return try {
61+
val installer = getPackageInstaller()
62+
val params = PackageInstaller.SessionParams(
63+
PackageInstaller.SessionParams.MODE_FULL_INSTALL
64+
)
65+
params.setSize(file.length())
66+
67+
val installerPackageName = "com.android.shell"
68+
val sessionId = installer.createSession(params, installerPackageName, null, android.os.Process.myUid())
69+
70+
val session = IPackageInstallerSession.Stub.asInterface(
71+
installer.openSession(sessionId)
72+
)
73+
74+
// Write APK to session
75+
val sizeBytes = file.length()
76+
val pfd = session.openWrite("base.apk", 0, sizeBytes)
77+
val output = ParcelFileDescriptor.AutoCloseOutputStream(pfd)
78+
79+
FileInputStream(file).use { input ->
80+
output.use { out ->
81+
input.copyTo(out, bufferSize = 65536)
82+
out.flush()
83+
}
84+
}
85+
86+
// Commit session with a status receiver via LocalSocket
87+
val resultCode = AtomicInteger(STATUS_FAILURE_TIMEOUT)
88+
val latch = CountDownLatch(1)
89+
90+
val socketName = "shizuku_install_${SystemClock.elapsedRealtimeNanos()}"
91+
val serverSocket = LocalServerSocket(socketName)
92+
93+
// Use a thread to listen for the result
94+
val listenerThread = Thread {
95+
try {
96+
val client = serverSocket.accept()
97+
val input = DataInputStream(client.inputStream)
98+
val status = input.readInt()
99+
resultCode.set(mapInstallStatus(status))
100+
input.close()
101+
client.close()
102+
} catch (e: Exception) {
103+
resultCode.set(STATUS_FAILURE)
104+
} finally {
105+
try { serverSocket.close() } catch (_: Exception) {}
106+
latch.countDown()
107+
}
108+
}
109+
listenerThread.isDaemon = true
110+
listenerThread.start()
111+
112+
// Create an IntentSender using a LocalSocket-based approach
113+
val statusReceiver = createStatusReceiver(socketName)
114+
session.commit(statusReceiver, false)
115+
116+
// Wait for result
117+
if (!latch.await(INSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
118+
resultCode.set(STATUS_FAILURE_TIMEOUT)
119+
try { serverSocket.close() } catch (_: Exception) {}
120+
}
121+
122+
resultCode.get()
123+
} catch (e: Exception) {
124+
STATUS_FAILURE
125+
}
126+
}
127+
128+
override fun uninstallPackage(packageName: String): Int {
129+
return try {
130+
val installer = getPackageInstaller()
131+
132+
val resultCode = AtomicInteger(STATUS_FAILURE_TIMEOUT)
133+
val latch = CountDownLatch(1)
134+
135+
val socketName = "shizuku_uninstall_${SystemClock.elapsedRealtimeNanos()}"
136+
val serverSocket = LocalServerSocket(socketName)
137+
138+
val listenerThread = Thread {
139+
try {
140+
val client = serverSocket.accept()
141+
val input = DataInputStream(client.inputStream)
142+
val status = input.readInt()
143+
resultCode.set(mapInstallStatus(status))
144+
input.close()
145+
client.close()
146+
} catch (e: Exception) {
147+
resultCode.set(STATUS_FAILURE)
148+
} finally {
149+
try { serverSocket.close() } catch (_: Exception) {}
150+
latch.countDown()
151+
}
152+
}
153+
listenerThread.isDaemon = true
154+
listenerThread.start()
155+
156+
val statusReceiver = createStatusReceiver(socketName)
157+
val versionedPackage = VersionedPackage(packageName, PackageManager.VERSION_CODE_HIGHEST)
158+
installer.uninstall(
159+
versionedPackage,
160+
"com.android.shell",
161+
0,
162+
statusReceiver,
163+
0
164+
)
165+
166+
if (!latch.await(UNINSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
167+
resultCode.set(STATUS_FAILURE_TIMEOUT)
168+
try { serverSocket.close() } catch (_: Exception) {}
169+
}
170+
171+
resultCode.get()
172+
} catch (e: Exception) {
173+
STATUS_FAILURE
174+
}
175+
}
176+
177+
/**
178+
* Creates an IntentSender that reports the install/uninstall status
179+
* back to the given local socket. This is the standard approach for
180+
* getting synchronous results from PackageInstaller in a Shizuku UserService.
181+
*/
182+
private fun createStatusReceiver(socketName: String): android.content.IntentSender {
183+
// Use a Binder-based callback approach since we're in a privileged process.
184+
// We create a lightweight Intent with an IIntentSender that writes the result
185+
// to a LocalSocket.
186+
val binder = object : android.content.IIntentSender.Stub() {
187+
override fun send(
188+
code: Int,
189+
intent: android.content.Intent?,
190+
resolvedType: String?,
191+
whitelistToken: IBinder?,
192+
finishedReceiver: android.content.IIntentReceiver?,
193+
requiredPermission: String?,
194+
options: android.os.Bundle?
195+
) {
196+
val status = intent?.getIntExtra(
197+
PackageInstaller.EXTRA_STATUS,
198+
PackageInstaller.STATUS_FAILURE
199+
) ?: PackageInstaller.STATUS_FAILURE
200+
201+
try {
202+
val socket = LocalSocket()
203+
socket.connect(LocalSocketAddress(socketName, LocalSocketAddress.Namespace.ABSTRACT))
204+
val output = DataOutputStream(socket.outputStream)
205+
output.writeInt(status)
206+
output.flush()
207+
output.close()
208+
socket.close()
209+
} catch (_: Exception) {
210+
// Socket may already be closed
211+
}
212+
}
213+
}
214+
215+
return android.content.IntentSender(binder)
216+
}
217+
218+
private fun mapInstallStatus(status: Int): Int {
219+
return when (status) {
220+
PackageInstaller.STATUS_SUCCESS -> STATUS_SUCCESS
221+
PackageInstaller.STATUS_FAILURE_ABORTED -> STATUS_FAILURE_ABORTED
222+
PackageInstaller.STATUS_FAILURE_BLOCKED -> STATUS_FAILURE_BLOCKED
223+
PackageInstaller.STATUS_FAILURE_CONFLICT -> STATUS_FAILURE_CONFLICT
224+
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> STATUS_FAILURE_INCOMPATIBLE
225+
PackageInstaller.STATUS_FAILURE_INVALID -> STATUS_FAILURE_INVALID
226+
PackageInstaller.STATUS_FAILURE_STORAGE -> STATUS_FAILURE_STORAGE
227+
else -> STATUS_FAILURE
228+
}
229+
}
230+
231+
override fun destroy() {
232+
// Cleanup — called when Shizuku unbinds the service
233+
}
234+
}

0 commit comments

Comments
 (0)