Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ android {
compose = true
viewBinding = true
buildConfig = true
aidl = true
}

dependenciesInfo {
Expand Down Expand Up @@ -200,6 +201,7 @@ dependencies {

implementation(libs.libsu.core)
implementation(libs.bundles.shizuku)
implementation(libs.dhizuku.api)

implementation(libs.jackson.core)
implementation(libs.serialization)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
android:name="android.hardware.touchscreen"
android:required="false" />

<!--
Dhizuku-API requires minSdk 26; the app supports 23. The Dhizuku installer is gated to
API 26+ at runtime (it reports unavailable and falls back below that), so overriding the
library's minSdk floor here is safe.
-->
<uses-sdk tools:overrideLibrary="com.rosan.dhizuku.api" />

<application
android:name=".Droidify"
android:allowBackup="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.looker.droidify.installer.installers.dhizuku;

/**
* Runs inside Dhizuku's device-owner process. Because the implementation executes with
* device-owner privileges, it can drive the standard PackageInstaller silently.
*
* Both methods block until the install/uninstall result is known and return a
* PackageInstaller.STATUS_* code (STATUS_SUCCESS == 0 on success).
*/
interface IDhizukuInstallerService {

// IMPORTANT: Dhizuku's UserService protocol transacts lifecycle signals on this binder at
// FIRST_CALL_TRANSACTION+1 ("created") and FIRST_CALL_TRANSACTION+2 ("destroy"). AIDL's "= N"
// is an OFFSET added to FIRST_CALL_TRANSACTION, so a method at offset 1 or 2 collides with those
// signals — the "created" signal would then invoke that method with an empty parcel, throwing
// and aborting the bind handshake (onServiceConnected never fires). Keep both methods off
// offsets 1 and 2.
int install(in ParcelFileDescriptor apk, long size, String installerPackageName) = 3;

int uninstall(String packageName) = 4;
}
Original file line number Diff line number Diff line change
Expand Up @@ -472,17 +472,22 @@ private fun InstallerTypeSetting(
selectedInstaller: InstallerType,
onInstallerSelected: (InstallerType) -> 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) {
InstallerType.LEGACY -> stringResource(R.string.legacy_installer)
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)
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum class InstallerType {
SESSION,
SHIZUKU,
ROOT,
DHIZUKU,
;

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,6 +152,7 @@ class InstallManager(
InstallerType.SESSION -> SessionInstaller(context)
InstallerType.SHIZUKU -> ShizukuInstaller(context)
InstallerType.ROOT -> RootInstaller(context)
InstallerType.DHIZUKU -> DhizukuInstaller(context)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading