Skip to content

Commit be0b868

Browse files
authored
feat: Store merged APK from split archives as original for repatching (#438)
1 parent 60b1a8f commit be0b868

11 files changed

Lines changed: 112 additions & 28 deletions

File tree

app/src/main/java/app/morphe/manager/ManagerApplication.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,5 +170,9 @@ class ManagerApplication : Application() {
170170
deleteRecursively()
171171
mkdirs()
172172
}
173+
// Logs all app-private directories and their contents with file sizes on fresh start
174+
scope.launch(Dispatchers.IO) {
175+
fs.logStorageContents()
176+
}
173177
}
174178
}

app/src/main/java/app/morphe/manager/data/platform/Filesystem.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ import android.content.Context
66
import android.content.pm.PackageManager
77
import android.os.Build
88
import android.os.Environment
9+
import android.util.Log
910
import androidx.activity.result.contract.ActivityResultContract
1011
import androidx.activity.result.contract.ActivityResultContracts
1112
import app.morphe.manager.util.FilenameUtils
1213
import app.morphe.manager.util.RequestManageStorageContract
14+
import app.morphe.manager.util.formatBytes
1315
import java.io.File
1416

17+
private const val TAG = "Filesystem"
18+
1519
class Filesystem(private val app: Application) {
1620
/**
1721
* A directory that gets cleared when the app restarts.
@@ -57,4 +61,31 @@ class Filesystem(private val app: Application) {
5761
val safeVersion = FilenameUtils.sanitize(version.ifBlank { "unspecified" })
5862
return patchedAppsDir.resolve("${safePackage}_${safeVersion}.apk")
5963
}
64+
65+
/**
66+
* Logs all app-private directories and their contents with file sizes.
67+
* Useful for diagnosing storage issues on startup.
68+
*/
69+
fun logStorageContents() {
70+
Log.i(TAG, "=== Storage contents ===")
71+
for (dir in listOf(tempDir, uiTempDir, patchedAppsDir, originalApksDir)) {
72+
logDir(dir.name, dir)
73+
}
74+
Log.i(TAG, "=== End of storage contents ===")
75+
}
76+
77+
private fun logDir(label: String, dir: File, indent: String = "") {
78+
val totalSize = dir.walkBottomUp().filter { it.isFile }.sumOf { it.length() }
79+
Log.i(TAG, "$indent[$label] ${dir.absolutePath} (total: ${formatBytes(totalSize)})")
80+
dir.listFiles()
81+
?.sortedWith(compareBy({ it.isFile }, { it.name }))
82+
?.forEach { entry ->
83+
if (entry.isDirectory) {
84+
logDir(entry.name, entry, "$indent ")
85+
} else {
86+
Log.i(TAG, "$indent ${entry.name} (${formatBytes(entry.length())})")
87+
}
88+
}
89+
?: Log.i(TAG, "$indent (empty or unreadable)")
90+
}
6091
}

