From c25a5143845242ee434c1bee07d45780204ea318 Mon Sep 17 00:00:00 2001 From: User-green <52010870+User-green@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:07:54 +0300 Subject: [PATCH] feat: add Dhizuku installer Adds a Dhizuku installer option that installs/uninstalls silently by binding a UserService inside Dhizuku's device-owner process and driving PackageInstaller there (no hidden APIs, no root). Gracefully falls back to the Session installer when no Dhizuku server is available or the privileged path is unusable, with a user-facing warning. Server discovery is package-agnostic, so it also works with third-party Dhizuku servers such as OwnDroid's built-in server. The installer option is hidden below API 26 (Dhizuku-API's minSdk) and gated at runtime; result delivery uses PackageInstaller.SessionCallback (binder-based) because the Dhizuku UserService runs in a phantom process that cannot receive broadcasts. ================================================================== AI-AUTHORED: This entire change was written by an AI assistant (Claude, by Anthropic). It was directed, reviewed, and tested on-device by the submitter, but the code itself is AI-generated. ================================================================== Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 7 + .../dhizuku/IDhizukuInstallerService.aidl | 21 ++ .../compose/settings/SettingsScreen.kt | 7 +- .../compose/settings/SettingsViewModel.kt | 24 ++ .../droidify/datastore/model/InstallerType.kt | 1 + .../droidify/installer/InstallManager.kt | 2 + .../installers/InstallerPermission.kt | 78 ++++++ .../installers/dhizuku/DhizukuInstaller.kt | 230 ++++++++++++++++++ .../dhizuku/DhizukuInstallerService.kt | 158 ++++++++++++ .../droidify/service/DownloadService.kt | 1 + app/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 2 + 13 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 app/src/main/aidl/com/looker/droidify/installer/installers/dhizuku/IDhizukuInstallerService.aidl create mode 100644 app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstaller.kt create mode 100644 app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstallerService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index abd0d8daf..57c68694a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,6 +100,7 @@ android { compose = true viewBinding = true buildConfig = true + aidl = true } dependenciesInfo { @@ -200,6 +201,7 @@ dependencies { implementation(libs.libsu.core) implementation(libs.bundles.shizuku) + implementation(libs.dhizuku.api) implementation(libs.jackson.core) implementation(libs.serialization) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1709004be..b06ace32f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,13 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + Unit, ) { + // Dhizuku-API requires API 26+; hide the option entirely on Android 5–7. + val installerTypes = remember { + InstallerType.entries.filter { it != InstallerType.DHIZUKU || SdkCheck.isOreo } + } SelectionSettingItem( title = stringResource(R.string.installer), selectedValue = selectedInstaller, - values = InstallerType.entries, + values = installerTypes, onValueSelected = onInstallerSelected, valueToString = { installer -> when (installer) { @@ -483,6 +487,7 @@ private fun InstallerTypeSetting( InstallerType.SESSION -> stringResource(R.string.session_installer) InstallerType.SHIZUKU -> stringResource(R.string.shizuku_installer) InstallerType.ROOT -> stringResource(R.string.root_installer) + InstallerType.DHIZUKU -> stringResource(R.string.dhizuku_installer) } }, ) diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt index e70d2d0cf..1ccd2962a 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt @@ -2,6 +2,7 @@ package com.looker.droidify.compose.settings import android.content.Context import android.net.Uri +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.compose.material3.SnackbarHostState @@ -24,10 +25,14 @@ import com.looker.droidify.datastore.model.LegacyInstallerComponent import com.looker.droidify.datastore.model.ProxyType import com.looker.droidify.datastore.model.Theme import com.looker.droidify.installer.installers.initSui +import com.looker.droidify.installer.installers.awaitDhizukuAlive +import com.looker.droidify.installer.installers.isDhizukuAlive +import com.looker.droidify.installer.installers.isDhizukuGranted import com.looker.droidify.installer.installers.isMagiskGranted import com.looker.droidify.installer.installers.isShizukuAlive import com.looker.droidify.installer.installers.isShizukuGranted import com.looker.droidify.installer.installers.isShizukuInstalled +import com.looker.droidify.installer.installers.requestDhizukuPermission import com.looker.droidify.installer.installers.requestPermissionListener import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.utility.common.extension.updateAsMutable @@ -150,6 +155,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { when (installerType) { InstallerType.SHIZUKU -> handleShizukuInstaller(context, installerType) + InstallerType.DHIZUKU -> handleDhizukuInstaller(context, installerType) InstallerType.ROOT -> handleRootInstaller(installerType) InstallerType.LEGACY -> { settingsRepository.setDeleteApkOnInstall(false) @@ -176,6 +182,24 @@ class SettingsViewModel @Inject constructor( } } + private suspend fun handleDhizukuInstaller(context: Context, installerType: InstallerType) { + // Already paired and reachable — switch silently, without the "please wait" toast. + if (isDhizukuAlive(context) && isDhizukuGranted()) { + settingsRepository.setInstallerType(installerType) + return + } + // Otherwise the server may need a few seconds to start (cold-start race); tell the user to + // wait rather than appearing to hang before we poll/request permission. + Toast.makeText(context, R.string.dhizuku_server_starting, Toast.LENGTH_SHORT).show() + if (!awaitDhizukuAlive(context)) { + showSnackbar(R.string.dhizuku_not_available) + return + } + if (isDhizukuGranted() || requestDhizukuPermission()) { + settingsRepository.setInstallerType(installerType) + } + } + private suspend fun handleRootInstaller(installerType: InstallerType) { if (isMagiskGranted()) { settingsRepository.setInstallerType(installerType) diff --git a/app/src/main/kotlin/com/looker/droidify/datastore/model/InstallerType.kt b/app/src/main/kotlin/com/looker/droidify/datastore/model/InstallerType.kt index 29705d59b..98dcfbbf2 100644 --- a/app/src/main/kotlin/com/looker/droidify/datastore/model/InstallerType.kt +++ b/app/src/main/kotlin/com/looker/droidify/datastore/model/InstallerType.kt @@ -7,6 +7,7 @@ enum class InstallerType { SESSION, SHIZUKU, ROOT, + DHIZUKU, ; companion object { diff --git a/app/src/main/kotlin/com/looker/droidify/installer/InstallManager.kt b/app/src/main/kotlin/com/looker/droidify/installer/InstallManager.kt index 90fb67dfe..bda7e6105 100644 --- a/app/src/main/kotlin/com/looker/droidify/installer/InstallManager.kt +++ b/app/src/main/kotlin/com/looker/droidify/installer/InstallManager.kt @@ -8,6 +8,7 @@ import com.looker.droidify.datastore.get import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.installer.installers.Installer import com.looker.droidify.installer.installers.LegacyInstaller +import com.looker.droidify.installer.installers.dhizuku.DhizukuInstaller import com.looker.droidify.installer.installers.root.RootInstaller import com.looker.droidify.installer.installers.session.SessionInstaller import com.looker.droidify.installer.installers.shizuku.ShizukuInstaller @@ -151,6 +152,7 @@ class InstallManager( InstallerType.SESSION -> SessionInstaller(context) InstallerType.SHIZUKU -> ShizukuInstaller(context) InstallerType.ROOT -> RootInstaller(context) + InstallerType.DHIZUKU -> DhizukuInstaller(context) } } } diff --git a/app/src/main/kotlin/com/looker/droidify/installer/installers/InstallerPermission.kt b/app/src/main/kotlin/com/looker/droidify/installer/installers/InstallerPermission.kt index 33c271d9c..2b421bc85 100644 --- a/app/src/main/kotlin/com/looker/droidify/installer/installers/InstallerPermission.kt +++ b/app/src/main/kotlin/com/looker/droidify/installer/installers/InstallerPermission.kt @@ -4,9 +4,17 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.util.Log +import com.looker.droidify.BuildConfig +import com.looker.droidify.utility.common.SdkCheck import com.looker.droidify.utility.common.extension.getLauncherActivities import com.looker.droidify.utility.common.extension.getPackageInfoCompat import com.looker.droidify.utility.common.extension.intent +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuRequestPermissionListener +import com.rosan.dhizuku.shared.DhizukuVariables +import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuProvider @@ -15,6 +23,31 @@ import kotlin.coroutines.resume private const val SHIZUKU_PERMISSION_REQUEST_CODE = 87263 +internal const val DHIZUKU_LOG_TAG = "DroidifyDhizuku" + +// Debug-only: stripped (no-op) in release builds. +internal fun logDhizuku(message: String, type: Int = Log.INFO) { + if (BuildConfig.DEBUG) Log.println(type, DHIZUKU_LOG_TAG, message) +} + +/** + * Gracefully unfreezes the Dhizuku server process (e.g. OwnDroid, which runs no foreground service + * and gets frozen when backgrounded). A *real* ContentProvider transaction is AMS-mediated and + * unfreezes the target, unlike acquiring the client alone or a raw binder call — the latter just + * returns "error -74 (sent to frozen apps)". Best-effort; callers still retry as a safety net. + */ +fun wakeDhizukuServer(context: Context) { + if (!SdkCheck.isOreo) return + runCatching { + val authority = DhizukuVariables.getProviderAuthorityName(Dhizuku.getOwnerPackageName()) + val uri = Uri.parse("content://$authority") + context.contentResolver.acquireUnstableContentProviderClient(uri)?.use { client -> + // Result is irrelevant — delivering the call is what forces the unfreeze. + runCatching { client.call("ping", null, null) } + } + }.onFailure { logDhizuku("wakeDhizukuServer failed: ${it.message}", Log.WARN) } +} + fun launchShizuku(context: Context) { val packageName = context.shizukuPackageName() ?: ShizukuProvider.MANAGER_APPLICATION_ID @@ -70,6 +103,51 @@ fun requestShizuku() { Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE) } +/** + * True when a Dhizuku-compatible server is present and bindable. [Dhizuku.init] discovers the + * server by intent action rather than a fixed package, so this also matches third-party servers + * such as OwnDroid's built-in Dhizuku server — not just the standalone Dhizuku app. + */ +fun isDhizukuAlive(context: Context): Boolean { + if (!SdkCheck.isOreo) return false + wakeDhizukuServer(context) + val alive = runCatching { Dhizuku.init(context) } + .onFailure { logDhizuku("Dhizuku.init threw: ${it.message}", Log.WARN) } + .getOrDefault(false) + logDhizuku("isDhizukuAlive=$alive") + return alive +} + +/** + * The Dhizuku server is often a frozen/cached process (e.g. OwnDroid has no foreground service), so + * the first contact can fail — binder calls to a frozen app return "error -74" and `Dhizuku.init` + * reports unavailable until the process is woken. [wakeDhizukuServer] (run inside [isDhizukuAlive]) + * unfreezes it via a provider transaction; poll a few times to ride out the wake window. + */ +suspend fun awaitDhizukuAlive( + context: Context, + attempts: Int = 3, + delayMs: Long = 300, +): Boolean { + repeat(attempts) { attempt -> + if (isDhizukuAlive(context)) return true + logDhizuku("awaitDhizukuAlive: attempt ${attempt + 1}/$attempts not ready", Log.WARN) + if (attempt < attempts - 1) delay(delayMs) + } + return false +} + +fun isDhizukuGranted() = + runCatching { Dhizuku.isPermissionGranted() }.getOrDefault(false) + +suspend fun requestDhizukuPermission() = suspendCancellableCoroutine { + Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { + override fun onRequestPermission(grantResult: Int) { + it.resume(grantResult == PackageManager.PERMISSION_GRANTED) + } + }) +} + fun isMagiskGranted(): Boolean { com.topjohnwu.superuser.Shell.getCachedShell() ?: com.topjohnwu.superuser.Shell.getShell() return com.topjohnwu.superuser.Shell.isAppGrantedRoot() == true diff --git a/app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstaller.kt b/app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstaller.kt new file mode 100644 index 000000000..130874ab7 --- /dev/null +++ b/app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstaller.kt @@ -0,0 +1,230 @@ +package com.looker.droidify.installer.installers.dhizuku + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.content.pm.PackageInstaller +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.ParcelFileDescriptor +import android.util.Log +import android.widget.Toast +import com.looker.droidify.BuildConfig +import com.looker.droidify.R +import com.looker.droidify.data.model.PackageName +import com.looker.droidify.installer.installers.Installer +import com.looker.droidify.installer.installers.session.SessionInstaller +import com.looker.droidify.installer.installers.wakeDhizukuServer +import com.looker.droidify.installer.model.InstallItem +import com.looker.droidify.installer.model.InstallState +import com.looker.droidify.utility.common.SdkCheck +import com.looker.droidify.utility.common.cache.Cache +import com.looker.droidify.utility.common.log +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuUserServiceArgs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume + +/** + * Installs apps through [Dhizuku], which holds device-owner privileges. + * + * Dhizuku loads [DhizukuInstallerService] inside its own device-owner process; this class binds to + * it and forwards the APK (as a [ParcelFileDescriptor]) so the privileged side can drive + * PackageInstaller silently. + * + * **Hardening:** this fallback only ever runs while the Dhizuku installer is the selected installer + * (it lives entirely inside this class, which [com.looker.droidify.installer.InstallManager] only + * creates for [com.looker.droidify.datastore.model.InstallerType.DHIZUKU]). The available Dhizuku + * server is not always the standalone Dhizuku app — it may be a third-party reimplementation (e.g. + * OwnDroid's built-in server) that doesn't fully support the UserService binding contract. Whenever + * the privileged path is unusable — pre-API-26, no server, permission not granted, bind times out, + * the server can't load our service, a binder call throws, or the install doesn't report success — + * we warn the user once and fall back to [SessionInstaller] (the normal system installer UI) + * instead of failing the install outright. + */ +class DhizukuInstaller(private val context: Context) : Installer { + + private val fallback = SessionInstaller(context) + + private val lock = Mutex() + private var connection: ServiceConnection? = null + private var service: IDhizukuInstallerService? = null + + private val userServiceArgs by lazy { + DhizukuUserServiceArgs(ComponentName(context, DhizukuInstallerService::class.java)) + } + + @Volatile + private var warned = false + + override suspend fun install(installItem: InstallItem): InstallState { + val file = Cache.getReleaseFile(context, installItem.installFileName) + if (!file.exists() || file.length() == 0L) { + logD("install: cache file missing/empty (${file.absolutePath})", Log.ERROR) + return InstallState.Failed + } + val remote = bind() ?: run { + logD("install: bind() returned null -> session fallback") + return fallbackInstall(installItem) + } + val status = try { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + remote.install(pfd, file.length(), context.packageName) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logD("install: remote.install threw -> session fallback\n${Log.getStackTraceString(e)}", Log.ERROR) + invalidate() + return fallbackInstall(installItem) + } + return if (status == PackageInstaller.STATUS_SUCCESS) { + logD("install: SUCCESS via Dhizuku (${installItem.packageName.name})") + InstallState.Installed + } else { + logD("install: Dhizuku returned status=$status -> session fallback", Log.WARN) + fallbackInstall(installItem) + } + } + + override suspend fun uninstall(packageName: PackageName) { + val remote = bind() ?: run { + logD("uninstall: bind() returned null -> session fallback") + return fallbackUninstall(packageName) + } + try { + val status = remote.uninstall(packageName.name) + logD("uninstall: Dhizuku returned status=$status (${packageName.name})") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logD("uninstall: remote.uninstall threw -> session fallback\n${Log.getStackTraceString(e)}", Log.ERROR) + invalidate() + fallbackUninstall(packageName) + } + } + + override fun close() { + invalidate() + fallback.close() + } + + private suspend fun fallbackInstall(installItem: InstallItem): InstallState { + warnFallback() + return fallback.install(installItem) + } + + private suspend fun fallbackUninstall(packageName: PackageName) { + warnFallback() + fallback.uninstall(packageName) + } + + /** + * Lazily binds (and caches) the privileged Dhizuku service, or null if it can't be used. + * + * Retries a few times: a third-party server (e.g. OwnDroid) is often a *frozen/cached* process, + * and the synchronous bind transaction makes Android kill it ("Sync transaction while in frozen + * state") instead of connecting — but it restarts immediately afterward, so the next attempt + * lands on the live process. Only after the attempts are exhausted does the caller fall back. + */ + private suspend fun bind(): IDhizukuInstallerService? = lock.withLock { + service?.let { return@withLock it } + // Dhizuku-API requires API 26+; below that the privileged path is unavailable. + if (!SdkCheck.isOreo) { + logD("bind: pre-API-26, unavailable") + return@withLock null + } + repeat(BIND_ATTEMPTS) { attempt -> + wakeDhizukuServer(context) + val ready = runCatching { + Dhizuku.init(context) && Dhizuku.isPermissionGranted() + }.getOrDefault(false) + if (ready) { + val bound = bindOnce() + if (bound != null) { + if (attempt > 0) logD("bind: connected on attempt ${attempt + 1}") + return@withLock bound + } + // Bind was accepted but never connected. The usual cause is the server holding a + // stale package context for us (our APK path changed after an update), so it can't + // load our class. stopUserService() makes the server tear down its UserService + // process (System.exit) so the next attempt spawns a fresh one that re-resolves our + // current APK. + runCatching { Dhizuku.stopUserService(userServiceArgs) } + .onFailure { logD("bind: stopUserService failed: ${it.message}", Log.WARN) } + } else { + logD("bind: not ready on attempt ${attempt + 1} (init/permission false)", Log.WARN) + } + if (attempt < BIND_ATTEMPTS - 1) delay(BIND_RETRY_DELAY_MS) + } + logD("bind: exhausted $BIND_ATTEMPTS attempts", Log.ERROR) + null + } + + /** A single bind attempt; resolves to the connected service or null on failure/timeout. */ + private suspend fun bindOnce(): IDhizukuInstallerService? { + val bound = withTimeoutOrNull(BIND_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + logD("bind: onServiceConnected (binder=${binder != null})") + val service = IDhizukuInstallerService.Stub.asInterface(binder) + this@DhizukuInstaller.service = service + connection = this + if (cont.isActive) cont.resume(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + logD("bind: onServiceDisconnected", Log.WARN) + service = null + connection = null + } + } + val started = runCatching { Dhizuku.bindUserService(userServiceArgs, conn) } + .onFailure { logD("bind: bindUserService threw\n${Log.getStackTraceString(it)}", Log.ERROR) } + .getOrDefault(false) + logD("bind: bindUserService started=$started") + if (!started && cont.isActive) cont.resume(null) + cont.invokeOnCancellation { runCatching { Dhizuku.unbindUserService(conn) } } + } + } + if (bound == null) logD("bind: attempt timed out after ${BIND_TIMEOUT_MS}ms", Log.ERROR) + return bound + } + + /** Drops the cached binding so the next operation re-binds (or falls back). */ + private fun invalidate() { + connection?.let { runCatching { Dhizuku.unbindUserService(it) } } + connection = null + service = null + } + + /** Warns the user once per installer lifetime that Dhizuku fell back to the default installer. */ + private fun warnFallback() { + if (warned) return + warned = true + Handler(Looper.getMainLooper()).post { + Toast.makeText(context, R.string.dhizuku_fallback_warning, Toast.LENGTH_LONG).show() + } + } + + // Debug-only: stripped (no-op) in release builds. + private fun logD(message: String, type: Int = Log.INFO) { + if (BuildConfig.DEBUG) log(message, TAG, type) + } + + private companion object { + const val BIND_ATTEMPTS = 2 + // Generous enough for a cold UserService spawn on a slow device (imageless ART fallback) to + // load our APK and instantiate the service before the attempt is abandoned. + const val BIND_TIMEOUT_MS = 10_000L + const val BIND_RETRY_DELAY_MS = 1_000L + const val TAG = "DroidifyDhizuku" + } +} diff --git a/app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstallerService.kt b/app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstallerService.kt new file mode 100644 index 000000000..8170ea60a --- /dev/null +++ b/app/src/main/kotlin/com/looker/droidify/installer/installers/dhizuku/DhizukuInstallerService.kt @@ -0,0 +1,158 @@ +package com.looker.droidify.installer.installers.dhizuku + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.SessionParams +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.annotation.Keep +import com.looker.droidify.BuildConfig +import com.looker.droidify.utility.common.log +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * Implementation of [IDhizukuInstallerService] that Dhizuku instantiates inside its own + * device-owner process. Because this code runs with device-owner privileges, the regular + * [PackageInstaller] installs silently — no hidden APIs are required. + * + * Dhizuku requires the bound class to expose a [Keep]-annotated `(Context)` constructor; the + * [Context] handed in belongs to the device-owner process. + * + * **Result delivery:** the Dhizuku server runs us in a *phantom* `app_process` (a child process not + * registered with ActivityManager), so AMS cannot deliver broadcasts here — a `BroadcastReceiver` + * for the commit status never fires. Instead we read the outcome from [PackageInstaller.SessionCallback], + * which is delivered over binder and therefore does reach a phantom process. (commit() still + * requires a status IntentSender, so we pass a no-op one.) Uninstall has no SessionCallback, so we + * poll the package state. + */ +@Keep +class DhizukuInstallerService(private val context: Context) : IDhizukuInstallerService.Stub() { + + init { + logD("service: instantiated pid=${android.os.Process.myPid()} uid=${android.os.Process.myUid()}") + } + + private val packageInstaller get() = context.packageManager.packageInstaller + + override fun install( + apk: ParcelFileDescriptor, + size: Long, + installerPackageName: String?, + ): Int { + return try { + val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) + } + } + val sessionId = packageInstaller.createSession(params) + logD("service.install: session=$sessionId size=$size") + packageInstaller.openSession(sessionId).use { session -> + ParcelFileDescriptor.AutoCloseInputStream(apk).use { input -> + session.openWrite("base.apk", 0, size).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + val status = commitAndAwait(session, sessionId) + logD("service.install: session=$sessionId status=$status") + status + } + } catch (e: Exception) { + logD("service.install threw\n${Log.getStackTraceString(e)}", Log.ERROR) + throw e + } + } + + override fun uninstall(packageName: String): Int { + return try { + packageInstaller.uninstall(packageName, noOpStatusReceiver()) + // No SessionCallback exists for uninstall and we can't receive the status broadcast in a + // phantom process, so confirm by polling the package state. + val removed = awaitPackageRemoved(packageName) + val status = if (removed) PackageInstaller.STATUS_SUCCESS else PackageInstaller.STATUS_FAILURE + logD("service.uninstall: $packageName removed=$removed") + status + } catch (e: Exception) { + logD("service.uninstall threw\n${Log.getStackTraceString(e)}", Log.ERROR) + throw e + } + } + + /** + * Commits [session] and blocks until [PackageInstaller.SessionCallback.onFinished] reports the + * outcome (binder-delivered, so it works in this phantom process). Returns a + * `PackageInstaller.STATUS_*` value. + */ + private fun commitAndAwait(session: PackageInstaller.Session, sessionId: Int): Int { + val results = ArrayBlockingQueue(1) + val dispatchThread = HandlerThread("dhizuku-install-result").apply { start() } + val handler = Handler(dispatchThread.looper) + val callback = object : PackageInstaller.SessionCallback() { + override fun onCreated(id: Int) = Unit + override fun onBadgingChanged(id: Int) = Unit + override fun onActiveChanged(id: Int, active: Boolean) = Unit + override fun onProgressChanged(id: Int, progress: Float) = Unit + override fun onFinished(id: Int, success: Boolean) { + if (id == sessionId) results.offer(success) + } + } + packageInstaller.registerSessionCallback(callback, handler) + return try { + session.commit(noOpStatusReceiver()) + val success = results.poll(TIMEOUT_MINUTES, TimeUnit.MINUTES) ?: false + if (success) PackageInstaller.STATUS_SUCCESS else PackageInstaller.STATUS_FAILURE + } finally { + runCatching { packageInstaller.unregisterSessionCallback(callback) } + dispatchThread.quitSafely() + } + } + + private fun awaitPackageRemoved(packageName: String): Boolean { + repeat(UNINSTALL_POLLS) { + if (!isInstalled(packageName)) return true + Thread.sleep(UNINSTALL_POLL_INTERVAL_MS) + } + return !isInstalled(packageName) + } + + private fun isInstalled(packageName: String): Boolean = runCatching { + context.packageManager.getPackageInfo(packageName, 0) + }.isSuccess + + /** + * commit()/uninstall() require a status IntentSender, but we read the result from the + * SessionCallback / package state — the broadcast this fires is never received (phantom process). + */ + private fun noOpStatusReceiver(): IntentSender { + // commit()/uninstall() reject immutable senders (the system fills in status extras), so this + // must be MUTABLE even though we never read the resulting broadcast. + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val intent = Intent(NOOP_ACTION).setPackage(context.packageName) + return PendingIntent.getBroadcast(context, 0, intent, flags).intentSender + } + + // Debug-only: stripped (no-op) in release builds. + private fun logD(message: String, type: Int = Log.INFO) { + if (BuildConfig.DEBUG) log(message, TAG, type) + } + + private companion object { + const val TIMEOUT_MINUTES = 3L + const val UNINSTALL_POLLS = 20 + const val UNINSTALL_POLL_INTERVAL_MS = 250L + const val NOOP_ACTION = "com.looker.droidify.DHIZUKU_STATUS_NOOP" + const val TAG = "DroidifyDhizuku" + } +} diff --git a/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt b/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt index 200d756b3..2a0c30e3d 100644 --- a/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt +++ b/app/src/main/kotlin/com/looker/droidify/service/DownloadService.kt @@ -306,6 +306,7 @@ class DownloadService : ConnectionService() { showNotificationInstall(task) if (currentInstaller == InstallerType.ROOT || currentInstaller == InstallerType.SHIZUKU || + currentInstaller == InstallerType.DHIZUKU || autoInstallWithSessionInstaller ) { val installItem = task.packageName installFrom task.release.cacheFileName diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be8e72065..bb479f32d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,6 +117,10 @@ Shizuku/Sui legacy installer Shizuku/Sui isn\'t running Shizuku/Sui isn\'t installed + Dhizuku installer + No Dhizuku server available + Dhizuku server can take a few seconds to start; please wait + Dhizuku unavailable — using the default installer Installed Installing Installation failed diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1fe00bd6..62b2d3c50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ ktor = "3.4.1" libsu = "6.0.0" room = "2.8.4" shizuku = "13.0.0" +dhizuku = "2.5.4" image-viewer = "1.0.1" junit-jupiter = "6.0.3" robolectric = "4.16.1" @@ -90,6 +91,7 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = " room-test = { group = "androidx.room", name = "room-testing", version.ref = "room" } shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } +dhizuku-api = { group = "io.github.iamr0s", name = "Dhizuku-API", version.ref = "dhizuku" } image-viewer = { module = "com.github.stfalcon-studio:StfalconImageViewer", version.ref = "image-viewer" } junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit-jupiter" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter" }