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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:33:04 +10:00
parent d6bc75604c
commit 0e22096780
5 changed files with 57 additions and 27 deletions
+4 -12
View File
@@ -56,19 +56,11 @@ jobs:
exit 1 exit 1
fi fi
if [[ "$tidal_src" == *.apkm ]]; then if [[ "$tidal_src" == *.apkm ]]; then
echo "Extracting & merging splits from $tidal_src" echo "Merging splits from $tidal_src via APKEditor"
workdir=$(mktemp -d) curl -sLo /tmp/APKEditor.jar https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar
unzip -q "$tidal_src" -d "$workdir" java -jar /tmp/APKEditor.jar m -i "$tidal_src" -o ./dist/tidal-stock.apk
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 "Merged tidal-stock.apk:" echo "Merged tidal-stock.apk:"
ls -la "$dist_abs" ls -la ./dist/tidal-stock.apk
else else
cp "$tidal_src" ./dist/tidal-stock.apk cp "$tidal_src" ./dist/tidal-stock.apk
fi fi
@@ -12,16 +12,19 @@ import kotlinx.parcelize.Parcelize
* that is captured by a receiver into something human readable. * that is captured by a receiver into something human readable.
*/ */
@Parcelize @Parcelize
data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable { data class PMInstallerError(val status: Int, val message: String? = null) : InstallerResult.Error(), Parcelable {
override fun getDebugReason() = when (status) { override fun getDebugReason(): String {
PackageInstaller.STATUS_FAILURE -> "Unknown failure" val reason = when (status) {
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked" PackageInstaller.STATUS_FAILURE -> "Unknown failure"
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package" PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict" PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error" PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict"
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility" PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error"
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout" PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility"
else -> "Unknown code ($status)" /* 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 { override fun getLocalizedReason(context: Context): String {
@@ -60,13 +60,14 @@ class PMIntentReceiver : BroadcastReceiver() {
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true) PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true)
else -> { 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) { if (status <= PackageInstaller.STATUS_SUCCESS) {
// Unknown status code (not an error) // Unknown status code (not an error)
return return
} else { } else {
PMInstallerError(status).also { PMInstallerError(status, message).also {
Toast.makeText( Toast.makeText(
/* context = */ context, /* context = */ context,
/* text = */ it.getLocalizedReason(context), /* text = */ it.getLocalizedReason(context),
@@ -38,11 +38,25 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent {
val patches = mutableListOf<LoadedPatch>() val patches = mutableListOf<LoadedPatch>()
// 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}") container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
smaliDir.mkdirs()
ZipReader(patchesZip).use { zip -> ZipReader(patchesZip).use { zip ->
for (patchFile in zip.entryNames) { for (patchFile in zip.entryNames) {
container.log("Parsing patch file $patchFile") 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 if (!patchFile.endsWith(".patch")) continue
val lines = zip.openEntry(patchFile)!!.read() val lines = zip.openEntry(patchFile)!!.read()
@@ -15,6 +15,9 @@ object ManifestPatcher {
private const val PACKAGE = "package" private const val PACKAGE = "package"
private const val COMPILE_SDK_VERSION = "compileSdkVersion" private const val COMPILE_SDK_VERSION = "compileSdkVersion"
private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename" 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( fun patchManifest(
manifestBytes: ByteArray, manifestBytes: ByteArray,
@@ -22,6 +25,18 @@ object ManifestPatcher {
appName: String, appName: String,
debuggable: Boolean, debuggable: Boolean,
): ByteArray { ): 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 reader = AxmlReader(manifestBytes)
val writer = AxmlWriter() val writer = AxmlWriter()
@@ -42,6 +57,11 @@ object ManifestPatcher {
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415" 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 private var addExternalStoragePerm = false
override fun child(ns: String?, name: String): NodeVisitor { override fun child(ns: String?, name: String): NodeVisitor {
@@ -84,7 +104,7 @@ object ManifestPatcher {
super.attr( super.attr(
ns, name, resourceId, type, ns, name, resourceId, type,
when (name) { when (name) {
"name" -> (value as String).replace("com.tidal.android", packageName) "name" -> (value as String).replace(origPkg, packageName)
else -> value else -> value
} }
) )
@@ -135,7 +155,7 @@ object ManifestPatcher {
super.attr( super.attr(
ns, name, resourceId, type, ns, name, resourceId, type,
if (name == "authorities") { if (name == "authorities") {
(value as String).replace("com.tidal.android", packageName) (value as String).replace(origPkg, packageName)
} else { } else {
value value
} }