From d8a979b76b50c14dda55d6c10da2f38dd8491e8f Mon Sep 17 00:00:00 2001 From: Kofhisho Date: Sat, 21 Mar 2026 01:11:58 +0000 Subject: [PATCH] feat(Shizuku): Bootstrap implementation --- library/build.gradle.kts | 24 +- .../installer/ShizukuAdbInstaller.kt | 131 ++++++++++ .../adb/AdbConnectionManager.kt | 165 ++++++++++++ .../revanced/shizukulibrary/adb/AdbStarter.kt | 121 +++++++++ .../receiver/BootCompleteReceiver.kt | 27 ++ .../receiver/ShizukuReceiverStarter.kt | 136 ++++++++++ .../shizukulibrary/starter/Starter.kt | 66 +++++ .../shizukulibrary/utils/EnvironmentUtils.kt | 54 ++++ .../utils/ShizukuStateMachine.kt | 52 ++++ .../shizukulibrary/utils/UserHandleCompat.kt | 25 ++ .../shizukulibrary/worker/AdbStartWorker.kt | 234 ++++++++++++++++++ .../src/androidMain/res/values/strings.xml | 12 + 12 files changed, 1043 insertions(+), 4 deletions(-) create mode 100644 library/src/androidMain/kotlin/app/revanced/library/installation/installer/ShizukuAdbInstaller.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbConnectionManager.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbStarter.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/BootCompleteReceiver.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/ShizukuReceiverStarter.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/starter/Starter.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/EnvironmentUtils.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/ShizukuStateMachine.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/UserHandleCompat.kt create mode 100644 library/src/androidMain/kotlin/app/revanced/shizukulibrary/worker/AdbStartWorker.kt create mode 100644 library/src/androidMain/res/values/strings.xml diff --git a/library/build.gradle.kts b/library/build.gradle.kts index fbab30c..01bcab0 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -21,10 +21,26 @@ kotlin { } sourceSets { - androidMain.dependencies { - implementation(libs.core.ktx) - implementation(libs.libsu.nio) - implementation(libs.libsu.service) + val androidMain by getting { + dependencies { + implementation(libs.core.ktx) + implementation(libs.libsu.nio) + implementation(libs.libsu.service) + + // Shizuku & ADB + val libadb = libs.libadb.android.get() + api("${libadb.group}:${libadb.name}:${libadb.version}") { + exclude(group = "org.bouncycastle") + } + val sunSecurity = libs.sun.security.android.get() + api("${sunSecurity.group}:${sunSecurity.name}:${sunSecurity.version}") { + exclude(group = "org.bouncycastle") + } + implementation(libs.conscrypt.android) + implementation(libs.shizuku.api) + implementation(libs.shizuku.provider) + implementation(libs.work.runtime.ktx) + } } commonMain.dependencies { diff --git a/library/src/androidMain/kotlin/app/revanced/library/installation/installer/ShizukuAdbInstaller.kt b/library/src/androidMain/kotlin/app/revanced/library/installation/installer/ShizukuAdbInstaller.kt new file mode 100644 index 0000000..eb4e6e6 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/library/installation/installer/ShizukuAdbInstaller.kt @@ -0,0 +1,131 @@ +package app.revanced.library.installation.installer + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import app.revanced.shizukulibrary.adb.AdbConnectionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +/** + * [AdbInstaller] for installing and uninstalling [Installer.Apk] files via ADB. + * + * @param context The [Context] to use for string resources. + * @param adbConnectionManager The [AdbConnectionManager] to use for ADB communication. + * @param mapErrorMessage The function to map ADB output to a localized error message. + * @param mapUninstallErrorMessage The function to map ADB uninstall output to a localized error message. + */ +class ShizukuAdbInstaller( + private val context: Context, + private val adbConnectionManager: AdbConnectionManager, + private val mapErrorMessage: (String) -> String = { it }, + private val mapUninstallErrorMessage: (String) -> String = { it } +) : Installer() { + + /** + * Checks if the ADB connection is active. + */ + fun isConnected(): Boolean = adbConnectionManager.isConnected + + override suspend fun install(apk: Apk): AdbInstallerResult = withContext(Dispatchers.IO) { + val size = apk.file.length() + Log.i("ShizukuAdbInstaller", "Installing ${apk.file.name} via ADB (size: $size bytes)") + + // Use exec:cmd package install for streaming + try { + adbConnectionManager.openStream("exec:cmd package install -r -t -S $size").use { stream -> + Log.i("ShizukuAdbInstaller", "ADB installation stream opened") + // Write APK bytes + try { + stream.openOutputStream().use { os -> + Log.i("ShizukuAdbInstaller", "Writing APK bytes to ADB stream...") + apk.file.inputStream().use { fis -> + val bytesCopied = fis.copyTo(os) + Log.i("ShizukuAdbInstaller", "Successfully wrote $bytesCopied bytes to ADB stream") + } + os.flush() + Log.i("ShizukuAdbInstaller", "ADB stream flushed") + } + } catch (e: Exception) { + // Log and continue, as the server might have already started processing + Log.w("ShizukuAdbInstaller", "Output stream closed during write: ${e.message}") + } + + Log.i("ShizukuAdbInstaller", "Waiting for ADB installation response...") + // Read response + val output = StringBuilder() + try { + stream.openInputStream().bufferedReader().use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + Log.i("ShizukuAdbInstaller", "ADB Output: $line") + output.append(line).append("\n") + if (line?.contains("Success", ignoreCase = true) == true || + line?.contains("Failure", ignoreCase = true) == true) { + break + } + } + } + } catch (e: IOException) { + // "Stream closed" is common if the server finishes abruptly after sending Success + Log.w("ShizukuAdbInstaller", "ADB input stream closed: ${e.message}") + if (output.isEmpty()) return@withContext AdbInstallerResult.Failure(e) + } + + val result = output.toString().trim() + Log.i("ShizukuAdbInstaller", "ADB Installation summary: $result") + if (!result.contains("Success")) { + AdbInstallerResult.Failure(AdbInstallationException(mapErrorMessage(result), result)) + } else { + AdbInstallerResult.Success + } + } + } catch (e: Exception) { + Log.e("ShizukuAdbInstaller", "Failed to open ADB installation stream: ${e.message}", e) + AdbInstallerResult.Failure(e) + } + } + + override suspend fun uninstall(packageName: String): AdbInstallerResult = withContext(Dispatchers.IO) { + Log.i("ShizukuAdbInstaller", "Uninstalling $packageName via ADB") + + adbConnectionManager.openStream("shell:pm uninstall $packageName").use { stream -> + val output = StringBuilder() + try { + stream.openInputStream().bufferedReader().use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line).append("\n") + if (line?.contains("Success", ignoreCase = true) == true || + line?.contains("Failure", ignoreCase = true) == true) { + break + } + } + } + } catch (e: IOException) { + // Ignore "Stream closed" if we already have some output + Log.w("ShizukuAdbInstaller", "ADB uninstall stream error: ${e.message}") + if (output.isEmpty()) return@withContext AdbInstallerResult.Failure(e) + } + + val result = output.toString().trim() + Log.i("ShizukuAdbInstaller", "ADB Uninstall summary: $result") + if (!result.contains("Success") && result.isNotEmpty()) { + val message = mapUninstallErrorMessage(result) + AdbInstallerResult.Failure(AdbInstallationException(message, result)) + } else { + AdbInstallerResult.Success + } + } + } + + override suspend fun getInstallation(packageName: String): Installation? = try { + val packageInfo = context.packageManager.getPackageInfo(packageName, 0) + Installation(packageInfo.applicationInfo!!.sourceDir) + } catch (e: PackageManager.NameNotFoundException) { + null + } + + class AdbInstallationException(message: String, val output: String) : Exception(message) +} diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbConnectionManager.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbConnectionManager.kt new file mode 100644 index 0000000..f1d1657 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbConnectionManager.kt @@ -0,0 +1,165 @@ +package app.revanced.shizukulibrary.adb + +import android.content.Context +import android.os.Build +import android.sun.misc.BASE64Encoder +import android.sun.security.provider.X509Factory +import android.sun.security.x509.* +import io.github.muntashirakon.adb.AbsAdbConnectionManager +import java.io.* +import java.nio.charset.StandardCharsets +import java.security.* +import java.security.cert.Certificate +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.InvalidKeySpecException +import java.security.spec.PKCS8EncodedKeySpec +import java.util.* + +class AdbConnectionManager private constructor(context: Context) : AbsAdbConnectionManager() { + private var mPrivateKey: PrivateKey? = null + private var mCertificate: Certificate? = null + + init { + setApi(Build.VERSION.SDK_INT) + try { + mPrivateKey = readPrivateKeyFromFile(context) + mCertificate = readCertificateFromFile(context) + } catch (e: Exception) { + // Log or handle initial read failure + } + + // Regenerate if key is missing or certificate is expired + var needsRegeneration = mPrivateKey == null || mCertificate == null + if (!needsRegeneration && mCertificate is X509Certificate) { + try { + (mCertificate as X509Certificate).checkValidity() + } catch (e: Exception) { + needsRegeneration = true + } + } + + if (needsRegeneration) { + try { + // Generate a new key pair + val keySize = 2048 + val keyPairGenerator = KeyPairGenerator.getInstance("RSA") + keyPairGenerator.initialize(keySize, SecureRandom.getInstance("SHA1PRNG")) + val generateKeyPair = keyPairGenerator.generateKeyPair() + val publicKey = generateKeyPair.public + mPrivateKey = generateKeyPair.private + + // Generate a new certificate + val subject = "CN=Revanced Library" + val algorithmName = "SHA512withRSA" + val expiryDate = System.currentTimeMillis() + 10L * 365 * 86400000 + + val certificateExtensions = CertificateExtensions() + certificateExtensions.set( + "SubjectKeyIdentifier", SubjectKeyIdentifierExtension( + KeyIdentifier(publicKey).identifier + ) + ) + val x500Name = X500Name(subject) + val notBefore = Date() + val notAfter = Date(expiryDate) + certificateExtensions.set("PrivateKeyUsage", PrivateKeyUsageExtension(notBefore, notAfter)) + val certificateValidity = CertificateValidity(notBefore, notAfter) + val x509CertInfo = X509CertInfo() + x509CertInfo.set("version", CertificateVersion(2)) + x509CertInfo.set("serialNumber", CertificateSerialNumber(Random().nextInt() and Int.MAX_VALUE)) + x509CertInfo.set("algorithmID", CertificateAlgorithmId(AlgorithmId.get(algorithmName))) + x509CertInfo.set("subject", CertificateSubjectName(x500Name)) + x509CertInfo.set("key", CertificateX509Key(publicKey)) + x509CertInfo.set("validity", certificateValidity) + x509CertInfo.set("issuer", CertificateIssuerName(x500Name)) + x509CertInfo.set("extensions", certificateExtensions) + + val x509CertImpl = X509CertImpl(x509CertInfo) + x509CertImpl.sign(mPrivateKey, algorithmName) + mCertificate = x509CertImpl + + // Write files + writePrivateKeyToFile(context, mPrivateKey!!) + writeCertificateToFile(context, mCertificate!!) + } catch (e: Exception) { + throw RuntimeException("Failed to generate ADB credentials", e) + } + } + } + + public override fun getPrivateKey(): PrivateKey { + return mPrivateKey ?: throw IllegalStateException("Private key not initialized") + } + + public override fun getCertificate(): Certificate { + return mCertificate ?: throw IllegalStateException("Certificate not initialized") + } + + override fun getDeviceName(): String { + return "MyAwesomeApp" + } + + companion object { + private var INSTANCE: AdbConnectionManager? = null + + @JvmStatic + @Synchronized + fun getInstance(context: Context): AdbConnectionManager { + if (INSTANCE == null) { + INSTANCE = AdbConnectionManager(context) + } + return INSTANCE!! + } + + @Throws(IOException::class, CertificateException::class) + private fun readCertificateFromFile(context: Context): Certificate? { + val certFile = File(context.filesDir, "cert.pem") + if (!certFile.exists()) return null + return FileInputStream(certFile).use { cert -> + CertificateFactory.getInstance("X.509").generateCertificate(cert) + } + } + + @Throws(CertificateEncodingException::class, IOException::class) + private fun writeCertificateToFile(context: Context, certificate: Certificate) { + val certFile = File(context.filesDir, "cert.pem") + val encoder = BASE64Encoder() + FileOutputStream(certFile).use { os -> + os.write(X509Factory.BEGIN_CERT.toByteArray(StandardCharsets.UTF_8)) + os.write('\n'.toInt()) + encoder.encode(certificate.encoded, os) + os.write('\n'.toInt()) + os.write(X509Factory.END_CERT.toByteArray(StandardCharsets.UTF_8)) + } + } + + @Throws(IOException::class, NoSuchAlgorithmException::class, InvalidKeySpecException::class) + private fun readPrivateKeyFromFile(context: Context): PrivateKey? { + val privateKeyFile = File(context.filesDir, "private.key") + if (!privateKeyFile.exists()) return null + val privKeyBytes = ByteArray(privateKeyFile.length().toInt()) + DataInputStream(FileInputStream(privateKeyFile)).use { dis -> + dis.readFully(privKeyBytes) + } + val keyFactory = KeyFactory.getInstance("RSA") + val privateKeySpec = PKCS8EncodedKeySpec(privKeyBytes) + return keyFactory.generatePrivate(privateKeySpec) + } + + @Throws(IOException::class) + private fun writePrivateKeyToFile(context: Context, privateKey: PrivateKey) { + val privateKeyFile = File(context.filesDir, "private.key") + FileOutputStream(privateKeyFile).use { os -> + os.write(privateKey.encoded) + } + // Restrict file permissions to owner only + privateKeyFile.setReadable(false, false) + privateKeyFile.setReadable(true, true) + privateKeyFile.setWritable(false, false) + privateKeyFile.setWritable(true, true) + } + } +} diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbStarter.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbStarter.kt new file mode 100644 index 0000000..28fa815 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/adb/AdbStarter.kt @@ -0,0 +1,121 @@ +package app.revanced.shizukulibrary.adb +import android.Manifest.permission.WRITE_SECURE_SETTINGS +import android.content.Context +import android.content.pm.PackageManager +import android.provider.Settings +import android.util.Log +import app.revanced.shizukulibrary.starter.Starter +import app.revanced.shizukulibrary.utils.EnvironmentUtils +import app.revanced.shizukulibrary.utils.ShizukuStateMachine +import io.github.muntashirakon.adb.AbsAdbConnectionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.EOFException +import java.net.ConnectException +import java.net.SocketException +import kotlin.coroutines.cancellation.CancellationException + +object AdbStarter { + private const val TAG = "AdbStarter" + + /** + * Connects with retry logic, since ADB daemon may be restarting (e.g. after TCP mode switch). + */ + private suspend fun connectWithRetry( + manager: AbsAdbConnectionManager, + host: String, + port: Int, + maxAttempts: Int = 5, + ) { + var delayTime = 0L + for (attempt in 1..maxAttempts) { + try { + delay(delayTime) + manager.connect(host, port) + return + } catch (e: Exception) { + if (attempt == maxAttempts || e is CancellationException) throw e + if (e !is ConnectException && e !is EOFException && e !is SocketException) throw e + delayTime += 1000 + } + } + } + + suspend fun startAdb( + context: Context, + port: Int, + tcpMode: Boolean = true, + tcpPort: Int = 5555, + log: ((String) -> Unit)? = null, + ) { + try { + ShizukuStateMachine.set(ShizukuStateMachine.State.STARTING) + log?.invoke("Starting with wireless adb...\n") + withContext(Dispatchers.IO) { + val manager = AdbConnectionManager.getInstance(context) + var activePort = port + if (tcpMode && activePort != tcpPort) { + log?.invoke("Connecting on port $activePort...") + manager.connect("127.0.0.1", activePort) + log?.invoke("Successfully connected on port $activePort...") + log?.invoke("\nRestarting in TCP mode port: $tcpPort") + activePort = tcpPort + try { + manager.openStream("tcpip:$activePort").use { stream -> + stream.openInputStream().bufferedReader().readText() + } + } catch (_: EOFException) { + // Expected when ADB restarts in TCP mode + } catch (_: SocketException) { + // Expected when ADB restarts in TCP mode + } + manager.disconnect() + delay(2000) + } + log?.invoke("Connecting on port $activePort...") + connectWithRetry(manager, "127.0.0.1", activePort) + log?.invoke("Successfully connected on port $activePort...\n") + val command = Starter.getInternalCommand(context) + manager.openStream("shell:$command").use { stream -> + stream.openInputStream().bufferedReader().forEachLine { line -> + log?.invoke(line) + } + } + manager.disconnect() + } + } finally { + if (context.checkSelfPermission(WRITE_SECURE_SETTINGS) == PackageManager.PERMISSION_GRANTED) { + Settings.Global.putInt(context.contentResolver, "adb_wifi_enabled", 0) + } + } + } + suspend fun stopTcp(context: Context, port: Int) { + try { + val cr = context.contentResolver + if (context.checkSelfPermission(WRITE_SECURE_SETTINGS) == PackageManager.PERMISSION_GRANTED) { + Settings.Global.putInt(cr, Settings.Global.ADB_ENABLED, 1) + Settings.Global.putLong(cr, "adb_allowed_connection_time", 0L) + } + val adbEnabled = Settings.Global.getInt(cr, Settings.Global.ADB_ENABLED, 0) + if (adbEnabled == 0) throw IllegalStateException("ADB is not enabled") + ShizukuStateMachine.set(ShizukuStateMachine.State.IDLE) + val manager = AdbConnectionManager.getInstance(context) + withContext(Dispatchers.IO) { + connectWithRetry(manager, "127.0.0.1", port) + try { + // "usb:" service switches ADB back to USB mode + manager.openStream("usb:").use { stream -> + stream.openInputStream().bufferedReader().readText() + } + } catch (_: Exception) {} + manager.disconnect() + } + } catch (e: Exception) { + if (EnvironmentUtils.getAdbTcpPort() > 0) { + ShizukuStateMachine.update() + } + Log.e(TAG, "Failed to stop TCP mode", e) + } + } +} diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/BootCompleteReceiver.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/BootCompleteReceiver.kt new file mode 100644 index 0000000..90699f9 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/BootCompleteReceiver.kt @@ -0,0 +1,27 @@ +package app.revanced.shizukulibrary.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** + * Receives ACTION_BOOT_COMPLETED after device reboot and triggers the Shizuku start chain. + * + * This receiver is **disabled by default** in the manifest (`android:enabled="false"`) + * and should be toggled on/off programmatically via `PackageManager.setComponentEnabledSetting`. + */ +class BootCompleteReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "BootCompleteReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + + Log.d(TAG, "Boot completed, starting Shizuku receiver starter") + ShizukuReceiverStarter.start(context) + } +} + diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/ShizukuReceiverStarter.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/ShizukuReceiverStarter.kt new file mode 100644 index 0000000..5eff315 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/receiver/ShizukuReceiverStarter.kt @@ -0,0 +1,136 @@ +package app.revanced.shizukulibrary.receiver + +import android.Manifest.permission.WRITE_SECURE_SETTINGS +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import app.revanced.shizukulibrary.utils.EnvironmentUtils +import app.revanced.shizukulibrary.utils.ShizukuStateMachine +import app.revanced.shizukulibrary.utils.UserHandleCompat +import app.revanced.shizukulibrary.worker.AdbStartWorker + +/** + * Decides how to start Shizuku based on the last launch mode and available permissions. + */ +object ShizukuReceiverStarter { + + private const val TAG = "ShizukuReceiverStarter" + const val NOTIFICATION_ID = 1447 + private const val CHANNEL_ID = "AdbStartWorker" + + enum class WorkerState { + AWAITING_WIFI, + AWAITING_RETRY, + RUNNING, + STOPPED + } + + /** + * Entry point for starting Shizuku in the background. + * Called from [BootCompleteReceiver] or any manual trigger. + * + * @param context Application context. + * @param forceStart If true, skips checks for running state and user ID. + */ + fun start(context: Context, forceStart: Boolean = false) { + // Skip if not the primary user or already running + if ((UserHandleCompat.myUserId() > 0 || ShizukuStateMachine.isRunning()) && !forceStart) return + + val launchMode = + context.getSharedPreferences("revanced_prefs", Context.MODE_PRIVATE) + .getInt("last_launch_mode", 0) + + if (launchMode == 0) { + // ADB mode requires Android 11+ OR TV OR existing TCP port + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + || EnvironmentUtils.isTelevision() + || EnvironmentUtils.getAdbTcpPort() > 0 + ) { + if (context.checkSelfPermission(WRITE_SECURE_SETTINGS) == PackageManager.PERMISSION_GRANTED) { + AdbStartWorker.enqueue(context) + updateNotification(context, WorkerState.AWAITING_WIFI) + } else { + Log.w(TAG, "WRITE_SECURE_SETTINGS not granted, cannot auto-start ADB") + showPermissionErrorNotification(context) + } + } else { + Log.w(TAG, "Background ADB start not supported on this device/Android version") + } + } else { + Log.w(TAG, "Last launch mode was not ADB, background start not supported by this library") + } + } + + /** + * Builds a notification for the ADB start progress. + */ + fun buildNotification(context: Context, msg: String? = null): Notification { + val channel = NotificationChannel( + CHANNEL_ID, + "Shizuku ADB Start", + NotificationManager.IMPORTANCE_LOW + ) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel) + + val nb = NotificationCompat.Builder(context, CHANNEL_ID) + + if (msg != null) nb.setContentText(msg) + + return nb + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("Starting Shizuku") + .setOngoing(true) + .setSilent(true) + .build() + } + + /** + * Updates the ongoing notification based on the current worker state. + */ + fun updateNotification(context: Context, state: WorkerState) { + if (state == WorkerState.STOPPED) return + + val msg = when (state) { + WorkerState.AWAITING_WIFI -> "Waiting for Wi-Fi connection..." + WorkerState.AWAITING_RETRY -> "Retrying..." + WorkerState.RUNNING -> "Starting Shizuku service..." + else -> null + } + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, buildNotification(context, msg)) + } + + /** + * Shows a notification indicating that WRITE_SECURE_SETTINGS is missing. + */ + private fun showPermissionErrorNotification(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + "Shizuku ADB Start", + NotificationManager.IMPORTANCE_LOW + ) + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel) + + val msg = "WRITE_SECURE_SETTINGS permission is not granted. " + + "Run: adb shell pm grant ${context.packageName} android.permission.WRITE_SECURE_SETTINGS" + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle("Shizuku: Permission Required") + .setContentText(msg) + .setSilent(true) + .setStyle(NotificationCompat.BigTextStyle().bigText(msg)) + .build() + + nm.notify(NOTIFICATION_ID, notification) + } +} + diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/starter/Starter.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/starter/Starter.kt new file mode 100644 index 0000000..ea32ced --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/starter/Starter.kt @@ -0,0 +1,66 @@ +package app.revanced.shizukulibrary.starter + +import android.content.Context +import android.util.Log +import app.revanced.shizukulibrary.utils.ShizukuStateMachine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.TimeoutCancellationException +import java.io.File +import java.util.concurrent.TimeoutException + +/** + * Utility for building the Shizuku server start command and waiting for the binder to become available. + */ +object Starter { + + private const val TAG = "ShizukuStarter" + + /** + * Returns the path to the native libshizuku.so binary. + */ + fun getStarterFile(context: Context): File = + File(context.applicationInfo.nativeLibraryDir, "libshizuku.so") + + /** + * The shell command to start the Shizuku server. + */ + fun getInternalCommand(context: Context): String { + val starterFile = getStarterFile(context) + return "${starterFile.absolutePath} --apk=${context.applicationInfo.sourceDir}" + } + + /** + * The ADB command that a user would type to start Shizuku manually. + */ + fun getAdbCommand(context: Context): String { + val starterFile = getStarterFile(context) + return "adb shell ${starterFile.absolutePath} --apk=${context.applicationInfo.sourceDir}" + } + + val serviceStartedMessage = + "Service started, this window will be automatically closed in 3 seconds" + + /** + * Waits for the Shizuku server binder to become available by observing [ShizukuStateMachine]. + * Times out after 60 seconds. + * + * @param log Optional logging callback for status messages. + * @throws TimeoutException if the binder does not become available within the timeout. + */ + suspend fun waitForBinder(log: ((String) -> Unit)? = null) { + try { + log?.invoke("\nWaiting for service. This may take up to 1 minute...") + withTimeout(60_000) { + ShizukuStateMachine.asFlow() + .first { it == ShizukuStateMachine.State.RUNNING } + } + log?.invoke("Service is running!") + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "Timed out waiting for Shizuku binder") + ShizukuStateMachine.set(ShizukuStateMachine.State.DEAD) + throw TimeoutException("Timed out waiting for Shizuku binder to become available") + } + } +} + diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/EnvironmentUtils.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/EnvironmentUtils.kt new file mode 100644 index 0000000..02a8a4e --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/EnvironmentUtils.kt @@ -0,0 +1,54 @@ +package app.revanced.shizukulibrary.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.os.Build + +/** + * Environment utility helpers for detecting ADB TCP mode, Wi-Fi requirements, and TV devices. + */ +object EnvironmentUtils { + + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + + /** + * Returns the currently configured ADB TCP port from system property `service.adb.tcp.port`. + * Returns -1 if ADB is not in TCP mode or the property is not set. + */ + @SuppressLint("PrivateApi") + fun getAdbTcpPort(): Int = runCatching { + val clazz = Class.forName("android.os.SystemProperties") + val method = clazz.getMethod("getInt", String::class.java, Int::class.javaPrimitiveType) + method.invoke(null, "service.adb.tcp.port", -1) as Int + }.getOrDefault(-1) + + /** + * Whether Wi-Fi / network connectivity is required for ADB connection. + * Wi-Fi is required on Android 11+ (wireless debugging uses mDNS on a random port). + * TVs always use wireless debugging even on older Android versions. + * If ADB is already in TCP mode, Wi-Fi is NOT required (localhost connection suffices). + */ + fun isWifiRequired(): Boolean { + if (getAdbTcpPort() > 0) return false + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || isTelevision() + } + + /** + * Returns true if TLS-based wireless debugging is supported (Android 11+). + */ + fun isTlsSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + /** + * Detects whether the device is a television (Android TV / Google TV). + */ + fun isTelevision(): Boolean { + val ctx = appContext ?: return false + val uiMode = ctx.resources.configuration.uiMode + return (uiMode and Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION + } +} diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/ShizukuStateMachine.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/ShizukuStateMachine.kt new file mode 100644 index 0000000..3867304 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/ShizukuStateMachine.kt @@ -0,0 +1,52 @@ +package app.revanced.shizukulibrary.utils + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * State machine tracking the Shizuku server lifecycle. + * Backed by [StateFlow] for thread-safe access from any thread/coroutine. + */ +object ShizukuStateMachine { + + enum class State { + /** No server process running. */ + IDLE, + /** Server is being started (ADB connection in progress). */ + STARTING, + /** Server binder is available and responsive. */ + RUNNING, + /** Server failed to start or crashed. */ + DEAD + } + + private val _stateFlow = MutableStateFlow(State.IDLE) + + fun get(): State = _stateFlow.value + + fun set(state: State) { + _stateFlow.value = state + } + + fun isRunning(): Boolean = get() == State.RUNNING + + fun isDead(): Boolean = get() == State.DEAD + + /** + * Checks actual server status and updates state accordingly. + * Returns the updated state. + */ + fun update(): State { + // In a full implementation this would ping the Shizuku binder. + return get() + } + + /** + * Expose as Flow for coroutine consumers. + */ + fun asFlow(): Flow = _stateFlow.asStateFlow() +} + + diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/UserHandleCompat.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/UserHandleCompat.kt new file mode 100644 index 0000000..386cd39 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/utils/UserHandleCompat.kt @@ -0,0 +1,25 @@ +package app.revanced.shizukulibrary.utils + +import android.os.Process + +/** + * Compat wrapper to obtain the current user ID. + * On multi-user devices, user 0 is the primary user (owner). + */ +object UserHandleCompat { + + /** + * Returns the user ID of the current process. + * Uses reflection to call the hidden `UserHandle.myUserId()` method, + * falling back to deriving it from the UID. + */ + fun myUserId(): Int = runCatching { + android.os.UserHandle::class.java + .getMethod("myUserId") + .invoke(null) as Int + }.getOrElse { + // Fallback: userId = uid / 100000 + Process.myUid() / 100_000 + } +} + diff --git a/library/src/androidMain/kotlin/app/revanced/shizukulibrary/worker/AdbStartWorker.kt b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/worker/AdbStartWorker.kt new file mode 100644 index 0000000..76594a5 --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/shizukulibrary/worker/AdbStartWorker.kt @@ -0,0 +1,234 @@ +package app.revanced.shizukulibrary.worker + +import android.app.KeyguardManager +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.database.ContentObserver +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.io.EOFException +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import app.revanced.shizukulibrary.adb.AdbStarter +import app.revanced.shizukulibrary.receiver.ShizukuReceiverStarter +import app.revanced.shizukulibrary.receiver.ShizukuReceiverStarter.WorkerState +import app.revanced.shizukulibrary.starter.Starter +import app.revanced.shizukulibrary.utils.EnvironmentUtils +import app.revanced.shizukulibrary.utils.ShizukuStateMachine +import io.github.muntashirakon.adb.android.AdbMdns + +/** + * WorkManager worker that handles the ADB start process. + * + * This worker: + * 1. Force-enables USB debugging via Settings.Global.ADB_ENABLED + * 2. Force-enables wireless ADB via Settings.Global "adb_wifi_enabled" + * 3. Discovers the wireless ADB port via mDNS (or uses TCP port) + * 4. Connects via ADB and starts the Shizuku server + * 5. Waits for the Shizuku binder to become available + */ +class AdbStartWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "AdbStartWorker" + + /** + * Enqueues the ADB start worker with appropriate network constraints. + * If Wi-Fi is required (Android 11+ wireless debugging), adds UNMETERED network constraint. + */ + fun enqueue(context: Context) { + val cb = Constraints.Builder() + if (EnvironmentUtils.isWifiRequired()) { + cb.setRequiredNetworkType(NetworkType.UNMETERED) + } + val constraints = cb.build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + "adb_start_worker", + ExistingWorkPolicy.REPLACE, + request + ) + } + } + + override suspend fun doWork(): Result { + try { + // Initialize EnvironmentUtils so isTelevision() works + EnvironmentUtils.init(applicationContext) + + ShizukuReceiverStarter.updateNotification(applicationContext, WorkerState.RUNNING) + + val cr = applicationContext.contentResolver + + // Step 1: Force-enable USB debugging (requires WRITE_SECURE_SETTINGS) + Settings.Global.putInt(cr, Settings.Global.ADB_ENABLED, 1) + Settings.Global.putLong(cr, "adb_allowed_connection_time", 0L) + + // Step 2: Check if already in TCP mode from before reboot + val tcpPort = EnvironmentUtils.getAdbTcpPort() + if (tcpPort > 0) { + AdbStarter.stopTcp(applicationContext, tcpPort) + } + + // Step 3: Discover the wireless ADB port via mDNS or use TCP port + val port = tcpPort.takeIf { !EnvironmentUtils.isWifiRequired() } ?: callbackFlow { + val adbMdns = AdbMdns( + applicationContext, + AdbMdns.SERVICE_TYPE_TLS_CONNECT + ) { _, p -> + if (p > 0) trySend(p) + } + + var awaitingAuth = false + var timeoutJob: Job? = null + var unlockReceiver: BroadcastReceiver? = null + + fun startDiscoveryWithTimeout() { + adbMdns.start() + timeoutJob?.cancel() + timeoutJob = launch { + delay(15_000) + close(TimeoutException("Timed out during mDNS port discovery")) + } + } + + fun handleAuth() { + val km = applicationContext.getSystemService(Context.KEYGUARD_SERVICE) + as KeyguardManager + if (km.isKeyguardLocked) { + // Device is locked — wait for user to unlock before enabling wireless ADB + val notification = ShizukuReceiverStarter.buildNotification( + applicationContext, null + ) + val foregroundInfo = ForegroundInfo( + ShizukuReceiverStarter.NOTIFICATION_ID, + notification + ) + setForegroundAsync(foregroundInfo) + + val filter = IntentFilter(Intent.ACTION_USER_PRESENT) + unlockReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_USER_PRESENT) { + context.unregisterReceiver(this) + unlockReceiver = null + Settings.Global.putInt(cr, "adb_wifi_enabled", 1) + } + } + } + applicationContext.registerReceiver(unlockReceiver, filter) + } else { + awaitingAuth = true + } + timeoutJob?.cancel() + adbMdns.stop() + } + + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + when (Settings.Global.getInt(cr, "adb_wifi_enabled", 0)) { + 0 -> if (awaitingAuth) { + close(SecurityException("Network is not authorized for wireless debugging")) + } else { + handleAuth() + } + + 1 -> startDiscoveryWithTimeout() + } + } + } + + // Register observer BEFORE enabling wireless ADB to avoid missing the change + cr.registerContentObserver( + Settings.Global.getUriFor("adb_wifi_enabled"), + false, + observer + ) + + // Force-enable wireless ADB + Settings.Global.putInt(cr, "adb_wifi_enabled", 1) + startDiscoveryWithTimeout() + + awaitClose { + adbMdns.stop() + timeoutJob?.cancel() + cr.unregisterContentObserver(observer) + unlockReceiver?.let { applicationContext.unregisterReceiver(it) } + } + }.first() + + // Step 4: Connect via ADB and start the Shizuku server + AdbStarter.startAdb(applicationContext, port) + + // Step 5: Wait for the Shizuku binder to become available + Starter.waitForBinder() + + // Dismiss notification on success + val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + nm.cancel(ShizukuReceiverStarter.NOTIFICATION_ID) + + return Result.success() + } catch (e: CancellationException) { + val state = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + WorkerState.AWAITING_RETRY + } else { + when (stopReason) { + WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY -> WorkerState.AWAITING_WIFI + WorkInfo.STOP_REASON_CANCELLED_BY_APP -> WorkerState.STOPPED + else -> WorkerState.AWAITING_RETRY + } + } + ShizukuReceiverStarter.updateNotification(applicationContext, state) + throw e + } catch (e: Exception) { + Log.e(TAG, "ADB start failed", e) + + val ignored = listOf( + EOFException::class, + SecurityException::class, + TimeoutException::class + ) + if (ignored.none { it.isInstance(e) }) { + Log.e(TAG, "Unexpected error during ADB start", e) + } + + if (ShizukuStateMachine.update() == ShizukuStateMachine.State.RUNNING) { + return Result.success() + } else { + ShizukuReceiverStarter.updateNotification( + applicationContext, + WorkerState.AWAITING_RETRY + ) + return Result.retry() + } + } + } +} + diff --git a/library/src/androidMain/res/values/strings.xml b/library/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..bca0dc8 --- /dev/null +++ b/library/src/androidMain/res/values/strings.xml @@ -0,0 +1,12 @@ + + Installation conflict. Please uninstall the existing app first. + Insufficient storage space. + The APK is invalid. + The APK is incompatible with your device. + Installation was aborted. + Installation timed out during verification. + Installation was blocked by the system. + Installation is restricted by the user. + Installation failed: %s + Uninstall failed: %s +