11package app.morphe.cli.command
22
3+ import app.morphe.cli.command.model.FailedPatch
4+ import app.morphe.cli.command.model.PatchingResult
5+ import app.morphe.cli.command.model.PatchingStep
6+ import app.morphe.cli.command.model.PatchingStepResult
7+ import app.morphe.cli.command.model.addStepResult
8+ import app.morphe.cli.command.model.toSerializablePatch
39import app.morphe.library.ApkUtils
410import app.morphe.library.ApkUtils.applyTo
511import app.morphe.library.installation.installer.*
@@ -9,6 +15,9 @@ import app.morphe.patcher.PatcherConfig
915import app.morphe.patcher.patch.Patch
1016import app.morphe.patcher.patch.loadPatchesFromJar
1117import kotlinx.coroutines.runBlocking
18+ import kotlinx.serialization.ExperimentalSerializationApi
19+ import kotlinx.serialization.json.Json
20+ import kotlinx.serialization.json.encodeToStream
1221import picocli.CommandLine
1322import picocli.CommandLine.ArgGroup
1423import picocli.CommandLine.Help.Visibility.ALWAYS
@@ -25,6 +34,7 @@ import java.util.zip.ZipEntry
2534import java.util.zip.ZipFile
2635import java.util.zip.ZipOutputStream
2736
37+ @OptIn(ExperimentalSerializationApi ::class )
2838@CommandLine.Command (
2939 name = " patch" ,
3040 description = [" Patch an APK file." ],
@@ -121,6 +131,17 @@ internal object PatchCommand : Runnable {
121131 this .outputFilePath = outputFilePath?.absoluteFile
122132 }
123133
134+ private var patchingResultOutputFilePath: File ? = null
135+
136+ @CommandLine.Option (
137+ names = [" -r" , " --result-file" ],
138+ description = [" Path to save the patching result file to" ],
139+ )
140+ @Suppress(" unused" )
141+ private fun setPatchingResultOutputFilePath (outputFilePath : File ? ) {
142+ this .patchingResultOutputFilePath = outputFilePath?.absoluteFile
143+ }
144+
124145 @CommandLine.Option (
125146 names = [" -i" , " --install" ],
126147 description = [" Serial of the ADB device to install to. If not specified, the first connected device will be used." ],
@@ -310,118 +331,168 @@ internal object PatchCommand : Runnable {
310331
311332 val patcherTemporaryFilesPath = temporaryFilesPath.resolve(" patcher" )
312333
313- val (packageName, patcherResult) = Patcher (
314- PatcherConfig (
315- apk,
316- patcherTemporaryFilesPath,
317- aaptBinaryPath?.path,
318- patcherTemporaryFilesPath.absolutePath,
319- ),
320- ).use { patcher ->
321- val packageName = patcher.context.packageMetadata.packageName
322- val packageVersion = patcher.context.packageMetadata.packageVersion
323-
324- val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
325-
326- logger.info(" Setting patch options" )
327-
328- val patchesList = patches.toList()
329- selection.filter { it.enabled != null }.associate {
330- val enabledSelection = it.enabled!!
331-
332- (enabledSelection.selector.name ? : patchesList[enabledSelection.selector.index!! ].name!! ) to
333- enabledSelection.options
334- }.let (filteredPatches::setOptions)
335-
336- patcher + = filteredPatches
337-
338- // Execute patches.
339- runBlocking {
340- patcher().collect { patchResult ->
341- val exception = patchResult.exception
342- ? : return @collect logger.info(" \" ${patchResult.patch} \" succeeded" )
343-
344- StringWriter ().use { writer ->
345- exception.printStackTrace(PrintWriter (writer))
346-
347- logger.severe(" \" ${patchResult.patch} \" failed:\n $writer " )
334+ val patchingResult = PatchingResult ()
335+
336+ try {
337+ val (packageName, patcherResult) = Patcher (
338+ PatcherConfig (
339+ apk,
340+ patcherTemporaryFilesPath,
341+ aaptBinaryPath?.path,
342+ patcherTemporaryFilesPath.absolutePath,
343+ ),
344+ ).use { patcher ->
345+ val packageName = patcher.context.packageMetadata.packageName
346+ val packageVersion = patcher.context.packageMetadata.packageVersion
347+
348+ patchingResult.packageName = packageName
349+ patchingResult.packageVersion = packageVersion
350+
351+ val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
352+
353+ logger.info(" Setting patch options" )
354+
355+ val patchesList = patches.toList()
356+ selection.filter { it.enabled != null }.associate {
357+ val enabledSelection = it.enabled!!
358+
359+ (enabledSelection.selector.name ? : patchesList[enabledSelection.selector.index!! ].name!! ) to
360+ enabledSelection.options
361+ }.let (filteredPatches::setOptions)
362+
363+ patcher + = filteredPatches
364+
365+ // Execute patches.
366+ patchingResult.addStepResult(
367+ PatchingStep .PATCHING ,
368+ {
369+ runBlocking {
370+ patcher().collect { patchResult ->
371+ patchResult.exception?.let { exception ->
372+ StringWriter ().use { writer ->
373+ exception.printStackTrace(PrintWriter (writer))
374+
375+ logger.severe(" \" ${patchResult.patch} \" failed:\n $writer " )
376+
377+ patchingResult.failedPatches.add(
378+ FailedPatch (
379+ patchResult.patch.toSerializablePatch(),
380+ writer.toString()
381+ )
382+ )
383+ patchingResult.success = false
384+ }
385+ } ? : patchResult.patch.let {
386+ patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch())
387+ logger.info(" \" ${patchResult.patch} \" succeeded" )
388+ }
389+ }
390+ }
348391 }
349- }
392+ )
393+
394+ patcher.context.packageMetadata.packageName to patcher.get()
350395 }
351396
352- patcher.context.packageMetadata.packageName to patcher.get()
353- }
397+ // region Save.
354398
355- // region Save.
399+ apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true ).apply {
400+ patchingResult.addStepResult(
401+ PatchingStep .REBUILDING ,
402+ {
403+ // 1. Apply patches to the APK
404+ patcherResult.applyTo(this )
356405
357- apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true ).apply {
358- patcherResult.applyTo(this )
359- }.let { patchedApkFile ->
406+ // 2. Rip Libraries (if requested)
407+ if (ripLibs.isNotEmpty()) {
408+ ripLibraries(this , ripLibs)
409+ }
360410
361- if (ripLibs.isNotEmpty()) {
362- ripLibraries(patchedApkFile, ripLibs)
363- }
411+ // 3. Zipalign (if not mounting)
412+ if (! mount) {
413+ logger.info(" Running zipalign" )
414+ val alignedApk = File .createTempFile(" aligned" , " .apk" , temporaryFilesPath)
415+ val zipalign = zipalignBinaryPath?.absolutePath ? : " zipalign"
364416
365- // zipalign logic start
366- if ( ! mount) {
367- logger.info( " Running zipalign " )
368- val alignedApk = File .createTempFile( " aligned " , " .apk " , temporaryFilesPath)
369- val zipalign = zipalignBinaryPath?.absolutePath ? : " zipalign "
417+ val process = ProcessBuilder (
418+ zipalign, " -p " , " -f " , " 4 " ,
419+ this .absolutePath,
420+ alignedApk.absolutePath
421+ ).redirectErrorStream( true ).start()
370422
371- val process = ProcessBuilder (
372- zipalign, " -p" , " -f" , " 4" ,
373- patchedApkFile.absolutePath,
374- alignedApk.absolutePath
375- ).redirectErrorStream(true ).start()
423+ // Consume output to prevent buffer blocking and for debug logging
424+ val output = process.inputStream.bufferedReader().use { it.readText() }
425+ val exitCode = process.waitFor()
376426
377- // Consume output to prevent buffer blocking and for debug logging
378- val output = process.inputStream.bufferedReader().use { it.readText() }
379- val exitCode = process.waitFor()
427+ if (exitCode != 0 ) {
428+ throw RuntimeException ( " zipalign failed (exit code $exitCode ): \n $output " )
429+ }
380430
381- if (exitCode != 0 ) {
382- throw RuntimeException (" zipalign failed (exit code $exitCode ):\n $output " )
431+ Files .move(alignedApk.toPath(), this .toPath(), StandardCopyOption .REPLACE_EXISTING )
432+ logger.info(" zipalign completed" )
433+ }
434+ }
435+ )
436+ }.let { patchedApkFile ->
437+ if (! mount && ! unsigned) {
438+ patchingResult.addStepResult(
439+ PatchingStep .SIGNING ,
440+ {
441+ ApkUtils .signApk(
442+ patchedApkFile,
443+ outputFilePath,
444+ signer,
445+ ApkUtils .KeyStoreDetails (
446+ keystoreFilePath,
447+ keyStorePassword,
448+ keyStoreEntryAlias,
449+ keyStoreEntryPassword,
450+ ),
451+ )
452+ }
453+ )
454+ } else {
455+ patchedApkFile.copyTo(outputFilePath, overwrite = true )
383456 }
384-
385- Files .move(alignedApk.toPath(), patchedApkFile.toPath(), StandardCopyOption .REPLACE_EXISTING )
386- logger.info(" zipalign completed" )
387457 }
388- // zipalign logic end
389-
390- if (! mount && ! unsigned) {
391- ApkUtils .signApk(
392- patchedApkFile,
393- outputFilePath,
394- signer,
395- ApkUtils .KeyStoreDetails (
396- keystoreFilePath,
397- keyStorePassword,
398- keyStoreEntryAlias,
399- keyStoreEntryPassword,
400- ),
458+ logger.info(" Saved to $outputFilePath " )
459+
460+ // endregion
461+
462+ // region Install.
463+
464+ deviceSerial?.let {
465+ patchingResult.addStepResult(
466+ PatchingStep .INSTALLING ,
467+ {
468+ runBlocking {
469+ val result = installer!! .install(Installer .Apk (outputFilePath, packageName))
470+ when (result) {
471+ RootInstallerResult .FAILURE -> {
472+ logger.severe(" Failed to mount the patched APK file" )
473+ throw IllegalStateException (" Failed to mount the patched APK file" )
474+ }
475+ is AdbInstallerResult .Failure -> {
476+ logger.severe(result.exception.toString())
477+ throw result.exception
478+ }
479+ else -> logger.info(" Installed the patched APK file" )
480+ }
481+ }
482+ }
401483 )
402- } else {
403- patchedApkFile.copyTo(outputFilePath, overwrite = true )
404484 }
405- }
406485
407- logger.info(" Saved to $outputFilePath " )
408-
409- // endregion
410-
411- // region Install.
412-
413- deviceSerial?.let {
414- runBlocking {
415- when (val result = installer!! .install(Installer .Apk (outputFilePath, packageName))) {
416- RootInstallerResult .FAILURE -> logger.severe(" Failed to mount the patched APK file" )
417- is AdbInstallerResult .Failure -> logger.severe(result.exception.toString())
418- else -> logger.info(" Installed the patched APK file" )
486+ // endregion
487+ } finally {
488+ patchingResultOutputFilePath?.let { outputFile ->
489+ outputFile.outputStream().use { outputStream ->
490+ Json .encodeToStream(patchingResult, outputStream)
419491 }
492+ logger.info(" Patching result saved to $outputFile " )
420493 }
421494 }
422495
423- // endregion
424-
425496 if (purge) {
426497 logger.info(" Purging temporary files" )
427498 purge(temporaryFilesPath)
0 commit comments