diff --git a/library/src/androidMain/kotlin/app/revanced/library/MagiskUtils.kt b/library/src/androidMain/kotlin/app/revanced/library/MagiskUtils.kt new file mode 100644 index 0000000..62cce8a --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/library/MagiskUtils.kt @@ -0,0 +1,328 @@ +package app.revanced.library + +import app.revanced.library.installation.installer.Constants +import app.revanced.library.installation.installer.Constants.invoke +import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.nio.FileSystemManager +import java.io.File + +object MagiskUtils { + const val MODULES_PATH = "/data/adb/modules" + + // Android user (0 for primary, 10+ for secondary/work profiles) via pure public API. + private val currentUserId = android.os.Process.myUid() / 100000 + + /* + Shell.isAppGrantedRoot() queries libsu's internal state. + It returns false until a root shell has actually been built and verified by libsu. + */ + fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false + + /* + Checks whether su exists in PATH. Pure filesystem check, no shell needed. + Returns true as soon as APatch makes su available, regardless of whether your app was granted root. + */ + fun isDeviceRooted() = + System.getenv("PATH")?.split(":")?.any { path -> File(path, "su").canExecute() } ?: false + + /* + Shell.getShell().isRoot forces a shell to be built (or returns the cached one) and checks if it came up as root. + This is the only reliable "root is usable right now" check. + */ + fun isMagiskInstalled() = Shell.getShell().isRoot + + /* + Returns true if root was granted, false if denied. + Must be called on a background thread. + */ + fun requestRoot(): Boolean { + Shell.getCachedShell()?.takeIf { !it.isRoot }?.close() + return Shell.getShell().isRoot + } + + fun isInstalled(packageName: String, remoteFS: FileSystemManager) = + remoteFS.getFile("$MODULES_PATH/$packageName-revanced").exists() + + fun isInstalledAsMagiskModule(packageName: String, remoteFS: FileSystemManager) = + remoteFS.getFile("$MODULES_PATH/revanced_${packageName.replace('.', '_')}").exists() + + /* + Bind-mounts the patched APK over the stock APK path using the Magisk mirror when + available, ensuring the mount is visible across all process namespaces (required on + Zygisk/MIUI). + */ + fun mount(packageName: String, sourceDir: String) { + if (Shell.getShell().newJob().add("mount | grep -q \"$sourceDir\"").exec().isSuccess) { + unmount(sourceDir) + } + + val patchedApkPath = Constants.MOUNTED_APK_PATH(packageName) + val fallbackPath = "$MODULES_PATH/$packageName-revanced/$packageName.apk" + + val patchedApk = when { + Shell.getShell().newJob().add("[ -f \"$patchedApkPath\" ]").exec().isSuccess -> patchedApkPath + Shell.getShell().newJob().add("[ -f \"$fallbackPath\" ]").exec().isSuccess -> fallbackPath + else -> throw ShellCommandException("Patched APK not found for $packageName", -1, emptyList(), emptyList()) + } + + Shell.getShell().newJob().add($$""" + MIRROR="" + if command -v magisk >/dev/null 2>&1; then + if ! MAGISKTMP=$(magisk --path 2>/dev/null); then MAGISKTMP=/sbin; fi + MIRROR=${MAGISKTMP}/.magisk/mirror + [ -d "${MIRROR}" ] || MIRROR="" + fi + mount -o bind "${MIRROR}$$patchedApk" "$$sourceDir" + """.trimIndent()).exec().assertSuccess("Failed to mount APK") + } + + fun unmount(sourceDir: String) { + // Skip if not mounted + val checkMount = Shell.getShell().newJob().add("mount | grep -q \"$sourceDir\"").exec() + if (!checkMount.isSuccess) return + + Shell.getShell().newJob() + .add("umount -l \"$sourceDir\"") + .exec() + .assertSuccess("Failed to unmount APK") + } + + fun uninstall(packageName: String, remoteFS: FileSystemManager) { + val unifiedPath = Constants.MOUNTED_APK_PATH(packageName).substringBeforeLast("/") + remoteFS.getFile(unifiedPath).deleteRecursively() + + remoteFS.getFile("$MODULES_PATH/$packageName-revanced").deleteRecursively() + .also { if (!it) throw Exception("Failed to delete files") } + } + + fun uninstallMagiskModule(packageName: String, patchedPackageName: String, remoteFS: FileSystemManager) { + val unifiedPath = Constants.MOUNTED_APK_PATH(packageName).substringBeforeLast("/") + remoteFS.getFile(unifiedPath).deleteRecursively() + + val formattedPackageName = packageName.replace('.', '_') + val handleDisabledScriptPath = Constants.HANDLE_DISABLED_SCRIPT_PATH(formattedPackageName) + + Shell.getShell().newJob() + .add("pm uninstall \"$patchedPackageName\"") + .add("rm -f \"$handleDisabledScriptPath\"") + .exec() + + remoteFS.getFile("$MODULES_PATH/revanced_$formattedPackageName").deleteRecursively() + .also { if (!it) throw Exception("Failed to delete Magisk module files") } + } + + /* + * When bind-mounting a patched APK over the unpatched/stock APK, Android's PM + * does not re-extract native libraries - it reuses whatever it already extracted from + * the unpatched APK at install time. If the patched APK introduces a new .so file that was + * never in the stock APK (i.e. libelements.so added by a Rev YT patch), PM's + * lib directory will never contain it, causing a crash at runtime: + * > UnsatisfiedLinkError: dlopen failed: library "libelements.so" not found + * + * This function addresses that by manually unpacking every .so from the patched APK into + * the app's target lib directory, fixing that issue + * + * This was commented out because: + * 1. Not needed for the Magisk module install path - service.sh calls pm install, so PM + * installs the patched APK fresh and extracts all native libs automatically + * + * 2. This logic would need to be moved since it belongs in RootInstaller.install() + * + * 3. Some potential issue is that writing to [system app's path]/lib would fail on read-only + * system partition (/system/app, /product/app, etc) + * Right now it's assuming it can write to it - Wrong! + * + * Considering the points above, if the bind-mount path needs to support patches + * that introduce new native libraries, this could very well be used + * + * fun extractNativeLibraries(apkFile: File, systemAppPath: String, remoteFS: FileSystemManager) { + * val libPath = "$systemAppPath/lib" + * remoteFS.getFile(libPath).apply { + * if (exists()) deleteRecursively() + * mkdirs() + * } + * + * ZipFile(apkFile).use { zip -> + * zip.entries().asSequence() + * .filter { it.name.startsWith("lib/") && it.name.endsWith(".so") } + * .forEach { entry -> + * val parts = entry.name.split("/") + * if (parts.size < 3) return@forEach + * + * val apkAbi = parts[1] + * val libName = parts.last() + * val systemAbi = when (apkAbi) { + * "arm64-v8a" -> "arm64" + * "armeabi-v7a" -> "arm" + * "x86_64" -> "x86_64" + * "x86" -> "x86" + * else -> apkAbi + * } + * + * val targetDir = "$libPath/$systemAbi" + * remoteFS.getFile(targetDir).apply { if (!exists()) mkdirs() } + * + * val targetFile = "$targetDir/$libName" + * zip.getInputStream(entry).use { inputStream -> + * remoteFS.getFile(targetFile).newOutputStream().use { outputStream -> + * inputStream.copyTo(outputStream) + * } + * } + * } + * } + * } + */ + + fun uninstallKeepData(packageName: String) = + Shell.getShell().newJob() + .add("pm uninstall -k \"$packageName\"") + .exec() + + fun prepareMagiskModule( + remoteFS: FileSystemManager, + packageName: String, + patchedPackageName: String, + patchedApk: File + ) { + val formattedPackageName = packageName.replace('.', '_') + val modulePath = "$MODULES_PATH/revanced_$formattedPackageName" + val unifiedApkPath = Constants.MOUNTED_APK_PATH(packageName) + val handleDisabledScriptPath = Constants.HANDLE_DISABLED_SCRIPT_PATH(formattedPackageName) + + // Ensure directories exist + val unifiedDir = unifiedApkPath.substringBeforeLast("/") + Shell.getShell().newJob() + .add("mkdir -p \"$modulePath\"") + .add("mkdir -p \"$unifiedDir\"") + .exec() + .assertSuccess("Failed to create module directories") + + writeModuleFiles(remoteFS, modulePath, packageName, patchedPackageName) + + // Handle-disabled script: uninstalls the patched app when the module is disabled or removed. + val handleDisabledSh = Constants.HANDLE_DISABLED_SCRIPT + .replace("__PATCHED_PKG__", patchedPackageName) + .replace("__FORMATTED_PKG__", formattedPackageName) + .replace("__USER_ID__", currentUserId.toString()) + remoteFS.getFile(handleDisabledScriptPath).newOutputStream().use { it.write(handleDisabledSh.toByteArray()) } + + // Source of truth APK + copyApk(remoteFS, patchedApk, unifiedApkPath) + + // Set permissions + Shell.getShell().newJob() + .add("chmod 644 \"$unifiedApkPath\"") + .add("chown system:system \"$unifiedApkPath\"") + .add("chcon u:object_r:apk_data_file:s0 \"$unifiedApkPath\"") + .add("chmod +x \"$modulePath/service.sh\"") + .add("chmod +x \"$modulePath/uninstall.sh\"") + .add("chmod +x \"$handleDisabledScriptPath\"") + .exec() + .assertSuccess("Failed to set file permissions") + } + + fun prepareRootFolder( + remoteFS: FileSystemManager, + packageName: String, + patchedApk: File + ) { + val modulePath = "$MODULES_PATH/$packageName-revanced" + val unifiedApkPath = Constants.MOUNTED_APK_PATH(packageName) + + // Ensure directories exist + val unifiedDir = unifiedApkPath.substringBeforeLast("/") + Shell.getShell().newJob() + .add("mkdir -p \"$modulePath\"") + .add("mkdir -p \"$unifiedDir\"") + .exec() + .assertSuccess("Failed to create module directories") + + // MOUNT type: patched package name == original package name (bind-mount, no rename) + writeModuleFiles(remoteFS, modulePath, packageName, packageName) + + // Source of truth APK + copyApk(remoteFS, patchedApk, unifiedApkPath) + + Shell.getShell().newJob() + .add("chmod 644 \"$unifiedApkPath\"") + .add("chown system:system \"$unifiedApkPath\"") + .add("chcon u:object_r:apk_data_file:s0 \"$unifiedApkPath\"") + .add("chmod +x \"$modulePath/service.sh\"") + .add("chmod +x \"$modulePath/uninstall.sh\"") + .exec() + .assertSuccess("Failed to set file permissions") + } + + private fun writeModuleFiles( + remoteFS: FileSystemManager, + modulePath: String, + packageName: String, + patchedPackageName: String, + ) { + val formattedPackageName = packageName.replace('.', '_') + + val moduleProp = Constants.MODULE_PROP + .replace("__FORMATTED_PKG__", formattedPackageName) + .replace("__PKG_NAME__", packageName) + remoteFS.getFile("$modulePath/module.prop").newOutputStream().use { it.write(moduleProp.toByteArray()) } + + val serviceSh = Constants.MODULE_SERVICE_SCRIPT + .replace("__PKG_NAME__", packageName) + .replace("__PATCHED_PKG__", patchedPackageName) + .replace("__USER_ID__", currentUserId.toString()) + remoteFS.getFile("$modulePath/service.sh").newOutputStream().use { it.write(serviceSh.toByteArray()) } + + val uninstallSh = Constants.MODULE_UNINSTALL_SCRIPT + .replace("__PKG_NAME__", packageName) + .replace("__PATCHED_PKG__", patchedPackageName) + .replace("__FORMATTED_PKG__", formattedPackageName) + remoteFS.getFile("$modulePath/uninstall.sh").newOutputStream().use { it.write(uninstallSh.toByteArray()) } + } + + private fun copyApk(remoteFS: FileSystemManager, source: File, destination: String) { + remoteFS.getFile(source.absolutePath) + .also { if (!it.exists()) throw Exception("Source APK file doesn't exist: ${source.absolutePath}") } + .newInputStream().use { inputStream -> + remoteFS.getFile(destination).newOutputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + + private fun Shell.Result.assertSuccess(errorMessage: String) { + if (!isSuccess) { + throw ShellCommandException(errorMessage, code, out, err) + } + } +} + +class ShellCommandException( + val userMessage: String, + val exitCode: Int, + val stdout: List, + val stderr: List +) : Exception(format(userMessage, exitCode, stdout, stderr)) { + companion object { + private fun format( + message: String, + exitCode: Int, + stdout: List, + stderr: List + ): String = buildString { + appendLine(message) + appendLine("Exit code: $exitCode") + + val output = stdout.filter { it.isNotBlank() } + val errors = stderr.filter { it.isNotBlank() } + + if (output.isNotEmpty()) { + appendLine("stdout:") + output.forEach(::appendLine) + } + if (errors.isNotEmpty()) { + appendLine("stderr:") + errors.forEach(::appendLine) + } + } + } +} diff --git a/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalMagiskRootInstaller.kt b/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalMagiskRootInstaller.kt new file mode 100644 index 0000000..5e36ffd --- /dev/null +++ b/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalMagiskRootInstaller.kt @@ -0,0 +1,43 @@ +package app.revanced.library.installation.installer + +import android.content.Context +import app.revanced.library.installation.command.LocalShellCommandRunner +import com.topjohnwu.superuser.ipc.RootService +import java.io.Closeable + +/** + * [LocalMagiskRootInstaller] for installing and uninstalling [Apk] files locally with root permissions via Magisk modules. + * + * @param context The [Context] to use for binding to the [RootService]. + * @param onReady A callback to be invoked when [LocalMagiskRootInstaller] is ready to be used. + * + * @throws NoRootPermissionException If the device does not have root permission. + * + * @see Installer + * @see LocalShellCommandRunner + */ +@Suppress("unused") +class LocalMagiskRootInstaller private constructor( + context: Context, + onReady: LocalMagiskRootInstaller.() -> Unit, + private val readyHook: Array<(() -> Unit)?>, +) : MagiskRootInstaller( + LocalShellCommandRunner(context) { readyHook[0]?.invoke() } +), + Closeable { + + constructor( + context: Context, + onReady: LocalMagiskRootInstaller.() -> Unit = {}, + ) : this(context, onReady, arrayOfNulls(1)) + + init { + // `this` doesn't exist as a subclass reference until after super-init, so the + // ready callback cannot capture it directly in the constructor argument above. + // Routing through [readyHook] is safe because [LocalShellCommandRunner.onServiceConnected] + // fires asynchronously after IPC bind - well after this init block completes. + readyHook[0] = { onReady() } + } + + override fun close() = (shellCommandRunner as LocalShellCommandRunner).close() +} diff --git a/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalRootInstaller.kt b/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalRootInstaller.kt index 02db711..0411dc6 100644 --- a/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalRootInstaller.kt +++ b/library/src/androidMain/kotlin/app/revanced/library/installation/installer/LocalRootInstaller.kt @@ -17,16 +17,27 @@ import java.io.Closeable * @see LocalShellCommandRunner */ @Suppress("unused") -class LocalRootInstaller( +class LocalRootInstaller private constructor( context: Context, - onReady: LocalRootInstaller.() -> Unit = {}, + onReady: LocalRootInstaller.() -> Unit, + private val readyHook: Array<(() -> Unit)?>, ) : RootInstaller( - { installer -> - LocalShellCommandRunner(context) { - (installer as LocalRootInstaller).onReady() - } - }, + LocalShellCommandRunner(context) { readyHook[0]?.invoke() } ), Closeable { + + constructor( + context: Context, + onReady: LocalRootInstaller.() -> Unit = {}, + ) : this(context, onReady, arrayOfNulls(1)) + + init { + // `this` doesn't exist as a subclass reference until after super-init, so the + // ready callback cannot capture it directly in the constructor argument above. + // Routing through [readyHook] is safe because [LocalShellCommandRunner.onServiceConnected] + // fires asynchronously after IPC bind - well after this init block completes. + readyHook[0] = { onReady() } + } + override fun close() = (shellCommandRunner as LocalShellCommandRunner).close() } diff --git a/library/src/commonMain/kotlin/app/revanced/library/ApkUtils.kt b/library/src/commonMain/kotlin/app/revanced/library/ApkUtils.kt index bea9f0f..5458239 100644 --- a/library/src/commonMain/kotlin/app/revanced/library/ApkUtils.kt +++ b/library/src/commonMain/kotlin/app/revanced/library/ApkUtils.kt @@ -45,7 +45,7 @@ object ApkUtils { * 2. Delete all resources in the target APK * 3. Merge resources.apk compiled by AAPT. * 4. Write raw resources. - * 5. Delete resources staged for deletion. + * 5. Delete resources marked for deletion. * 6. Realign the APK. * * @param apkFile The file to apply the patched files to. @@ -53,8 +53,9 @@ object ApkUtils { fun PatchesResult.applyTo(apkFile: File) { ZFile.openReadWrite(apkFile, zFileOptions).use { targetApkZFile -> dexFiles.forEach { dexFile -> - targetApkZFile.add(dexFile.name, dexFile.stream) - dexFile.stream.close() + dexFile.stream.use { stream -> + targetApkZFile.add(dexFile.name, stream) + } } resources?.let { resources -> @@ -80,7 +81,7 @@ object ApkUtils { } } - // Delete resources that were staged for deletion. + // Delete resources that were marked for deletion. if (resources.deleteResources.isNotEmpty()) { targetApkZFile.entries().filter { entry -> entry.centralDirectoryHeader.name in resources.deleteResources diff --git a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbMagiskRootInstaller.kt b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbMagiskRootInstaller.kt new file mode 100644 index 0000000..72d71b3 --- /dev/null +++ b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbMagiskRootInstaller.kt @@ -0,0 +1,21 @@ +package app.revanced.library.installation.installer + +import app.revanced.library.installation.command.AdbShellCommandRunner + +/** + * [AdbMagiskRootInstaller] for installing and uninstalling [Apk] files using ADB root permissions via Magisk modules. + * + * @param deviceSerial The device serial. If null, the first connected device will be used. + * + * @throws NoRootPermissionException If the device does not have root permission. + * + * @see MagiskRootInstaller + * @see AdbShellCommandRunner + */ +class AdbMagiskRootInstaller( + deviceSerial: String? = null, +) : MagiskRootInstaller(AdbShellCommandRunner(deviceSerial)) { + init { + logger.fine("Connected to $deviceSerial") + } +} diff --git a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbRootInstaller.kt b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbRootInstaller.kt index d2d771f..ebc61c9 100644 --- a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbRootInstaller.kt +++ b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/AdbRootInstaller.kt @@ -16,7 +16,7 @@ import app.revanced.library.installation.installer.RootInstaller.NoRootPermissio */ class AdbRootInstaller( deviceSerial: String? = null, -) : RootInstaller({ AdbShellCommandRunner(deviceSerial) }) { +) : RootInstaller(AdbShellCommandRunner(deviceSerial)) { init { logger.fine("Connected to $deviceSerial") } diff --git a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Constants.kt b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Constants.kt index dc2d020..8a72ab7 100644 --- a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Constants.kt +++ b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Constants.kt @@ -1,7 +1,7 @@ package app.revanced.library.installation.installer @Suppress("MemberVisibilityCanBePrivate") -internal object Constants { +object Constants { const val PLACEHOLDER = "PLACEHOLDER" const val SELINUX_CONTEXT = "u:object_r:apk_data_file:s0" @@ -9,6 +9,8 @@ internal object Constants { const val MOUNT_PATH = "/data/adb/revanced/" const val MOUNTED_APK_PATH = "$MOUNT_PATH$PLACEHOLDER.apk" const val MOUNT_SCRIPT_PATH = "/data/adb/service.d/mount_revanced_$PLACEHOLDER.sh" + const val HANDLE_DISABLED_SCRIPT_PATH = "/data/adb/service.d/revanced_handle_disabled_$PLACEHOLDER.sh" + const val MODULE_PATH = "/data/adb/modules/revanced_$PLACEHOLDER" const val EXISTS = "[[ -f $PLACEHOLDER ]] || exit 1" const val MOUNT_GREP = "grep -F $PLACEHOLDER /proc/mounts" @@ -17,36 +19,100 @@ internal object Constants { const val RESTART = "am start -S $PLACEHOLDER" const val KILL = "am force-stop $PLACEHOLDER" const val INSTALLED_APK_PATH = "pm path $PLACEHOLDER" - const val CREATE_INSTALLATION_PATH = "$CREATE_DIR $MOUNT_PATH" + const val CREATE_INSTALLATION_PATH = "$CREATE_DIR $MOUNT_PATH$PLACEHOLDER" const val GET_SDK_VERSION = "getprop ro.build.version.sdk" - - const val MOUNT_APK = + const val MODULE_PROP_FILE = "module.prop" + const val SERVICE_SCRIPT_FILE = "service.sh" + const val UNINSTALL_SCRIPT_FILE = "uninstall.sh" + const val PREPARE_APK = "base_path=\"$MOUNTED_APK_PATH\" && " + - $$"mv $$TMP_FILE_PATH $base_path && " + - $$"chmod 644 $base_path && " + - $$"chown system:system $base_path && " + - $$"chcon $$SELINUX_CONTEXT $base_path" - - const val UMOUNT = - "grep $PLACEHOLDER /proc/mounts | " + - $$"while read -r line; do echo $line | " + - "cut -d ' ' -f 2 | " + - "sed 's/apk.*/apk/' | " + - "xargs -r umount -l; done" - - const val INSTALL_MOUNT_SCRIPT = - "mv $TMP_FILE_PATH $MOUNT_SCRIPT_PATH && chmod +x $MOUNT_SCRIPT_PATH" - - val MOUNT_SCRIPT = - $$""" + "mkdir -p \"\$(dirname \"\${base_path}\")\" && " + + "mv $TMP_FILE_PATH \"\${base_path}\" && " + + "chmod 644 \"\${base_path}\" && " + + "chown system:system \"\${base_path}\" && " + + "chcon $SELINUX_CONTEXT \"\${base_path}\"" + + const val PREPARE_MOUNT_SCRIPT = "mv $TMP_FILE_PATH $MOUNT_SCRIPT_PATH && chmod +x $MOUNT_SCRIPT_PATH" + + /** + * Magisk module property template. + * The id MUST match the module directory name (revanced___FORMATTED_PKG__) so that + * Magisk can find the module by ID for enable/disable operations. + * + * Placeholders: __FORMATTED_PKG__ (original with dots→underscores), __VERSION__, __LABEL__ + */ + val MODULE_PROP = """ + id=revanced___FORMATTED_PKG__ + name=__PKG_NAME__ ReVanced + version=1.0 + versionCode=0 + author=ReVanced + description=Mounts the patched APK on top of the original one + """.trimIndent() + + /** + * Magisk module uninstall script template. Magisk runs this when the module is + * removed via the Magisk app. It cleans up the patched APK file and the + * boot-time handle-disabled script. + * + * Placeholders: __PKG_NAME__ (unpatched), __PATCHED_PKG__ (patched), + * __FORMATTED_PKG__ (unpatched package name with dots replaced by underscores, used as Magisk module ID) + */ + val MODULE_UNINSTALL_SCRIPT = """ + #!/system/bin/sh + pm uninstall "__PATCHED_PKG__" + rm -f "/data/adb/revanced/__PKG_NAME__.apk" + rm -f "/data/adb/service.d/revanced_handle_disabled___FORMATTED_PKG__.sh" + """.trimIndent() + + /** + * Boot-time handle-disabled script. Runs on every boot (via service.d, independent of module state). + * Uninstalls the patched app when the module is disabled or removed, so the app + * disappears when the module is toggled off. + * + * Placeholders: __PATCHED_PKG__ (patched), __FORMATTED_PKG__ (original with dots→underscores) + */ + val HANDLE_DISABLED_SCRIPT = $$""" #!/system/bin/sh - until [ "$( getprop sys.boot_completed )" = 1 ]; do sleep 3; done + patched_pkg="__PATCHED_PKG__" + module_path="/data/adb/modules/revanced___FORMATTED_PKG__" + + until [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 5; done + sleep 11 + + # Module was fully removed. Uninstall app and self-destruct this script. + if [ ! -d "${module_path}" ]; then + pm uninstall "${patched_pkg}" 2>/dev/null + rm -f "$0" + exit 0 + fi + + # If service.sh did not run this boot, the module is disabled - disable the app so it + # disappears from the launcher without losing data. service.sh re-enables it on next boot. + current_boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) + stored_boot_id=$(cat "${module_path}/.last_boot_id" 2>/dev/null) + if [ "${stored_boot_id}" != "${current_boot_id}" ]; then + pm disable-user --user __USER_ID__ "${patched_pkg}" 2>/dev/null + fi + """.trimIndent() + + val UMOUNT = $$""" + grep -F "/$$PLACEHOLDER/" /proc/mounts | + while read -r line; do echo ${line} | + cut -d ' ' -f 2 | + sed 's/apk.*/apk/' | + xargs -r umount -l; done + """.trimIndent() + + val MOUNT_SCRIPT = $$""" + #!/system/bin/sh + until [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 3; done until [ -d "/sdcard/Android" ]; do sleep 1; done - stock_path=$( pm path $$PLACEHOLDER | grep base | sed 's/package://g' ) + stock_path=$(pm path $$PLACEHOLDER | grep base | sed 's/package://g') # Make sure the app is installed. - if [ -z "$stock_path" ]; then + if [ -z "${stock_path}" ]; then exit 1 fi @@ -55,23 +121,99 @@ internal object Constants { base_path="$$MOUNTED_APK_PATH" - chcon $$SELINUX_CONTEXT $base_path + chcon $$SELINUX_CONTEXT ${base_path} # Mount using Magisk mirror, if available. if command -v magisk >/dev/null 2>&1; then if ! MAGISKTMP="$(magisk --path 2>/dev/null)"; then MAGISKTMP=/sbin fi - MIRROR="$MAGISKTMP/.magisk/mirror" - [ -d "$MIRROR" ] || MIRROR="" + MIRROR="${MAGISKTMP}/.magisk/mirror" + [ -d "${MIRROR}" ] || MIRROR="" fi - mount -o bind $MIRROR$base_path $stock_path + mount -o bind ${MIRROR}${base_path} ${stock_path} # Kill the app to force it to restart the mounted APK in case it's currently running. $$KILL """.trimIndent() + + /** + * Magisk module service script template. Runs on every boot when the module is enabled. + * Installs the patched APK as a standalone app if not already installed. + * + * Placeholders: __PKG_NAME__ (original, used for APK path), __PATCHED_PKG__ (patched, used for pm commands) + */ + val MODULE_SERVICE_SCRIPT = $$""" + #!/system/bin/sh + DIR=${0%/*} + + package_name="__PATCHED_PKG__" + + # Write a boot token so the handle-disabled script can detect whether the module was enabled this boot. + cp /proc/sys/kernel/random/boot_id "${DIR}/.last_boot_id" + + LOG="${DIR}/log" + MAX_LOG_LINES=200 + + # Trim log to last MAX_LOG_LINES lines to prevent unbounded growth. + if [ -f "${LOG}" ]; then + tail -n "${MAX_LOG_LINES}" "${LOG}" > "${LOG}.tmp" && mv "${LOG}.tmp" "${LOG}" + fi + + { + + echo "--- $(date '+%Y-%m-%d %H:%M:%S') | pkg=${package_name} ---" + + until [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 5; done + + # Wait until PM is fully responsive - sys.boot_completed=1 fires before the PM + # binder handles transactions. Poll until it returns at least one package entry. + until pm list packages --user __USER_ID__ 2>/dev/null | grep -q "^package:"; do sleep 5; done + + base_path="/data/adb/revanced/__PKG_NAME__.apk" + + echo "Base path: ${base_path}" + + if [ ! -f "${base_path}" ]; then + echo "Patched APK not found." + exit 1 + fi + + # Re-enable the app if it was disabled by the handle-disabled script (module was toggled off + # then back on). If not installed at all, fall through to the install block. + if pm list packages --user __USER_ID__ | grep -q "^package:${package_name}$"; then + pm enable --user __USER_ID__ "${package_name}" 2>/dev/null + echo "Package enabled." + else + # Retry loop - sys.boot_completed=1 fires before the PM binder is stable for + # write transactions, causing "Failed transaction" errors. Pipe-based install + # (pm install -S size < file) uses a simpler code path than session-based + # (install-create/write/commit) and is less prone to early-boot binder failures. + # NOTE: On Xiaomi devices (MIUI/HyperOS), pm install may still fail due to + # package verification restrictions. Manual install via ReVanced Manager + # may be required in that case. + max_retries=3 + attempt=0 + install_exit=1 + + while [ ${attempt} -lt ${max_retries} ] && [ ${install_exit} -ne 0 ]; do + attempt=$((attempt + 1)) + echo "Install attempt ${attempt}/${max_retries}..." + pm install -r -d --user __USER_ID__ -S $(stat -c%s "${base_path}") < "${base_path}" + install_exit=$? + echo "Install exit code: ${install_exit}" + if [ ${install_exit} -ne 0 ] && [ ${attempt} -lt ${max_retries} ]; then + echo "Retrying in 15s..." + sleep 5 + fi + done + fi + + } >> "${LOG}" + """.trimIndent() + /** * Replaces the [PLACEHOLDER] with the given [replacement]. * diff --git a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Installer.kt b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Installer.kt index da709ea..cc69117 100644 --- a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Installer.kt +++ b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/Installer.kt @@ -1,6 +1,5 @@ package app.revanced.library.installation.installer -import app.revanced.library.installation.installer.Installer.Apk import java.io.File import java.util.logging.Logger @@ -49,5 +48,8 @@ abstract class Installer interna * @param file The [Apk] file. * @param packageName The package name of the [Apk] file. */ - class Apk(val file: File, val packageName: String? = null) + class Apk( + val file: File, + val packageName: String? = null, + ) } diff --git a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/MagiskInstaller.kt b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/MagiskInstaller.kt new file mode 100644 index 0000000..27663af --- /dev/null +++ b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/MagiskInstaller.kt @@ -0,0 +1,137 @@ +package app.revanced.library.installation.installer + +import app.revanced.library.installation.command.ShellCommandRunner +import app.revanced.library.installation.installer.Constants.DELETE +import app.revanced.library.installation.installer.Constants.EXISTS +import app.revanced.library.installation.installer.Constants.INSTALLED_APK_PATH +import app.revanced.library.installation.installer.Constants.KILL +import app.revanced.library.installation.installer.Constants.MODULE_PATH +import app.revanced.library.installation.installer.Constants.MODULE_PROP +import app.revanced.library.installation.installer.Constants.MODULE_UNINSTALL_SCRIPT +import app.revanced.library.installation.installer.Constants.MODULE_PROP_FILE +import app.revanced.library.installation.installer.Constants.MODULE_SERVICE_SCRIPT +import app.revanced.library.installation.installer.Constants.MOUNTED_APK_PATH +import app.revanced.library.installation.installer.Constants.RESTART +import app.revanced.library.installation.installer.Constants.SERVICE_SCRIPT_FILE +import app.revanced.library.installation.installer.Constants.TMP_FILE_PATH +import app.revanced.library.installation.installer.Constants.UMOUNT +import app.revanced.library.installation.installer.Constants.UNINSTALL_SCRIPT_FILE +import app.revanced.library.installation.installer.Constants.invoke + +/** + * [MagiskRootInstaller] for installing and uninstalling [Apk] files using root permissions via Magisk modules. + * + * @param shellCommandRunner The [ShellCommandRunner] to use. + * + * @throws NoRootPermissionException If the device does not have root permission. + */ +@Suppress("MemberVisibilityCanBePrivate") +abstract class MagiskRootInstaller internal constructor( + shellCommandRunner: ShellCommandRunner, +) : RootInstaller(shellCommandRunner) { + + /** + * Installs the given [apk] as a Magisk module. + * + * @param apk The [Apk] to install. + * + * @throws PackageNameRequiredException If the [Apk] does not have a package name. + */ + override suspend fun install(apk: Apk): RootInstallerResult { + logger.info("Installing ${apk.packageName} as a Magisk module") + + val packageName = apk.packageName ?: throw PackageNameRequiredException() + val formattedPackageName = packageName.replace('.', '_') + val modulePath = MODULE_PATH(formattedPackageName) + + // Track whether the app was already on-device so uninstall() knows whether to pm uninstall. + val isPreInstalled = INSTALLED_APK_PATH(packageName)().output.isNotEmpty() + + // Prepare the patched APK at the unified source-of-truth path. + apk.file.move(TMP_FILE_PATH) + prepareApk(packageName) + + // Create the Magisk module directory. + "mkdir -p $modulePath"().waitFor() + + // Write module.prop. + val moduleProp = MODULE_PROP + .replace("__FORMATTED_PKG__", formattedPackageName) + .replace("__PKG_NAME__", packageName) + "$modulePath/$MODULE_PROP_FILE".write(moduleProp) + + // Write service.sh - Magisk runs this on every boot to install the patched APK. + val serviceScriptPath = "$modulePath/$SERVICE_SCRIPT_FILE" + serviceScriptPath.write(MODULE_SERVICE_SCRIPT + .replace("__PKG_NAME__", packageName) + .replace("__PATCHED_PKG__", packageName) + .replace("__USER_ID__", "0")) + "chmod +x $serviceScriptPath"().waitFor() + + // Write uninstall.sh - Magisk runs this when the module is removed via the Magisk app. + "$modulePath/$UNINSTALL_SCRIPT_FILE".write(MODULE_UNINSTALL_SCRIPT + .replace("__PKG_NAME__", packageName) + .replace("__PATCHED_PKG__", packageName) + .replace("__FORMATTED_PKG__", formattedPackageName)) + "chmod +x $modulePath/$UNINSTALL_SCRIPT_FILE"() + + // Mark as newly installed so uninstall() also calls pm uninstall. + if (!isPreInstalled) "$modulePath/.newly_installed".write("") + + // Live trigger: execute service.sh now so the install takes effect without a reboot. + "sh $serviceScriptPath"().waitFor() + + RESTART(packageName)() + + return RootInstallerResult.SUCCESS + } + + /** + * Uninstalls the Magisk module for the given [packageName]. + * + * Performs an immediate live unmount and removes both the module directory and the + * unified source APK so the rollback is visible without a reboot. + */ + override suspend fun uninstall(packageName: String): RootInstallerResult { + logger.info("Uninstalling $packageName Magisk module") + + val formattedPackageName = packageName.replace('.', '_') + val modulePath = MODULE_PATH(formattedPackageName) + + // Read the flag before removing the module directory. + val newlyInstalled = EXISTS("$modulePath/.newly_installed")().exitCode == 0 + + // Live unmount so the stock APK is restored immediately. + UMOUNT(packageName)() + + // Remove the Magisk module directory. + DELETE(modulePath)().waitFor() + + // Remove the unified source APK. + DELETE(MOUNTED_APK_PATH(packageName))().waitFor() + + // Clean up any residual tmp file. + DELETE(TMP_FILE_PATH)() + + // If the app was freshly installed by the module, fully remove it. + if (newlyInstalled) "pm uninstall $packageName"() + + KILL(packageName)() + + return RootInstallerResult.SUCCESS + } + + override suspend fun getInstallation(packageName: String): RootInstallation? { + val formattedPackageName = packageName.replace('.', '_') + val modulePath = MODULE_PATH(formattedPackageName) + + val moduleExists = EXISTS("$modulePath/module.prop")().exitCode == 0 + if (!moduleExists) return null + + return RootInstallation( + INSTALLED_APK_PATH(packageName)().output.ifEmpty { null }, + MOUNTED_APK_PATH(packageName), + false, // Magisk module install uses pm install, not bind-mount. + ) + } +} diff --git a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/RootInstaller.kt b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/RootInstaller.kt index 27992f6..dc6b85a 100644 --- a/library/src/commonMain/kotlin/app/revanced/library/installation/installer/RootInstaller.kt +++ b/library/src/commonMain/kotlin/app/revanced/library/installation/installer/RootInstaller.kt @@ -1,47 +1,44 @@ package app.revanced.library.installation.installer import app.revanced.library.installation.command.ShellCommandRunner -import app.revanced.library.installation.installer.Constants.CREATE_INSTALLATION_PATH import app.revanced.library.installation.installer.Constants.DELETE import app.revanced.library.installation.installer.Constants.EXISTS import app.revanced.library.installation.installer.Constants.INSTALLED_APK_PATH -import app.revanced.library.installation.installer.Constants.INSTALL_MOUNT_SCRIPT +import app.revanced.library.installation.installer.Constants.PREPARE_MOUNT_SCRIPT import app.revanced.library.installation.installer.Constants.KILL import app.revanced.library.installation.installer.Constants.MOUNTED_APK_PATH -import app.revanced.library.installation.installer.Constants.MOUNT_APK import app.revanced.library.installation.installer.Constants.MOUNT_GREP +import app.revanced.library.installation.installer.Constants.PREPARE_APK import app.revanced.library.installation.installer.Constants.MOUNT_SCRIPT import app.revanced.library.installation.installer.Constants.MOUNT_SCRIPT_PATH import app.revanced.library.installation.installer.Constants.RESTART import app.revanced.library.installation.installer.Constants.TMP_FILE_PATH import app.revanced.library.installation.installer.Constants.UMOUNT import app.revanced.library.installation.installer.Constants.invoke -import app.revanced.library.installation.installer.Installer.Apk -import app.revanced.library.installation.installer.RootInstaller.NoRootPermissionException import java.io.File /** * [RootInstaller] for installing and uninstalling [Apk] files using root permissions by mounting. * - * @param shellCommandRunnerSupplier A supplier for the [ShellCommandRunner] to use. + * @param shellCommandRunner The [ShellCommandRunner] to use. * * @throws NoRootPermissionException If the device does not have root permission. */ @Suppress("MemberVisibilityCanBePrivate") abstract class RootInstaller internal constructor( - shellCommandRunnerSupplier: (RootInstaller) -> ShellCommandRunner, + protected val shellCommandRunner: ShellCommandRunner, ) : Installer() { - /** - * The command runner used to run commands on the device. - */ - @Suppress("LeakingThis") - protected val shellCommandRunner = shellCommandRunnerSupplier(this) - init { if (!shellCommandRunner.hasRootPermission()) throw NoRootPermissionException() } + /** + * Prepares the APK from tmp file path "[TMP_FILE_PATH]" by saving it to a + * persistent location and applying permissions/SELinux context. + */ + protected fun prepareApk(packageName: String) = PREPARE_APK(packageName)().waitFor() + /** * Installs the given [apk] by mounting. * @@ -56,12 +53,11 @@ abstract class RootInstaller internal constructor( // Setup files. apk.file.move(TMP_FILE_PATH) - CREATE_INSTALLATION_PATH().waitFor() - MOUNT_APK(packageName)().waitFor() + prepareApk(packageName) // Install and run. TMP_FILE_PATH.write(MOUNT_SCRIPT(packageName)) - INSTALL_MOUNT_SCRIPT(packageName)().waitFor() + PREPARE_MOUNT_SCRIPT(packageName)().waitFor() MOUNT_SCRIPT_PATH(packageName)().waitFor() RESTART(packageName)() @@ -75,8 +71,8 @@ abstract class RootInstaller internal constructor( UMOUNT(packageName)() - DELETE(MOUNTED_APK_PATH)(packageName)() - DELETE(MOUNT_SCRIPT_PATH)(packageName)() + DELETE(MOUNTED_APK_PATH(packageName))() + DELETE(MOUNT_SCRIPT_PATH(packageName))() DELETE(TMP_FILE_PATH)() // Remove residual. KILL(packageName)() @@ -85,10 +81,7 @@ abstract class RootInstaller internal constructor( } override suspend fun getInstallation(packageName: String): RootInstallation? { - val patchedApkPath = MOUNTED_APK_PATH(packageName) - - val patchedApkExists = EXISTS(patchedApkPath)().exitCode == 0 - if (patchedApkExists) return null + val patchedApkPath = MOUNTED_APK_PATH(packageName).takeIf { EXISTS(it)().exitCode == 0 } ?: return null return RootInstallation( INSTALLED_APK_PATH(packageName)().output.ifEmpty { null }, @@ -121,7 +114,7 @@ abstract class RootInstaller internal constructor( * * @throws FailedToFindInstalledPackageException If the package is not installed. */ - private fun String.assertInstalled() { + protected fun String.assertInstalled() { if (INSTALLED_APK_PATH(this)().output.isEmpty()) { throw FailedToFindInstalledPackageException(this) }