Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>

<!-- Shizuku provider for optional silent install support -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>

</manifest>
9 changes: 9 additions & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ plugins {
alias(libs.plugins.convention.buildkonfig)
}

android {
buildFeatures {
aidl = true
}
}

kotlin {
sourceSets {
commonMain {
Expand All @@ -28,6 +34,9 @@ kotlin {
dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.work.runtime)
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
compileOnly(libs.hidden.api.stub)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package zed.rainxch.core.data.services.shizuku;

interface IShizukuInstallerService {
int installPackage(in ParcelFileDescriptor pfd, long fileSize);
int uninstallPackage(String packageName);
void destroy();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,90 +2,124 @@ package zed.rainxch.core.data.di

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import zed.rainxch.core.data.local.data_store.createDataStore
import zed.rainxch.core.data.local.db.AppDatabase
import zed.rainxch.core.data.local.db.initDatabase
import zed.rainxch.core.data.services.AndroidInstallerInfoExtractor
import zed.rainxch.core.data.services.AndroidDownloader
import zed.rainxch.core.data.services.AndroidFileLocationsProvider
import zed.rainxch.core.data.services.AndroidInstaller
import zed.rainxch.core.data.services.AndroidInstallerInfoExtractor
import zed.rainxch.core.data.services.AndroidLocalizationManager
import zed.rainxch.core.data.services.AndroidPackageMonitor
import zed.rainxch.core.data.services.FileLocationsProvider
import zed.rainxch.core.data.services.LocalizationManager
import zed.rainxch.core.data.services.shizuku.AndroidInstallerStatusProvider
import zed.rainxch.core.data.services.shizuku.ShizukuInstallerWrapper
import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager
import zed.rainxch.core.data.utils.AndroidAppLauncher
import zed.rainxch.core.data.utils.AndroidBrowserHelper
import zed.rainxch.core.data.utils.AndroidClipboardHelper
import zed.rainxch.core.data.utils.AndroidShareManager
import zed.rainxch.core.domain.network.Downloader
import zed.rainxch.core.domain.system.Installer
import zed.rainxch.core.domain.system.InstallerStatusProvider
import zed.rainxch.core.domain.system.PackageMonitor
import zed.rainxch.core.domain.utils.AppLauncher
import zed.rainxch.core.domain.utils.BrowserHelper
import zed.rainxch.core.domain.utils.ClipboardHelper
import zed.rainxch.core.domain.utils.ShareManager

actual val corePlatformModule =
module {
// Core
actual val corePlatformModule = module {
// Core

single<Downloader> {
AndroidDownloader(
files = get(),
)
}
single<Downloader> {
AndroidDownloader(
files = get(),
)
}

single<Installer> {
AndroidInstaller(
context = get(),
installerInfoExtractor = AndroidInstallerInfoExtractor(androidContext()),
)
}
// AndroidInstaller — registered by class so the wrapper can inject it
single {
AndroidInstaller(
context = get(),
installerInfoExtractor = AndroidInstallerInfoExtractor(androidContext())
)
}

single<FileLocationsProvider> {
AndroidFileLocationsProvider(context = get())
}
// ShizukuServiceManager — manages Shizuku lifecycle, permissions, service binding
single {
ShizukuServiceManager(
context = androidContext()
).also { it.initialize() }
}

single<PackageMonitor> {
AndroidPackageMonitor(androidContext())
// Installer — the ShizukuInstallerWrapper is the public Installer singleton.
// It delegates to AndroidInstaller by default, intercepting with Shizuku when enabled.
single<Installer> {
ShizukuInstallerWrapper(
androidInstaller = get<AndroidInstaller>(),
shizukuServiceManager = get(),
themesRepository = get()
).also { wrapper ->
wrapper.observeInstallerPreference(get<CoroutineScope>())
}
}

single<LocalizationManager> {
AndroidLocalizationManager()
}
// InstallerStatusProvider — exposes Shizuku availability to the UI layer
single<InstallerStatusProvider> {
AndroidInstallerStatusProvider(
shizukuServiceManager = get(),
scope = get()
)
}

// Locals
single<FileLocationsProvider> {
AndroidFileLocationsProvider(context = get())
}

single<AppDatabase> {
initDatabase(androidContext())
}
single<PackageMonitor> {
AndroidPackageMonitor(androidContext())
}

single<DataStore<Preferences>> {
createDataStore(androidContext())
}
single<LocalizationManager> {
AndroidLocalizationManager()
}

// Utils
// Locals

single<BrowserHelper> {
AndroidBrowserHelper(androidContext())
}
single<AppDatabase> {
initDatabase(androidContext())
}

single<ClipboardHelper> {
AndroidClipboardHelper(androidContext())
}
single<DataStore<Preferences>> {
createDataStore(androidContext())
}

single<AppLauncher> {
AndroidAppLauncher(
context = androidContext(),
logger = get(),
)
}

single<ShareManager> {
AndroidShareManager(
context = androidContext(),
)
}
// Utils

single<BrowserHelper> {
AndroidBrowserHelper(androidContext())
}

single<ClipboardHelper> {
AndroidClipboardHelper(androidContext())
}

single<AppLauncher> {
AndroidAppLauncher(
context = androidContext(),
logger = get()
)
}

single<ShareManager> {
AndroidShareManager(
context = androidContext()
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package zed.rainxch.core.data.services.shizuku

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import zed.rainxch.core.domain.model.ShizukuAvailability
import zed.rainxch.core.domain.system.InstallerStatusProvider

/**
* Android implementation of [InstallerStatusProvider].
* Maps [ShizukuServiceManager.status] to the platform-agnostic [ShizukuAvailability] enum.
*/
class AndroidInstallerStatusProvider(
private val shizukuServiceManager: ShizukuServiceManager,
scope: CoroutineScope
) : InstallerStatusProvider {

override val shizukuAvailability: StateFlow<ShizukuAvailability> =
shizukuServiceManager.status.map { status ->
when (status) {
ShizukuStatus.NOT_INSTALLED -> ShizukuAvailability.UNAVAILABLE
ShizukuStatus.NOT_RUNNING -> ShizukuAvailability.NOT_RUNNING
ShizukuStatus.PERMISSION_NEEDED -> ShizukuAvailability.PERMISSION_NEEDED
ShizukuStatus.READY -> ShizukuAvailability.READY
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = ShizukuAvailability.UNAVAILABLE
Comment on lines +20 to +31
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.

⚠️ Potential issue | 🟠 Major

Preserve the NOT_INSTALLED state instead of collapsing it to UNAVAILABLE.

Line 23 drops a user-actionable distinction the new UI needs. With this mapping, the profile flow cannot tell “Shizuku is not installed” apart from generic unavailability, so the install-specific status/hint strings added in this PR become unreachable. Please expose a dedicated availability for that case, or pass the raw ShizukuStatus through to the UI layer.

)

override fun requestShizukuPermission() {
shizukuServiceManager.requestPermission()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package zed.rainxch.core.data.services.shizuku

import android.os.ParcelFileDescriptor
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit

/**
* Shizuku UserService implementation that runs in a privileged process (shell/root).
* Provides silent package install/uninstall via `pm` shell commands.
*
* This class runs in Shizuku's process, NOT in the app's process.
* It has shell-level (UID 2000) or root-level (UID 0) privileges.
*
* Uses `pm install` with stdin pipe for install, `pm uninstall` for uninstall.
* This is the most reliable approach — avoids fragile reflection on hidden
* IPackageInstaller/IPackageInstallerSession/IIntentSender APIs.
*
* MUST have a default no-arg constructor for Shizuku's UserService framework.
*/
class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() {

companion object {
private const val TAG = "ShizukuService"

private const val STATUS_SUCCESS = 0
private const val STATUS_FAILURE = -1
private const val INSTALL_TIMEOUT_SECONDS = 120L
private const val UNINSTALL_TIMEOUT_SECONDS = 30L

private fun log(msg: String) = android.util.Log.d(TAG, msg)
private fun logW(msg: String) = android.util.Log.w(TAG, msg)
private fun logE(msg: String, e: Throwable? = null) = android.util.Log.e(TAG, msg, e)
}

override fun installPackage(pfd: ParcelFileDescriptor, fileSize: Long): Int {
log("installPackage() called — fileSize=$fileSize")
log("Process UID: ${android.os.Process.myUid()}, PID: ${android.os.Process.myPid()}")

return try {
// Use "pm install -S <size>" which reads the APK from stdin
val command = arrayOf("pm", "install", "-S", fileSize.toString())
log("Executing: ${command.joinToString(" ")}")

val process = Runtime.getRuntime().exec(command)

// Pipe the APK from the ParcelFileDescriptor to pm's stdin
val writeThread = Thread {
try {
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
process.outputStream.use { output ->
val buffer = ByteArray(65536)
var bytesWritten = 0L
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
bytesWritten += read
}
output.flush()
log("APK piped to pm stdin: $bytesWritten bytes (expected: $fileSize)")
}
}
} catch (e: Exception) {
logE("Error piping APK to pm stdin", e)
}
}
writeThread.start()

// Read stdout/stderr for result
val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim()
val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim()

writeThread.join(INSTALL_TIMEOUT_SECONDS * 1000)
if (writeThread.isAlive) {
logE("Write thread timed out after ${INSTALL_TIMEOUT_SECONDS}s, aborting install")
writeThread.interrupt()
process.destroyForcibly()
return STATUS_FAILURE
}

val finished = process.waitFor(INSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
logE("pm install timed out after ${INSTALL_TIMEOUT_SECONDS}s, aborting")
process.destroyForcibly()
return STATUS_FAILURE
}

val exitCode = process.exitValue()
log("pm install — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'")

if (exitCode == 0 && stdout.contains("Success")) {
log("Install SUCCESS")
STATUS_SUCCESS
} else {
logE("Install FAILED — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'")
STATUS_FAILURE
}
} catch (e: Exception) {
logE("installPackage() exception", e)
STATUS_FAILURE
}
}

override fun uninstallPackage(packageName: String): Int {
log("uninstallPackage() called for: $packageName")
return try {
val command = arrayOf("pm", "uninstall", packageName)
log("Executing: ${command.joinToString(" ")}")

val process = Runtime.getRuntime().exec(command)
val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim()
val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim()

val finished = process.waitFor(UNINSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
logE("pm uninstall timed out after ${UNINSTALL_TIMEOUT_SECONDS}s, aborting")
process.destroyForcibly()
return STATUS_FAILURE
}

val exitCode = process.exitValue()
log("pm uninstall — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'")

if (exitCode == 0 && stdout.contains("Success")) {
log("Uninstall SUCCESS")
STATUS_SUCCESS
} else {
logE("Uninstall FAILED — exitCode=$exitCode, stdout='$stdout', stderr='$stderr'")
STATUS_FAILURE
}
} catch (e: Exception) {
logE("uninstallPackage() exception", e)
STATUS_FAILURE
}
}

override fun destroy() {
log("destroy() — service being unbound")
}
}
Loading