app/src/main/java/app/morphe/manager/patcher/runtime/CoroutineRuntime.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
2525
onPatchCompleted: suspend () -> Unit,
2626
onProgress: ProgressEventHandler,
2727
stripNativeLibs: Boolean,
28+
onMergedApkReady: (suspend (File) -> Unit)?,
2829
) {
2930
MemoryMonitor.startMemoryPolling(logger)
3031

@@ -68,6 +69,7 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
6869
try {
6970
if (preparation.merged) {
7071
onProgress(null, State.COMPLETED, null)
72+
onMergedApkReady?.invoke(preparation.file)
7173
}
7274

7375
Session(

app/src/main/java/app/morphe/manager/patcher/runtime/ProcessRuntime.kt

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import app.morphe.manager.patcher.runtime.process.IPatcherProcess
1515
import app.morphe.manager.patcher.runtime.process.Parameters
1616
import app.morphe.manager.patcher.runtime.process.PatchConfiguration
1717
import app.morphe.manager.patcher.runtime.process.PatcherProcess
18+
import app.morphe.manager.patcher.split.SplitApkPreparer
1819
import app.morphe.manager.patcher.worker.ProgressEventHandler
1920
import app.morphe.manager.ui.model.State
2021
import app.morphe.manager.util.Options
@@ -114,6 +115,7 @@ class ProcessRuntime(
114115
onPatchCompleted: suspend () -> Unit,
115116
onProgress: ProgressEventHandler,
116117
stripNativeLibs: Boolean,
118+
onMergedApkReady: (suspend (File) -> Unit)?,
117119
) = coroutineScope {
118120
val minMemoryLimit = 200
119121
var memoryMB = max(minMemoryLimit, prefs.patcherProcessMemoryLimit.get())
@@ -131,7 +133,8 @@ class ProcessRuntime(
131133
stripNativeLibs,
132134
logger,
133135
onPatchCompleted,
134-
onProgress
136+
onProgress,
137+
onMergedApkReady
135138
)
136139
// Success - update preference and return.
137140
if (retried && prefs.patcherProcessMemoryLimit.get() != memoryMB) {
@@ -174,6 +177,7 @@ class ProcessRuntime(
174177
logger: Logger,
175178
onPatchCompleted: suspend () -> Unit,
176179
onProgress: ProgressEventHandler,
180+
onMergedApkReady: (suspend (File) -> Unit)?,
177181
) = coroutineScope {
178182
// Get the location of our own Apk.
179183
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
@@ -195,6 +199,14 @@ class ProcessRuntime(
195199

196200
val appProcessBin = resolveAppProcessBin(context)
197201

202+
// Determine merged APK path before launching the process so it is accessible
203+
// after patching.await() to invoke onMergedApkReady in the coroutineScope.
204+
val mergedInputPath = if (SplitApkPreparer.isSplitArchive(File(inputFile))) {
205+
File(cacheDir).resolve("merged-process-input-${System.currentTimeMillis()}.apk").absolutePath
206+
} else {
207+
null
208+
}
209+
198210
launch(Dispatchers.IO) {
199211
val result = process(
200212
appProcessBin,
@@ -263,14 +275,26 @@ class ProcessRuntime(
263275
options[uid].orEmpty()
264276
)
265277
},
266-
stripNativeLibs = stripNativeLibs
278+
stripNativeLibs = stripNativeLibs,
279+
mergedInputFile = mergedInputPath
267280
)
268281

269282
binder.start(parameters, eventHandler)
270283
}
271284

272-
// Wait until patching finishes.
273-
patching.await()
285+
// Wait until patching finishes
286+
val mergedFile = mergedInputPath?.let { File(it) }
287+
try {
288+
patching.await()
289+
// If PatcherProcess merged a split archive, notify the caller so the merged APK
290+
// can be saved to originalApksDir for future repatching
291+
if (mergedFile?.exists() == true) {
292+
onMergedApkReady?.invoke(mergedFile)
293+
}
294+
} finally {
295+
// Always clean up the temporary merged file regardless of success or failure
296+
mergedFile?.takeIf { it.exists() }?.delete()
297+
}
274298
}
275299

276300
companion object : LibraryResolver() {

app/src/main/java/app/morphe/manager/patcher/runtime/Runtime.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import app.morphe.manager.util.PatchSelection
1212
import kotlinx.coroutines.flow.first
1313
import org.koin.core.component.KoinComponent
1414
import org.koin.core.component.inject
15+
import java.io.File
1516
import java.io.FileNotFoundException
1617

1718
sealed class Runtime(context: Context) : KoinComponent {
@@ -37,5 +38,6 @@ sealed class Runtime(context: Context) : KoinComponent {
3738
onPatchCompleted: suspend () -> Unit,
3839
onProgress: ProgressEventHandler,
3940
stripNativeLibs: Boolean,
41+
onMergedApkReady: (suspend (File) -> Unit)? = null,
4042
)
4143
}

app/src/main/java/app/morphe/manager/patcher/runtime/process/Parameters.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ data class Parameters(
1515
val outputFile: String,
1616
val configurations: List<PatchConfiguration>,
1717
val stripNativeLibs: Boolean,
18+
// If non-null, PatcherProcess writes the merged mono-APK to this path after prepareIfNeeded.
19+
// ProcessRuntime reads it back so the main process knows the merged file location.
20+
val mergedInputFile: String? = null,
1821
) : Parcelable
1922

2023
@Parcelize

app/src/main/java/app/morphe/manager/patcher/runtime/process/PatcherProcess.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
9191
try {
9292
if (preparation.merged) {
9393
events.progress(null, State.COMPLETED.name, null)
94+
95+
// Copy merged APK to the agreed path so ProcessRuntime can read it back
96+
// in the main process after this process finishes
97+
parameters.mergedInputFile?.let { dest ->
98+
preparation.file.copyTo(File(dest), overwrite = true)
99+
}
94100
}
95101

96102
Session(

app/src/main/java/app/morphe/manager/patcher/worker/PatcherWorker.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import app.morphe.manager.domain.installer.RootInstaller
2020
import app.morphe.manager.domain.manager.KeystoreManager
2121
import app.morphe.manager.domain.manager.PreferencesManager
2222
import app.morphe.manager.domain.repository.InstalledAppRepository
23+
import app.morphe.manager.domain.repository.OriginalApkRepository
2324
import app.morphe.manager.domain.worker.Worker
2425
import app.morphe.manager.domain.worker.WorkerRepository
2526
import app.morphe.manager.patcher.logger.Logger
@@ -47,6 +48,7 @@ class PatcherWorker(
4748
private val pm: PM by inject()
4849
private val fs: Filesystem by inject()
4950
private val installedAppRepository: InstalledAppRepository by inject()
51+
private val originalApkRepository: OriginalApkRepository by inject()
5052
private val rootInstaller: RootInstaller by inject()
5153

5254
class Args(
@@ -215,6 +217,21 @@ class PatcherWorker(
215217
CoroutineRuntime(applicationContext)
216218
}
217219

220+
// After merging a split archive (in either runtime), save the resulting mono-APK
221+
// directly to originalApksDir so it is used for repatching instead of the archive
222+
val onMergedApkReady: suspend (File) -> Unit = { mergedFile ->
223+
val version = pm.getPackageInfo(mergedFile)?.versionName
224+
?.takeUnless { it.isBlank() }
225+
?: args.input.version
226+
?: "unknown"
227+
val savedFile = originalApkRepository.saveOriginalApk(
228+
packageName = args.packageName,
229+
version = version,
230+
sourceFile = mergedFile
231+
)
232+
args.setInputFile(savedFile ?: mergedFile, true, true)
233+
}
234+
218235
try {
219236
runtime.execute(
220237
inputFile.absolutePath,
@@ -225,7 +242,8 @@ class PatcherWorker(
225242
args.logger,
226243
args.onPatchCompleted,
227244
args.onProgress,
228-
stripNativeLibs
245+
stripNativeLibs,
246+
onMergedApkReady
229247
)
230248
} catch (e: Exception) {
231249
if (!useProcessRuntime || Build.VERSION.SDK_INT > Build.VERSION_CODES.Q || !isOomRelated(e)) {
@@ -243,7 +261,8 @@ class PatcherWorker(
243261
args.logger,
244262
args.onPatchCompleted,
245263
args.onProgress,
246-
stripNativeLibs
264+
stripNativeLibs,
265+
onMergedApkReady
247266
)
248267
}
249268

app/src/main/java/app/morphe/manager/ui/viewmodel/HomeViewModel.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1334,7 +1334,9 @@ class HomeViewModel(
13341334
}
13351335

13361336
if (selectedApp != null) {
1337-
processSelectedApp(selectedApp)
1337+
// The saved file is a merged mono-APK signed with our keystore.
1338+
// Skip signature verification to avoid a false "invalid signature" dialog
1339+
processSelectedAppIgnoringSignature(selectedApp)
13381340
} else {
13391341
cleanupPendingData()
13401342
}

app/src/main/java/app/morphe/manager/ui/viewmodel/InstalledAppInfoViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class InstalledAppInfoViewModel(
7373

7474
if (app != null) {
7575
// Run all checks in parallel
76-
val deferredMounted = async { rootInstaller.isAppMounted(app.currentPackageName) }
76+
val deferredMounted = async { rootInstaller.isDeviceRooted() && rootInstaller.isAppMounted(app.currentPackageName) }
7777
val deferredOriginalApk = async { originalApkRepository.get(app.originalPackageName) != null }
7878
val deferredAppState = async { refreshAppState(app) }
7979
val deferredPatches = async { resolveAppliedSelection(app) }
@@ -231,7 +231,7 @@ class InstalledAppInfoViewModel(
231231
}
232232

233233
// Update mounted state
234-
isMounted = rootInstaller.isAppMounted(app.currentPackageName)
234+
isMounted = rootInstaller.isDeviceRooted() && rootInstaller.isAppMounted(app.currentPackageName)
235235
}
236236

237237
/** Manually refresh app state (e.g., after app installation/uninstallation) */

0 commit comments

Comments
 (0)