11package app.morphe.cli.command
22
33import app.morphe.cli.command.model.FailedPatch
4- import app.morphe.cli.command.model.PatchEntry
5- import app.morphe.cli.command.model.PatchOptionsFile
4+ import app.morphe.cli.command.model.PatchBundle
65import app.morphe.cli.command.model.PatchingResult
76import app.morphe.cli.command.model.PatchingStep
87import app.morphe.cli.command.model.addStepResult
9- import app.morphe.cli.command.model.PatchSerializer
108import app.morphe.cli.command.model.deserializeOptionValue
9+ import app.morphe.cli.command.model.findMatchingBundle
1110import app.morphe.cli.command.model.mergeWith
12- import app.morphe.cli.command.model.mergeWithOptionsFile
13- import app.morphe.cli.command.model.toPatchOptionsFile
11+ import app.morphe.cli.command.model.toPatchBundle
1412import app.morphe.cli.command.model.toSerializablePatch
13+ import app.morphe.cli.command.model.withUpdatedBundle
1514import app.morphe.gui.util.ApkLibraryStripper
1615import app.morphe.library.ApkUtils
1716import app.morphe.library.ApkUtils.applyTo
@@ -352,45 +351,43 @@ internal object PatchCommand : Callable<Int> {
352351 var mergedApkToCleanup: File ? = null
353352
354353 // Lightweight snapshot of patch metadata for use in finally block (auto-update).
354+ // Lightweight snapshot of current bundle metadata for use in finally block (auto-update).
355355 // The heavy Patch objects hold DEX classloaders and must not leak into finally.
356- var patchesSnapshot: PatchOptionsFile ? = null
356+ var patchesSnapshot: PatchBundle ? = null
357357
358358 try {
359359 logger.info(" Loading patches" )
360- val patches = loadPatchesFromJar(patchesFiles)
361- patchesSnapshot = patches.toPatchOptionsFile(
362- sourcePatches = patchesFiles.joinToString(" , " ) { it.name }
363- )
360+ val patches: MutableSet <Patch <* >> = loadPatchesFromJar(patchesFiles).toMutableSet()
361+ patchesSnapshot = patches.toPatchBundle(sourceFiles = patchesFiles)
364362
365363 // region Parse options JSON
366364
367- val patchOptionsFile = optionsFilePath?.let { file ->
365+ val patchOptionsBundle : PatchBundle ? = optionsFilePath?.let { file ->
368366 if (file.exists()) {
369367 logger.info(" Reading options from ${file.path} " )
370- Json .decodeFromString<PatchOptionsFile >(file.readText())
368+ val bundles = Json .decodeFromString<List <PatchBundle >>(file.readText())
369+ bundles.findMatchingBundle(patchesFiles)
371370 } else {
372371 logger.info(" Options file ${file.path} does not exist, generating with defaults" )
373- val generated = patches.toPatchOptionsFile(
374- sourcePatches = patchesFiles.joinToString(" , " ) { it.name }
375- )
372+ val bundle = patches.toPatchBundle(sourceFiles = patchesFiles)
376373 val json = Json { prettyPrint = true }
377374 file.absoluteFile.parentFile?.mkdirs()
378- file.writeText(json.encodeToString(generated ))
375+ file.writeText(json.encodeToString(listOf (bundle) ))
379376 logger.info(" Generated options file at ${file.path} " )
380- generated
377+ bundle
381378 }
382379 }
383380
384381 // Build enable/disable sets from JSON (lowercase for case-insensitive matching)
385- val jsonEnabledPatches = patchOptionsFile ?.patches
382+ val jsonEnabledPatches = patchOptionsBundle ?.patches
386383 ?.filter { (_, entry) -> entry.enabled }
387384 ?.keys?.map { it.lowercase() }?.toSet() ? : emptySet()
388- val jsonDisabledPatches = patchOptionsFile ?.patches
385+ val jsonDisabledPatches = patchOptionsBundle ?.patches
389386 ?.filter { (_, entry) -> ! entry.enabled }
390387 ?.keys?.map { it.lowercase() }?.toSet() ? : emptySet()
391388
392389 // Build options map from JSON, deserializing values using each patch's option types
393- val jsonOptionsMap: Map <String , Map <String , Any ?>> = patchOptionsFile ?.patches
390+ val jsonOptionsMap: Map <String , Map <String , Any ?>> = patchOptionsBundle ?.patches
394391 ?.mapNotNull { (patchName, entry) ->
395392 if (entry.options.isEmpty()) return @mapNotNull null
396393 val patch = patches.firstOrNull { it.name.equals(patchName, ignoreCase = true ) }
@@ -439,7 +436,7 @@ internal object PatchCommand : Callable<Int> {
439436 inputApk,
440437 patcherTemporaryFilesPath,
441438 aaptBinaryPath?.path,
442- patcherTemporaryFilesPath.absolutePath,
439+ patcherTemporaryFilesPath.absolutePath
443440 ),
444441 ).use { patcher ->
445442 val packageName = patcher.context.packageMetadata.packageName
@@ -449,15 +446,15 @@ internal object PatchCommand : Callable<Int> {
449446 patchingResult.packageVersion = packageVersion
450447
451448 // Warn if options file is out of date (only for patches compatible with this app)
452- if (patchOptionsFile != null && optionsFilePath?.exists() == true && ! updateOptions) {
449+ if (patchOptionsBundle != null && optionsFilePath?.exists() == true && ! updateOptions) {
453450 val compatiblePatchNames = patches
454451 .filter { patch ->
455452 patch.compatiblePackages == null ||
456453 patch.compatiblePackages!! .any { (name, _) -> name == packageName }
457454 }
458455 .mapNotNull { it.name?.lowercase() }
459456 .toSet()
460- val jsonPatchNames = patchOptionsFile .patches.keys.map { it.lowercase() }.toSet()
457+ val jsonPatchNames = patchOptionsBundle .patches.keys.map { it.lowercase() }.toSet()
461458
462459 val newPatches = compatiblePatchNames - jsonPatchNames
463460 val removedPatches = jsonPatchNames - compatiblePatchNames
@@ -466,7 +463,7 @@ internal object PatchCommand : Callable<Int> {
466463 var patchesWithNewOptions = 0
467464 for ((patchName, snapshotEntry) in patchesSnapshot.patches) {
468465 if (patchName.lowercase() !in compatiblePatchNames) continue
469- val jsonEntry = patchOptionsFile .patches.entries
466+ val jsonEntry = patchOptionsBundle .patches.entries
470467 .firstOrNull { it.key.equals(patchName, ignoreCase = true ) }?.value
471468 ? : continue
472469 val newOptionKeys = snapshotEntry.options.keys - jsonEntry.options.keys
@@ -488,46 +485,44 @@ internal object PatchCommand : Callable<Int> {
488485 }
489486 }
490487
491- val filteredPatches = patches.filterPatchSelection(
488+ logger.info(" Setting patch options" )
489+
490+ // Scope filteredPatches inside let so it goes out of scope immediately after
491+ // patcher += filteredPatches, matching the pattern from PR #54.
492+ patches.filterPatchSelection(
492493 packageName,
493494 packageVersion,
494495 jsonEnabledPatches,
495496 jsonDisabledPatches,
496- )
497+ ).let { filteredPatches ->
498+ val patchesList = patches.toList()
499+ val cliOptionsMap = selection.filter { it.enabled != null }.associate {
500+ val enabledSelection = it.enabled!!
497501
498- logger.info(" Setting patch options" )
499-
500- // Build CLI options map (CLI flags take precedence over JSON)
501- val patchesList = patches.toList()
502- val cliOptionsMap = selection.filter { it.enabled != null }.associate {
503- val enabledSelection = it.enabled!!
504-
505- val resolvedName = enabledSelection.selector.name?.let { userInput ->
506- patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true ) }?.name ? : userInput
507- } ? : patchesList[enabledSelection.selector.index!! ].name!!
502+ val resolvedName = enabledSelection.selector.name?.let { userInput ->
503+ patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true ) }?.name ? : userInput
504+ } ? : patchesList[enabledSelection.selector.index!! ].name!!
508505
509- resolvedName to enabledSelection.options
510- }
506+ resolvedName to enabledSelection.options
507+ }
511508
512- // Merge: JSON options as base, CLI options override
513- val mergedOptionsMap = (jsonOptionsMap.keys + cliOptionsMap.keys).associateWith { patchName ->
514- val jsonOpts = jsonOptionsMap[patchName] ? : emptyMap()
515- val cliOpts = cliOptionsMap[patchName] ? : emptyMap()
509+ (jsonOptionsMap.keys + cliOptionsMap.keys).associateWith { patchName ->
510+ val jsonOpts = jsonOptionsMap[patchName] ? : emptyMap()
511+ val cliOpts = cliOptionsMap[patchName] ? : emptyMap()
516512
517- // Log when CLI options override JSON values
518- for ((key, cliValue) in cliOpts) {
519- val jsonValue = jsonOpts[key]
520- if (jsonValue != null && jsonValue != cliValue) {
521- logger.info(" CLI option overrides JSON for \" $patchName \" -> \" $key \" : $jsonValue -> $cliValue " )
513+ // Log when CLI options override JSON values
514+ for ((key, cliValue) in cliOpts) {
515+ val jsonValue = jsonOpts[key]
516+ if (jsonValue != null && jsonValue != cliValue) {
517+ logger.info(" CLI option overrides JSON for \" $patchName \" -> \" $key \" : $jsonValue -> $cliValue " )
518+ }
522519 }
523- }
524520
525- jsonOpts + cliOpts // CLI entries override JSON entries for same key
526- }
521+ jsonOpts + cliOpts // CLI entries override JSON entries for same key
522+ }. let (filteredPatches::setOptions)
527523
528- mergedOptionsMap.let (filteredPatches::setOptions)
529-
530- patcher + = filteredPatches
524+ patcher + = filteredPatches
525+ } // filteredPatches and patchesList go out of scope here
531526
532527 // Execute patches.
533528 patchingResult.addStepResult(
@@ -565,6 +560,12 @@ internal object PatchCommand : Callable<Int> {
565560 }
566561 )
567562
563+ // patches lives in the outer try scope (needed for patchesSnapshot and options
564+ // file generation before the Patcher block). Clear it explicitly now — after
565+ // patcher() finishes and before patcher.get() — so the JVM can GC the DEX
566+ // classloaders before the most memory-intensive step.
567+ patches.clear()
568+
568569 patcher.context.packageMetadata.packageName to patcher.get()
569570 }
570571
@@ -663,20 +664,17 @@ internal object PatchCommand : Callable<Int> {
663664 val snapshot = patchesSnapshot
664665 if (optionsFilePath != null && updateOptions && snapshot != null ) {
665666 try {
666- val currentOptionsFile = optionsFilePath!! .let { file ->
667+ val existingBundles = optionsFilePath!! .let { file ->
667668 if (file.exists()) {
668- Json .decodeFromString<PatchOptionsFile >(file.readText())
669- } else {
670- null
671- }
669+ try { Json .decodeFromString<List <PatchBundle >>(file.readText()) }
670+ catch (e: Exception ) { emptyList() }
671+ } else emptyList()
672672 }
673-
674- val updatedFile = snapshot.mergeWith(
675- existing = currentOptionsFile,
676- sourcePatches = patchesFiles.joinToString(" , " ) { it.name },
677- )
673+ val existingBundle = existingBundles.findMatchingBundle(patchesFiles)
674+ val updatedBundle = snapshot.mergeWith(existingBundle)
675+ val updatedBundles = existingBundles.withUpdatedBundle(updatedBundle)
678676 val json = Json { prettyPrint = true }
679- optionsFilePath!! .writeText(json.encodeToString(updatedFile ))
677+ optionsFilePath!! .writeText(json.encodeToString(updatedBundles ))
680678 logger.info(" Updated options file ${optionsFilePath!! .path} " )
681679 } catch (e: Exception ) {
682680 logger.warning(" Failed to update options file: ${e.message} " )
0 commit comments