From 0e22096780b64463a2fc99b465ad360c3302c685 Mon Sep 17 00:00:00 2001 From: meowarex Date: Wed, 20 May 2026 23:33:04 +1000 Subject: [PATCH] Manager: full split-APK merge support - CI now uses APKEditor's merge command for proper resources.arsc + split merging (previous ad-hoc Java merge missed split-only resource entries like notification_icon, causing runtime Resources$NotFoundException) - ManifestPatcher: dynamically detect the original package name and rewrite every reference to it (permissions/provider authorities); drop split-only attributes (isSplitRequired, requiredSplitTypes, splitTypes) so the installer doesn't reject the merged APK with "Missing split" - SmaliPatchStep: extract extension/**/*.smali entries from patches.zip into the smali dir before assembly, so new helper classes (radiant/SparkleButton etc) actually end up in the patched dex - PMIntentReceiver / PMInstallerError: surface PackageInstaller EXTRA_STATUS_MESSAGE so install failures show Android's real reason instead of the generic enum label Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 16 ++++--------- .../installers/pm/PMInstallerError.kt | 23 ++++++++++-------- .../installers/pm/PMIntentReceiver.kt | 5 ++-- .../patcher/steps/patch/SmaliPatchStep.kt | 16 ++++++++++++- .../rlmobile/patcher/util/ManifestPatcher.kt | 24 +++++++++++++++++-- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5a0cbd..7b57f5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,19 +56,11 @@ jobs: exit 1 fi if [[ "$tidal_src" == *.apkm ]]; then - echo "Extracting & merging splits from $tidal_src" - workdir=$(mktemp -d) - unzip -q "$tidal_src" -d "$workdir" - ls "$workdir" - dist_abs=$(realpath ./dist)/tidal-stock.apk - - javac scripts/MergeApk.java -d scripts - splits=("$workdir"/split_*.apk) - java -cp scripts MergeApk "$dist_abs" "$workdir/base.apk" "${splits[@]}" - - rm -rf "$workdir" + echo "Merging splits from $tidal_src via APKEditor" + curl -sLo /tmp/APKEditor.jar https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar + java -jar /tmp/APKEditor.jar m -i "$tidal_src" -o ./dist/tidal-stock.apk echo "Merged tidal-stock.apk:" - ls -la "$dist_abs" + ls -la ./dist/tidal-stock.apk else cp "$tidal_src" ./dist/tidal-stock.apk fi diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt index e30f85e..f157b73 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt @@ -12,16 +12,19 @@ import kotlinx.parcelize.Parcelize * that is captured by a receiver into something human readable. */ @Parcelize -data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable { - override fun getDebugReason() = when (status) { - PackageInstaller.STATUS_FAILURE -> "Unknown failure" - PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked" - PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package" - PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict" - PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error" - PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility" - /* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout" - else -> "Unknown code ($status)" +data class PMInstallerError(val status: Int, val message: String? = null) : InstallerResult.Error(), Parcelable { + override fun getDebugReason(): String { + val reason = when (status) { + PackageInstaller.STATUS_FAILURE -> "Unknown failure" + PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked" + PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package" + PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict" + PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error" + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility" + /* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout" + else -> "Unknown code ($status)" + } + return if (message != null) "$reason: $message" else reason } override fun getLocalizedReason(context: Context): String { diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt index 906c67b..affcfe8 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt @@ -60,13 +60,14 @@ class PMIntentReceiver : BroadcastReceiver() { PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true) else -> { - Log.w(BuildConfig.TAG, "PM failed with error code $status") + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + Log.w(BuildConfig.TAG, "PM failed with error code $status: $message") if (status <= PackageInstaller.STATUS_SUCCESS) { // Unknown status code (not an error) return } else { - PMInstallerError(status).also { + PMInstallerError(status, message).also { Toast.makeText( /* context = */ context, /* text = */ it.getLocalizedReason(context), diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt index 67b353c..9892273 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt @@ -38,11 +38,25 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { val patches = mutableListOf() - // Load and parse all the patches from the smali patch archive + // Load and parse all the patches from the smali patch archive. + // Extension classes (extension/**/*.smali) are extracted into smaliDir + // so they get assembled into the new dex alongside patched classes. container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}") + smaliDir.mkdirs() ZipReader(patchesZip).use { zip -> for (patchFile in zip.entryNames) { container.log("Parsing patch file $patchFile") + if (patchFile.endsWith("/")) continue + + if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) { + val relative = patchFile.removePrefix("extension/") + val out = smaliDir.resolve(relative) + out.parentFile?.mkdirs() + out.writeBytes(zip.openEntry(patchFile)!!.read()) + container.log("Extracted extension smali: $relative") + continue + } + if (!patchFile.endsWith(".patch")) continue val lines = zip.openEntry(patchFile)!!.read() diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt index bdb199a..dba3ccd 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt @@ -15,6 +15,9 @@ object ManifestPatcher { private const val PACKAGE = "package" private const val COMPILE_SDK_VERSION = "compileSdkVersion" private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename" + private const val IS_SPLIT_REQUIRED = "isSplitRequired" + private const val REQUIRED_SPLIT_TYPES = "requiredSplitTypes" + private const val SPLIT_TYPES = "splitTypes" fun patchManifest( manifestBytes: ByteArray, @@ -22,6 +25,18 @@ object ManifestPatcher { appName: String, debuggable: Boolean, ): ByteArray { + // Extract original package name so we can rewrite every reference to it + // (permissions, provider authorities) to the new packageName. + var originalPackage: String? = null + AxmlReader(manifestBytes).accept(object : AxmlVisitor() { + override fun child(ns: String?, name: String?) = object : NodeVisitor() { + override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) { + if (name == PACKAGE && value is String) originalPackage = value + } + } + }) + val origPkg = originalPackage ?: "com.aspiro.tidal" + val reader = AxmlReader(manifestBytes) val writer = AxmlWriter() @@ -42,6 +57,11 @@ object ManifestPatcher { COMPILE_SDK_VERSION_CODENAME to "6.0-2438415" ) ) { + // Drop split-only manifest attributes — we merged all splits into one APK + override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) { + if (name == IS_SPLIT_REQUIRED || name == REQUIRED_SPLIT_TYPES || name == SPLIT_TYPES) return + super.attr(ns, name, resourceId, type, value) + } private var addExternalStoragePerm = false override fun child(ns: String?, name: String): NodeVisitor { @@ -84,7 +104,7 @@ object ManifestPatcher { super.attr( ns, name, resourceId, type, when (name) { - "name" -> (value as String).replace("com.tidal.android", packageName) + "name" -> (value as String).replace(origPkg, packageName) else -> value } ) @@ -135,7 +155,7 @@ object ManifestPatcher { super.attr( ns, name, resourceId, type, if (name == "authorities") { - (value as String).replace("com.tidal.android", packageName) + (value as String).replace(origPkg, packageName) } else { value }