From e489b4f79a3a965656bc8b922b1cd80b77c76bef Mon Sep 17 00:00:00 2001 From: krvstek <138402769+krvstek@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:05:31 +0200 Subject: [PATCH] chore: Unify CLI & GUI logic into engine --- build.gradle.kts | 3 - gradle/libs.versions.toml | 2 - .../morphe/desktop/command/CliHttpClient.kt | 54 -- .../morphe/desktop/command/CommandUtils.kt | 111 +-- .../desktop/command/ListCompatibleVersions.kt | 28 +- .../desktop/command/ListPatchesCommand.kt | 37 +- .../app/morphe/desktop/command/MainCommand.kt | 6 +- .../morphe/desktop/command/OptionsCommand.kt | 41 +- .../morphe/desktop/command/PatchCommand.kt | 781 ++++-------------- .../desktop/command/PatchFileResolver.kt | 107 --- .../desktop/command/model/FailedPatch.kt | 16 - .../desktop/command/model/PatchingResult.kt | 48 -- .../desktop/command/model/PatchingStep.kt | 13 - .../command/model/PatchingStepResult.kt | 17 - .../command/model/SerializablePatch.kt | 107 --- .../command/utility/ClearCacheCommand.kt | 4 +- .../desktop/command/utility/InstallCommand.kt | 85 +- .../command/utility/UninstallCommand.kt | 40 +- .../kotlin/app/morphe/engine/CacheManager.kt | 31 +- .../app/morphe/engine/MorpheConstants.kt | 16 + .../app/morphe/engine/MultiSourceLoader.kt | 12 +- .../kotlin/app/morphe/engine/PatchEngine.kt | 672 ++++++++++++--- .../app/morphe/engine/PatchExtensions.kt | 220 ----- .../morphe/engine/apk/ApkInspectionResult.kt | 22 + .../app/morphe/engine/apk/ApkInspector.kt | 201 +++++ .../engine/{util => apk}/ApkOutputNaming.kt | 88 +- .../app/morphe/engine/apk/BundleFormats.kt | 34 + .../app/morphe/engine/config/EngineConfig.kt | 57 ++ .../engine/config/EngineConfigRepository.kt | 131 +++ .../app/morphe/engine/model/PatchMetadata.kt | 66 ++ .../data => engine}/model/SupportedApp.kt | 23 +- .../app/morphe/engine/model/VersionStatus.kt | 52 ++ .../app/morphe/engine/network/HttpClient.kt | 73 ++ .../app/morphe/engine/network/HttpService.kt | 17 +- .../app/morphe/engine/options/PatchOption.kt | 150 ++++ .../engine/options/PatchOptionValues.kt | 303 +++++++ .../options}/PatchOptionsFile.kt | 215 +++-- .../options}/PatchPreferencesRepository.kt | 36 +- .../engine/patches/AppVersionResolver.kt | 70 ++ .../engine/patches/GitHubPatchSource.kt | 10 +- .../engine/patches/GitLabPatchSource.kt | 15 +- .../engine/patches/PatchBundleLoader.kt | 4 +- .../app/morphe/engine/patches/PatchCache.kt | 233 +++++- .../morphe/engine/patches/PatchExtensions.kt | 97 +++ .../morphe/engine/patches/PatchRepository.kt | 153 ++++ .../morphe/engine/patches/PatchResolver.kt | 335 ++++++++ .../engine/patches/PullRequestPatchSource.kt | 46 +- .../engine/patches/RemotePatchSource.kt | 7 +- .../patches/RemotePatchSourceFactory.kt | 88 +- .../engine/patches/SupportedAppCatalog.kt | 360 ++++++++ .../morphe/{gui => engine}/util/AdbManager.kt | 651 +++++++++++---- .../morphe/engine/util/ApkManifestReader.kt | 89 -- .../app/morphe/engine/util/AppLinkCommands.kt | 6 +- .../app/morphe/engine/util/BundleFormats.kt | 26 - .../util/DownloadUrlResolver.kt | 17 +- .../app/morphe/engine/util/FileChecksum.kt | 5 +- .../app/morphe/engine/util/KeystoreService.kt | 195 +++++ .../app/morphe/engine/util/VersionUtils.kt | 95 +++ .../engine/workspace/SessionWorkspace.kt | 51 ++ .../engine/workspace/WorkspaceManager.kt | 104 +++ src/main/kotlin/app/morphe/gui/App.kt | 10 +- src/main/kotlin/app/morphe/gui/GuiMain.kt | 6 +- .../morphe/gui/data/constants/AppConstants.kt | 9 +- .../app/morphe/gui/data/model/AppConfig.kt | 54 -- .../kotlin/app/morphe/gui/data/model/Patch.kt | 141 ---- .../gui/data/repository/ConfigRepository.kt | 129 +-- .../gui/data/repository/PatchRepository.kt | 226 ----- .../gui/data/repository/PatchSourceManager.kt | 28 +- .../data/repository/SeenPatchesRepository.kt | 4 +- .../kotlin/app/morphe/gui/di/AppModule.kt | 63 +- .../gui/ui/components/DeviceIndicator.kt | 2 +- .../gui/ui/components/MorphePathInput.kt | 2 +- .../gui/ui/components/PatchSourceDialogs.kt | 14 +- .../gui/ui/components/SettingsButton.kt | 45 +- .../ui/components/SourceManagementSheet.kt | 25 +- .../morphe/gui/ui/components/SourcesPill.kt | 25 +- .../morphe/gui/ui/components/ToolsDialog.kt | 14 +- .../components/settings/KeystoreInfoDialog.kt | 118 +-- .../settings/PatchedAppRuntimeLogsSection.kt | 9 +- .../ui/components/settings/SigningSection.kt | 28 +- .../morphe/gui/ui/screens/home/HomeScreen.kt | 10 +- .../gui/ui/screens/home/HomeViewModel.kt | 251 ++---- .../ui/screens/home/components/ApkInfoCard.kt | 2 +- .../screens/home/components/ApkStagePane.kt | 2 +- .../home/components/HomeAppSortMode.kt | 2 +- .../ui/screens/home/components/HomeDialogs.kt | 5 +- .../home/components/HomeNavigationHelpers.kt | 5 +- .../home/components/PatchedAppDetailDialog.kt | 2 +- .../components/PatchedAppDetailSections.kt | 2 +- .../home/components/SupportedAppListRow.kt | 4 +- .../home/components/SupportedAppsListPane.kt | 2 +- .../screens/patches/PatchSelectionScreen.kt | 10 +- .../patches/PatchSelectionViewModel.kt | 184 ++--- .../ui/screens/patches/PatchesViewModel.kt | 137 +-- .../components/patches/ChannelSelector.kt | 2 +- .../components/selection/PatchListItem.kt | 4 +- .../components/selection/PatchOptionEditor.kt | 32 +- .../components/selection/PatchOptionKind.kt | 38 +- .../gui/ui/screens/patching/PatchingScreen.kt | 25 +- .../ui/screens/patching/PatchingViewModel.kt | 199 ++--- .../components/PatchingFailureContent.kt | 14 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 5 +- .../ui/screens/quick/QuickPatchViewModel.kt | 298 +++---- .../quick/components/CompletedContent.kt | 20 +- .../screens/quick/components/QuickBranding.kt | 7 +- .../screens/quick/components/ReadyContent.kt | 10 +- .../quick/components/SupportedAppsRow.kt | 4 +- .../gui/ui/screens/result/ResultScreen.kt | 41 +- .../result/components/AdbInstallSection.kt | 4 +- .../result/components/LinkHandlingSection.kt | 4 +- .../app/morphe/gui/ui/theme/ChannelColors.kt | 2 +- .../app/morphe/gui/util/AdbErrorMessages.kt | 53 ++ .../app/morphe/gui/util/ChangelogParser.kt | 3 + .../app/morphe/gui/util/ChecksumUtils.kt | 24 - .../app/morphe/gui/util/DeviceMonitor.kt | 2 + .../morphe/gui/util/EnabledSourcesLoader.kt | 377 ++------- .../kotlin/app/morphe/gui/util/FileUtils.kt | 229 ----- src/main/kotlin/app/morphe/gui/util/Logger.kt | 36 +- .../app/morphe/gui/util/PatchException.kt | 19 + .../app/morphe/gui/util/PatchOptionValues.kt | 151 ---- .../app/morphe/gui/util/PatchService.kt | 423 ---------- .../morphe/gui/util/PatcherLogInterceptor.kt | 53 -- .../morphe/gui/util/SupportedAppExtractor.kt | 99 --- .../app/morphe/gui/util/VersionStatusInfo.kt | 1 + .../app/morphe/gui/util/VersionUtils.kt | 201 ----- .../options}/OptionValueConverterTest.kt | 64 +- .../options}/PatchOptionsFileTest.kt | 34 +- .../{gui => engine}/util/VersionUtilsTest.kt | 2 +- .../morphe/gui/util/BuildCodeSupportTest.kt | 4 +- 129 files changed, 5346 insertions(+), 5284 deletions(-) delete mode 100644 src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt delete mode 100644 src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt delete mode 100644 src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt delete mode 100644 src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt delete mode 100644 src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt delete mode 100644 src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt delete mode 100644 src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt create mode 100644 src/main/kotlin/app/morphe/engine/MorpheConstants.kt delete mode 100644 src/main/kotlin/app/morphe/engine/PatchExtensions.kt create mode 100644 src/main/kotlin/app/morphe/engine/apk/ApkInspectionResult.kt create mode 100644 src/main/kotlin/app/morphe/engine/apk/ApkInspector.kt rename src/main/kotlin/app/morphe/engine/{util => apk}/ApkOutputNaming.kt (62%) create mode 100644 src/main/kotlin/app/morphe/engine/apk/BundleFormats.kt create mode 100644 src/main/kotlin/app/morphe/engine/config/EngineConfig.kt create mode 100644 src/main/kotlin/app/morphe/engine/config/EngineConfigRepository.kt create mode 100644 src/main/kotlin/app/morphe/engine/model/PatchMetadata.kt rename src/main/kotlin/app/morphe/{gui/data => engine}/model/SupportedApp.kt (80%) create mode 100644 src/main/kotlin/app/morphe/engine/model/VersionStatus.kt create mode 100644 src/main/kotlin/app/morphe/engine/network/HttpClient.kt create mode 100644 src/main/kotlin/app/morphe/engine/options/PatchOption.kt create mode 100644 src/main/kotlin/app/morphe/engine/options/PatchOptionValues.kt rename src/main/kotlin/app/morphe/{desktop/command/model => engine/options}/PatchOptionsFile.kt (50%) rename src/main/kotlin/app/morphe/{gui/data/repository => engine/options}/PatchPreferencesRepository.kt (83%) create mode 100644 src/main/kotlin/app/morphe/engine/patches/AppVersionResolver.kt create mode 100644 src/main/kotlin/app/morphe/engine/patches/PatchExtensions.kt create mode 100644 src/main/kotlin/app/morphe/engine/patches/PatchRepository.kt create mode 100644 src/main/kotlin/app/morphe/engine/patches/PatchResolver.kt create mode 100644 src/main/kotlin/app/morphe/engine/patches/SupportedAppCatalog.kt rename src/main/kotlin/app/morphe/{gui => engine}/util/AdbManager.kt (57%) delete mode 100644 src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt delete mode 100644 src/main/kotlin/app/morphe/engine/util/BundleFormats.kt rename src/main/kotlin/app/morphe/{gui => engine}/util/DownloadUrlResolver.kt (83%) create mode 100644 src/main/kotlin/app/morphe/engine/util/KeystoreService.kt create mode 100644 src/main/kotlin/app/morphe/engine/util/VersionUtils.kt create mode 100644 src/main/kotlin/app/morphe/engine/workspace/SessionWorkspace.kt create mode 100644 src/main/kotlin/app/morphe/engine/workspace/WorkspaceManager.kt delete mode 100644 src/main/kotlin/app/morphe/gui/data/model/Patch.kt delete mode 100644 src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/AdbErrorMessages.kt delete mode 100644 src/main/kotlin/app/morphe/gui/util/FileUtils.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/PatchException.kt delete mode 100644 src/main/kotlin/app/morphe/gui/util/PatchOptionValues.kt delete mode 100644 src/main/kotlin/app/morphe/gui/util/PatchService.kt delete mode 100644 src/main/kotlin/app/morphe/gui/util/PatcherLogInterceptor.kt delete mode 100644 src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt delete mode 100644 src/main/kotlin/app/morphe/gui/util/VersionUtils.kt rename src/test/kotlin/app/morphe/{desktop/command => engine/options}/OptionValueConverterTest.kt (62%) rename src/test/kotlin/app/morphe/{desktop/command => engine/options}/PatchOptionsFileTest.kt (86%) rename src/test/kotlin/app/morphe/{gui => engine}/util/VersionUtilsTest.kt (99%) diff --git a/build.gradle.kts b/build.gradle.kts index b5b8cff0..b50ec431 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,9 +80,6 @@ dependencies { api(libs.morphe.patcher) implementation(libs.arsclib) implementation(libs.morphe.library) - implementation(libs.jadb) { - exclude(group = "org.mockito") - } implementation(libs.picocli) // -- Bootstrap (Code Generation) --------------------------------------- diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0aa65661..882128f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,6 @@ picocli = "4.7.7" arsclib = "9b742c412d" morphe-patcher = "1.15.1-dev.6" morphe-library = "1.4.0" -jadb = "1.2.3" # Compose Desktop compose = "1.12.1" @@ -53,7 +52,6 @@ junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref picocli = { module = "info.picocli:picocli", version.ref = "picocli" } morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" } morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" } -jadb = { module = "app.morphe:jadb", version.ref = "jadb" } # Ktor Client ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } diff --git a/src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt b/src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt deleted file mode 100644 index 2d92c538..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command - -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import okhttp3.Dns -import okhttp3.Protocol -import java.net.Inet4Address - -/** - * Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived - * `morhpe-cli ....` invocations. Engine remote sources (like GitHub and GitLab) require this to be passed in. - * - * We could later swap `by lazy` for `fun create()` if we ever want the CLI to share lifecycle with anything else. - */ -object CliHttpClient { - val instance: HttpClient by lazy { - HttpClient(OkHttp) { - engine { - config { - // Prefer IPv4 so an advertised but unroutable IPv6 address does not - // burn the connect timeout before the working A record is tried. - dns { hostname -> - val all = Dns.SYSTEM.lookup(hostname) - all.filterIsInstance().ifEmpty { all } - } - // Pin HTTP/1.1 to avoid intermittent HTTP/2 stream resets against - // GitHub-backed download endpoints. - protocols(listOf(Protocol.HTTP_1_1)) - followRedirects(true) - followSslRedirects(true) - } - } - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - // Idle/socket timeouts (not a total cap) so large .mpp downloads don't fail - // for being big. Only genuine stalls or issues fail. - install(HttpTimeout) { - connectTimeoutMillis = 30_000 - socketTimeoutMillis = 60_000 - } - // Retry/429 handling lives in HttpService (single layer), not a client plugin. - } - } -} diff --git a/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt b/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt index c97e1040..e6e3f046 100644 --- a/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt +++ b/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt @@ -5,119 +5,16 @@ package app.morphe.desktop.command +import app.morphe.engine.patches.RemotePatchSourceFactory +import java.io.File import picocli.CommandLine import picocli.CommandLine.Model.CommandSpec -import java.io.File -internal fun checkFileExistsOrIsUrl(files: Set, spec: CommandSpec) : Set { +internal fun checkFileExistsOrIsUrl(files: Set, spec: CommandSpec): Set { files.firstOrNull { - !it.exists() && !it.invariantSeparatorsPath.let { url -> - url.startsWith("http:/") || url.startsWith("https:/") - } + !it.exists() && RemotePatchSourceFactory.parse(it.invariantSeparatorsPath) == null }?.let { throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can not be found") } return files } - -class OptionKeyConverter : CommandLine.ITypeConverter { - override fun convert(value: String): String = value -} - -class OptionValueConverter : CommandLine.ITypeConverter { - override fun convert(value: String?): Any? { - value ?: return null - - return when { - value.startsWith("[") && value.endsWith("]") -> { - val innerValue = value.substring(1, value.length - 1) - - buildList { - var nestLevel = 0 - var insideQuote = false - var escaped = false - - val item = buildString { - for (char in innerValue) { - when (char) { - '\\' -> { - if (escaped || nestLevel != 0) { - append(char) - } - - escaped = !escaped - } - - '"', '\'' -> { - if (!escaped) { - insideQuote = !insideQuote - } else { - escaped = false - } - - append(char) - } - - '[' -> { - if (!insideQuote) { - nestLevel++ - } - - append(char) - } - - ']' -> { - if (!insideQuote) { - nestLevel-- - - if (nestLevel == -1) { - return value - } - } - - append(char) - } - - ',' -> if (nestLevel == 0) { - if (insideQuote) { - append(char) - } else { - add(convert(toString())) - setLength(0) - } - } else { - append(char) - } - - else -> append(char) - } - } - } - - if (item.isNotEmpty()) { - add(convert(item)) - } - } - } - - value.startsWith("\"") && value.endsWith("\"") -> value.substring(1, value.length - 1) - value.startsWith("'") && value.endsWith("'") -> value.substring(1, value.length - 1) - value.endsWith("f") -> value.dropLast(1).toFloat() - value.endsWith("L") -> value.dropLast(1).toLong() - value.equals("true", ignoreCase = true) -> true - value.equals("false", ignoreCase = true) -> false - value.toIntOrNull() != null -> value.toInt() - value.toLongOrNull() != null -> value.toLong() - value.toDoubleOrNull() != null -> value.toDouble() - value.toFloatOrNull() != null -> value.toFloat() - value == "null" -> null - value == "int[]" -> emptyList() - value == "long[]" -> emptyList() - value == "double[]" -> emptyList() - value == "float[]" -> emptyList() - value == "boolean[]" -> emptyList() - value == "string[]" -> emptyList() - else -> value - } - } -} diff --git a/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt index 32427997..e1e2b80f 100644 --- a/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt @@ -5,19 +5,19 @@ package app.morphe.desktop.command -import app.morphe.desktop.command.CliHttpClient -import app.morphe.engine.VersionMap -import app.morphe.engine.mostCommonCompatibleVersions -import app.morphe.engine.versionCodesFor -import app.morphe.patcher.patch.loadPatchesFromJar +import app.morphe.engine.patches.PatchBundleLoader +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.patches.SupportedAppCatalog +import app.morphe.engine.patches.VersionMap +import app.morphe.engine.patches.versionCodesFor +import java.io.File +import java.util.logging.Logger import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Option import picocli.CommandLine.Spec -import java.io.File -import java.util.logging.Logger @Command( name = "list-versions", @@ -73,10 +73,9 @@ internal class ListCompatibleVersions : Runnable { override fun run() { try { - patchesFiles = PatchFileResolver.resolve( + patchesFiles = PatchResolver.resolveCliFiles( patchesFiles, prerelease, - CliHttpClient.instance ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( @@ -85,7 +84,7 @@ internal class ListCompatibleVersions : Runnable { ) } - val patches = loadPatchesFromJar(patchesFiles) + val patches = PatchBundleLoader.loadFlat(patchesFiles) fun getVersionCodesString(pkgName: String, versionName: String): String { return patches.versionCodesFor(pkgName, versionName)?.let { codes -> @@ -112,10 +111,11 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString(name).prependIndent("\t")) } - patches.mostCommonCompatibleVersions( - packageNames, - countUnusedPatches, - includeExperimental, + SupportedAppCatalog.getCompatibilityMap( + packageNames = packageNames, + countUnusedPatches = countUnusedPatches, + includeExperimental = includeExperimental, + patches = patches, ).entries.joinToString("\n", transform = ::buildString).let(logger::info) } } diff --git a/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt index 97a94293..2322610a 100644 --- a/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt @@ -8,21 +8,21 @@ package app.morphe.desktop.command -import app.morphe.desktop.command.CliHttpClient -import app.morphe.engine.compatibleVersionsForDisplay -import app.morphe.engine.isCompatibleWith -import app.morphe.engine.versionCodesFor +import app.morphe.engine.patches.compatibleVersionsForDisplay +import app.morphe.engine.patches.isCompatibleWith +import app.morphe.engine.patches.versionCodesFor +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.patches.PatchBundleLoader +import app.morphe.patcher.patch.Option as PatchOption import app.morphe.patcher.patch.Patch -import app.morphe.patcher.patch.loadPatchesFromJar +import java.io.File +import java.util.logging.Logger import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Option import picocli.CommandLine.Spec import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Help.Visibility.ALWAYS -import java.io.File -import java.util.logging.Logger -import app.morphe.patcher.patch.Option as PatchOption @Command( name = "list-patches", @@ -193,19 +193,10 @@ internal object ListPatchesCommand : Runnable { } } - fun Patch<*>.filterCompatiblePackages(name: String) = - isCompatibleWith( - packageName = name, - includeExperimental = includeExperimental, - includeUniversalPatches = withUniversalPatches, - ) - - try { - patchesFiles = PatchFileResolver.resolve( + patchesFiles = PatchResolver.resolveCliFiles( patchesFiles, prerelease, - CliHttpClient.instance ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( @@ -214,12 +205,14 @@ internal object ListPatchesCommand : Runnable { ) } - val patches = loadPatchesFromJar(patchesFiles).withIndex().toList() + val patches = PatchBundleLoader.loadFlat(patchesFiles).withIndex().toList() - val filtered = packageName?.let { + val filtered = packageName?.let { name -> patches.filter { (_, patch) -> - patch.filterCompatiblePackages( - it + patch.isCompatibleWith( + packageName = name, + includeExperimental = includeExperimental, + includeUniversalPatches = withUniversalPatches, ) } } ?: patches diff --git a/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt b/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt index ef3c7896..16bfd4f0 100644 --- a/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt @@ -6,16 +6,18 @@ package app.morphe.desktop.command import app.morphe.desktop.command.utility.UtilityCommand +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.library.logging.Logger +import java.util.Properties +import kotlin.system.exitProcess import org.jetbrains.annotations.VisibleForTesting import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.IVersionProvider -import java.util.Properties -import kotlin.system.exitProcess fun desktopMain(args: Array) { Logger.setDefault() + runCatching { WorkspaceManager.reapStaleWorkspaces() } val exitCode = CommandLine(MainCommand).execute(*args) exitProcess(exitCode) } diff --git a/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt index 03d1abc5..e30b422e 100644 --- a/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt @@ -5,24 +5,25 @@ package app.morphe.desktop.command -import app.morphe.desktop.command.model.PatchBundle -import app.morphe.engine.isCompatibleWith +import app.morphe.engine.options.PatchBundle +import app.morphe.engine.options.findMatchingBundle +import app.morphe.engine.options.mergeWithBundle +import app.morphe.engine.options.readPatchBundles +import app.morphe.engine.options.withUpdatedBundle +import app.morphe.engine.options.writePatchBundles import app.morphe.engine.patches.LoadedBundle import app.morphe.engine.patches.PatchBundleLoader -import app.morphe.desktop.command.model.findMatchingBundle -import app.morphe.desktop.command.model.mergeWithBundle -import app.morphe.desktop.command.model.withUpdatedBundle -import app.morphe.desktop.command.CliHttpClient -import kotlinx.serialization.json.Json +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.patches.isCompatibleWith +import java.io.File +import java.util.concurrent.Callable +import java.util.logging.Logger import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Option import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Spec -import java.io.File -import java.util.concurrent.Callable -import java.util.logging.Logger @Command( name = "options-create", @@ -70,19 +71,12 @@ internal object OptionsCommand : Callable { ) private var packageName: String? = null - private val json = Json { prettyPrint = true } - override fun call(): Int { try { - // Since we could have many URLs, we resolve each of them separately - patchesFiles = patchesFiles.map { file -> - val resolved = PatchFileResolver.resolve( - setOf(file), - prerelease, - CliHttpClient.instance - ) - resolved.single() - }.toSet() + patchesFiles = PatchResolver.resolveCliFiles( + patchesFiles, + prerelease, + ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( spec.commandLine(), @@ -101,7 +95,7 @@ internal object OptionsCommand : Callable { val existingBundles: List = if (outputFile.exists()) { try { - Json.decodeFromString>(outputFile.readText()) + readPatchBundles(outputFile) } catch (e: Exception) { logger.warning( "Could not parse existing file, creating fresh: ${e.message}" @@ -148,8 +142,7 @@ internal object OptionsCommand : Callable { } } - outputFile.absoluteFile.parentFile?.mkdirs() - outputFile.writeText(json.encodeToString(updatedBundles)) + writePatchBundles(outputFile, updatedBundles) logger.info("Options file saved to ${outputFile.path}") diff --git a/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt b/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt index 39e72f56..5fae8fb7 100644 --- a/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt @@ -8,33 +8,25 @@ package app.morphe.desktop.command -import app.morphe.desktop.command.model.* -import app.morphe.desktop.command.CliHttpClient -import app.morphe.engine.isCompatibleWith -import app.morphe.engine.compatibleVersionsForDisplay -import app.morphe.engine.MorpheData -import app.morphe.engine.supportedVersionsFor +import app.morphe.engine.PatchEngine import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME import app.morphe.engine.UpdateChecker -import app.morphe.engine.util.deleteRecursivelyInParallel -import app.morphe.engine.util.signWithLegacyFallback -import app.morphe.engine.patches.LoadedBundle -import app.morphe.engine.patches.PatchBundleLoader -import app.morphe.library.installation.installer.* -import app.morphe.patcher.Patcher -import app.morphe.patcher.PatcherConfig -import app.morphe.patcher.apk.ApkMerger +import app.morphe.engine.apk.ApkOutputNaming +import app.morphe.engine.config.EngineConfigRepository +import app.morphe.engine.options.parseCliOptionValue +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.util.AdbErrorCode +import app.morphe.engine.util.AdbException +import app.morphe.engine.util.AdbManager +import app.morphe.engine.util.KeystoreService import app.morphe.patcher.apk.ApkUtils -import app.morphe.patcher.apk.ApkUtils.applyTo import app.morphe.patcher.dex.BytecodeMode -import app.morphe.patcher.dex.NoOpDexVerifier -import app.morphe.patcher.dex.SdkDexVerifier -import app.morphe.patcher.logging.toMorpheLogger -import app.morphe.patcher.patch.Patch -import app.morphe.patcher.patch.setOptions import app.morphe.patcher.resource.CpuArchitecture +import java.io.File +import java.util.concurrent.Callable +import java.util.logging.Logger import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -45,12 +37,6 @@ import picocli.CommandLine.ArgGroup import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Spec -import java.io.File -import java.io.PrintWriter -import java.io.StringWriter -import java.util.concurrent.Callable -import java.util.logging.Logger -import kotlin.collections.plus @OptIn(ExperimentalSerializationApi::class) @VisibleForTesting @@ -390,663 +376,178 @@ internal object PatchCommand : Callable { // Check for any newer version UpdateChecker.check(logger)?.let { logger.info(it) } - // region Setup - - // Default output uses the unified scheme shared with the GUI: - // //-Morphe-{apkVer}-patches-{patchesVer}.apk - // The folder name uses the APK's human-friendly label (e.g. "Youtube") - // when readable from the manifest, falling back to the filename for - // corrupt or unparseable APKs. GUI populates this from apkInfo; - // CLI parses the APK here so both surfaces produce identical paths. - // Users who want the legacy `./-patched.apk` layout pass --out. - val outputFilePath = outputFilePath ?: run { - val displayName = app.morphe.engine.util.ApkOutputNaming.resolveAppDisplayName(apk) - app.morphe.engine.util.ApkOutputNaming.outputApkPath( - inputApk = apk, - patchesFile = bundles.firstOrNull()?.patchesFile, - appDisplayName = displayName, - ) - } - - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - - // Resolve and possibly convert the keystore. The patcher only loads BKS, - // but users frequently pass PKCS12 (Android Studio default) or JKS (older - // projects, URV exports). KeystoreImporter sniffs by magic bytes (not - // extension — URV ships the same bytes under multiple suffixes), short- - // circuits when already BKS so existing setups are zero-risk, and writes - // converted bytes to a separate file so the user's source is never mutated. - val keystoreFilePath = run { - val source = keyStoreFilePath ?: return@run MorpheData.defaultKeystoreFile - if (!source.exists()) return@run source // patcher will produce a clearer error - val importResult = app.morphe.engine.util.KeystoreImporter.ensureBks( - source = source, - convertedOutput = MorpheData.importedKeystoreFile, - alias = keyStoreEntryAlias, - password = keyStoreEntryPassword, - ) - when (importResult) { - is app.morphe.engine.util.KeystoreImporter.Result.AlreadyBks -> importResult.file - is app.morphe.engine.util.KeystoreImporter.Result.Converted -> { - logger.info( - "Converted ${importResult.sourceFormat.displayName} keystore → BKS: ${importResult.file.absolutePath}" - ) - importResult.file - } - is app.morphe.engine.util.KeystoreImporter.Result.Failed -> { - logger.severe("Keystore conversion failed: ${importResult.reason}") - importResult.cause?.let { logger.severe(it.stackTraceToString()) } - throw IllegalArgumentException( - "Could not use keystore '${source.absolutePath}': ${importResult.reason}" - ) - } - } - } - - val installer = if (deviceSerial != null) { - val deviceSerial = deviceSerial!!.ifEmpty { null } - - try { - if (mount) { - AdbRootInstaller(deviceSerial) - } else { - AdbInstaller(deviceSerial) + val adbManager = if (deviceSerial != null) AdbManager() else null + val targetDeviceId = if (deviceSerial != null) { + val requestedSerial = deviceSerial!!.ifEmpty { null } + val verifiedDevice = runBlocking { + adbManager!!.startServer().onFailure { e -> + logger.severe(e.message ?: "Failed to start ADB server.") + return@runBlocking null } - } catch (_: DeviceNotFoundException) { - if (deviceSerial?.isNotEmpty() == true) { - logger.severe( - "Device with serial $deviceSerial not found to install to. " + - "Ensure the device is connected and the serial is correct when using the --install option.", - ) - } else { - logger.severe( - "No device has been found to install to. " + - "Ensure a device is connected when using the --install option.", - ) + adbManager.verifyTargetDevice(requestedSerial, mount = mount).getOrElse { e -> + when ((e as? AdbException)?.errorCode) { + AdbErrorCode.DEVICE_NOT_FOUND -> logger.severe( + "Device with serial $requestedSerial not found to install to. " + + "Ensure the device is connected and the serial is correct when using the --install option.", + ) + AdbErrorCode.NO_DEVICES, + AdbErrorCode.UNAUTHORIZED_DEVICE -> logger.severe( + "No device has been found to install to. " + + "Ensure a device is connected when using the --install option.", + ) + else -> logger.severe(e.message ?: e.toString()) + } + adbManager.killServerIfOwned() + return@runBlocking null } - - return EXIT_CODE_ERROR - } + } ?: return EXIT_CODE_ERROR + verifiedDevice.id } else { null } - // endregion - - val patchingResult = PatchingResult() - var mergedApkToCleanup: File? = null - val verifier = if (sdkToolsPath == null) NoOpDexVerifier else SdkDexVerifier(sdkToolsPath!!) - - // Lightweight snapshot of patch metadata for use in finally block (auto-update). - // Lightweight snapshot of current bundle metadata for use in finally block (auto-update). - // The heavy Patch objects hold DEX classloaders and must not leak into finally. - var patchesSnapshotForFinally: List = emptyList() - try { - // We resolve each bundle's URL separately. bundles.forEach { bundle -> - val resolved = PatchFileResolver.resolve( + val resolved = PatchResolver.resolveCliFiles( setOf(bundle.patchesFile), prerelease, - CliHttpClient.instance ) bundle.patchesFile = resolved.single() } } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( spec.commandLine(), - e.message ?: "Failed to resolve patch URL" + e.message ?: "Failed to resolve patch URL", ) } - // Per-session scratch dir. Hoisted out of the patching `try` block so - // the `finally` block can reference it for the auto-purge (unless --disable-purge). - // Naming matches the GUI's FileUtils.createPatchingTempDir() so the - // tmp/ folder shows consistent siblings across CLI + GUI sessions. - val patcherTemporaryFilesPath = - temporaryFilesPath.resolve("patching-${System.currentTimeMillis()}").also { it.mkdirs() } - - try { - logger.info("Loading patches...") - - // We load each bundle separately so each bundle's options can be scoped correctly. - val loadedBundles: List = PatchBundleLoader.loadEach( - bundles.map { it.patchesFile } + val bundleScopes = bundles.map { bundleArg -> + val enabledNames = bundleArg.selections.mapNotNull { it.enabled?.selector?.name }.toSet() + val enabledIndices = bundleArg.selections.mapNotNull { it.enabled?.selector?.index }.toSet() + val disabledNames = bundleArg.selections.mapNotNull { it.disable?.selector?.name }.toSet() + val disabledIndices = bundleArg.selections.mapNotNull { it.disable?.selector?.index }.toSet() + val patchOptionsByName = bundleArg.selections + .filter { it.enabled?.selector?.name != null && it.enabled!!.options.isNotEmpty() } + .associate { it.enabled!!.selector.name!! to it.enabled!!.options } + val patchOptionIndices = bundleArg.selections + .filter { it.enabled?.selector?.index != null && it.enabled!!.options.isNotEmpty() } + .associate { it.enabled!!.selector.index!! to it.enabled!!.options } + + PatchEngine.BundleScope( + bundleFile = bundleArg.patchesFile, + enabledPatchNames = enabledNames, + enabledPatchIndices = enabledIndices, + disabledPatchNames = disabledNames, + disabledPatchIndices = disabledIndices, + patchOptions = patchOptionsByName, + patchOptionIndices = patchOptionIndices, ) + } + val savedConfig = runBlocking { EngineConfigRepository.shared.loadConfig() } - val patches: MutableSet> = loadedBundles.flatMap { it.patches }.toMutableSet() - val patchSnapshots: List = loadedBundles.map { lb -> - lb.patches.toPatchBundle(sourceFiles = setOf(lb.sourceFile)) - } - - // region Parse options JSON - val patchOptionsByFile: Map = optionsFilePath?.let { file -> - if (file.exists()){ - logger.info("Reading options from ${file.path}") - val jsonBundles = Json.decodeFromString>(file.readText()) - loadedBundles.associate { lb -> - lb.sourceFile to jsonBundles.findMatchingBundle(setOf(lb.sourceFile)) - } - } else { - logger.info("Options file ${file.path} does not exist, generating with defaults") - val json = Json { prettyPrint = true } - file.absoluteFile.parentFile?.mkdirs() - file.writeText(json.encodeToString(patchSnapshots)) - logger.info("Generated options file at ${file.path}") - loadedBundles.zip(patchSnapshots).associate { (lb, b) -> - lb.sourceFile to b - } - } - } ?: emptyMap() - - // Per-bundle JSON-sourced enable/disable. Same patch name in two bundles can - // have different enabled states across mpps. - val jsonEnabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) -> - bundle?.patches?.filter { it.value.enabled }?.keys?.map { - it.lowercase() }?.toSet() - ?: emptySet() - } - - val jsonDisabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) -> - bundle?.patches?.filter { !it.value.enabled }?.keys?.map { - it.lowercase() }?.toSet() - ?: emptySet() - } - - // Per-bundle options map. Same as before but indexed by source file. - // Option values are deserialized using the patch types from that file's bundle, not the global pool. - val jsonOptionsByFile: Map>> = - loadedBundles.associate { lb -> - val bundle = patchOptionsByFile[lb.sourceFile] - val opts = bundle?.patches?.mapNotNull { (patchName, entry) -> - if (entry.options.isEmpty()) return@mapNotNull null - val patch = lb.patches.firstOrNull { - it.name.equals(patchName, ignoreCase = true) - } ?: return@mapNotNull null - val resolvedName = patch.name ?: return@mapNotNull null - val deserializedOptions = entry.options.mapNotNull { (key, element) -> - if (!patch.options.containsKey(key)) return@mapNotNull null - val option = patch.options[key] - try { - key to deserializeOptionValue(element, option.type) - } catch (e: Exception) { - logger.warning( - "Failed to deserialize option $key for $patchName in ${lb.sourceFile.name}: ${e.message}" - ) - null - } - }.toMap() - - if (deserializedOptions.isEmpty()) null - else resolvedName to deserializedOptions - }?.toMap() ?: emptyMap() - lb.sourceFile to opts - } - - // Hand the per-bundle snapshots off to the finally block before we - // enter the Patcher use{} (which holds DEX classloaders we don't - // want leaking into finally). - patchesSnapshotForFinally = patchSnapshots - - // endregion - - // (patcherTemporaryFilesPath is declared above the outer try - // block so it's visible to the auto-purge in the finally clause.) - - // We need to check for apkm (like reddit), xapk and apks formats here - - val inputApk = if (app.morphe.engine.util.BundleFormats.isBundle(apk)) { - - logger.info("Merging split APK bundle") - - // Save merged APK to output directory (will be cleaned up after patching) - val outputApk = outputFilePath.parentFile.resolve("${apk.nameWithoutExtension}-merged.apk") - - // Use APKEditor's Merger directly (handles extraction and merging) - ApkMerger(logger.toMorpheLogger()).merge( - inputFile = apk, - outputFile = outputApk, - cleanMetaInf = false - ) - - mergedApkToCleanup = outputApk - outputApk - } else { - apk - } - - logger.info("Initializing patcher...") - val (packageName, patcherResult) = Patcher( - PatcherConfig( - inputApk, - patcherTemporaryFilesPath, - useArsclib = true, - keepArchitectures = keepArchitectures, - useBytecodeMode = bytecodeMode, - verifier = verifier - ), - ).use { patcher -> - val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.versionName - - patchingResult.packageName = packageName - patchingResult.packageVersion = packageVersion - - // Warn if options file is out of date — checked PER BUNDLE so - // each .mpp's drift is reported against its own JSON entry. - if (optionsFilePath?.exists() == true && !updateOptions) { - loadedBundles.forEachIndexed { i, lb -> - val bundleOpts = patchOptionsByFile[lb.sourceFile] ?: return@forEachIndexed - val bundleSnapshot = patchSnapshots[i] - val bundlePatches = lb.patches - - val compatiblePatchNames = bundlePatches - .filter { patch -> - patch.isCompatibleWith( - packageName = packageName, - includeExperimental = true, - includeUniversalPatches = true, - ) - } - .mapNotNull { it.name?.lowercase() } - .toSet() - // All patch names in this bundle regardless of app compatibility. - // Used for "removed" detection: a patch is only truly removed if - // it's gone from the .mpp entirely, not just incompatible with - // this app. - val allMppPatchNames = bundlePatches.mapNotNull { it.name?.lowercase() }.toSet() - val jsonPatchNames = bundleOpts.patches.keys.map { it.lowercase() }.toSet() - - val newPatches = compatiblePatchNames - jsonPatchNames - val oldPatches = jsonPatchNames - compatiblePatchNames - val removedPatches = jsonPatchNames - allMppPatchNames - - // Per-patch option-key drift. - val patchesWithNewOptions = mutableMapOf>() - val patchesWithOldOptions = mutableMapOf>() - - for ((patchName, _) in bundleSnapshot.patches) { - if (patchName.lowercase() !in compatiblePatchNames) continue - val jsonEntry = bundleOpts.patches.entries - .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value - ?: continue - - // Compare against the live patch in this bundle (not the snapshot) - // so multi-app patches with the same name aren't merged together. - val actualPatch = bundlePatches.find { patch -> - if (!patch.name.equals(patchName, ignoreCase = true)) return@find false - patch.isCompatibleWith( - packageName = packageName, - includeExperimental = true, - includeUniversalPatches = true, - ) - } - val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() - - val newOptionKeys = actualOptionKeys - jsonEntry.options.keys - if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys - - val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys - if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys - } - - if (newPatches.isNotEmpty() || oldPatches.isNotEmpty() || removedPatches.isNotEmpty() || - patchesWithNewOptions.isNotEmpty() || patchesWithOldOptions.isNotEmpty() - ) { - logger.warning("Options file is out of date for ${lb.sourceFile.name}:") - if (newPatches.isNotEmpty()) { - logger.warning(" ${newPatches.size} new patches not in your options file, default patch values will be applied. New patches are:") - newPatches.forEach { logger.warning(" - $it") } - } - if (removedPatches.isNotEmpty()) { - logger.warning(" ${removedPatches.size} patches in your options file no longer exist and will be ignored") - } - if (oldPatches.isNotEmpty()) { - logger.warning(" ${oldPatches.size} patches in your options file are not compatible with the app:") - oldPatches.forEach { logger.warning(" - $it") } - } - if (patchesWithNewOptions.isNotEmpty()) { - patchesWithNewOptions.forEach { (patch, key) -> - logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}") - } - } - if (patchesWithOldOptions.isNotEmpty()) { - patchesWithOldOptions.forEach { (patch, key) -> - logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") - } - } - logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.") - } - } - } - - logger.info("Filtering patches for $packageName v$packageVersion...") - - // Filter + apply options PER BUNDLE so each bundle's selectors - // and options only touch its own patches. Final patcher input - // is the union across all bundles. - val finalPatches = mutableSetOf>() - loadedBundles.forEachIndexed { i, lb -> - val bundleArg = bundles[i] - val jsonEnabled = jsonEnabledByFile[lb.sourceFile] ?: emptySet() - val jsonDisabled = jsonDisabledByFile[lb.sourceFile] ?: emptySet() - val jsonOpts = jsonOptionsByFile[lb.sourceFile] ?: emptyMap() - - val patchesList = lb.patches.toList() - - // CLI options map scoped to this bundle. Name resolution looks - // up only this bundle's patches; --ei index is interpreted as - // an index INTO THIS BUNDLE'S patch list. - val cliOptionsMap = bundleArg.selections.filter { it.enabled != null }.associate { sel -> - val enabledSel = sel.enabled!! - val resolvedName = enabledSel.selector.name?.let { userInput -> - patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name - ?: userInput - } ?: patchesList[enabledSel.selector.index!!].name!! - resolvedName to enabledSel.options - } - - val filtered = lb.patches.filterPatchSelection( - packageName, - packageVersion, - bundleArg.selections, - jsonEnabled, - jsonDisabled, - ) - - // Merge JSON + CLI options (CLI overrides JSON for same key) - // and apply to this bundle's filtered patches. - (jsonOpts.keys + cliOptionsMap.keys).associateWith { patchName -> - val js = jsonOpts[patchName] ?: emptyMap() - val cl = cliOptionsMap[patchName] ?: emptyMap() - for ((key, cliValue) in cl) { - val jsonValue = js[key] - if (jsonValue != null && jsonValue != cliValue) { - logger.info( - "CLI option overrides JSON for \"$patchName\" " + - "(${lb.sourceFile.name}) -> \"$key\": $jsonValue -> $cliValue" - ) - } - } - js + cl - }.let(filtered::setOptions) - - finalPatches += filtered - } - patcher += finalPatches - - // Execute patches. Log lines match the engine's "Applying N - // patches…" → "Applied: " / "FAILED: " format so - // CLI and GUI output is consistent. CLI still appends the - // stacktrace on failure since there's no "View details" UI - // in a terminal. - logger.info("Applying ${finalPatches.size} patches...") - patchingResult.addStepResult(PatchingStep.PATCHING) { - runBlocking { - patcher().collect { patchResult -> - val patchName = patchResult.patch.name ?: "Unknown" - patchResult.exception?.let { exception -> - StringWriter().use { writer -> - exception.printStackTrace(PrintWriter(writer)) - - logger.severe("FAILED: $patchName\n$writer") - - patchingResult.failedPatches.add( - FailedPatch( - patchResult.patch.toSerializablePatch(), - writer.toString() - ) - ) - - if (!continueOnError) { - patchingResult.success = false - throw PatchFailedException( - "FAILED: $patchName", - exception - ) - } - } - } ?: run { - patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) - logger.info("Applied: $patchName") - } - } - } - } - - // patches lives in the outer try scope (needed for patchesSnapshot and options - // file generation before the Patcher block). Clear it explicitly now — after - // patcher() finishes and before patcher.get() — so the JVM can GC the DEX - // classloaders before the most memory-intensive step. - patches.clear() - - patcher.context.packageMetadata.packageName to patcher.get() - } - - // region Save. - - inputApk.copyTo(patcherTemporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { - patchingResult.addStepResult(PatchingStep.REBUILDING) { - patcherResult.applyTo(this) - } - }.let { patchedApkFile -> - if (!mount && !unsigned) { - patchingResult.addStepResult(PatchingStep.SIGNING) { - signWithLegacyFallback( - primary = ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), - allowLegacyFallback = keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS && - keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD, - logger = logger, - ) { details -> - ApkUtils.signApk(patchedApkFile, outputFilePath, signer, details) - } - } - } else { - patchedApkFile.copyTo(outputFilePath, overwrite = true) - } - verifier.verifyApkFile(outputFilePath) - } + val cliKeystoreDetails = keyStoreFilePath?.let { ks -> + ApkUtils.KeyStoreDetails( + keyStore = ks, + keyStorePassword = keyStorePassword, + alias = keyStoreEntryAlias, + password = keyStoreEntryPassword, + ) + } + val resolvedKeystoreDetails = runBlocking { + KeystoreService.shared.resolveSigningDetails(cliKeystoreDetails) + } - logger.info("Saved to $outputFilePath") + val effectiveOutputFile = outputFilePath ?: savedConfig.resolvedDefaultOutputDirectory()?.let { defaultDir -> + ApkOutputNaming.outputApkPath( + inputApk = apk, + patchesFile = bundleScopes.firstOrNull()?.bundleFile, + baseOutputDir = defaultDir, + ) + } - // endregion + val effectiveArchitectures = keepArchitectures.ifEmpty { + savedConfig.keepArchitectures.mapNotNull { CpuArchitecture.valueOfOrNull(it) }.toSet() + } - // region Install. + val engineConfig = PatchEngine.Config( + inputApk = apk, + outputApk = effectiveOutputFile, + bundleScopes = bundleScopes, + exclusiveMode = exclusive, + forceCompatibility = force, + unsigned = unsigned || mount, + signerName = signer, + keystoreDetails = resolvedKeystoreDetails, + architecturesToKeep = effectiveArchitectures, + tempDir = temporaryFilesPath, + failOnError = !continueOnError, + bytecodeMode = bytecodeMode, + sdkToolsPath = sdkToolsPath, + disablePurge = disablePurge, + optionsFile = optionsFilePath, + updateOptions = updateOptions, + recordHistory = true, + ) - deviceSerial?.let { - patchingResult.addStepResult(PatchingStep.INSTALLING) { - runBlocking { - when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) { - RootInstallerResult.FAILURE -> { - logger.severe("Failed to mount the patched APK file") - throw IllegalStateException("Failed to mount the patched APK file") - } - is AdbInstallerResult.Failure -> { - logger.severe(result.exception.toString()) - throw result.exception - } - else -> logger.info("Installed the patched APK file") + try { + val engineResult = runBlocking { + PatchEngine.patch( + config = engineConfig, + onProgress = { line -> + when { + line.startsWith("ERROR:", ignoreCase = true) || line.startsWith("FAILED:", ignoreCase = true) -> logger.severe(line) + line.startsWith("WARNING:", ignoreCase = true) -> logger.warning(line) + else -> logger.info(line) } - } - } + }, + ) } - // endregion - } catch (e: PatchFailedException) { - logger.severe("Patching aborted: ${e.message}") - logger.info( - "Use --continue-on-error to skip failed patches and continue patching" - ) - return EXIT_CODE_ERROR - } catch (e: Exception) { - // Should never happen. - logger.severe("An unexpected error occurred: ${e.message}") - e.printStackTrace() - return EXIT_CODE_ERROR - } finally { patchingResultOutputFilePath?.let { outputFile -> outputFile.outputStream().use { outputStream -> - Json.encodeToStream(patchingResult, outputStream) + Json.encodeToStream(engineResult, outputStream) } logger.info("Patching result saved to $outputFile") } - // Auto-update options JSON file using the per-bundle snapshots - // (no DEX references). One JSON entry per .mpp, matched by source. - if (optionsFilePath != null && updateOptions && patchesSnapshotForFinally.isNotEmpty()) { - try { - val existingBundles = optionsFilePath!!.let { file -> - if (file.exists()) { - try { Json.decodeFromString>(file.readText()) } - catch (e: Exception) { emptyList() } - } else emptyList() - } - // Walk each bundle's snapshot, merge against its matching - // existing entry (by sha256 / source name), and splice the - // updated entry back into the list. Bundles without a prior - // entry get appended. - var updatedBundles = existingBundles - patchesSnapshotForFinally.forEach { snapshot -> - val sourceFile = snapshot.meta.source?.let { File(it) } - val existing = if (sourceFile != null) { - updatedBundles.findMatchingBundle(setOf(sourceFile)) - } else null - val updated = snapshot.mergeWith(existing) - updatedBundles = updatedBundles.withUpdatedBundle(updated) - } - val json = Json { prettyPrint = true } - optionsFilePath!!.writeText(json.encodeToString(updatedBundles)) - logger.info("Updated options file ${optionsFilePath!!.path}") - } catch (e: Exception) { - logger.warning("Failed to update options file: ${e.message}") - } - } - - if (!disablePurge) { - // Scope: only THIS session's tmp subfolder. Cached patches, - // logs, config, and other in-flight sessions (CLI or GUI) are - // never touched. - if (patcherTemporaryFilesPath.deleteRecursivelyInParallel()) { - logger.info("Purged this session's temp files: ${patcherTemporaryFilesPath.name}") - } else { - logger.warning("Failed to purge ${patcherTemporaryFilesPath.path}") - } - } - - // Clean up merged apk if we created one from apkm, xapk or apks - mergedApkToCleanup?.let { - if (!it.delete()) { - logger.warning("Could not clean up merged APK: ${it.path}") - } - } - } - - return EXIT_CODE_SUCCESS - } - - /** - * Filter the patches based on the selection. - * - * @param packageName The package name of the APK file to be patched. - * @param packageVersion The version of the APK file to be patched. - * @param jsonEnabledPatches Patch names enabled via JSON options file (lowercase). - * @param jsonDisabledPatches Patch names disabled via JSON options file (lowercase). - * @return The filtered patches. - */ - private fun Set>.filterPatchSelection( - packageName: String, - packageVersion: String, - bundleSelections: List, - jsonEnabledPatches: Set = emptySet(), - jsonDisabledPatches: Set = emptySet(), - ): Set> = buildSet { - // CLI flags (take precedence over JSON) - val cliEnabledByName = - bundleSelections.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet() - val cliEnabledByIndex = - bundleSelections.mapNotNull { it.enabled?.selector?.index }.toSet() - val cliDisabledByName = - bundleSelections.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet() - val cliDisabledByIndex = - bundleSelections.mapNotNull { it.disable?.selector?.index }.toSet() - - this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) -> - val patchName = patch.name!! - val patchNameLower = patchName.lowercase() - - // Check package compatibility first to avoid duplicate logs for multi-app patches. - val supportedVersions = patch.supportedVersionsFor(packageName) - when { - supportedVersions != null && supportedVersions.isEmpty() -> { - return@patchLoop logger.fine( - "Skipping \"$patchName\": incompatible with $packageName " + - "(only compatible with " + - patch.compatibleVersionsForDisplay(includeExperimental = true) - .mapNotNull { (name, _) -> name } - .joinToString(", ") + ")" - ) - } - supportedVersions != null -> { - val matchesVersion = force || packageVersion in supportedVersions - if (!matchesVersion) { - val hint = supportedVersions.joinToString(", ") - return@patchLoop logger.warning( - "Skipping \"$patchName\": incompatible with $packageName $packageVersion " + - "(supported: $packageName $hint)" - ) + if (engineResult.success) { + if (targetDeviceId != null) { + val finalApkFile = File(engineResult.outputPath) + val installSuccess = runBlocking { + val installResult = if (mount) { + adbManager!!.mountApk(finalApkFile, engineResult.packageName, targetDeviceId) + } else { + adbManager!!.installApk( + apkPath = finalApkFile.absolutePath, + deviceId = targetDeviceId, + ) + } + installResult.isSuccess } + if (!installSuccess) return EXIT_CODE_ERROR } - } - // CLI flags take precedence over JSON, JSON takes precedence over defaults. - // Log strings match the GUI engine's "Skipping disabled: …" format so - // surfaces stay consistent. CLI-specific override hints are preserved - // as parentheticals. - val isCliDisabled = patchNameLower in cliDisabledByName || i in cliDisabledByIndex - if (isCliDisabled) { - if (patchNameLower in jsonEnabledPatches) { - logger.info("Skipping disabled: $patchName (overrides options file: enabled)") - } else { - logger.info("Skipping disabled: $patchName") + return EXIT_CODE_SUCCESS + } else { + logger.severe("Patching aborted: ${engineResult.failureReason ?: "Unknown error"}") + if (!continueOnError && engineResult.failedPatches.isNotEmpty()) { + logger.info("Use --continue-on-error to skip failed patches and continue patching") } - return@patchLoop - } - - val isCliEnabled = patchNameLower in cliEnabledByName || i in cliEnabledByIndex - if (isCliEnabled && patchNameLower in jsonDisabledPatches) { - logger.info("Enabling: $patchName (overrides options file: disabled)") + return EXIT_CODE_ERROR } - - // JSON-sourced enable/disable (only applies if no CLI flag for this patch) - val isJsonDisabled = !isCliEnabled && patchNameLower in jsonDisabledPatches - if (isJsonDisabled) return@patchLoop logger.info( - "Skipping disabled: $patchName (from options file)" - ) - - val isJsonEnabled = patchNameLower in jsonEnabledPatches - - val isEnabled = !exclusive && patch.default - - if (!(isEnabled || isCliEnabled || isJsonEnabled)) { - // Default-disabled patches (the patch ships with use=false and - // wasn't explicitly enabled). Log at info level — most CLI - // users want to see WHY each patch was skipped, even the - // ones that opted-out by default. - return@patchLoop logger.info("Skipping disabled: $patchName (default)") + } finally { + adbManager?.let { + runBlocking { it.killServerIfOwned() } } - - add(patch) - - logger.fine("\"$patchName\" added") } } +} +class OptionKeyConverter : CommandLine.ITypeConverter { + override fun convert(value: String): String = value } -private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) +class OptionValueConverter : CommandLine.ITypeConverter { + override fun convert(value: String?): Any? = parseCliOptionValue(value) +} diff --git a/src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt deleted file mode 100644 index a33431b6..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command - -import app.morphe.engine.patches.PatchCache -import app.morphe.engine.patches.PatchProvider -import app.morphe.engine.patches.RemotePatchSourceFactory -import app.morphe.engine.patches.findPatchAsset -import io.ktor.client.HttpClient -import kotlinx.coroutines.runBlocking -import java.io.File -import java.util.logging.Logger - - -object PatchFileResolver { - private val logger = Logger.getLogger(this::class.java.name) - - /** - * Takes the user's provided Patch Files and resolves any URLs that might be present. - * Returns a new Set with URLs replaced by downloaded/cached .mpp files. - * - * Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine. - * This function owns the "which release do we pick" decision. The on-disk cache - * layout is shared with the GUI via [PatchCache] (one cache for both surfaces). - */ - fun resolve( - patchFiles: Set, - prerelease: Boolean, - httpClient: HttpClient - ): Set { - val urlEntry = patchFiles.firstOrNull { - it.invariantSeparatorsPath.startsWith("http:/") || - it.invariantSeparatorsPath.startsWith("https:/") - } ?: return patchFiles - - val url = urlEntry.invariantSeparatorsPath - - return runBlocking { - try { - // Parse the URL here, the engine handles github.com, gitlab.com, - // morphe.software/add-source links, bare owner/repo. - - val parsed = RemotePatchSourceFactory.parse(url) - ?: throw IllegalArgumentException("Unrecognized patch URL: $url") - - val source = parsed.instantiate(httpClient) - - // List releases and decide which one to use. `releases/tag/` in the URL pins to - // that exact tag. - - val pinnedTag = Regex("release/tag/([^/]+)").find(url)?.groupValues?.get(1) - - val release = source.listReleases().getOrThrow() - val targetRelease = when { - parsed.provider == PatchProvider.GITHUB_PR -> release.firstOrNull() - ?: throw IllegalArgumentException("No release artifact found for ${parsed.canonicalUrl}") - - pinnedTag != null -> release.firstOrNull { - it.tagName == pinnedTag - } - ?: throw IllegalArgumentException("Version $pinnedTag not found in ${parsed.repoPath}") - - prerelease -> release.firstOrNull { - it.isDevRelease() - } - ?: throw IllegalArgumentException("Could not get dev release from ${parsed.repoPath}") - - else -> release.firstOrNull { - !it.isDevRelease() - } - ?: throw IllegalArgumentException("Could not get stable release from ${parsed.repoPath}") - } - - // Find the .mpp asset in that release. - val asset = targetRelease.findPatchAsset() - ?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}") - - // Shared on-disk cache (via PatchCache): the same directory and tag-prefixed filename - // the GUI uses, so a .mpp downloaded by either side is reused by the other. - // Layout: morphe-data/patches/-/__.mpp - val repoCacheDir = PatchCache.sourceDir(parsed.repoPath) - val targetFile = File(repoCacheDir, PatchCache.cachedFileName(targetRelease, asset)) - - val resolvedFile = if (targetFile.exists() && targetFile.length() > 0L) { - logger.info("Using cached patch file at ${targetFile.absolutePath}") - targetFile - } else { - logger.info("Downloading patches from ${parsed.repoPath} ${targetRelease.tagName}...") - source.downloadAsset(asset, targetFile).getOrThrow() - logger.info("Patches mpp saved to ${targetFile.absolutePath}. This file will be used on your next run as long as it is not deleted!") - targetFile - } - - buildSet { - addAll(patchFiles) - remove(urlEntry) - add(resolvedFile) - } - } catch (e: Exception) { - throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt b/src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt deleted file mode 100644 index 64913d29..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command.model - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable - -@ExperimentalSerializationApi -@Serializable -data class FailedPatch( - val patch: SerializablePatch, - val reason: String -) diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt b/src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt deleted file mode 100644 index 16871b82..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command.model - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable - -@ExperimentalSerializationApi -@Serializable -data class PatchingResult( - var packageName: String? = null, - var packageVersion: String? = null, - var success: Boolean = true, - @EncodeDefault val patchingSteps: MutableList = mutableListOf(), - @EncodeDefault val appliedPatches: MutableList = mutableListOf(), - @EncodeDefault val failedPatches: MutableList = mutableListOf() -) - -@ExperimentalSerializationApi -fun PatchingResult.addStepResult( - step: PatchingStep, - block: () -> R, -): R { - try { - val result = block() - this.patchingSteps.add( - PatchingStepResult( - step = step, - success = true - ) - ) - return result - } catch (e: Exception) { - this.success = false - this.patchingSteps.add( - PatchingStepResult( - step = step, - success = false, - message = e.toString() - ) - ) - throw e - } -} diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt b/src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt deleted file mode 100644 index 42b6b2b9..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command.model - -enum class PatchingStep { - PATCHING, - REBUILDING, - SIGNING, - INSTALLING -} \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt b/src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt deleted file mode 100644 index ad970cdb..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command.model - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable - -@ExperimentalSerializationApi -@Serializable -data class PatchingStepResult( - val step: PatchingStep, - val success: Boolean, - val message: String? = null -) diff --git a/src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt b/src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt deleted file mode 100644 index 575cf71a..00000000 --- a/src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.desktop.command.model - -import app.morphe.patcher.patch.Patch -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.descriptors.element -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject - -@ExperimentalSerializationApi -@Serializable(with = PatchSerializer::class) -data class SerializablePatch( - val name: String? = null, - val index: Int? = null, - val options: Map = emptyMap() -) - -@ExperimentalSerializationApi -fun Patch<*>.toSerializablePatch(): SerializablePatch { - return SerializablePatch( - name = this.name, - options = this.options.mapValues { PatchSerializer.serializeValue(it.value.value) } - ) -} - -@ExperimentalSerializationApi -object PatchSerializer : KSerializer { - fun serializeValue(value: Any?): JsonElement { - return when (value) { - null -> JsonNull - is JsonElement -> value - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is String -> JsonPrimitive(value) - is List<*> -> { - buildJsonArray { - value.forEach { item -> - add(serializeValue(item)) - } - } - } - is Map<*, *> -> { - buildJsonObject { - value.forEach { - require(it.key is String) { - "Map keys must be of type String for serialization, but found: ${it.key?.let { k -> k::class }}" - } - put(it.key as String, serializeValue(it.value)) - } - } - } - else -> JsonPrimitive(value.toString()) - } - } - - override fun serialize(encoder: Encoder, value: SerializablePatch) { - require(encoder is JsonEncoder) - - val jsonElement = buildJsonObject { - require(value.name != null || value.index != null) { - "Either name or index must be provided for a Patch." - } - - if (value.name != null) { - put("name", JsonPrimitive(value.name)) - } else { - put("index", JsonPrimitive(value.index)) - } - - if (value.options.isNotEmpty()) { - put("options", buildJsonArray { - value.options.forEach { (key, optionValue) -> - add(buildJsonObject { - put("key", JsonPrimitive(key)) - put("value", optionValue) - }) - } - }) - } - } - encoder.encodeJsonElement(jsonElement) - } - - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Patch") { - element("name") - element("index") - element>("options") - } - - override fun deserialize(decoder: Decoder): SerializablePatch { - TODO("Not yet implemented") - } -} diff --git a/src/main/kotlin/app/morphe/desktop/command/utility/ClearCacheCommand.kt b/src/main/kotlin/app/morphe/desktop/command/utility/ClearCacheCommand.kt index 7460738f..0aa429bf 100644 --- a/src/main/kotlin/app/morphe/desktop/command/utility/ClearCacheCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/utility/ClearCacheCommand.kt @@ -6,10 +6,10 @@ package app.morphe.desktop.command.utility import app.morphe.engine.CacheManager -import picocli.CommandLine.Command -import picocli.CommandLine.Option import java.util.concurrent.Callable import java.util.logging.Logger +import picocli.CommandLine.Command +import picocli.CommandLine.Option @Command( name = "clear-cache", diff --git a/src/main/kotlin/app/morphe/desktop/command/utility/InstallCommand.kt b/src/main/kotlin/app/morphe/desktop/command/utility/InstallCommand.kt index 91df7566..b5831ddd 100644 --- a/src/main/kotlin/app/morphe/desktop/command/utility/InstallCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/utility/InstallCommand.kt @@ -5,16 +5,14 @@ package app.morphe.desktop.command.utility -import app.morphe.engine.util.ApkManifestReader -import app.morphe.engine.util.AppLinkCommands -import app.morphe.library.installation.installer.* +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.util.AdbManager +import java.io.File +import java.util.logging.Logger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import picocli.CommandLine.* -import se.vidstige.jadb.JadbConnection -import java.io.File -import java.util.logging.Logger @Command( name = "install", @@ -55,60 +53,51 @@ internal object InstallCommand : Runnable { private var stockPackage: String? = null override fun run() { + val adbManager = AdbManager() + suspend fun install(deviceSerial: String? = null) { - val result = try { - if (packageName != null) { - AdbRootInstaller(deviceSerial) - } else { - AdbInstaller(deviceSerial) - }.install(Installer.Apk(apk, packageName)) - } catch (e: Exception) { - logger.severe(e.toString()) + val targetDevice = adbManager.resolveTargetDevice(deviceSerial).getOrElse { e -> + logger.severe(e.message ?: e.toString()) return } - when (result) { - RootInstallerResult.FAILURE -> { - logger.severe("Failed to mount the APK file") + val installResult = if (packageName != null) { + adbManager.mountApk(apk, packageName!!, targetDevice.id) + } else { + adbManager.installApk( + apkPath = apk.absolutePath, + deviceId = targetDevice.id, + ) + } + + if (installResult.isFailure) return + + if (routeLinks) { + val patched = ApkInspector.inspect(apk)?.packageName ?: run { + logger.severe("Could not read package name from APK; skipping link routing") return } - is AdbInstallerResult.Failure -> { - logger.severe(result.exception.toString()) - return + adbManager.setLinkHandling( + deviceId = targetDevice.id, + patchedPackage = patched, + stockPackage = stockPackage, + enable = true, + ).onFailure { e -> + logger.severe(e.message ?: e.toString()) } - else -> logger.info("Installed the APK file") } - - if (routeLinks) routeLinks(deviceSerial) } runBlocking { - deviceSerials?.map { async { install(it) } }?.awaitAll() ?: install() - } - } - - private fun routeLinks(deviceSerial: String?) { - val patched = ApkManifestReader.read(apk)?.packageName ?: run { - logger.severe("Could not read package name from APK; skipping link routing") - return - } - val commands = AppLinkCommands.enablePatched(patched) + - (stockPackage?.let { AppLinkCommands.disableStock(it) } ?: emptyList()) - - val devices = JadbConnection().devices - val device = deviceSerial?.let { s -> devices.firstOrNull { it.serial == s } } - ?: devices.firstOrNull() - ?: run { logger.severe("No ADB device for link routing"); return } - - commands.forEach { argv -> - val cmd = argv.joinToString(" ") - val process = device.shellProcessBuilder(cmd).start() - val out = process.inputStream.bufferedReader().readText().trim() - val exit = process.waitFor() - if (exit != 0 || out.contains("Error", true) || out.contains("Failure", true)) { - logger.severe("Link command failed: $cmd -> ${out.ifBlank { "exit $exit" }}") + adbManager.startServer().onFailure { e -> + logger.severe(e.message ?: "Failed to start ADB server") + return@runBlocking + } + try { + deviceSerials?.takeIf { it.isNotEmpty() }?.map { async { install(it) } }?.awaitAll() ?: install() + } finally { + adbManager.killServerIfOwned() } } - logger.info("Routed links to $patched") } } diff --git a/src/main/kotlin/app/morphe/desktop/command/utility/UninstallCommand.kt b/src/main/kotlin/app/morphe/desktop/command/utility/UninstallCommand.kt index 3672bf11..6e7186fc 100644 --- a/src/main/kotlin/app/morphe/desktop/command/utility/UninstallCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/utility/UninstallCommand.kt @@ -5,16 +5,13 @@ package app.morphe.desktop.command.utility -import app.morphe.library.installation.installer.AdbInstaller -import app.morphe.library.installation.installer.AdbInstallerResult -import app.morphe.library.installation.installer.AdbRootInstaller -import app.morphe.library.installation.installer.RootInstallerResult +import app.morphe.engine.util.AdbManager +import java.util.logging.Logger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import picocli.CommandLine.* import picocli.CommandLine.Help.Visibility.ALWAYS -import java.util.logging.Logger @Command( name = "uninstall", @@ -44,28 +41,31 @@ internal object UninstallCommand : Runnable { private var unmount: Boolean = false override fun run() { + val adbManager = AdbManager() + suspend fun uninstall(deviceSerial: String? = null) { - val result = try { - if (unmount) { - AdbRootInstaller(deviceSerial) - } else { - AdbInstaller(deviceSerial) - }.uninstall(packageName) - } catch (e: Exception) { - logger.severe(e.toString()) + val targetDevice = adbManager.resolveTargetDevice(deviceSerial).getOrElse { e -> + logger.severe(e.message ?: e.toString()) + return } - when (result) { - RootInstallerResult.FAILURE -> - logger.severe("Failed to unmount the patched APK file") - is AdbInstallerResult.Failure -> - logger.severe(result.exception.toString()) - else -> logger.info("Uninstalled the patched APK file") + if (unmount) { + adbManager.unmountApk(packageName, targetDevice.id) + } else { + adbManager.uninstallApk(packageName, targetDevice.id) } } runBlocking { - deviceSerials?.map { async { uninstall(it) } }?.awaitAll() ?: uninstall() + adbManager.startServer().onFailure { e -> + logger.severe(e.message ?: "Failed to start ADB server") + return@runBlocking + } + try { + deviceSerials?.takeIf { it.isNotEmpty() }?.map { async { uninstall(it) } }?.awaitAll() ?: uninstall() + } finally { + adbManager.killServerIfOwned() + } } } } diff --git a/src/main/kotlin/app/morphe/engine/CacheManager.kt b/src/main/kotlin/app/morphe/engine/CacheManager.kt index 5015d607..c9084a61 100644 --- a/src/main/kotlin/app/morphe/engine/CacheManager.kt +++ b/src/main/kotlin/app/morphe/engine/CacheManager.kt @@ -5,6 +5,7 @@ package app.morphe.engine +import app.morphe.engine.workspace.WorkspaceManager import java.io.File /** @@ -31,6 +32,24 @@ object CacheManager { val success: Boolean get() = failedFiles == 0 } + /** + * Calculates the total byte size across all cached directories + * (patches cache, logs, and workspace scratch). + */ + fun getCacheSizeBytes(): Long = + getDirectorySize(MorpheData.patchesDir) + + getDirectorySize(MorpheData.logsDir) + + WorkspaceManager.getScratchSize() + + /** + * Computes the total file size inside [dir]. + */ + fun getDirectorySize(dir: File): Long = try { + dir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } catch (_: Exception) { + 0L + } + /** * Deletes the contents of the patches cache, logs, and temp scratch * directories (keeping the top-level directories themselves). @@ -41,13 +60,17 @@ object CacheManager { */ fun clearCaches(): ClearResult = ClearResult( listOf( - clearContents("Patches", MorpheData.patchesDir), - clearContents("Logs", MorpheData.logsDir), - clearContents("Temp", MorpheData.tmpDir), + clearDirectoryContents("Patches", MorpheData.patchesDir), + clearDirectoryContents("Logs", MorpheData.logsDir), + WorkspaceManager.clearScratch(), ), ) - private fun clearContents(label: String, dir: File): DirResult { + /** + * Deletes the contents of [dir] while retaining the directory itself, + * returning metrics on files deleted, bytes freed, and failures. + */ + fun clearDirectoryContents(label: String, dir: File): DirResult { var bytesFreed = 0L var filesDeleted = 0 var failedFiles = 0 diff --git a/src/main/kotlin/app/morphe/engine/MorpheConstants.kt b/src/main/kotlin/app/morphe/engine/MorpheConstants.kt new file mode 100644 index 00000000..edecaa43 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/MorpheConstants.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine + +object MorpheConstants { + const val MORPHE_API_URL = "https://api.morphe.software" + + val FALLBACK_PACKAGES = listOf( + "com.google.android.youtube", + "com.google.android.apps.youtube.music", + "com.reddit.frontpage", + ) +} diff --git a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt index c5a4f133..566095f0 100644 --- a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt +++ b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import app.morphe.engine.workspace.WorkspaceManager import java.io.File import java.util.logging.Level import java.util.logging.Logger @@ -23,8 +24,7 @@ import java.util.logging.Logger * GUI/CLI consumers that want to badge or filter by origin. * * Lives in the engine package so the CLI can consume the same multi-source - * loading path when it adopts multi-source. GUI-specific aggregation (display - * name resolution, icon color picking) lives in the GUI layer. + * loading path when it adopts multi-source. */ object MultiSourceLoader { @@ -59,8 +59,7 @@ object MultiSourceLoader { /** * Load patches from each input in parallel. Each .mpp is copied to a temp file - * before loading to work around Windows URLClassLoader file-locking (mirrors - * the same workaround in PatchService.kt). + * before loading to work around Windows URLClassLoader file-locking. */ suspend fun load(inputs: List): Result = coroutineScope { val loaded = inputs.map { input -> @@ -85,9 +84,8 @@ object MultiSourceLoader { } private suspend fun loadOne(input: SourceInput): LoadedSource = withContext(Dispatchers.IO) { - val tempCopy = File.createTempFile("morphe-mp-${input.sourceId}-", ".mpp") + val tempCopy = WorkspaceManager.createShadowCopy(input.patchFile, prefix = "morphe-mp-${input.sourceId}") try { - input.patchFile.copyTo(tempCopy, overwrite = true) val patches = loadPatchesFromJar(setOf(tempCopy)) logger.info("MultiSourceLoader: loaded ${patches.size} patches from '${input.sourceName}'") LoadedSource( @@ -126,8 +124,6 @@ object MultiSourceLoader { patches = emptySet(), error = error, ) - } finally { - tempCopy.deleteOnExit() } } } diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 4b9016eb..5bf1bbf4 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -8,108 +8,231 @@ package app.morphe.engine -import app.morphe.engine.util.BundleFormats -import app.morphe.engine.util.deleteRecursivelyInParallel +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.apk.ApkOutputNaming +import app.morphe.engine.apk.BundleFormats +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.options.PatchBundle +import app.morphe.engine.options.computeOptionsDrift +import app.morphe.engine.options.deserializeOptionsFor +import app.morphe.engine.options.findMatchingBundle +import app.morphe.engine.options.readPatchBundles +import app.morphe.engine.options.resolveFlatPatchOptions +import app.morphe.engine.options.toPatchBundle +import app.morphe.engine.options.updateOptionsFileFromSnapshots +import app.morphe.engine.options.writePatchBundles +import app.morphe.engine.patches.LoadedBundle +import app.morphe.engine.patches.PatchBundleLoader +import app.morphe.engine.patches.supportedVersionsFor +import app.morphe.engine.util.FileChecksum +import app.morphe.engine.util.KeystoreImporter import app.morphe.engine.util.signWithLegacyFallback +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig import app.morphe.patcher.apk.ApkMerger import app.morphe.patcher.apk.ApkUtils import app.morphe.patcher.apk.ApkUtils.applyTo import app.morphe.patcher.dex.BytecodeMode +import app.morphe.patcher.dex.NoOpDexVerifier +import app.morphe.patcher.dex.SdkDexVerifier import app.morphe.patcher.logging.toMorpheLogger import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.setOptions import app.morphe.patcher.resource.CpuArchitecture +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import java.io.File import java.io.PrintWriter import java.io.StringWriter -import java.nio.file.Files +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord import java.util.logging.Logger -/* - * Single patching pipeline shared by CLI and GUI. (Eventually. Right now we are still having 2 pipelines) +/** + * Single patching pipeline shared directly by CLI and GUI. */ - - object PatchEngine { enum class PatchStep { PATCHING, REBUILDING, SIGNING } + @Serializable data class StepResult(val step: PatchStep, val success: Boolean, val error: String? = null) + data class BundleScope( + val bundleFile: File, + val enabledPatchNames: Set = emptySet(), + val enabledPatchIndices: Set = emptySet(), + val disabledPatchNames: Set = emptySet(), + val disabledPatchIndices: Set = emptySet(), + val patchOptions: Map> = emptyMap(), + val patchOptionIndices: Map> = emptyMap(), + ) + + data class HistoryMetadata( + val originalPackageName: String? = null, + val displayName: String? = null, + val patchSelectionByBundle: Map> = emptyMap(), + val patchOptionValues: Map = emptyMap(), + val sourcesSnapshot: List = emptyList(), + ) + data class Config( val inputApk: File, - val patches: Set>, - val outputApk: File, + val outputApk: File? = null, + val patchFiles: List = emptyList(), + val bundleScopes: List = emptyList(), + val patches: Set> = emptySet(), val enabledPatches: Set = emptySet(), val disabledPatches: Set = emptySet(), val exclusiveMode: Boolean = false, val forceCompatibility: Boolean = false, val patchOptions: Map> = emptyMap(), + val flatPatchOptions: Map = emptyMap(), val unsigned: Boolean = false, val signerName: String = DEFAULT_SIGNER_NAME, val keystoreDetails: ApkUtils.KeyStoreDetails? = null, val architecturesToKeep: Set = emptySet(), val tempDir: File? = null, val failOnError: Boolean = true, - val bytecodeMode: BytecodeMode = BytecodeMode.STRIP_FAST + val bytecodeMode: BytecodeMode = BytecodeMode.STRIP_FAST, + val sdkToolsPath: File? = null, + val disablePurge: Boolean = false, + val optionsFile: File? = null, + val updateOptions: Boolean = false, + val recordHistory: Boolean = true, + val appDisplayName: String? = null, + val historyMetadata: HistoryMetadata? = null, ) { companion object { - internal const val DEFAULT_KEYSTORE_ALIAS = "Morphe" - internal const val DEFAULT_KEYSTORE_PASSWORD = "Morphe" - internal const val DEFAULT_SIGNER_NAME = "Morphe" - internal const val LEGACY_KEYSTORE_ALIAS = "Morphe Key" - internal const val LEGACY_KEYSTORE_PASSWORD = "" + const val DEFAULT_KEYSTORE_ALIAS = "Morphe" + const val DEFAULT_KEYSTORE_PASSWORD = "Morphe" + const val DEFAULT_SIGNER_NAME = "Morphe" + const val LEGACY_KEYSTORE_ALIAS = "Morphe Key" + const val LEGACY_KEYSTORE_PASSWORD = "" } } + @Serializable data class Result( val success: Boolean, val outputPath: String, val packageName: String, val packageVersion: String, - val appliedPatches: List, - val failedPatches: List, - val stepResults: List, + @EncodeDefault val appliedPatches: List = emptyList(), + @EncodeDefault val failedPatches: List = emptyList(), + @EncodeDefault val stepResults: List = emptyList(), + val failureReason: String? = null, + val failureDetail: String? = null, + @Transient val historyRecord: PatchedAppRecord? = null, ) + @Serializable data class FailedPatch(val name: String, val error: String) private val logger = Logger.getLogger(this::class.java.name) /** - * The single patching pipeline. + * The single unified patching pipeline. * CLI wraps with runBlocking, GUI calls from coroutine scope. * * Always returns a [Result] — does not throw for pipeline step failures. - * Only throws for init errors (e.g. Patcher can't open the APK). + * Only throws for initialization errors (e.g. invalid input file). */ - suspend fun patch(config: Config, onProgress: (String) -> Unit = {}): Result { - val tempDir = config.tempDir ?: withContext(Dispatchers.IO) { - Files.createTempDirectory("morphe-patching") - }.toFile() - var mergedApkToCleanup: File? = null + suspend fun patch(config: Config, onProgress: (String) -> Unit = {}): Result = withContext(Dispatchers.IO) { + require(config.inputApk.exists()) { "Input APK file does not exist: ${config.inputApk.absolutePath}" } + + val finalOutputApk = config.outputApk ?: run { + val displayName = config.appDisplayName + ?: config.historyMetadata?.displayName + ?: ApkOutputNaming.resolveAppDisplayName(config.inputApk) + val primaryPatchesFile = config.bundleScopes.firstOrNull()?.bundleFile + ?: config.patchFiles.firstOrNull() + ApkOutputNaming.outputApkPath( + inputApk = config.inputApk, + patchesFile = primaryPatchesFile, + appDisplayName = displayName, + ) + } + + // Early validation: If custom keystore is configured for signing, it must exist and be readable. + if (!config.unsigned && config.keystoreDetails != null) { + val customKeystore = config.keystoreDetails.keyStore + if (!customKeystore.exists()) { + val msg = "Keystore file not found at ${customKeystore.absolutePath}" + return@withContext Result( + success = false, + outputPath = finalOutputApk.absolutePath, + packageName = "", + packageVersion = "", + stepResults = listOf(StepResult(PatchStep.SIGNING, false, msg)), + failureReason = msg, + failureDetail = msg, + ) + } + if (!customKeystore.canRead()) { + val msg = "Keystore file is not readable: ${customKeystore.absolutePath}" + return@withContext Result( + success = false, + outputPath = finalOutputApk.absolutePath, + packageName = "", + packageVersion = "", + stepResults = listOf(StepResult(PatchStep.SIGNING, false, msg)), + failureReason = msg, + failureDetail = msg, + ) + } + } + + return@withContext WorkspaceManager.useSession( + customDir = config.tempDir, + disablePurge = config.disablePurge, + ) { session -> + val tempDir = session.root + var mergedApkToCleanup: File? = null val stepResults = mutableListOf() val appliedPatches = mutableListOf() val failedPatches = mutableListOf() + var patchesSnapshotForFinally: List = emptyList() + + // Capture internal logs from morphe-patcher and pipe to onProgress + val patcherLogger = Logger.getLogger("app.morphe.patcher") + val prevUseParentHandlers = patcherLogger.useParentHandlers + patcherLogger.useParentHandlers = false + val patcherLogHandler = object : Handler() { + override fun publish(record: LogRecord) { + val rawMessage = record.message ?: return + if (rawMessage.isBlank()) return + val prefix = when (record.level) { + Level.SEVERE -> "ERROR: " + Level.WARNING -> "WARNING: " + else -> "" + } + onProgress("$prefix$rawMessage") + } + override fun flush() {} + override fun close() {} + } + patcherLogger.addHandler(patcherLogHandler) try { - // 1. Handle split-APK bundles (.apkm/.xapk/.apks) — merge splits into - // a single APK. ApkMerger is format-agnostic (it just extracts the - // .apk entries from the zip), so all three formats go through here. + // 1. Handle split-APK bundles (.apkm/.xapk/.apks) val actualInputApk = if (BundleFormats.isBundle(config.inputApk)) { + onProgress("Merging split APK bundle...") val mergedApk = File(tempDir, "${config.inputApk.nameWithoutExtension}-merged.apk") ApkMerger(Logger.getLogger("app.morphe.patcher.ApkMerger").toMorpheLogger()).merge( inputFile = config.inputApk, outputFile = mergedApk, - cleanMetaInf = false + cleanMetaInf = false, ) mergedApkToCleanup = mergedApk mergedApk @@ -119,11 +242,14 @@ object PatchEngine { currentCoroutineContext().ensureActive() - // 2. Initialize patcher - val patcherTempDir = File(tempDir, "patcher") - patcherTempDir.mkdirs() + // 2. Initialize verifier and patcher + val verifier = if (config.sdkToolsPath != null) { + SdkDexVerifier(config.sdkToolsPath) + } else { + NoOpDexVerifier + } - onProgress("Initializing patcher...") + val patcherTempDir = File(tempDir, "patcher").also { it.mkdirs() } val patcherConfig = PatcherConfig( actualInputApk, patcherTempDir, @@ -131,54 +257,232 @@ object PatchEngine { useArsclib = true, keepArchitectures = config.architecturesToKeep, useBytecodeMode = config.bytecodeMode, - fileWorkspacePath = File(tempDir, "workspace").also { it.mkdirs() } + fileWorkspacePath = File(tempDir, "workspace").also { it.mkdirs() }, + verifier = verifier, ) + onProgress("Initializing patcher...") Patcher(patcherConfig).use { patcher -> val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.versionCode + val packageVersion = patcher.context.packageMetadata.versionName currentCoroutineContext().ensureActive() - // 3. Filter patches - onProgress("Filtering patches for $packageName v$packageVersion...") - val filteredPatches = filterPatches( - patches = config.patches, + fun earlyResult(reason: String? = null, detail: String? = null) = Result( + success = false, + outputPath = finalOutputApk.absolutePath, packageName = packageName, packageVersion = packageVersion, - enabledPatches = config.enabledPatches, - disabledPatches = config.disabledPatches, - exclusiveMode = config.exclusiveMode, - forceCompatibility = config.forceCompatibility, - onProgress = onProgress, + appliedPatches = appliedPatches, + failedPatches = failedPatches, + stepResults = stepResults, + failureReason = reason, + failureDetail = detail, ) - currentCoroutineContext().ensureActive() + // 3. Filter patches and apply options + val finalPatches = mutableSetOf>() - // 4. Set options - if (config.patchOptions.isNotEmpty()) { - val relevantOptions = config.patchOptions.filter { it.value.isNotEmpty() } - if (relevantOptions.isNotEmpty()) { - filteredPatches.setOptions(relevantOptions) - } + val effectiveBundleScopes = if (config.bundleScopes.isNotEmpty()) { + config.bundleScopes + } else if (config.patchFiles.isNotEmpty()) { + config.patchFiles.map { BundleScope(it) } + } else { + emptyList() } - patcher += filteredPatches + if (effectiveBundleScopes.isNotEmpty()) { + onProgress("Loading patches from ${effectiveBundleScopes.size} bundle(s)...") + val loadedBundles = PatchBundleLoader.loadEach(effectiveBundleScopes.map { it.bundleFile }) + val patchSnapshots = loadedBundles.map { lb -> + lb.patches.toPatchBundle(sourceFiles = setOf(lb.sourceFile)) + } + patchesSnapshotForFinally = patchSnapshots + + // Parse options file if present + val patchOptionsByFile: Map = config.optionsFile?.let { file -> + if (file.exists()) { + onProgress("Reading options from ${file.path}") + val jsonBundles = readPatchBundles(file) + loadedBundles.associate { lb -> + lb.sourceFile to jsonBundles.findMatchingBundle(setOf(lb.sourceFile)) + } + } else { + onProgress("Options file ${file.path} does not exist, generating with defaults") + writePatchBundles(file, patchSnapshots) + loadedBundles.zip(patchSnapshots).associate { (lb, b) -> + lb.sourceFile to b + } + } + } ?: emptyMap() + + // Check options drift + if (config.optionsFile?.exists() == true && !config.updateOptions) { + loadedBundles.forEachIndexed { i, lb -> + val bundleOpts = patchOptionsByFile[lb.sourceFile] ?: return@forEachIndexed + val drift = computeOptionsDrift( + bundlePatches = lb.patches, + bundleSnapshot = patchSnapshots[i], + bundleOpts = bundleOpts, + packageName = packageName, + ) + if (drift.hasDrift) { + logger.warning("Options file is out of date for ${lb.sourceFile.name}") + if (drift.oldPatches.isNotEmpty()) { + logger.warning(" ${drift.oldPatches.size} patches in your options file are not compatible with the app:") + drift.oldPatches.forEach { logger.warning(" - $it") } + } + if (drift.patchesWithNewOptions.isNotEmpty()) { + drift.patchesWithNewOptions.forEach { (patch, key) -> + logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}") + } + } + if (drift.patchesWithOldOptions.isNotEmpty()) { + drift.patchesWithOldOptions.forEach { (patch, key) -> + logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") + } + } + logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.") + } + } + } - currentCoroutineContext().ensureActive() + val jsonEnabledByFile = patchOptionsByFile.mapValues { (_, bundle) -> + bundle?.patches?.filter { it.value.enabled }?.keys?.map { it.lowercase() }?.toSet() ?: emptySet() + } + val jsonDisabledByFile = patchOptionsByFile.mapValues { (_, bundle) -> + bundle?.patches?.filter { !it.value.enabled }?.keys?.map { it.lowercase() }?.toSet() ?: emptySet() + } + val jsonOptionsByFile = loadedBundles.associate { lb -> + val bundle = patchOptionsByFile[lb.sourceFile] + val opts = bundle?.deserializeOptionsFor(lb.patches) { patchName, key, e -> + logger.warning("Failed to deserialize option $key for $patchName in ${lb.sourceFile.name}: ${e.message ?: e::class.simpleName}") + } ?: emptyMap() + lb.sourceFile to opts + } - fun earlyResult() = Result( - success = false, - outputPath = config.outputApk.absolutePath, - packageName = packageName, - packageVersion = packageVersion, - appliedPatches = appliedPatches, - failedPatches = failedPatches, - stepResults = stepResults, - ) + val allLoadedPatches = loadedBundles.flatMap { it.patches }.toSet() + val flatResolvedOpts = if (config.flatPatchOptions.isNotEmpty()) { + resolveFlatPatchOptions( + patches = allLoadedPatches, + enabledPatches = config.enabledPatches.ifEmpty { allLoadedPatches.mapNotNull { it.name } }, + flatOptions = config.flatPatchOptions, + ) + } else emptyMap() + + onProgress("Filtering patches for $packageName v$packageVersion...") + loadedBundles.forEachIndexed { i, lb -> + val scope = effectiveBundleScopes[i] + val jsonEnabled = jsonEnabledByFile[lb.sourceFile] ?: emptySet() + val jsonDisabled = jsonDisabledByFile[lb.sourceFile] ?: emptySet() + val jsonOpts = jsonOptionsByFile[lb.sourceFile] ?: emptyMap() + + val patchesList = lb.patches.toList() + val resolvedScopeOptions = scope.patchOptions.toMutableMap() + scope.patchOptionIndices.forEach { (idx, opts) -> + if (idx in patchesList.indices) { + val name = patchesList[idx].name + if (name != null) { + val existing = resolvedScopeOptions[name] ?: emptyMap() + resolvedScopeOptions[name] = existing + opts + } + } + } + + val effectiveEnabledNames = scope.enabledPatchNames.ifEmpty { config.enabledPatches } + val effectiveDisabledNames = scope.disabledPatchNames.ifEmpty { config.disabledPatches } + + val filtered = filterBundlePatches( + patches = lb.patches, + packageName = packageName, + packageVersion = packageVersion, + enabledNames = effectiveEnabledNames, + enabledIndices = scope.enabledPatchIndices, + disabledNames = effectiveDisabledNames, + disabledIndices = scope.disabledPatchIndices, + jsonEnabledPatches = jsonEnabled, + jsonDisabledPatches = jsonDisabled, + exclusive = config.exclusiveMode, + force = config.forceCompatibility, + onProgress = onProgress, + ) + + // Merge options (JSON + flat + CLI/scope, scope overrides flat overrides JSON) + val mergedOpts = buildSet { + addAll(jsonOpts.keys) + addAll(flatResolvedOpts.keys) + addAll(resolvedScopeOptions.keys) + addAll(config.patchOptions.keys) + }.associateWith { pName -> + val js = jsonOpts[pName] ?: emptyMap() + val fl = flatResolvedOpts[pName] ?: emptyMap() + val sc = resolvedScopeOptions[pName] ?: emptyMap() + val co = config.patchOptions[pName] ?: emptyMap() + js + fl + co + sc + } + if (mergedOpts.isNotEmpty()) { + filtered.setOptions(mergedOpts) + } - // 5. Execute patches - onProgress("Applying ${filteredPatches.size} patches...") + finalPatches += filtered + } + } else { + onProgress("Filtering patches for $packageName v$packageVersion...") + if (config.patches.isNotEmpty()) { + val flatSnapshot = config.patches.toPatchBundle(sourceFiles = emptySet()) + patchesSnapshotForFinally = listOf(flatSnapshot) + if (config.optionsFile != null && !config.optionsFile.exists()) { + onProgress("Options file ${config.optionsFile.path} does not exist, generating with defaults") + writePatchBundles(config.optionsFile, patchesSnapshotForFinally) + } + } + val filtered = filterPatches( + patches = config.patches, + packageName = packageName, + packageVersion = packageVersion, + enabledPatches = config.enabledPatches, + disabledPatches = config.disabledPatches, + exclusiveMode = config.exclusiveMode, + forceCompatibility = config.forceCompatibility, + onProgress = onProgress, + ) + val jsonOptions = config.optionsFile?.takeIf { it.exists() }?.let { file -> + onProgress("Reading options from ${file.path}") + val jsonBundles = readPatchBundles(file) + jsonBundles.firstOrNull()?.deserializeOptionsFor(config.patches) { patchName, key, e -> + logger.warning("Failed to deserialize option $key for $patchName: ${e.message ?: e::class.simpleName}") + } ?: emptyMap() + } ?: emptyMap() + + val flatResolved = if (config.flatPatchOptions.isNotEmpty()) { + resolveFlatPatchOptions( + patches = config.patches, + enabledPatches = config.enabledPatches.ifEmpty { config.patches.mapNotNull { it.name } }, + flatOptions = config.flatPatchOptions, + ) + } else emptyMap() + + val mergedOpts = buildSet { + addAll(jsonOptions.keys) + addAll(config.patchOptions.keys) + addAll(flatResolved.keys) + }.associateWith { pName -> + val js = jsonOptions[pName] ?: emptyMap() + val po = config.patchOptions[pName] ?: emptyMap() + val fl = flatResolved[pName] ?: emptyMap() + js + fl + po + } + if (mergedOpts.isNotEmpty()) { + filtered.setOptions(mergedOpts) + } + finalPatches += filtered + } + + patcher += finalPatches + currentCoroutineContext().ensureActive() + + // 4. Execute patches + onProgress("Applying ${finalPatches.size} patches...") try { patcher().collect { patchResult -> val patchName = patchResult.patch.name ?: "Unknown" @@ -191,10 +495,7 @@ object PatchEngine { failedPatches.add(FailedPatch(patchName, error)) if (config.failOnError) { - throw PatchFailedException( - "Patch \"$patchName\" failed: ${exception.message}", - exception, - ) + throw PatchFailedException("Patch \"$patchName\" failed: ${exception.message}", exception) } } ?: run { onProgress("Applied: $patchName") @@ -204,43 +505,83 @@ object PatchEngine { stepResults.add(StepResult(PatchStep.PATCHING, failedPatches.isEmpty())) } catch (e: PatchFailedException) { stepResults.add(StepResult(PatchStep.PATCHING, false, e.message)) - return earlyResult() + val firstError = failedPatches.firstOrNull()?.error ?: e.message + return@withContext earlyResult("Patching failed: ${e.message}", firstError) } currentCoroutineContext().ensureActive() + // 5. Memory cleanup: Release DEX classloaders before heavy rebuild + finalPatches.clear() + // 6. Rebuild APK + val rebuiltApk = File(tempDir, "rebuilt.apk") try { + onProgress("Rebuilding APK...") val patcherResult = patcher.get() - val rebuiltApk = File(tempDir, "rebuilt.apk") actualInputApk.copyTo(rebuiltApk, overwrite = true) patcherResult.applyTo(rebuiltApk) stepResults.add(StepResult(PatchStep.REBUILDING, true)) } catch (e: Exception) { stepResults.add(StepResult(PatchStep.REBUILDING, false, e.toString())) - return earlyResult() + val sw = StringWriter().also { e.printStackTrace(PrintWriter(it)) }.toString() + return@withContext earlyResult("Rebuilding APK failed: ${e.message}", sw) } - val rebuiltApk = File(tempDir, "rebuilt.apk") - currentCoroutineContext().ensureActive() // 7. Sign APK (unless unsigned) - val tempOutput = File(tempDir, config.outputApk.name) + val tempOutput = File(tempDir, finalOutputApk.name) if (!config.unsigned) { - val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails( - File(tempDir, "morphe.keystore"), + val rawKeystore = config.keystoreDetails ?: ApkUtils.KeyStoreDetails( + MorpheData.defaultKeystoreFile, null, Config.DEFAULT_KEYSTORE_ALIAS, Config.DEFAULT_KEYSTORE_PASSWORD, ) + if (!rawKeystore.keyStore.exists() && rawKeystore.keyStore != MorpheData.defaultKeystoreFile) { + val msg = "Keystore file not found at ${rawKeystore.keyStore.absolutePath}" + stepResults.add(StepResult(PatchStep.SIGNING, false, msg)) + return@withContext earlyResult("Signing failed: $msg", msg) + } + val resolvedKeystoreFile = if (rawKeystore.keyStore.exists()) { + val importResult = KeystoreImporter.ensureBks( + source = rawKeystore.keyStore, + convertedOutput = MorpheData.importedKeystoreFile, + alias = rawKeystore.alias, + password = rawKeystore.password, + ) + when (importResult) { + is KeystoreImporter.Result.AlreadyBks -> importResult.file + is KeystoreImporter.Result.Converted -> { + logger.info("Converted ${importResult.sourceFormat.displayName} keystore → BKS: ${importResult.file.absolutePath}") + importResult.file + } + is KeystoreImporter.Result.Failed -> { + val msg = "Keystore conversion failed: ${importResult.reason}" + stepResults.add(StepResult(PatchStep.SIGNING, false, msg)) + return@withContext earlyResult("Signing failed: $msg", msg) + } + } + } else { + rawKeystore.keyStore + } + + val finalKeystoreDetails = ApkUtils.KeyStoreDetails( + resolvedKeystoreFile, + rawKeystore.keyStorePassword, + rawKeystore.alias, + rawKeystore.password, + ) try { signWithLegacyFallback( - primary = keystoreDetails, - allowLegacyFallback = config.keystoreDetails == null, + primary = finalKeystoreDetails, + allowLegacyFallback = config.keystoreDetails == null || + (finalKeystoreDetails.alias == Config.DEFAULT_KEYSTORE_ALIAS && + finalKeystoreDetails.password == Config.DEFAULT_KEYSTORE_PASSWORD), logger = logger, ) { details -> ApkUtils.signApk( @@ -253,49 +594,172 @@ object PatchEngine { stepResults.add(StepResult(PatchStep.SIGNING, true)) } catch (e: Exception) { stepResults.add(StepResult(PatchStep.SIGNING, false, e.toString())) - return earlyResult() + val sw = StringWriter().also { e.printStackTrace(PrintWriter(it)) }.toString() + return@withContext earlyResult("Signing failed: ${e.message}", sw) } } else { rebuiltApk.copyTo(tempOutput, overwrite = true) } - // 8. Copy to final output - config.outputApk.parentFile?.mkdirs() - tempOutput.copyTo(config.outputApk, overwrite = true) + currentCoroutineContext().ensureActive() + + // 8. SDK DEX Verification (if configured) + if (config.sdkToolsPath != null) { + try { + onProgress("Verifying APK with Android SDK...") + verifier.verifyApkFile(tempOutput) + } catch (e: Exception) { + logger.warning("SDK DEX Verification warning: ${e.message}") + } + } + + // 9. Copy to final output + finalOutputApk.parentFile?.mkdirs() + tempOutput.copyTo(finalOutputApk, overwrite = true) + onProgress("Saved to ${finalOutputApk.absolutePath}") + val isSuccess = !config.failOnError || failedPatches.isEmpty() + // 10. Record to PatchedAppStore + var historyRecord: PatchedAppRecord? = null + if (config.recordHistory && isSuccess) { + try { + val (sha, size) = FileChecksum.fingerprintOrNull(finalOutputApk.absolutePath) + val manifest = runCatching { ApkInspector.inspect(finalOutputApk) }.getOrNull() + val record = PatchedAppRecord( + packageName = config.historyMetadata?.originalPackageName?.takeIf { it.isNotBlank() } ?: packageName, + currentPackageName = manifest?.packageName, + displayName = config.appDisplayName?.takeIf { it.isNotBlank() } + ?: config.historyMetadata?.displayName?.takeIf { it.isNotBlank() } + ?: packageName, + apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: packageVersion, + apkVersionCode = manifest?.versionCode, + inputApkPath = config.inputApk.absolutePath, + outputApkPath = finalOutputApk.absolutePath, + outputApkSha256 = sha, + outputApkSize = if (size > 0L) size else finalOutputApk.length(), + patchSelectionByBundle = config.historyMetadata?.patchSelectionByBundle ?: emptyMap(), + patchOptionValues = config.historyMetadata?.patchOptionValues ?: emptyMap(), + sourcesSnapshot = config.historyMetadata?.sourcesSnapshot ?: emptyList(), + patchedAt = System.currentTimeMillis(), + patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", + ) + PatchedAppStore.shared.upsert(record) + historyRecord = record + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warning("Failed to record patched app to store: ${e.message ?: e::class.simpleName}") + } + } - // When failOnError=false (user asked to continue on error), reaching this - // line means the APK was successfully rebuilt from the patches that worked, - // treat the run as a success. Individual failures are still reported via - // `failedPatches` for the UI to display. Only strict mode (failOnError=true) - // treats any failure as an overall failure. - return Result( - success = !config.failOnError || failedPatches.isEmpty(), - outputPath = config.outputApk.absolutePath, + val failureReason = if (isSuccess) null else { + failedPatches.firstOrNull()?.let { "${it.name}: ${it.error.lineSequence().first()}" } + ?: stepResults.lastOrNull { !it.success }?.let { "Step ${it.step} failed: ${it.error}" } + ?: "Unknown patching failure" + } + + val failureDetail = if (isSuccess) null else { + buildString { + failedPatches.forEach { fp -> + appendLine("Patch '${fp.name}' failed:") + appendLine(fp.error) + } + stepResults.filter { !it.success && it.error != null }.forEach { sr -> + appendLine("Step ${sr.step} failed: ${sr.error}") + } + }.takeIf { it.isNotBlank() } + } + + return@withContext Result( + success = isSuccess, + outputPath = finalOutputApk.absolutePath, packageName = packageName, packageVersion = packageVersion, appliedPatches = appliedPatches, failedPatches = failedPatches, stepResults = stepResults, + failureReason = failureReason, + failureDetail = failureDetail, + historyRecord = historyRecord, ) } } finally { - mergedApkToCleanup?.delete() - if (config.tempDir == null) { + patcherLogger.removeHandler(patcherLogHandler) + patcherLogger.useParentHandlers = prevUseParentHandlers + if (!session.retain) { + mergedApkToCleanup?.delete() + } + + if (config.optionsFile != null && config.updateOptions && patchesSnapshotForFinally.isNotEmpty()) { try { - tempDir.deleteRecursivelyInParallel() - } catch (_: Exception) { - // Best effort cleanup + updateOptionsFileFromSnapshots(config.optionsFile, patchesSnapshotForFinally) + } catch (e: Exception) { + logger.warning("Failed to update options file: ${e.message ?: e::class.simpleName}") } } } + } + } + + private fun filterBundlePatches( + patches: Set>, + packageName: String, + packageVersion: String, + enabledNames: Set, + enabledIndices: Set, + disabledNames: Set, + disabledIndices: Set, + jsonEnabledPatches: Set, + jsonDisabledPatches: Set, + exclusive: Boolean, + force: Boolean, + onProgress: (String) -> Unit, + ): Set> = buildSet { + val enabledLower = enabledNames.map { it.lowercase() }.toSet() + val disabledLower = disabledNames.map { it.lowercase() }.toSet() + + patches.withIndex().forEach patchLoop@{ (i, patch) -> + val patchName = patch.name ?: return@patchLoop + val patchNameLower = patchName.lowercase() + + val supportedVersions = patch.supportedVersionsFor(packageName) + when { + supportedVersions != null && supportedVersions.isEmpty() -> return@patchLoop + supportedVersions != null -> { + val matchesVersion = force || packageVersion in supportedVersions + if (!matchesVersion) { + onProgress("Skipping \"$patchName\": incompatible with $packageName $packageVersion") + return@patchLoop + } + } + } + + val isCliDisabled = patchNameLower in disabledLower || i in disabledIndices + if (isCliDisabled) { + onProgress("Skipping disabled: $patchName") + return@patchLoop + } + + val isCliEnabled = patchNameLower in enabledLower || i in enabledIndices + val isJsonDisabled = !isCliEnabled && patchNameLower in jsonDisabledPatches + if (isJsonDisabled) { + onProgress("Skipping disabled: $patchName (from options file)") + return@patchLoop + } + + val isJsonEnabled = patchNameLower in jsonEnabledPatches + val isEnabled = !exclusive && patch.default + + if (!(isEnabled || isCliEnabled || isJsonEnabled)) { + onProgress("Skipping disabled: $patchName (default)") + return@patchLoop + } + + add(patch) + } } - /** - * Unified patch filtering logic. - * Filters patches based on compatibility, enabled/disabled lists, and exclusive mode. - */ private fun filterPatches( patches: Set>, packageName: String, @@ -306,10 +770,13 @@ object PatchEngine { forceCompatibility: Boolean, onProgress: (String) -> Unit, ): Set> = buildSet { + val disabledLower = disabledPatches.map { it.lowercase() }.toSet() + val enabledLower = enabledPatches.map { it.lowercase() }.toSet() + patches.forEach patchLoop@{ patch -> val patchName = patch.name ?: return@patchLoop + val patchNameLower = patchName.lowercase() - // Check package compatibility first to avoid duplicate logs for multi-app patches. val supportedVersions = patch.supportedVersionsFor(packageName) when { supportedVersions != null && supportedVersions.isEmpty() -> return@patchLoop @@ -322,13 +789,12 @@ object PatchEngine { } } - // Check if explicitly disabled - if (patchName in disabledPatches) { + if (patchNameLower in disabledLower) { onProgress("Skipping disabled: $patchName") return@patchLoop } - val isManuallyEnabled = patchName in enabledPatches + val isManuallyEnabled = patchNameLower in enabledLower val isEnabledByDefault = !exclusiveMode && patch.default if (!(isEnabledByDefault || isManuallyEnabled)) { diff --git a/src/main/kotlin/app/morphe/engine/PatchExtensions.kt b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt deleted file mode 100644 index 00f83f30..00000000 --- a/src/main/kotlin/app/morphe/engine/PatchExtensions.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.engine - -import app.morphe.patcher.patch.Patch -import app.morphe.patcher.patch.SupportedAbi - -typealias VersionMap = LinkedHashMap -typealias CompatibleVersionsMap = Map - - -fun Patch<*>.versionCodesFor( - packageName: String?, - versionName: String, -): Map? { - val compat = compatibility ?: return null - return compat - .filter { packageName == null || it.packageName == null || it.packageName == packageName } - .flatMap { it.targets } - .find { it.version == versionName && !it.versionCodes.isNullOrEmpty() } - ?.versionCodes -} - -fun Iterable>.versionCodesFor( - packageName: String?, - versionName: String, -): Map? = - firstNotNullOfOrNull { it.versionCodesFor(packageName, versionName) } - -@Suppress("DEPRECATION") -fun Patch<*>.versionsFor( - packageName: String?, - includeExperimental: Boolean, -): List { - val compat = compatibility - - if (!compat.isNullOrEmpty()) { - val matchingEntries = compat.filter { entry -> - packageName == null || entry.packageName == null || entry.packageName == packageName - } - return matchingEntries.flatMap { entry -> - entry.targets - .filter { target -> includeExperimental || !target.isExperimental } - .mapNotNull { it.version } - } - } - - val legacyPackages = compatiblePackages ?: return emptyList() - val matchingPkgs = if (packageName == null) { - legacyPackages - } else { - legacyPackages.filter { (name, _) -> name == packageName } - } - return matchingPkgs.flatMap { (_, versions) -> versions?.toList() ?: emptyList() } -} - -@Suppress("DEPRECATION") -fun Patch<*>.isCompatibleWith( - packageName: String, - includeExperimental: Boolean, - includeUniversalPatches: Boolean, -): Boolean { - val compat = compatibility - - if (!compat.isNullOrEmpty()) { - return compat.any { entry -> - when { - entry.packageName == null -> includeUniversalPatches - entry.packageName != packageName -> false - else -> { - entry.targets.any { target -> - includeExperimental || !target.isExperimental - } - } - } - } - } - - val legacyPackages = compatiblePackages - ?: return includeUniversalPatches - - return legacyPackages.any { (name, _) -> name == packageName } -} - -@Suppress("DEPRECATION") -fun Patch<*>.supportedVersionsFor(packageName: String): List? { - val compat = compatibility - - if (!compat.isNullOrEmpty()) { - val matching = compat.filter { it.packageName == packageName } - if (matching.isEmpty()) { - val hasUniversalEntry = compat.any { it.packageName == null } - return if (hasUniversalEntry) null else emptyList() - } - val versions = matching.flatMap { entry -> entry.targets.mapNotNull { it.version } } - return versions.ifEmpty { null } - } - - val legacyPackages = compatiblePackages ?: return null - val match = legacyPackages.singleOrNull { (name, _) -> name == packageName } - ?: return emptyList() - val legacyVersions = match.second - return if (legacyVersions.isNullOrEmpty()) null else legacyVersions.toList() -} - -@Suppress("DEPRECATION") -fun Patch<*>.compatibleVersionsForDisplay( - includeExperimental: Boolean, -): List>> { - val compat = compatibility - - if (!compat.isNullOrEmpty()) { - return compat.map { entry -> - val versions = entry.targets - .filter { includeExperimental || !it.isExperimental } - .mapNotNull { it.version } - entry.packageName to versions - } - } - - val legacyPackages = compatiblePackages ?: return emptyList() - return legacyPackages.map { (name, versions) -> - name to (versions?.toList() ?: emptyList()) - } -} - -@Suppress("DEPRECATION") -fun Iterable>.mostCommonCompatibleVersions( - packageNames: Set?, - countUnusedPatches: Boolean, - includeExperimental: Boolean = false, -): CompatibleVersionsMap { - val allPackageNames: Set = buildSet { - for (patch in this@mostCommonCompatibleVersions) { - val compat = patch.compatibility - if (!compat.isNullOrEmpty()) { - for ((packageName) in compat) { - packageName?.let { add(it) } - } - } else { - @Suppress("DEPRECATION") - patch.compatiblePackages?.forEach { (name, _) -> add(name) } - } - } - } - - val targetPackages = if (packageNames != null) { - allPackageNames.intersect(packageNames) - } else { - allPackageNames - } - - val result: MutableMap = LinkedHashMap() - - for (pkgName in targetPackages) { - val versionCount = VersionMap() - - for (patch in this) { - if (!countUnusedPatches && !patch.default) continue - - val compat = patch.compatibility - - if (!compat.isNullOrEmpty()) { - val isUniversal = compat.all { it.packageName == null } - val matchingEntries = compat.filter { entry -> - entry.packageName == null || entry.packageName == pkgName - } - - if (matchingEntries.isEmpty()) continue - - val versions = matchingEntries.flatMap { entry -> - entry.targets - .filter { includeExperimental || !it.isExperimental } - .mapNotNull { it.version } - } - - if (versions.isEmpty()) { - versionCount[""] = (versionCount[""] ?: 0) + 1 - } else { - for (version in versions) { - versionCount[version] = (versionCount[version] ?: 0) + 1 - } - } - } else { - @Suppress("DEPRECATION") - val legacyPackages = patch.compatiblePackages - if (legacyPackages == null) { - versionCount[""] = (versionCount[""] ?: 0) + 1 - continue - } - val matching = legacyPackages.filter { (name, _) -> name == pkgName } - if (matching.isEmpty()) continue - for ((_, versions) in matching) { - if (versions.isNullOrEmpty()) { - versionCount[""] = (versionCount[""] ?: 0) + 1 - } else { - for (version in versions) { - versionCount[version] = (versionCount[version] ?: 0) + 1 - } - } - } - } - } - - if (versionCount.isNotEmpty()) { - val sorted = versionCount.entries - .sortedByDescending { it.value } - .associateTo(LinkedHashMap()) { it.key to it.value } - sorted.remove("") - result[pkgName] = sorted - } - } - - return result.entries - .sortedBy { it.key } - .associateTo(LinkedHashMap()) { it.key to it.value } -} diff --git a/src/main/kotlin/app/morphe/engine/apk/ApkInspectionResult.kt b/src/main/kotlin/app/morphe/engine/apk/ApkInspectionResult.kt new file mode 100644 index 00000000..94f2a65a --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/apk/ApkInspectionResult.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.apk + +import java.io.File + +/** + * Inspection metadata extracted from an APK or split APK bundle (.apkm, .xapk, .apks). + */ +data class ApkInspectionResult( + val file: File, + val packageName: String, + val versionName: String?, + val versionCode: Int?, + val minSdkVersion: Int?, + val applicationLabel: String?, + val architectures: Set, + val isBundle: Boolean, +) diff --git a/src/main/kotlin/app/morphe/engine/apk/ApkInspector.kt b/src/main/kotlin/app/morphe/engine/apk/ApkInspector.kt new file mode 100644 index 00000000..14e330fb --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/apk/ApkInspector.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.apk + +import com.reandroid.arsc.chunk.xml.AndroidManifestBlock +import java.io.ByteArrayInputStream +import java.io.File +import java.util.logging.Logger +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream + +object AndroidArchitectures { + const val UNIVERSAL = "universal" + const val ARM64_V8A = "arm64-v8a" + const val ARMEABI_V7A = "armeabi-v7a" + const val X86_64 = "x86_64" + const val X86 = "x86" + + val ALL = setOf(ARM64_V8A, ARMEABI_V7A, X86, X86_64) +} + +/** + * Archive inspection engine for standalone APKs and split APK bundles + * (.apkm, .xapk, .apks). + * + * All operations execute in-memory with zero temporary disk writes. + */ +object ApkInspector { + private val logger = Logger.getLogger(ApkInspector::class.java.name) + private val splitPatterns = listOf("split_config", "config.", "split_") + + /** + * Inspect an APK or bundle archive and extract its manifest attributes and CPU architectures. + * Returns null if the file does not exist, is not an archive, or has no valid Android manifest. + */ + fun inspect(file: File): ApkInspectionResult? { + if (!file.exists() || !file.isFile) return null + + return try { + if (BundleFormats.isBundle(file)) { + inspectBundle(file) + } else { + inspectApk(file) + } + } catch (e: Exception) { + logger.warning("Failed to inspect ${file.name}: ${e.message ?: e::class.simpleName}") + null + } + } + + /** + * Extract CPU architectures from an APK or bundle. + */ + fun extractArchitectures(file: File): Set { + return inspect(file)?.architectures ?: emptySet() + } + + private fun inspectApk(file: File): ApkInspectionResult? { + ZipFile(file).use { zip -> + val manifestEntry = zip.getEntry("AndroidManifest.xml") ?: run { + logger.warning("No AndroidManifest.xml found in APK ${file.name}") + return null + } + + val block = zip.getInputStream(manifestEntry).use { input -> + AndroidManifestBlock.load(input) + } + + val packageName = block.packageName ?: run { + logger.warning("No package name in manifest of ${file.name}") + return null + } + + val archDirs = mutableSetOf() + zip.entries().asSequence() + .map { it.name } + .filter { it.startsWith("lib/") } + .forEach { path -> + val parts = path.split("/") + if (parts.size >= 2 && parts[1].isNotBlank()) { + archDirs.add(parts[1]) + } + } + + val architectures = if (archDirs.isEmpty()) { + setOf(AndroidArchitectures.UNIVERSAL) + } else { + archDirs + } + + return ApkInspectionResult( + file = file, + packageName = packageName, + versionName = block.versionName, + versionCode = block.versionCode, + minSdkVersion = block.minSdkVersion, + applicationLabel = block.applicationLabelString, + architectures = architectures, + isBundle = false, + ) + } + } + + private fun inspectBundle(file: File): ApkInspectionResult? { + ZipFile(file).use { zip -> + val baseEntry = findBaseApkEntry(zip) ?: run { + logger.warning("Could not find base APK inside bundle ${file.name}") + return null + } + + var manifestBlock: AndroidManifestBlock? = null + val baseEmbeddedArchs = mutableSetOf() + + // Stream base.apk directly in memory without writing to disk + zip.getInputStream(baseEntry).use { baseIn -> + ZipInputStream(baseIn).use { innerZip -> + var innerEntry = innerZip.nextEntry + while (innerEntry != null) { + if (innerEntry.name == "AndroidManifest.xml") { + val manifestBytes = innerZip.readBytes() + manifestBlock = AndroidManifestBlock.load(ByteArrayInputStream(manifestBytes)) + } else if (innerEntry.name.startsWith("lib/")) { + val parts = innerEntry.name.split("/") + if (parts.size >= 2 && parts[1].isNotBlank()) { + baseEmbeddedArchs.add(parts[1]) + } + } + innerZip.closeEntry() + innerEntry = innerZip.nextEntry + } + } + } + + val block = manifestBlock ?: run { + logger.warning("No AndroidManifest.xml found inside base APK of bundle ${file.name}") + return null + } + + val packageName = block.packageName ?: run { + logger.warning("No package name in manifest of bundle ${file.name}") + return null + } + + val architectures = mutableSetOf() + architectures.addAll(baseEmbeddedArchs) + + // Detect architectures from split package names in bundle (e.g. split_config.arm64_v8a.apk) + zip.entries().asSequence() + .map { it.name } + .filter { it.endsWith(".apk", ignoreCase = true) } + .forEach { name -> + val normalized = name.replace("_", "-") + AndroidArchitectures.ALL.filter { arch -> normalized.contains(arch) } + .forEach { architectures.add(it) } + } + + val finalArchitectures = if (architectures.isEmpty()) { + setOf(AndroidArchitectures.UNIVERSAL) + } else { + architectures + } + + return ApkInspectionResult( + file = file, + packageName = packageName, + versionName = block.versionName, + versionCode = block.versionCode, + minSdkVersion = block.minSdkVersion, + applicationLabel = block.applicationLabelString, + architectures = finalArchitectures, + isBundle = true, + ) + } + } + + private fun findBaseApkEntry(zip: ZipFile): ZipEntry? { + // 1. APKM / standard bundle convention + zip.getEntry("base.apk")?.let { return it } + + // 2. APKS (bundletool / SAI) convention + zip.getEntry("splits/base-master.apk")?.let { return it } + zip.getEntry("splits/base.apk")?.let { return it } + + // 3. XAPK / custom bundle: look for non-split .apk entry + val allApks = zip.entries().asSequence() + .filter { !it.isDirectory && it.name.endsWith(".apk", ignoreCase = true) } + .toList() + + val nonSplit = allApks.firstOrNull { entry -> + val simpleName = entry.name.substringAfterLast('/').lowercase() + splitPatterns.none { simpleName.startsWith(it) } + } + + // 4. Fallback: largest APK by compressed size + return nonSplit ?: allApks.maxByOrNull { it.compressedSize } + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt b/src/main/kotlin/app/morphe/engine/apk/ApkOutputNaming.kt similarity index 62% rename from src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt rename to src/main/kotlin/app/morphe/engine/apk/ApkOutputNaming.kt index 2bb58526..fb6e0b5d 100644 --- a/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt +++ b/src/main/kotlin/app/morphe/engine/apk/ApkOutputNaming.kt @@ -3,21 +3,14 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.engine.util +package app.morphe.engine.apk import app.morphe.engine.patches.PatchBundleLoader import java.io.File /** * Shared filename helpers + output-path computation for patched APKs. Used by - * both the GUI ([app.morphe.gui.ui.screens.patches.PatchSelectionViewModel]) - * and the CLI ([app.morphe.desktop.command.PatchCommand]) so identical inputs - * produce identical output paths — no surprises when users switch between - * surfaces. - * - * Lives in `engine.util` because output naming is a pure data transformation - * with no UI or CLI dependencies, and consolidating it in the engine moves - * one more thing toward the long-term "engine is the heart" architecture. + * both the GUI and the CLI so identical inputs produce identical output paths. */ object ApkOutputNaming { @@ -26,27 +19,58 @@ object ApkOutputNaming { /** * Extract APK version from an APKMirror-style filename: * `_-.apk` → returns ``. - * Also handles the same convention with .apkm/.xapk/.apks extensions — - * `_.apkm` → returns ``. Returns null for - * filenames that don't follow this convention. + * Also handles the same convention with .apkm/.xapk/.apks. + * Returns null for filenames that don't follow this convention. */ fun extractApkVersionFromFilename(fileName: String): String? = try { - // Strip the bundle extension first so it doesn't leak into the version. - // File.nameWithoutExtension handles single extensions cleanly; we list - // the .apk-family ones explicitly because filenames like - // `soundcloud_2026.04.27.apkm` have multiple "extensions" in a row - // (the version dots look like extensions to nameWithoutExtension). - val withoutExt = fileName - .removeSuffix(".apk") - .removeSuffix(".apkm") - .removeSuffix(".xapk") - .removeSuffix(".apks") + val withoutExt = stripKnownExtension(fileName) val afterPackage = withoutExt.substringAfter("_") afterPackage.substringBefore("-").takeIf { it.isNotEmpty() } } catch (e: Exception) { null } + /** + * Best-effort package + version extraction from APKMirror-style filenames: + * `com.google.android.youtube_19.20.30-12345.apk` → `("com.google.android.youtube", "19.20.30")` + * + * Returns `(null, null)` when the filename doesn't match a package_version pattern. + * Scans for semver / date patterns as fallback when not in standard APKMirror format. + */ + fun extractPackageAndVersionFromFilename(fileName: String): Pair = try { + val withoutExt = stripKnownExtension(fileName) + val splitOnUnderscore = withoutExt.split('_', limit = 2) + + val packageCandidate = splitOnUnderscore.getOrNull(0) + val afterUnderscore = splitOnUnderscore.getOrNull(1) + + val looksLikePackage = packageCandidate != null && + packageCandidate.contains('.') && + packageCandidate.split('.').all { segment -> + segment.isNotEmpty() && segment.all { c -> c.isLowerCase() || c.isDigit() || c == '_' } + } + + val packageName = if (looksLikePackage) packageCandidate else null + + val versionAfterUnderscore = afterUnderscore?.substringBefore('-')?.takeIf { it.isNotBlank() } + val version = versionAfterUnderscore + ?: Regex("""\d+\.\d+\.\d+(?:-dev\.\d+)?""").find(withoutExt)?.value + ?: Regex("""\d+\.\d+(?:\.\d+)?""").find(withoutExt)?.value + + packageName to version + } catch (e: Exception) { + null to null + } + + private fun stripKnownExtension(fileName: String): String { + for (ext in BundleFormats.SUPPORTED_EXTENSIONS) { + if (fileName.endsWith(".$ext", ignoreCase = true)) { + return fileName.dropLast(ext.length + 1) + } + } + return fileName.substringBeforeLast('.') + } + /** * Extract patches version from a .mpp filename like * `morphe-patches-1.13.0.mpp` or `morphe-patches-1.13.0-dev.5.mpp`. @@ -57,25 +81,23 @@ object ApkOutputNaming { patchesVersionRegex.find(patchesFileName)?.groupValues?.get(1) /** - * Resolve the human-friendly app label from an APK file via ARSCLib - * (engine [ApkManifestReader]). Returns null when: - * - the manifest can't be read at all (corrupt APK) + * Resolve the human-friendly app label from an APK or bundle archive via ARSCLib. + * Returns null when: + * - the manifest can't be read at all (corrupt archive) * - the manifest has no label * - the label is stored as a resource reference (`@string/app_name`) - * instead of a literal string — common for big apps. Callers should - * fall back to a supported-apps lookup or filename in that case. + * instead of a literal string. Callers should fall back to + * a supported-apps lookup or filename in that case. */ fun resolveAppDisplayName(apkFile: File): String? = - ApkManifestReader.read(apkFile)?.applicationLabel?.takeIf { it.isNotBlank() } + ApkInspector.inspect(apkFile)?.applicationLabel?.takeIf { it.isNotBlank() } /** - * The app's versionName from its manifest (engine [ApkManifestReader]), or null when it - * can't be read or is blank. The reliable source of the app version, unlike - * [extractApkVersionFromFilename], which only works when the input file follows the - * APKMirror naming convention. + * The app's versionName from its manifest or null when it can't be read or + * is blank. Supports both standalone APKs and split APK bundles. */ fun resolveAppVersion(apkFile: File): String? = - ApkManifestReader.read(apkFile)?.versionName?.takeIf { it.isNotBlank() } + ApkInspector.inspect(apkFile)?.versionName?.takeIf { it.isNotBlank() } /** * Compute the unified output APK path. Layout: diff --git a/src/main/kotlin/app/morphe/engine/apk/BundleFormats.kt b/src/main/kotlin/app/morphe/engine/apk/BundleFormats.kt new file mode 100644 index 00000000..06f1715d --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/apk/BundleFormats.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.apk + +import java.io.File + +/** + * Split-APK bundle container formats - ZIP archives that hold a base APK plus + * config/density/abi split APKs (`.apkm` from APKMirror, `.xapk` from APKPure, + * `.apks` from bundletool/SAI). + * + * Single source of truth for "is this a bundle or APK?" so the engine, GUI, and CLI + * all agree. + */ +object BundleFormats { + /** Bundle file extensions, lowercase, without the leading dot. */ + val EXTENSIONS = setOf("apkm", "xapk", "apks") + + /** All supported APK & bundle file extensions, lowercase, without the leading dot. */ + val SUPPORTED_EXTENSIONS = listOf("apk") + EXTENSIONS.toList() + + /** True if [file]'s extension is a split-APK bundle format. */ + fun isBundle(file: File): Boolean = file.extension.lowercase() in EXTENSIONS + + /** True if [file] is a standalone APK or a split-APK bundle format. */ + fun isApkOrBundle(file: File): Boolean { + if (!file.isFile) return false + val ext = file.extension.lowercase() + return ext == "apk" || ext in EXTENSIONS + } +} diff --git a/src/main/kotlin/app/morphe/engine/config/EngineConfig.kt b/src/main/kotlin/app/morphe/engine/config/EngineConfig.kt new file mode 100644 index 00000000..b268c9b4 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/config/EngineConfig.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.config + +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.apk.AndroidArchitectures +import app.morphe.engine.util.PortablePaths +import app.morphe.patcher.apk.ApkUtils +import java.io.File +import kotlinx.serialization.Serializable + +@Serializable +data class EngineConfig( + val keystorePath: String? = null, + val keystorePassword: String? = null, + val keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, + val keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD, + val defaultOutputDirectory: String? = null, + val keepArchitectures: Set = AndroidArchitectures.ALL, + val gitHubPat: String = "", + val autoStartAdb: Boolean = false, + val autoRouteLinksAfterInstall: Boolean = false, + val disableStockLinksAfterInstall: Boolean = false, + val autoCleanupTempFiles: Boolean = true, + val developerOptions: Boolean = false, + val excludedMppPatterns: List = emptyList(), + val lastLocalPatchDir: String? = null, +) { + /** + * Resolved live [File] for [defaultOutputDirectory] via [PortablePaths.resolve]. + */ + fun resolvedDefaultOutputDirectory(): File? = + defaultOutputDirectory?.let(PortablePaths::resolve) + + /** + * Resolved live [File] for [keystorePath] via [PortablePaths.resolve]. + */ + fun resolvedKeystorePath(): File? = + keystorePath?.let(PortablePaths::resolve) + + /** + * Constructs [ApkUtils.KeyStoreDetails] from configured keystore fields, or null if no path configured. + */ + fun toKeyStoreDetails(): ApkUtils.KeyStoreDetails? { + val ks = resolvedKeystorePath() ?: return null + return ApkUtils.KeyStoreDetails( + keyStore = ks, + keyStorePassword = keystorePassword, + alias = keystoreAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, + password = keystoreEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD }, + ) + } +} diff --git a/src/main/kotlin/app/morphe/engine/config/EngineConfigRepository.kt b/src/main/kotlin/app/morphe/engine/config/EngineConfigRepository.kt new file mode 100644 index 00000000..6fb2146f --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/config/EngineConfigRepository.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.config + +import app.morphe.engine.MorpheData +import app.morphe.engine.util.PortablePaths +import java.io.File +import java.util.logging.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject + +open class EngineConfigRepository( + private val configFile: File = MorpheData.configFile, +) { + private val logger = Logger.getLogger(EngineConfigRepository::class.java.name) + private val mutex = Mutex() + private var cachedConfig: EngineConfig? = null + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + open suspend fun loadConfig(): EngineConfig = withContext(Dispatchers.IO) { + mutex.withLock { + cachedConfig?.let { return@withContext it } + if (!configFile.exists()) { + val default = EngineConfig() + cachedConfig = default + return@withContext default + } + try { + val element = json.parseToJsonElement(configFile.readText()) + val config = json.decodeFromJsonElement(element) + cachedConfig = config + config + } catch (e: Exception) { + logger.warning("Failed to parse engine config from ${configFile.absolutePath}: ${e.message}") + EngineConfig() + } + } + } + + suspend fun saveConfig(update: (EngineConfig) -> EngineConfig) = withContext(Dispatchers.IO) { + mutex.withLock { + val current = cachedConfig ?: if (configFile.exists()) { + runCatching { + json.decodeFromJsonElement(json.parseToJsonElement(configFile.readText())) + }.getOrDefault(EngineConfig()) + } else EngineConfig() + + val updated = update(current) + + val existingObj = if (configFile.exists()) { + runCatching { json.parseToJsonElement(configFile.readText()).jsonObject }.getOrDefault(JsonObject(emptyMap())) + } else JsonObject(emptyMap()) + + val engineObj = json.encodeToJsonElement(EngineConfig.serializer(), updated).jsonObject + val mergedMap = existingObj.toMutableMap() + engineObj.forEach { (k, v) -> mergedMap[k] = v } + val mergedJson = json.encodeToString(JsonObject.serializer(), JsonObject(mergedMap)) + + configFile.parentFile?.mkdirs() + configFile.writeText(mergedJson) + cachedConfig = updated + } + } + + suspend fun getGitHubPat(): String = loadConfig().gitHubPat + + suspend fun setGitHubPat(pat: String) = saveConfig { it.copy(gitHubPat = pat) } + + suspend fun setKeystoreDetails(path: String?, password: String?, alias: String, entryPassword: String) = + saveConfig { + it.copy( + keystorePath = path?.let(PortablePaths::storableForm), + keystorePassword = password, + keystoreAlias = alias, + keystoreEntryPassword = entryPassword, + ) + } + + suspend fun setKeystorePath(path: String?) = + saveConfig { it.copy(keystorePath = path?.let(PortablePaths::storableForm)) } + + suspend fun setDefaultOutputDirectory(path: String?) = + saveConfig { it.copy(defaultOutputDirectory = path?.let(PortablePaths::storableForm)) } + + suspend fun setKeepArchitectures(keep: Set) = + saveConfig { it.copy(keepArchitectures = keep) } + + suspend fun setAutoCleanupTempFiles(enabled: Boolean) = + saveConfig { it.copy(autoCleanupTempFiles = enabled) } + + suspend fun setAutoStartAdb(enabled: Boolean) = + saveConfig { it.copy(autoStartAdb = enabled) } + + suspend fun setAutoRouteLinksAfterInstall(enabled: Boolean) = + saveConfig { it.copy(autoRouteLinksAfterInstall = enabled) } + + suspend fun setDisableStockLinksAfterInstall(enabled: Boolean) = + saveConfig { it.copy(disableStockLinksAfterInstall = enabled) } + + suspend fun setDeveloperOptions(enabled: Boolean) = + saveConfig { it.copy(developerOptions = enabled) } + + suspend fun setLastLocalPatchDir(path: String?) = + saveConfig { it.copy(lastLocalPatchDir = path) } + + suspend fun setExcludedMppPatterns(patterns: List) = + saveConfig { it.copy(excludedMppPatterns = patterns) } + + fun clearCache() { + cachedConfig = null + } + + companion object { + val shared: EngineConfigRepository by lazy { EngineConfigRepository() } + } +} diff --git a/src/main/kotlin/app/morphe/engine/model/PatchMetadata.kt b/src/main/kotlin/app/morphe/engine/model/PatchMetadata.kt new file mode 100644 index 00000000..0a2ba7cf --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/model/PatchMetadata.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.model + +import app.morphe.engine.options.PatchOption +import kotlinx.serialization.Serializable + +/** + * Represents metadata for a single patch from Morphe patches bundle. + */ +@Serializable +data class PatchMetadata( + val name: String, + val description: String = "", + val compatiblePackages: List = emptyList(), + val options: List = emptyList(), + val isEnabled: Boolean = true, + val category: String? = null +) { + /** Whether this patch targets no specific package (applies universally / system-wide). */ + val isUniversal: Boolean + get() = compatiblePackages.isEmpty() + + /** + * Unique identifier for this patch. + * Combines name, packages, and description hash for true uniqueness. + */ + val uniqueId: String + get() { + val packages = compatiblePackages.joinToString(",") { it.name } + val descHash = description.hashCode().toString(16) + return "$name|$packages|$descHash" + } + + /** + * Check if patch is compatible with a given package and version. + * Patches with no compatible packages listed are universal (applicable to any app). + */ + fun isCompatibleWith( + packageName: String, + versionName: String? = null, + includeExperimental: Boolean = true, + ): Boolean { + return compatiblePackages.any { pkg -> + pkg.name == packageName && ( + versionName == null || + (pkg.versions.isEmpty() && pkg.experimentalVersions.isEmpty()) || + pkg.versions.contains(versionName) || + (includeExperimental && pkg.experimentalVersions.contains(versionName)) + ) + } + } +} + +@Serializable +data class CompatiblePackage( + val name: String, + val displayName: String? = null, + val versions: List = emptyList(), + val experimentalVersions: List = emptyList(), + val appIconColor: String? = null, + val versionBuildCodes: Map> = emptyMap() +) diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/engine/model/SupportedApp.kt similarity index 80% rename from src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt rename to src/main/kotlin/app/morphe/engine/model/SupportedApp.kt index 39cd0a59..510bd771 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/engine/model/SupportedApp.kt @@ -3,13 +3,14 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.gui.data.model +package app.morphe.engine.model -import app.morphe.gui.util.DownloadUrlResolver +import app.morphe.engine.util.DownloadUrlResolver +import app.morphe.engine.util.compareVersions /** * Represents a supported app extracted dynamically from patch metadata. - * This is populated by parsing the .mpp file's compatible packages. + * This is populated by parsing patch bundle compatible packages. */ data class SupportedApp( val packageName: String, @@ -81,21 +82,5 @@ data class SupportedApp( compareVersions(v2, v1) // Descending order }.firstOrNull() } - - /** - * Compare two version strings. - * Returns positive if v1 > v2, negative if v1 < v2, 0 if equal. - */ - private fun compareVersions(v1: String, v2: String): Int { - val parts1 = v1.split(".").mapNotNull { it.toIntOrNull() } - val parts2 = v2.split(".").mapNotNull { it.toIntOrNull() } - - for (i in 0 until maxOf(parts1.size, parts2.size)) { - val p1 = parts1.getOrElse(i) { 0 } - val p2 = parts2.getOrElse(i) { 0 } - if (p1 != p2) return p1.compareTo(p2) - } - return 0 - } } } diff --git a/src/main/kotlin/app/morphe/engine/model/VersionStatus.kt b/src/main/kotlin/app/morphe/engine/model/VersionStatus.kt new file mode 100644 index 00000000..8a52abec --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/model/VersionStatus.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.model + +/** + * The "bucket" an APK's version falls into relative to a [SupportedApp]'s + * stable + experimental version lists. + */ +enum class VersionStatus { + /** Current version is the latest stable. Happy path. */ + LATEST_STABLE, + + /** In the stable list but older than the latest stable. */ + OLDER_STABLE, + + /** Current version is the latest experimental. */ + LATEST_EXPERIMENTAL, + + /** In the experimental list but older than the latest experimental. */ + OLDER_EXPERIMENTAL, + + /** Newer than every known version (stable + experimental). */ + TOO_NEW, + + /** Older than every known stable version. */ + TOO_OLD, + + /** Between supported versions but not in either list. */ + UNSUPPORTED_BETWEEN, + + BUILD_UNSUPPORTED, + + /** No patch metadata, can't determine. */ + UNKNOWN +} + +/** + * The result of resolving a current APK version against a [SupportedApp]. + * + * @param status which bucket the current version falls into. + * @param suggestedVersion the version most relevant to surface in UI for this + * status, e.g. the latest stable for [VersionStatus.OLDER_STABLE], the + * latest experimental for [VersionStatus.OLDER_EXPERIMENTAL], the newest + * known version for [VersionStatus.TOO_NEW], etc. + */ +data class VersionResolution( + val status: VersionStatus, + val suggestedVersion: String? +) diff --git a/src/main/kotlin/app/morphe/engine/network/HttpClient.kt b/src/main/kotlin/app/morphe/engine/network/HttpClient.kt new file mode 100644 index 00000000..f514ec40 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/network/HttpClient.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.kotlinx.json.json +import java.net.Inet4Address +import kotlinx.serialization.json.Json +import okhttp3.Dns +import okhttp3.Protocol + +private val defaultHttpJson = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + isLenient = true +} + +/** + * Shared lazy [HttpClient] instance for CLI commands and engine utilities + * that don't manage a separate DI container. + */ +val sharedHttpClient: HttpClient by lazy { createHttpClient() } + +/** + * Factory for Morphe's standard Ktor [HttpClient] backed by OkHttp: + * - Prefers IPv4 A records when available so broken IPv6 routes don't stall connects + * - Pins HTTP/1.1 to avoid intermittent HTTP/2 stream resets on GitHub CDN redirects + * - Follows HTTP and SSL redirects + * - Uses idle/socket timeouts (30s connect / 60s socket) without a total wall-clock cap + */ +fun createHttpClient( + json: Json = defaultHttpJson, + onLog: ((String) -> Unit)? = null, +): HttpClient = HttpClient(OkHttp) { + engine { + config { + dns { hostname -> + val all = Dns.SYSTEM.lookup(hostname) + all.filterIsInstance().ifEmpty { all } + } + protocols(listOf(Protocol.HTTP_1_1)) + followRedirects(true) + followSslRedirects(true) + } + } + install(ContentNegotiation) { + json(json) + } + if (onLog != null) { + install(Logging) { + level = LogLevel.INFO + logger = object : Logger { + override fun log(message: String) { + onLog(message) + } + } + } + } + install(HttpTimeout) { + connectTimeoutMillis = 30_000 + socketTimeoutMillis = 60_000 + } +} diff --git a/src/main/kotlin/app/morphe/engine/network/HttpService.kt b/src/main/kotlin/app/morphe/engine/network/HttpService.kt index 406305ec..b346c69e 100644 --- a/src/main/kotlin/app/morphe/engine/network/HttpService.kt +++ b/src/main/kotlin/app/morphe/engine/network/HttpService.kt @@ -417,7 +417,22 @@ class HttpService( append("HTTP request failed") if (status != null) append(" with status $status") if (requestUrl != null) append(" for $requestUrl") - if (responseBodySnippet != null) append(": ${responseBodySnippet.take(200)}") + if (responseBodySnippet != null) { + val trimmed = responseBodySnippet.trim() + val snippet = if (trimmed.startsWith("<")) { + Regex("(.*?)", RegexOption.IGNORE_CASE) + .find(trimmed)?.groupValues?.get(1)?.trim() + ?: "HTML response" + } else { + val formatted = runCatching { + Json.parseToJsonElement(trimmed).toString() + }.getOrElse { + trimmed.replace(Regex("\\s+"), " ") + } + formatted.take(300) + } + append(": $snippet") + } }, cause, ) { diff --git a/src/main/kotlin/app/morphe/engine/options/PatchOption.kt b/src/main/kotlin/app/morphe/engine/options/PatchOption.kt new file mode 100644 index 00000000..e0266d1d --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/options/PatchOption.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.options + +import app.morphe.patcher.patch.ColorOption +import app.morphe.patcher.patch.FilePathOption +import app.morphe.patcher.patch.FilesOption +import app.morphe.patcher.patch.FloatRangeOption +import app.morphe.patcher.patch.FloatSliderOption +import app.morphe.patcher.patch.FolderOption +import app.morphe.patcher.patch.ImageOption +import app.morphe.patcher.patch.IntRangeOption +import app.morphe.patcher.patch.IntSliderOption +import app.morphe.patcher.patch.Option +import kotlin.reflect.KType +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +/** + * Semantic UI hint produced by a typed patcher option subclass. + * Null when the underlying option is a plain untyped option. + */ +enum class ExplicitOptionKind { + Folder, FilePath, Files, Image, Color, IntSlider, FloatSlider, IntRange, FloatRange +} + +/** Recommended pixel dimensions for an [ExplicitOptionKind.Image] option. */ +data class ImageSize(val width: Int, val height: Int) + +/** + * Bounds declared by a slider option, normalized so one carrier serves the integer and the + * floating point kinds alike. + */ +data class SliderBounds(val min: Float, val max: Float, val step: Float?) + +@Serializable +data class PatchOption( + val key: String, + val title: String, + val description: String = "", + val type: PatchOptionType = PatchOptionType.STRING, + val default: String? = null, + val required: Boolean = false, + /** The type the patch declared. [type] cannot express a list's element type. */ + @Transient val valueType: KType? = null, + @Transient val explicitKind: ExplicitOptionKind? = null, + @Transient val allowedExtensions: List? = null, + @Transient val recommendedSize: ImageSize? = null, + @Transient val sliderBounds: SliderBounds? = null, + @Transient val presets: Map? = null, + @Transient val rawDefault: Any? = null, +) + +@Serializable +enum class PatchOptionType { + STRING, + BOOLEAN, + INT, + LONG, + FLOAT, + LIST, + FILE +} + +/** + * Converts a `morphe-patcher` [Option] into the shared [PatchOption] metadata model, + * preserving explicit option subclass hints, allowed file extensions, image dimensions, + * slider bounds, and presets. + */ +fun Option<*>.toPatchOption(): PatchOption { + val explicitKind = when (this) { + is FolderOption -> ExplicitOptionKind.Folder + is FilePathOption -> ExplicitOptionKind.FilePath + is FilesOption -> ExplicitOptionKind.Files + is ImageOption -> ExplicitOptionKind.Image + is ColorOption -> ExplicitOptionKind.Color + is IntSliderOption -> ExplicitOptionKind.IntSlider + is FloatSliderOption -> ExplicitOptionKind.FloatSlider + is IntRangeOption -> ExplicitOptionKind.IntRange + is FloatRangeOption -> ExplicitOptionKind.FloatRange + else -> null + } + val allowedExtensions = when (this) { + is FilePathOption -> this.allowedExtensions + is FilesOption -> this.allowedExtensions + is ImageOption -> this.allowedExtensions + else -> null + } + val recommendedSize = when (this) { + is ImageOption -> this.recommendedSize?.let { ImageSize(it.width, it.height) } + else -> null + } + val sliderBounds = when (this) { + is IntSliderOption -> SliderBounds(this.min.toFloat(), this.max.toFloat(), this.step.toFloat()) + is FloatSliderOption -> SliderBounds(this.min, this.max, this.step) + is IntRangeOption -> SliderBounds(this.min.toFloat(), this.max.toFloat(), this.step.toFloat()) + is FloatRangeOption -> SliderBounds(this.min, this.max, this.step) + else -> null + } + return PatchOption( + key = this.name, + title = this.name, + description = this.description ?: "", + type = mapKTypeToOptionType(this.type, this.name, this.name), + default = when (val def = this.default) { + null -> null + is List<*> -> def.joinToString(", ") + else -> def.toString() + }, + required = this.required, + valueType = this.type, + explicitKind = explicitKind, + allowedExtensions = allowedExtensions, + recommendedSize = recommendedSize, + sliderBounds = sliderBounds, + presets = this.values, + rawDefault = this.default, + ) +} + +/** + * Shared heuristic keywords for classifying untyped options (files, folders, images). + */ +object OptionKeywords { + val IMAGE = listOf("image", "wallpaper", "banner", "logo") + val ASSET_FOLDER = listOf("folder", "mipmap", "drawable") + val FILE = listOf("icon", "image", "logo", "banner", "path", "file", "png", "jpg") +} + +/** + * Maps a Kotlin [KType] (plus key/title fallback heuristics) to [PatchOptionType]. + */ +fun mapKTypeToOptionType(kType: KType, key: String, title: String): PatchOptionType { + val typeName = kType.toString() + return when { + typeName.contains("Boolean") -> PatchOptionType.BOOLEAN + typeName.contains("Int") -> PatchOptionType.INT + typeName.contains("Long") -> PatchOptionType.LONG + typeName.contains("Float") || typeName.contains("Double") -> PatchOptionType.FLOAT + typeName.contains("List") || typeName.contains("Array") || typeName.contains("Set") -> PatchOptionType.LIST + typeName.contains("File") || typeName.contains("Path") || typeName.contains("InputStream") -> PatchOptionType.FILE + else -> { + val combined = "$key $title".lowercase() + if (OptionKeywords.FILE.any { it in combined }) PatchOptionType.FILE else PatchOptionType.STRING + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/options/PatchOptionValues.kt b/src/main/kotlin/app/morphe/engine/options/PatchOptionValues.kt new file mode 100644 index 00000000..d199aac1 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/options/PatchOptionValues.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.options + +import app.morphe.patcher.patch.Patch +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Returns [value] coerced to [type], or null when it cannot stand for one, since the patcher + * aborts the run over a value of any other type. An unmodelled type is passed through untouched. + */ +fun coerceOptionValue(type: KType, value: Any?): Any? { + if (value == null) return null + + if (type.classifier == List::class) { + val elementType = type.arguments.firstOrNull()?.type ?: return value + val elements = when (value) { + is List<*> -> value + is Array<*> -> value.asList() + // How the list editor and imported profiles carry a list + is String -> value.split(',').map(String::trim).filter(String::isNotEmpty) + else -> return null + } + + return elements.map { element -> coerceOptionValue(elementType, element) ?: return null } + } + + return when (type.classifier) { + String::class -> when (value) { + is String -> value + is Number, is Boolean -> value.toString() + else -> null + } + + Boolean::class -> when (value) { + is Boolean -> value + is String -> value.trim().lowercase().toBooleanStrictOrNull() + else -> null + } + + Int::class -> value.integralOrNull()?.takeIf { it in INT_RANGE }?.toInt() + Long::class -> value.integralOrNull() + Float::class -> value.decimalOrNull()?.toFloat() + Double::class -> value.decimalOrNull() + else -> value + } +} + +private val INT_RANGE = Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() + +/** The whole number [this] stands for, or null when it is none. */ +private fun Any.integralOrNull(): Long? = when (this) { + is Long -> this + is Int, is Short, is Byte -> (this as Number).toLong() + is Float, is Double -> (this as Number).toDouble().toWholeOrNull() + is String -> trim().let { it.toLongOrNull() ?: it.toDoubleOrNull()?.toWholeOrNull() } + else -> null +} + +/** The decimal [this] stands for, or null when it is none. */ +private fun Any.decimalOrNull(): Double? = when (this) { + is Number -> toDouble() + is String -> trim().toDoubleOrNull() + else -> null +} + +/** Rejects fractions, NaN and infinities, which no integer option can hold. */ +private fun Double.toWholeOrNull(): Long? = takeIf { it == it.toLong().toDouble() }?.toLong() + +/** + * Serializes an already-typed option value (or default) to a [JsonElement], + * preserving primitive types, lists/arrays, and string-keyed maps. + */ +fun optionValueToJson(value: Any?): JsonElement = when (value) { + null -> JsonNull + is JsonElement -> value + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is List<*> -> buildJsonArray { value.forEach { add(optionValueToJson(it)) } } + is Iterable<*> -> buildJsonArray { value.forEach { add(optionValueToJson(it)) } } + is Array<*> -> buildJsonArray { value.forEach { add(optionValueToJson(it)) } } + is Map<*, *> -> buildJsonObject { + value.forEach { (k, v) -> + require(k is String) { + "Map keys must be of type String for serialization, but found: ${k?.let { it::class }}" + } + put(k, optionValueToJson(v)) + } + } + else -> JsonPrimitive(value.toString()) +} + +/** + * The text an editor shows for a stored [JsonElement] value, whatever JSON type it was saved as. + */ +fun optionValueFromJson(element: JsonElement): String = when (element) { + is JsonNull -> "" + is JsonArray -> element.joinToString(", ") { optionValueFromJson(it) } + is JsonPrimitive -> element.content + else -> element.toString() +} + +/** + * Deserializes a [JsonElement] to a typed value based on the option's [KType]. + */ +fun deserializeOptionValue(element: JsonElement, type: KType): Any? { + if (element is JsonNull) return null + + if (element is JsonPrimitive) { + val classifier = type.classifier + return when (classifier) { + Boolean::class -> element.booleanOrNull + ?: throw IllegalArgumentException("Expected Boolean, got: $element") + Int::class -> element.intOrNull + ?: throw IllegalArgumentException("Expected Int, got: $element") + Long::class -> element.longOrNull + ?: throw IllegalArgumentException("Expected Long, got: $element") + Float::class -> element.floatOrNull + ?: throw IllegalArgumentException("Expected Float, got: $element") + Double::class -> element.doubleOrNull + ?: throw IllegalArgumentException("Expected Double, got: $element") + String::class -> element.content + else -> element.content + } + } + + if (element is JsonArray) { + val elementType = type.arguments.firstOrNull()?.type ?: typeOf() + return element.map { deserializeOptionValue(it, elementType) } + } + + return element.toString() +} + +/** + * Converts a flat `"patchName.optionKey" -> String` map into the engine's nested + * `Map>` format, coercing string values to each + * option's declared [KType] on [patches]. + */ +fun resolveFlatPatchOptions( + patches: Set>, + enabledPatches: Iterable, + flatOptions: Map, +): Map> { + val patchOptionTypes: Map> = patches + .filter { it.name != null } + .associate { patch -> + patch.name!! to patch.options.mapValues { (_, opt) -> opt.type } + } + + return enabledPatches.associateWith { patchName -> + flatOptions.filterKeys { it.startsWith("$patchName.") } + .mapKeys { it.key.removePrefix("$patchName.") } + .map { (optKey, strValue) -> + val kType = patchOptionTypes[patchName]?.get(optKey) + val coerced = if (kType != null) { + coerceOptionValue(kType, strValue) ?: strValue + } else { + strValue + } + optKey to coerced + }.toMap() + }.filter { it.value.isNotEmpty() } +} + +/** + * Groups flat `"patchName.optKey" -> rawStringValue` entries into a structured + * `Map>` suitable for preference persistence, + * coercing values to their declared [KType]s if known. + */ +fun groupFlatOptionsToJson( + flatOptions: Map, + declaredTypes: Map> = emptyMap(), +): Map> { + val grouped = mutableMapOf>() + for ((compoundKey, value) in flatOptions) { + val dotIdx = compoundKey.indexOf('.') + if (dotIdx <= 0) continue + val patchName = compoundKey.substring(0, dotIdx) + val optKey = compoundKey.substring(dotIdx + 1) + val type = declaredTypes[patchName]?.get(optKey) + val typed = type?.let { coerceOptionValue(it, value) } + grouped.getOrPut(patchName) { mutableMapOf() }[optKey] = + if (typed == null) JsonPrimitive(value) else optionValueToJson(typed) + } + return grouped +} + +/** + * Parses a raw string value from CLI option flags (`-O`) into typed primitives, lists, + * or strings according to literal syntax (`[a, b]`, numbers, booleans, quotes). + */ +fun parseCliOptionValue(value: String?): Any? { + value ?: return null + + return when { + value.startsWith("[") && value.endsWith("]") -> { + val innerValue = value.substring(1, value.length - 1) + + buildList { + var nestLevel = 0 + var insideQuote = false + var escaped = false + + val item = buildString { + for (char in innerValue) { + when (char) { + '\\' -> { + if (escaped || nestLevel != 0) { + append(char) + } + + escaped = !escaped + } + + '"', '\'' -> { + if (!escaped) { + insideQuote = !insideQuote + } else { + escaped = false + } + + append(char) + } + + '[' -> { + if (!insideQuote) { + nestLevel++ + } + + append(char) + } + + ']' -> { + if (!insideQuote) { + nestLevel-- + + if (nestLevel == -1) { + return value + } + } + + append(char) + } + + ',' -> if (nestLevel == 0) { + if (insideQuote) { + append(char) + } else { + add(parseCliOptionValue(toString())) + setLength(0) + } + } else { + append(char) + } + + else -> append(char) + } + } + } + + if (item.isNotEmpty()) { + add(parseCliOptionValue(item)) + } + } + } + + value.startsWith("\"") && value.endsWith("\"") -> value.substring(1, value.length - 1) + value.startsWith("'") && value.endsWith("'") -> value.substring(1, value.length - 1) + value.endsWith("f") -> value.dropLast(1).toFloat() + value.endsWith("L") -> value.dropLast(1).toLong() + value.equals("true", ignoreCase = true) -> true + value.equals("false", ignoreCase = true) -> false + value.toIntOrNull() != null -> value.toInt() + value.toLongOrNull() != null -> value.toLong() + value.toDoubleOrNull() != null -> value.toDouble() + value.toFloatOrNull() != null -> value.toFloat() + value == "null" -> null + value == "int[]" -> emptyList() + value == "long[]" -> emptyList() + value == "double[]" -> emptyList() + value == "float[]" -> emptyList() + value == "boolean[]" -> emptyList() + value == "string[]" -> emptyList() + else -> value + } +} diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchOptionsFile.kt b/src/main/kotlin/app/morphe/engine/options/PatchOptionsFile.kt similarity index 50% rename from src/main/kotlin/app/morphe/desktop/command/model/PatchOptionsFile.kt rename to src/main/kotlin/app/morphe/engine/options/PatchOptionsFile.kt index dff8bd60..fe253c5c 100644 --- a/src/main/kotlin/app/morphe/desktop/command/model/PatchOptionsFile.kt +++ b/src/main/kotlin/app/morphe/engine/options/PatchOptionsFile.kt @@ -3,27 +3,18 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.desktop.command.model +package app.morphe.engine.options +import app.morphe.engine.patches.isCompatibleWith +import app.morphe.engine.util.FileChecksum import app.morphe.patcher.patch.Patch -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.floatOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.longOrNull -import kotlin.reflect.KType -import kotlin.reflect.typeOf import java.io.File -import java.security.MessageDigest import java.time.Instant import java.time.format.DateTimeFormatter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement /** * Metadata for a patch bundle entry in the options file. @@ -41,8 +32,7 @@ data class PatchBundleMeta( /** * A single patch bundle entry in the options file. * - * The options file is a JSON array of [PatchBundle]s. Currently always a single-element list, - * but structured for forward compatibility with multiple patch bundles. + * The options file is a JSON array of [PatchBundle]s. */ @Serializable data class PatchBundle( @@ -58,25 +48,12 @@ data class PatchEntry( private fun now(): String = DateTimeFormatter.ISO_INSTANT.format(Instant.now()) -/** - * Computes the SHA-256 hex digest of this file's contents. - */ -fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().buffered().use { stream -> - val buffer = ByteArray(8192) - var read: Int - while (stream.read(buffer).also { read = it } != -1) { - digest.update(buffer, 0, read) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } -} +private val optionsJson = Json { prettyPrint = true } private fun sha256ForFiles(files: Set): String? = when { files.isEmpty() -> null - files.size == 1 -> files.first().sha256() - else -> files.sortedBy { it.name }.joinToString(";") { it.sha256() } + files.size == 1 -> FileChecksum.sha256(files.first()) + else -> files.sortedBy { it.name }.joinToString(";") { FileChecksum.sha256(it) } } /** @@ -85,7 +62,6 @@ private fun sha256ForFiles(files: Set): String? = when { * @param sourceFiles the .mpp file(s) used to load these patches. * @param existingMeta optional metadata to preserve (e.g. original [PatchBundleMeta.createdAt]). */ -@OptIn(ExperimentalSerializationApi::class) fun Set>.toPatchBundle( sourceFiles: Set = emptySet(), existingMeta: PatchBundleMeta? = null, @@ -96,7 +72,7 @@ fun Set>.toPatchBundle( patch.name!! to PatchEntry( enabled = patch.default, options = patch.options.mapValues { (_, option) -> - PatchSerializer.serializeValue(option.default) + optionValueToJson(option.default) }, ) } @@ -122,7 +98,6 @@ fun Set>.toPatchBundle( * @param existing the existing bundle to merge with, or null to create fresh. * @param sourceFiles the .mpp file(s) used to load these patches. */ -@OptIn(ExperimentalSerializationApi::class) fun Set>.mergeWithBundle( existing: PatchBundle?, sourceFiles: Set = emptySet(), @@ -136,7 +111,7 @@ fun Set>.mergeWithBundle( val updatedOptions = patch.options.keys.associateWith { key -> existingEntry?.options?.get(key) - ?: PatchSerializer.serializeValue(patch.options[key].default) + ?: optionValueToJson(patch.options[key].default) } patchName to PatchEntry( @@ -191,7 +166,7 @@ fun PatchBundle.mergeWith(existing: PatchBundle?): PatchBundle { /** * Finds the [PatchBundle] in this list that best matches the given source files. - * Matching priority: sha256 → source name → first element (fallback). + * Matching priority: sha256 -> source name -> first element (fallback). */ fun List.findMatchingBundle(sourceFiles: Set): PatchBundle? { if (isEmpty()) return null @@ -217,33 +192,147 @@ fun List.withUpdatedBundle(bundle: PatchBundle): List } /** - * Deserializes a [JsonElement] to a typed value based on the option's [KType]. + * Deserializes a [PatchBundle]'s stored JSON option values using the live [patches] in that bundle. + */ +fun PatchBundle.deserializeOptionsFor( + patches: Set>, + onError: (patchName: String, key: String, Exception) -> Unit = { _, _, _ -> }, +): Map> = + this.patches.mapNotNull { (patchName, entry) -> + if (entry.options.isEmpty()) return@mapNotNull null + val patch = patches.firstOrNull { + it.name.equals(patchName, ignoreCase = true) + } ?: return@mapNotNull null + val resolvedName = patch.name ?: return@mapNotNull null + val deserializedOptions = entry.options.mapNotNull { (key, element) -> + if (!patch.options.containsKey(key)) return@mapNotNull null + val option = patch.options[key] + try { + key to deserializeOptionValue(element, option.type) + } catch (e: Exception) { + onError(patchName, key, e) + null + } + }.toMap() + + if (deserializedOptions.isEmpty()) null + else resolvedName to deserializedOptions + }.toMap() + +/** + * Drift report between a loaded `.mpp` bundle and an existing [PatchBundle] options entry. + */ +data class OptionsFileDrift( + val newPatches: Set, + val oldPatches: Set, + val removedPatches: Set, + val patchesWithNewOptions: Map>, + val patchesWithOldOptions: Map>, +) { + val hasDrift: Boolean + get() = newPatches.isNotEmpty() || + oldPatches.isNotEmpty() || + removedPatches.isNotEmpty() || + patchesWithNewOptions.isNotEmpty() || + patchesWithOldOptions.isNotEmpty() +} + +/** + * Computes patch and option-key drift for a single `.mpp` bundle against its JSON [bundleOpts]. */ -fun deserializeOptionValue(element: JsonElement, type: KType): Any? { - if (element is JsonNull) return null - - if (element is JsonPrimitive) { - val classifier = type.classifier - return when (classifier) { - Boolean::class -> element.booleanOrNull - ?: throw IllegalArgumentException("Expected Boolean, got: $element") - Int::class -> element.intOrNull - ?: throw IllegalArgumentException("Expected Int, got: $element") - Long::class -> element.longOrNull - ?: throw IllegalArgumentException("Expected Long, got: $element") - Float::class -> element.floatOrNull - ?: throw IllegalArgumentException("Expected Float, got: $element") - Double::class -> element.doubleOrNull - ?: throw IllegalArgumentException("Expected Double, got: $element") - String::class -> element.content - else -> element.content +fun computeOptionsDrift( + bundlePatches: Set>, + bundleSnapshot: PatchBundle, + bundleOpts: PatchBundle, + packageName: String, +): OptionsFileDrift { + val compatiblePatchNames = bundlePatches + .filter { patch -> + patch.isCompatibleWith( + packageName = packageName, + includeExperimental = true, + includeUniversalPatches = true, + ) } - } + .mapNotNull { it.name?.lowercase() } + .toSet() + + val allMppPatchNames = bundlePatches.mapNotNull { it.name?.lowercase() }.toSet() + val jsonPatchNames = bundleOpts.patches.keys.map { it.lowercase() }.toSet() - if (element is JsonArray) { - val elementType = type.arguments.firstOrNull()?.type ?: typeOf() - return element.map { deserializeOptionValue(it, elementType) } + val newPatches = compatiblePatchNames - jsonPatchNames + val oldPatches = jsonPatchNames - compatiblePatchNames + val removedPatches = jsonPatchNames - allMppPatchNames + + val patchesWithNewOptions = mutableMapOf>() + val patchesWithOldOptions = mutableMapOf>() + + for ((patchName, _) in bundleSnapshot.patches) { + if (patchName.lowercase() !in compatiblePatchNames) continue + val jsonEntry = bundleOpts.patches.entries + .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value + ?: continue + + val actualPatch = bundlePatches.find { patch -> + if (!patch.name.equals(patchName, ignoreCase = true)) return@find false + patch.isCompatibleWith( + packageName = packageName, + includeExperimental = true, + includeUniversalPatches = true, + ) + } + val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() + + val newOptionKeys = actualOptionKeys - jsonEntry.options.keys + if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys + + val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys + if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys } - return element.toString() + return OptionsFileDrift( + newPatches = newPatches, + oldPatches = oldPatches, + removedPatches = removedPatches, + patchesWithNewOptions = patchesWithNewOptions, + patchesWithOldOptions = patchesWithOldOptions, + ) +} + +/** + * Reads a list of [PatchBundle]s from [file]. + */ +fun readPatchBundles(file: File): List = + Json.decodeFromString>(file.readText()) + +/** + * Writes a pretty-printed list of [PatchBundle]s to [file], creating parent directories if needed. + */ +fun writePatchBundles(file: File, bundles: List) { + file.absoluteFile.parentFile?.mkdirs() + file.writeText(optionsJson.encodeToString(bundles)) +} + +/** + * Merges [snapshots] into [file] (preserving existing user settings) and writes the result back. + */ +fun updateOptionsFileFromSnapshots(file: File, snapshots: List) { + val existingBundles = if (file.exists()) { + try { + readPatchBundles(file) + } catch (_: Exception) { + emptyList() + } + } else emptyList() + + var updatedBundles = existingBundles + snapshots.forEach { snapshot -> + val sourceFile = snapshot.meta.source?.let { File(it) } + val existing = if (sourceFile != null) { + updatedBundles.findMatchingBundle(setOf(sourceFile)) + } else null + val updated = snapshot.mergeWith(existing) + updatedBundles = updatedBundles.withUpdatedBundle(updated) + } + writePatchBundles(file, updatedBundles) } diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchPreferencesRepository.kt b/src/main/kotlin/app/morphe/engine/options/PatchPreferencesRepository.kt similarity index 83% rename from src/main/kotlin/app/morphe/gui/data/repository/PatchPreferencesRepository.kt rename to src/main/kotlin/app/morphe/engine/options/PatchPreferencesRepository.kt index a656dfc6..882016e7 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchPreferencesRepository.kt +++ b/src/main/kotlin/app/morphe/engine/options/PatchPreferencesRepository.kt @@ -3,32 +3,34 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.gui.data.repository +package app.morphe.engine.options -import app.morphe.desktop.command.model.PatchBundle -import app.morphe.desktop.command.model.PatchBundleMeta -import app.morphe.desktop.command.model.PatchEntry -import app.morphe.gui.util.FileUtils -import app.morphe.gui.util.Logger +import app.morphe.engine.MorpheData import java.io.File import java.time.Instant import java.time.format.DateTimeFormatter +import java.util.logging.Level +import java.util.logging.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement + /** * Stores per-(source, package) patch selections and option values across sessions. * - * On disk: /patch-preferences.json - * Schema: Map> + * On disk: `/patch-preferences.json` + * Schema: `Map>` * - * The on-disk shape reuses [PatchBundle] / [PatchEntry] from the CLI options file + * The on-disk shape reuses [PatchBundle] / [PatchEntry] from the options file * format, so prefs files are interchangeable with `patch --options-file` input/output. */ -class PatchPreferencesRepository { +class PatchPreferencesRepository( + private val file: File = File(MorpheData.root, FILE_NAME), +) { + private val logger = Logger.getLogger(PatchPreferencesRepository::class.java.name) private val json = Json { prettyPrint = true @@ -39,11 +41,8 @@ class PatchPreferencesRepository { private val mutex = Mutex() private var cache: MutableMap>? = null - private fun prefsFile(): File = File(FileUtils.getAppDataDir(), "patch-preferences.json") - private fun load(): MutableMap> { cache?.let { return it } - val file = prefsFile() val parsed = try { if (file.exists()) { json.decodeFromString>>(file.readText()) @@ -53,7 +52,7 @@ class PatchPreferencesRepository { mutableMapOf() } } catch (e: Exception) { - Logger.error("Failed to load patch preferences, starting fresh", e) + logger.log(Level.WARNING, "Failed to load patch preferences, starting fresh", e) mutableMapOf() } cache = parsed @@ -124,13 +123,16 @@ class PatchPreferencesRepository { byPkg[packageName] = bundle try { - val file = prefsFile() file.parentFile?.mkdirs() file.writeText(json.encodeToString(all as Map>)) - Logger.info("Saved patch preferences for $sourceName / $packageName (${patches.size} entries)") + logger.info("Saved patch preferences for $sourceName / $packageName (${patches.size} entries)") } catch (e: Exception) { - Logger.error("Failed to write patch preferences", e) + logger.log(Level.SEVERE, "Failed to write patch preferences", e) } } } + + companion object { + const val FILE_NAME = "patch-preferences.json" + } } diff --git a/src/main/kotlin/app/morphe/engine/patches/AppVersionResolver.kt b/src/main/kotlin/app/morphe/engine/patches/AppVersionResolver.kt new file mode 100644 index 00000000..20dbe46a --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/AppVersionResolver.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.model.VersionResolution +import app.morphe.engine.model.VersionStatus +import app.morphe.engine.util.compareVersions + +/** + * Determine the status of [currentVersion] relative to the stable and + * experimental versions known for [app]. + */ +fun resolveVersionStatus( + currentVersion: String, + app: SupportedApp, + versionCode: Int? = null, +): VersionResolution { + val stableList = app.supportedVersions + val experimentalList = app.experimentalVersions + + val latestStable = stableList.firstOrNull() + val oldestStable = stableList.lastOrNull() + val latestExperimental = experimentalList.firstOrNull() + + if (latestStable == null && latestExperimental == null) { + return VersionResolution(VersionStatus.UNKNOWN, null) + } + + val versionIsKnown = currentVersion in stableList || currentVersion in experimentalList + if (versionIsKnown && !app.buildCodeSupported(currentVersion, versionCode)) { + return VersionResolution(VersionStatus.BUILD_UNSUPPORTED, currentVersion) + } + + // Exact matches in either bucket + if (latestStable != null && currentVersion == latestStable) { + return VersionResolution(VersionStatus.LATEST_STABLE, latestStable) + } + if (latestExperimental != null && currentVersion == latestExperimental) { + return VersionResolution(VersionStatus.LATEST_EXPERIMENTAL, latestExperimental) + } + if (currentVersion in stableList) { + return VersionResolution(VersionStatus.OLDER_STABLE, latestStable) + } + if (currentVersion in experimentalList) { + return VersionResolution(VersionStatus.OLDER_EXPERIMENTAL, latestExperimental) + } + + val newestKnown = when { + latestStable == null -> latestExperimental + latestExperimental == null -> latestStable + compareVersions(latestStable, latestExperimental) >= 0 -> latestStable + else -> latestExperimental + } + + if (compareVersions(currentVersion, newestKnown) > 0) { + return VersionResolution(VersionStatus.TOO_NEW, newestKnown) + } + if (oldestStable != null && compareVersions(currentVersion, oldestStable) < 0) { + return VersionResolution(VersionStatus.TOO_OLD, oldestStable) + } + + return VersionResolution( + VersionStatus.UNSUPPORTED_BETWEEN, + latestStable ?: latestExperimental + ) +} diff --git a/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt index 403b2748..9c48c9d4 100644 --- a/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt @@ -11,10 +11,10 @@ import app.morphe.engine.model.ReleaseAsset import app.morphe.engine.network.HttpService import io.ktor.client.request.headers import io.ktor.http.HttpHeaders -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.io.File import java.util.logging.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * GitHub provider. Hits api.github.com/repos/{owner}/{repo}/releases for the @@ -52,15 +52,16 @@ open class GitHubPatchSource( } override suspend fun fetchLatestFromManifest(prerelease: Boolean): Result = withContext(Dispatchers.IO) { + val branch = if (prerelease) "dev" else "main" try { - val branch = if (prerelease) "dev" else "main" val url = "$RAW_BASE/$repoPath/$branch/patches-bundle.json" logger.info("GitHub: fetching patches-bundle.json from $url") val bundle: PatchesBundle = http.request(url) logger.info("GitHub: bundle ($branch) → v${bundle.version} @ ${bundle.downloadUrl}") Result.success(bundle.toRelease(prerelease)) } catch (e: Exception) { - logger.info("GitHub: no usable patches-bundle.json for $repoPath (${e.message}). Falling back to releases API") + val reason = (e as? HttpService.HttpException)?.status?.toString() ?: (e.message ?: "unknown error") + logger.info("GitHub: no usable patches-bundle.json ($branch) for $repoPath ($reason); falling back to releases API") Result.failure(e) } } @@ -78,7 +79,6 @@ open class GitHubPatchSource( logger.info("GitHub: wrote ${file.length()} bytes to ${file.absolutePath}") Result.success(file) } catch (e: Exception) { - logger.warning("GitHub download failed (${e::class.simpleName}): ${e.message}") Result.failure(e) } } diff --git a/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt index 82ded480..e6930ff7 100644 --- a/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt @@ -13,6 +13,10 @@ import io.ktor.client.request.headers import io.ktor.client.statement.HttpResponse import io.ktor.http.HttpHeaders import io.ktor.http.isSuccess +import java.io.File +import java.net.URLEncoder +import java.util.logging.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -24,9 +28,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.io.File -import java.net.URLEncoder -import java.util.logging.Logger /** * GitLab provider. Hits gitlab.com/api/v4/projects/{owner%2Frepo}/releases for @@ -71,15 +72,16 @@ class GitLabPatchSource( } override suspend fun fetchLatestFromManifest(prerelease: Boolean): Result = withContext(Dispatchers.IO) { + val branch = if (prerelease) "dev" else "main" try { - val branch = if (prerelease) "dev" else "main" val url = "$RAW_BASE/$repoPath/-/raw/$branch/patches-bundle.json" logger.info("GitLab: fetching patches-bundle.json from $url") val bundle: PatchesBundle = http.request(url) logger.info("GitLab: bundle ($branch) → v${bundle.version} @ ${bundle.downloadUrl}") Result.success(bundle.toRelease(prerelease)) } catch (e: Exception) { - logger.info("GitLab: no usable patches-bundle.json for $repoPath (${e.message}); will fall back to releases API") + val reason = (e as? HttpService.HttpException)?.status?.toString() ?: (e.message ?: "unknown error") + logger.info("GitLab: no usable patches-bundle.json ($branch) for $repoPath ($reason); falling back to releases API") Result.failure(e) } } @@ -97,7 +99,6 @@ class GitLabPatchSource( logger.info("GitLab: wrote ${file.length()} bytes to ${file.absolutePath}") Result.success(file) } catch (e: Exception) { - logger.warning("GitLab download failed (${e::class.simpleName}): ${e.message}") Result.failure(e) } } @@ -186,6 +187,8 @@ class GitLabPatchSource( return 0L } response.headers[HttpHeaders.ContentLength]?.toLongOrNull() ?: 0L + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger.fine("HEAD failed for $url: ${e.message}") 0L diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt b/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt index 3e3381a2..e1700dcc 100644 --- a/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt +++ b/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt @@ -5,6 +5,7 @@ package app.morphe.engine.patches +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar import java.io.File @@ -42,9 +43,10 @@ object PatchBundleLoader { */ fun loadEach(files: Iterable): List = files.map { file -> + val shadowCopy = WorkspaceManager.createShadowCopy(file, prefix = "bundle-${file.nameWithoutExtension}") LoadedBundle( sourceFile = file, - patches = loadPatchesFromJar(setOf(file)).toSet(), + patches = loadPatchesFromJar(setOf(shadowCopy)).toSet(), ) } diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt index 6a6bf815..6a4cbae9 100644 --- a/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt +++ b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt @@ -8,18 +8,32 @@ package app.morphe.engine.patches import app.morphe.engine.MorpheData import app.morphe.engine.model.Release import app.morphe.engine.model.ReleaseAsset +import app.morphe.engine.util.compareVersions +import app.morphe.engine.util.isDevTag +import app.morphe.engine.util.normalizeVersion import java.io.File +import java.time.Instant +import java.util.logging.Logger /** - * Shared on-disk cache layout for downloaded `.mpp` patch files. + * Shared on-disk cache and local `.mpp` discovery layer for patch bundles. * - * Both the GUI ([app.morphe.gui.data.repository.PatchRepository]) and the CLI - * ([app.morphe.desktop.command.PatchFileResolver]) resolve their cache paths through here, - * so a patch file downloaded by one side is reused by the other instead of each keeping its own copy in a different place. + * Both the GUI and the CLI resolve and validate their cached patch files through + * this object so a patch file downloaded by one surface is reused by the other. * * Layout: `/-/__.mpp` */ object PatchCache { + private val logger = Logger.getLogger(PatchCache::class.java.name) + + /** + * Build-output classifiers a patch build emits alongside the real bundle (same + * convention as Maven's `-sources.jar` / `-javadoc.jar`). Always excluded when + * auto-picking the newest `.mpp` in a developer directory. + */ + val DEFAULT_EXCLUDED_MPP_GLOBS = listOf("*-sources.mpp", "*-javadoc.mpp") + + private val versionRegex = Regex("""v?(\d+\.\d+\.\d+(?:[-._][A-Za-z0-9.]+)?)""") /** Per-source cache directory, e.g. `morphe-data/patches/MorpheApp-morphe-patches/`. */ fun sourceDir(repoPath: String): File = @@ -33,5 +47,214 @@ object PatchCache { * overwriting each other in the cache. */ fun cachedFileName(release: Release, asset: ReleaseAsset): String = - "${release.tagName}__${asset.name}" + if (asset.name.startsWith("${release.tagName}__")) asset.name + else "${release.tagName}__${asset.name}" + + /** Target cache [File] for a given [repoPath], [release], and [asset]. */ + fun cachedFile(repoPath: String, release: Release, asset: ReleaseAsset): File = + File(sourceDir(repoPath), cachedFileName(release, asset)) + + /** + * Cache-hit check: + * - File must exist and be non-empty (`length() > 0L`). + * - When [expectedSize] is known (`> 0L`), file length must match [expectedSize] exactly. + * - When [expectedSize] is `0L` (unknown size, e.g. GitLab or PR zip artifacts), any non-empty file is valid. + */ + fun isValidCachedFile(file: File, expectedSize: Long = 0L): Boolean = + file.exists() && file.length() > 0L && (expectedSize <= 0L || file.length() == expectedSize) + + /** + * Return the cached `.mpp` file for [release] in [repoPath] if it is already downloaded + * and passes size validation, or `null` on a cache miss. + * Also supports synthetic offline releases whose asset name is already the on-disk filename. + */ + fun getCachedFile(repoPath: String, release: Release): File? { + val asset = release.findPatchAsset() ?: release.assets.firstOrNull() ?: return null + val dir = sourceDir(repoPath) + val primary = File(dir, cachedFileName(release, asset)) + if (isValidCachedFile(primary, asset.size)) return primary + + val direct = File(dir, asset.name) + if (direct != primary && isValidCachedFile(direct, asset.size)) return direct + return null + } + + /** + * List all non-empty cached `.mpp` / `.jar` files for [repoPath]. + * + * @param prNumber when non-null, returns only cached files for that PR (`pr--*`). + * When null, excludes `pr-*` files unless [includePrs] is true. + */ + fun listCachedFiles( + repoPath: String, + prNumber: String? = null, + includePrs: Boolean = false, + ): List { + val dir = sourceDir(repoPath) + val prPrefix = prNumber?.let { "pr-$it-" } + return dir.listFiles { file -> + if (!file.isFile || file.length() <= 0L) return@listFiles false + val ext = file.extension.lowercase() + if (ext != "mpp" && ext != "jar") return@listFiles false + when { + prPrefix != null -> file.name.startsWith(prPrefix) + !includePrs -> !file.name.startsWith("pr-") + else -> true + } + }?.toList() ?: emptyList() + } + + /** Return the most recently modified cached patch file for [repoPath], or `null` if none exists. */ + fun findLatestCachedFile(repoPath: String, prNumber: String? = null): File? = + listCachedFiles(repoPath, prNumber).maxByOrNull { it.lastModified() } + + /** + * Find a cached patch file for a specific [version] or tag in [repoPath]. + * Checks the `__` filename prefix first, then filename substring, then `MANIFEST.MF` `Version`. + */ + fun findCachedByVersion(repoPath: String, version: String): File? { + val cleanTarget = version.normalizeVersion() + if (cleanTarget.isBlank()) return null + val files = listCachedFiles(repoPath, includePrs = version.startsWith("pr-")) + + // 1. Exact tag prefix match (__.mpp) + files.filter { file -> + file.name.contains("__") && + file.name.substringBefore("__").normalizeVersion().equals(cleanTarget, ignoreCase = true) + }.maxByOrNull { it.lastModified() }?.let { return it } + + // 2. Filename contains version or MANIFEST.MF Version matches + return files.filter { file -> + file.name.contains(version, ignoreCase = true) || + file.name.contains(cleanTarget, ignoreCase = true) || + PatchBundleLoader.extractVersion(file)?.normalizeVersion()?.equals(cleanTarget, ignoreCase = true) == true + }.maxByOrNull { it.lastModified() } + } + + /** + * Extract a display/resolution version label from a cached or local `.mpp` [file]. + */ + fun extractVersionLabel(file: File, isPrSource: Boolean = false): String { + if (isPrSource) { + PatchBundleLoader.extractVersion(file)?.let { return it } + } + val prefix = file.nameWithoutExtension.substringBefore("__") + if (file.name.contains("__") && prefix.isNotBlank()) { + return prefix + } + val match = versionRegex.find(prefix) + if (match != null) return match.value + return PatchBundleLoader.extractVersion(file) ?: file.nameWithoutExtension + } + + /** + * Synthesize offline [Release] objects from cached `.mpp` files in [repoPath], + * sorted from newest to oldest version via [compareVersions]. + */ + fun listOfflineReleases(repoPath: String, prNumber: String? = null): List { + return listCachedFiles(repoPath, prNumber) + .mapNotNull { buildOfflineRelease(it) } + .sortedWith { a, b -> compareVersions(b.tagName, a.tagName) } + } + + /** + * Build a synthetic [Release] from a cached `.mpp` file for offline display and selection. + */ + fun buildOfflineRelease(file: File): Release? { + val rawVersion = if (file.name.contains("__")) { + file.name.substringBefore("__").takeIf { it.isNotBlank() } + } else { + versionRegex.find(file.nameWithoutExtension)?.groupValues?.get(1) + ?: PatchBundleLoader.extractVersion(file) + } ?: return null + + val tagName = if (rawVersion.firstOrNull()?.isDigit() == true) "v$rawVersion" else rawVersion + + return Release( + id = file.name.hashCode().toLong(), + tagName = tagName, + name = tagName, + isPrerelease = tagName.isDevTag() || tagName.startsWith("pr-", ignoreCase = true), + publishedAt = Instant.ofEpochMilli(file.lastModified()).toString(), + assets = listOf( + ReleaseAsset( + id = file.name.hashCode().toLong(), + name = file.name, + downloadUrl = "", + size = file.length(), + contentType = "application/octet-stream", + ) + ), + ) + } + + /** + * Delete all cached patch files for [repoPath]. + * @return `true` if all files in the source's cache directory were deleted. + */ + fun clearSource(repoPath: String): Boolean { + return try { + val patchesDir = sourceDir(repoPath) + var failedCount = 0 + patchesDir.listFiles()?.forEach { file -> + try { + if (!file.deleteRecursively()) { + failedCount++ + logger.warning("Failed to delete ${file.name}") + } + } catch (e: Exception) { + failedCount++ + logger.warning("Failed to delete ${file.name}: ${e.message ?: e::class.simpleName}") + } + } + if (failedCount > 0) { + logger.warning("Patches cache clear incomplete for $repoPath: $failedCount file(s) locked") + false + } else { + logger.info("Patches cache cleared for $repoPath") + true + } + } catch (e: Exception) { + logger.warning("Failed to clear patches cache for $repoPath: ${e.message ?: e::class.simpleName}") + false + } + } + + /** + * Newest loadable `.mpp` file directly inside [dir] (by last-modified time), or `null` + * when the folder contains none. Ignores non-`.mpp` files, [DEFAULT_EXCLUDED_MPP_GLOBS], + * and any [extraExcludedPatterns]. + */ + fun newestMppIn(dir: File, extraExcludedPatterns: List = emptyList()): File? { + val matchers = (DEFAULT_EXCLUDED_MPP_GLOBS + extraExcludedPatterns) + .mapNotNull { p -> p.trim().takeIf { it.isNotEmpty() }?.let(::toExclusionMatcher) } + return dir.listFiles { f -> + f.isFile && + f.extension.equals("mpp", ignoreCase = true) && + matchers.none { it(f.name) } + }?.maxByOrNull { it.lastModified() } + } + + private fun toExclusionMatcher(pattern: String): (String) -> Boolean { + if ('*' in pattern || '?' in pattern) { + val regex = globToRegex(pattern) + return { name -> regex.matches(name) } + } + return { name -> name.contains(pattern, ignoreCase = true) } + } + + private fun globToRegex(glob: String): Regex { + val pattern = buildString { + for (c in glob) when (c) { + '*' -> append(".*") + '?' -> append('.') + '.', '(', ')', '[', ']', '{', '}', '+', '^', '$', '|', '\\' -> { + append('\\') + append(c) + } + else -> append(c) + } + } + return Regex(pattern, RegexOption.IGNORE_CASE) + } } diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchExtensions.kt b/src/main/kotlin/app/morphe/engine/patches/PatchExtensions.kt new file mode 100644 index 00000000..07e0ad06 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchExtensions.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.patcher.patch.Patch +import app.morphe.patcher.patch.SupportedAbi + +fun Patch<*>.versionCodesFor( + packageName: String?, + versionName: String, +): Map? { + val compat = compatibility ?: return null + return compat + .filter { packageName == null || it.packageName == null || it.packageName == packageName } + .flatMap { it.targets } + .find { it.version == versionName && !it.versionCodes.isNullOrEmpty() } + ?.versionCodes +} + +fun Iterable>.versionCodesFor( + packageName: String?, + versionName: String, +): Map? = + firstNotNullOfOrNull { it.versionCodesFor(packageName, versionName) } + +@Suppress("DEPRECATION") +fun Patch<*>.isCompatibleWith( + packageName: String, + includeExperimental: Boolean, + includeUniversalPatches: Boolean, +): Boolean { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + return compat.any { entry -> + when { + entry.packageName == null -> includeUniversalPatches + entry.packageName != packageName -> false + else -> { + entry.targets.any { target -> + includeExperimental || !target.isExperimental + } + } + } + } + } + + val legacyPackages = compatiblePackages + ?: return includeUniversalPatches + + return legacyPackages.any { (name, _) -> name == packageName } +} + +@Suppress("DEPRECATION") +fun Patch<*>.supportedVersionsFor(packageName: String): List? { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + val matching = compat.filter { it.packageName == packageName } + if (matching.isEmpty()) { + val hasUniversalEntry = compat.any { it.packageName == null } + return if (hasUniversalEntry) null else emptyList() + } + val versions = matching.flatMap { entry -> entry.targets.mapNotNull { it.version } } + return versions.ifEmpty { null } + } + + val legacyPackages = compatiblePackages ?: return null + val match = legacyPackages.singleOrNull { (name, _) -> name == packageName } + ?: return emptyList() + val legacyVersions = match.second + return if (legacyVersions.isNullOrEmpty()) null else legacyVersions.toList() +} + +@Suppress("DEPRECATION") +fun Patch<*>.compatibleVersionsForDisplay( + includeExperimental: Boolean, +): List>> { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + return compat.map { entry -> + val versions = entry.targets + .filter { includeExperimental || !it.isExperimental } + .mapNotNull { it.version } + entry.packageName to versions + } + } + + val legacyPackages = compatiblePackages ?: return emptyList() + return legacyPackages.map { (name, versions) -> + name to (versions?.toList() ?: emptyList()) + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchRepository.kt b/src/main/kotlin/app/morphe/engine/patches/PatchRepository.kt new file mode 100644 index 00000000..c9a45496 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchRepository.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import java.io.File +import java.util.logging.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Engine-level caching and release-resolution repository wrapping a [RemotePatchSource]. + * + * Provides: + * - 5-minute in-memory TTL on the release listing with stale-on-error fallback + * - Manifest-first (`patches-bundle.json`) latest release lookup for stable and dev channels + * - Size-validated on-disk `.mpp` caching via [PatchCache] + */ +class PatchRepository( + val remoteSource: RemotePatchSource, +) { + val repoPath: String get() = remoteSource.repoPath + + companion object { + private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes + private const val FAILURE_TTL_MS = 10 * 1000L // 10 seconds to coalesce concurrent callers on failure + private val logger = Logger.getLogger(PatchRepository::class.java.name) + } + + private val fetchMutex = Mutex() + + // In-memory cache so multiple callers don't re-fetch from the remote API + private var cachedReleases: List? = null + private var lastFailureResult: Result>? = null + private var cacheTimestamp: Long = 0L + + /** + * Fetch all releases. Returns cached result if still fresh. + * @param forceRefresh bypass the in-memory cache + */ + suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = + withContext(Dispatchers.IO) { + fetchMutex.withLock { + val cached = cachedReleases + if (!forceRefresh && cached != null && + (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS + ) { + logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") + return@withContext Result.success(cached) + } + + // If a fetch failed within the last FAILURE_TTL_MS, reuse that failure instead of + // spamming concurrent API calls to the same failing endpoint. + if (!forceRefresh && lastFailureResult != null && + (System.currentTimeMillis() - cacheTimestamp) < FAILURE_TTL_MS + ) { + val stale = cachedReleases + if (stale != null) { + return@withContext Result.success(stale) + } + return@withContext lastFailureResult!! + } + + val result = remoteSource.listReleases() + cacheTimestamp = System.currentTimeMillis() + result.onSuccess { fresh -> + cachedReleases = fresh + lastFailureResult = null + } + // If fetch failed but we still have stale data, prefer the stale + // data over a hard error so offline / flaky-network sessions stay usable. + if (result.isFailure) { + lastFailureResult = result + val stale = cachedReleases + if (stale != null) { + logger.info("Returning stale cached releases after fetch failure") + return@withContext Result.success(stale) + } + } + result + } + } + + /** Stable releases only (non-prerelease). */ + suspend fun fetchStableReleases(): Result> = + fetchReleases().map { releases -> releases.filter { !it.isDevRelease() } } + + /** Dev / prerelease versions only. */ + suspend fun fetchDevReleases(): Result> = + fetchReleases().map { releases -> releases.filter { it.isDevRelease() } } + + suspend fun getLatestStableRelease(): Result = + latestFromManifestOrApi(prerelease = false) + + suspend fun getLatestDevRelease(): Result = + latestFromManifestOrApi(prerelease = true) + + /** + * Resolve the latest release for a channel via the repo's `patches-bundle.json` + * (raw CDN, no API rate limit); fall back to the releases API only when the source + * publishes no manifest. This keeps the 60/hr GitHub API budget for listing older versions. + */ + private suspend fun latestFromManifestOrApi(prerelease: Boolean): Result = + withContext(Dispatchers.IO) { + remoteSource.fetchLatestFromManifest(prerelease).getOrNull()?.let { + logger.info("Latest ${if (prerelease) "dev" else "stable"} via patches-bundle.json: ${it.tagName}") + return@withContext Result.success(it) + } + if (prerelease) fetchDevReleases().map { it.firstOrNull() } + else fetchStableReleases().map { it.firstOrNull() } + } + + /** + * Download the patch `.mpp` file from a release. Uses [PatchCache] — + * if a size-verified matching file is already present on disk, skips the network call. + */ + suspend fun downloadPatches( + release: Release, + onProgress: (Float) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + val asset = release.findPatchAsset() + ?: return@withContext Result.failure( + Exception("No .mpp patch files found in release ${release.tagName}") + ) + + PatchCache.getCachedFile(repoPath, release)?.let { cachedFile -> + logger.info("Using cached patches: ${cachedFile.absolutePath} (${cachedFile.length()} bytes)") + onProgress(1f) + return@withContext Result.success(cachedFile) + } + + val targetFile = PatchCache.cachedFile(repoPath, release, asset) + val result = remoteSource.downloadAsset(asset, targetFile) { bytesRead, contentLength -> + if (contentLength != null && contentLength > 0L) { + onProgress((bytesRead.toFloat() / contentLength).coerceIn(0f, 1f)) + } + } + if (result.isSuccess) onProgress(1f) + result + } + + /** Delete cached patches (both in-memory release list and on-disk files). */ + fun clearCache(): Boolean { + cachedReleases = null + cacheTimestamp = 0L + return PatchCache.clearSource(repoPath) + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchResolver.kt b/src/main/kotlin/app/morphe/engine/patches/PatchResolver.kt new file mode 100644 index 00000000..1b249b92 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchResolver.kt @@ -0,0 +1,335 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.GitHubPatMissingException +import app.morphe.engine.model.Release +import app.morphe.engine.network.sharedHttpClient +import app.morphe.engine.util.newerRelease +import app.morphe.engine.util.normalizeVersion +import io.ktor.client.HttpClient +import java.io.File +import java.util.logging.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +/** + * Patch resolution engine shared by both CLI and GUI. + * + * Handles: + * - Local `.mpp` files and developer build directories ([PatchCache.newestMppIn]) + * - Remote sources (GitHub, GitLab, GitHub PRs) via manifest-first `patches-bundle.json` + * or release API for pinned tags + * - Semantic channel comparison ([newerRelease]) so outdated pre-releases never beat newer stables + * - Size-verified on-disk caching and offline fallback via [PatchCache] + */ +object PatchResolver { + private val logger = Logger.getLogger(PatchResolver::class.java.name) + + /** Release channel classification for a resolved patch bundle. */ + enum class Channel { STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER, LOCAL, UNKNOWN } + + /** Structured resolution failure reason so callers can format or localize errors. */ + sealed class Error(open val message: String) { + data object LocalPathEmpty : Error("Local source path is empty") + data class LocalFileNotFound(val fileName: String) : Error("Local source file not found: $fileName") + data class LocalNoMppInDirectory(val dirName: String) : Error("No .mpp patch file found in $dirName") + data class NoRepository(val sourceName: String) : Error("No repository configured for source $sourceName") + data class GitHubPatRequired(override val message: String) : Error(message) + data class PrNoArtifact(override val message: String) : Error(message) + data class PinnedTagNotFound(val tag: String, val repoPath: String) : Error("Version $tag not found in $repoPath") + data class ReleaseFetchFailed(override val message: String = "Failed to fetch releases") : Error(message) + data class DownloadFailed(override val message: String) : Error(message) + } + + /** Outcome of resolving a single local or remote patch source. */ + data class ResolutionResult( + val patchFile: File? = null, + val resolvedVersion: String? = null, + val latestAvailableVersion: String? = null, + val isOffline: Boolean = false, + val channel: Channel = Channel.UNKNOWN, + val error: Error? = null, + ) + + /** + * Resolve a local `.mpp` file or developer directory (auto-selecting the newest `.mpp` + * while excluding build classifiers and [excludedMppPatterns]). + */ + fun resolveLocal( + path: String?, + excludedMppPatterns: List = emptyList(), + ): ResolutionResult { + if (path.isNullOrBlank()) { + return ResolutionResult(error = Error.LocalPathEmpty) + } + val target = File(path) + if (!target.exists()) { + return ResolutionResult(error = Error.LocalFileNotFound(target.name)) + } + val file = if (target.isDirectory) { + PatchCache.newestMppIn(target, excludedMppPatterns) + ?: return ResolutionResult(error = Error.LocalNoMppInDirectory(target.name)) + } else { + target + } + val manifestVersion = PatchBundleLoader.extractVersion(file) + return ResolutionResult( + patchFile = file, + resolvedVersion = manifestVersion ?: file.nameWithoutExtension, + isOffline = false, + channel = Channel.LOCAL, + ) + } + + /** + * Resolve a remote patch source via [repo]. + * + * @param usePreRelease whether to track the dev/pre-release channel (compared via [newerRelease]) + * @param pinnedTag optional tag to pin to instead of following the latest channel release + * @param strictPin when true (CLI explicit tag URL), fails with [Error.PinnedTagNotFound] if + * [pinnedTag] is missing from both remote releases and the local disk cache. + * When false (GUI saved version pref), falls back to latest stable if the + * saved tag was deleted upstream. + */ + suspend fun resolveRemote( + repo: PatchRepository, + usePreRelease: Boolean, + pinnedTag: String? = null, + strictPin: Boolean = false, + onDownloadProgress: ((Float) -> Unit)? = null, + ): ResolutionResult = withContext(Dispatchers.IO) { + val isPrSource = repo.remoteSource is PullRequestPatchSource + val release: Release? + val latestStableTag: String? + val latestDevTag: String? + + if (pinnedTag != null) { + val releasesResult = repo.fetchReleases() + val releases = releasesResult.getOrNull() + if (releases.isNullOrEmpty()) { + if (isPrSource) { + return@withContext classifyPrOrFetchError(releasesResult.exceptionOrNull()) + } + return@withContext offlinePinnedOrFallback(repo, pinnedTag, strictPin, releasesResult.exceptionOrNull()) + } + val latestStable = releases.firstOrNull { !it.isDevRelease() } + val matched = releases.find { + it.tagName == pinnedTag || + it.tagName.normalizeVersion().equals(pinnedTag.normalizeVersion(), ignoreCase = true) + } + if (matched == null && strictPin) { + return@withContext ResolutionResult(error = Error.PinnedTagNotFound(pinnedTag, repo.repoPath)) + } + release = matched ?: latestStable ?: releases.firstOrNull() + latestStableTag = latestStable?.tagName + latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName + } else if (isPrSource) { + val prReleasesResult = repo.fetchReleases() + val prRelease = prReleasesResult.getOrNull()?.firstOrNull() + ?: return@withContext classifyPrOrFetchError(prReleasesResult.exceptionOrNull()) + release = prRelease + latestStableTag = null + latestDevTag = prRelease.tagName + } else { + val (stableResult, devResult) = coroutineScope { + val stableAsync = async { repo.getLatestStableRelease() } + val devAsync = async { repo.getLatestDevRelease() } + stableAsync.await() to devAsync.await() + } + val stable = stableResult.getOrNull() + val dev = devResult.getOrNull() + release = if (usePreRelease) newerRelease(dev, stable) else (stable ?: dev) + latestStableTag = stable?.tagName + latestDevTag = dev?.tagName + + if (release == null) { + val cause = if (usePreRelease) { + devResult.exceptionOrNull() ?: stableResult.exceptionOrNull() + } else { + stableResult.exceptionOrNull() ?: devResult.exceptionOrNull() + } + return@withContext offlineOrError(repo, cause) + } + } + + if (release == null) { + return@withContext offlineOrError(repo) + } + + val channel = when { + release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST + release.isDevRelease() -> Channel.DEV_OLDER + release.tagName == latestStableTag -> Channel.STABLE_LATEST + else -> Channel.STABLE_OLDER + } + + val downloadResult = repo.downloadPatches(release) { pct -> + onDownloadProgress?.invoke(pct) + } + val patchFile = downloadResult.getOrNull() ?: run { + val ex = downloadResult.exceptionOrNull() + if (isPrSource) { + return@withContext classifyPrOrDownloadError(ex) + } + return@withContext ResolutionResult( + error = Error.DownloadFailed(ex?.message ?: "Failed to download patches") + ) + } + + val manifestVersion = PatchBundleLoader.extractVersion(patchFile) + val resolvedVersion = if (isPrSource) { + manifestVersion ?: release.tagName + } else { + release.tagName + } + + ResolutionResult( + patchFile = patchFile, + resolvedVersion = resolvedVersion, + latestAvailableVersion = if (isPrSource) { + resolvedVersion + } else { + if (release.isDevRelease()) latestDevTag else latestStableTag + }, + isOffline = false, + channel = channel, + ) + } + + /** + * Resolve a set of CLI `--patches` inputs (local `.mpp` files, local directories, + * full GitHub/GitLab/PR URLs, deep links, or bare `owner/repo` specifiers). + * + * Preserves input order while replacing directories and remote specifiers with their + * resolved on-disk `.mpp` files. + */ + fun resolveCliFiles( + patchFiles: Iterable, + prerelease: Boolean, + httpClient: HttpClient = sharedHttpClient, + ): Set = runBlocking { + patchFiles.map { entry -> + resolveSingleCliEntry(entry, prerelease, httpClient) + }.toCollection(LinkedHashSet()) + } + + private suspend fun resolveSingleCliEntry( + entry: File, + prerelease: Boolean, + httpClient: HttpClient, + ): File { + if (entry.exists()) { + if (entry.isDirectory) { + val res = resolveLocal(entry.path) + val file = res.patchFile + ?: throw IllegalArgumentException(res.error?.message ?: "No .mpp file found in ${entry.path}") + logger.info("Resolved local directory ${entry.path} to ${file.absolutePath}") + return file + } + return entry + } + + val rawInput = entry.invariantSeparatorsPath + val parsed = RemotePatchSourceFactory.parse(rawInput) + ?: throw IllegalArgumentException("Unrecognized patch source or missing file: $rawInput") + + val source = parsed.instantiate(httpClient) + val repo = PatchRepository(source) + val res = resolveRemote( + repo = repo, + usePreRelease = prerelease, + pinnedTag = parsed.pinnedTag, + strictPin = parsed.pinnedTag != null, + ) + + val resolvedFile = res.patchFile + ?: throw IllegalArgumentException( + res.error?.message ?: "Failed to resolve patches from ${parsed.canonicalUrl}" + ) + + if (res.isOffline) { + logger.warning( + "Offline or rate-limited — using cached patch file at ${resolvedFile.absolutePath} (${res.resolvedVersion ?: resolvedFile.name})" + ) + } else { + logger.info("Resolved patches (${res.resolvedVersion ?: resolvedFile.name}) at ${resolvedFile.absolutePath}") + } + return resolvedFile + } + + private fun offlinePinnedOrFallback( + repo: PatchRepository, + pinnedTag: String, + strictPin: Boolean, + cause: Throwable? = null, + ): ResolutionResult { + val cachedForTag = PatchCache.findCachedByVersion(repo.repoPath, pinnedTag) + if (cachedForTag != null) { + val isPrSource = repo.remoteSource is PullRequestPatchSource + val resolvedVersion = PatchCache.extractVersionLabel(cachedForTag, isPrSource) + return ResolutionResult( + patchFile = cachedForTag, + resolvedVersion = resolvedVersion, + latestAvailableVersion = if (isPrSource) resolvedVersion else null, + isOffline = true, + ) + } + return if (strictPin) { + ResolutionResult(error = Error.PinnedTagNotFound(pinnedTag, repo.repoPath)) + } else { + offlineOrError(repo, cause) + } + } + + private fun offlineOrError(repo: PatchRepository, cause: Throwable? = null): ResolutionResult { + val prSource = repo.remoteSource as? PullRequestPatchSource + val cached = PatchCache.findLatestCachedFile(repo.repoPath, prSource?.prNumber) + return if (cached != null) { + val isPrSource = prSource != null + val resolvedVersion = PatchCache.extractVersionLabel(cached, isPrSource) + ResolutionResult( + patchFile = cached, + resolvedVersion = resolvedVersion, + latestAvailableVersion = if (isPrSource) resolvedVersion else null, + isOffline = true, + ) + } else { + val message = cause?.message?.takeIf { it.isNotBlank() } + ?: "Failed to fetch releases for ${repo.repoPath}" + ResolutionResult(error = Error.ReleaseFetchFailed(message)) + } + } + + private fun classifyPrOrFetchError(ex: Throwable?): ResolutionResult { + val msg = ex?.message ?: "No artifacts found for pull request" + val err = when { + ex is GitHubPatMissingException || msg.contains("A GitHub PAT is required", ignoreCase = true) -> + Error.GitHubPatRequired(msg) + msg.contains("No artifacts found", ignoreCase = true) || + msg.contains("No GitHub Actions run found", ignoreCase = true) -> + Error.PrNoArtifact(msg) + else -> Error.ReleaseFetchFailed(msg) + } + return ResolutionResult(error = err) + } + + private fun classifyPrOrDownloadError(ex: Throwable?): ResolutionResult { + val msg = ex?.message ?: "" + val err = when { + ex is GitHubPatMissingException || msg.contains("A GitHub PAT is required", ignoreCase = true) -> + Error.GitHubPatRequired(msg) + msg.contains("No artifacts found", ignoreCase = true) || + msg.contains("No GitHub Actions run found", ignoreCase = true) -> + Error.PrNoArtifact(msg) + else -> Error.DownloadFailed(msg) + } + return ResolutionResult(error = err) + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PullRequestPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/PullRequestPatchSource.kt index d87df00d..adcca194 100644 --- a/src/main/kotlin/app/morphe/engine/patches/PullRequestPatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/PullRequestPatchSource.kt @@ -6,15 +6,14 @@ package app.morphe.engine.patches import app.morphe.engine.GitHubPatMissingException +import app.morphe.engine.config.EngineConfigRepository import app.morphe.engine.model.Release import app.morphe.engine.model.ReleaseAsset import app.morphe.engine.network.HttpService -import app.morphe.gui.data.model.AppConfig -import app.morphe.gui.data.repository.ConfigRepository import java.io.File +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.koin.core.context.GlobalContext /** * Remote patch source for GitHub Pull Requests. @@ -27,20 +26,13 @@ class PullRequestPatchSource( val owner: String, val repo: String, val prNumber: String, - private val configRepository: ConfigRepository? = null, + private val patProvider: suspend () -> String? = ::defaultGitHubPatProvider, ) : GitHubPatchSource(http, "$owner/$repo") { override val provider: PatchProvider = PatchProvider.GITHUB_PR private var cachedRelease: Release? = null - private suspend fun getConfig(): AppConfig { - val repo = configRepository - ?: runCatching { GlobalContext.get().get() }.getOrNull() - ?: ConfigRepository() - return repo.loadConfig() - } - override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { try { cachedRelease?.let { return@withContext Result.success(listOf(it)) } @@ -48,7 +40,6 @@ class PullRequestPatchSource( cachedRelease = release Result.success(listOf(release)) } catch (e: Exception) { - logger.warning("GitHub PR: release resolution failed for $repoPath#$prNumber: ${e.message}") Result.failure(e) } } @@ -60,17 +51,11 @@ class PullRequestPatchSource( cachedRelease = release Result.success(release) } catch (e: Exception) { - logger.warning("GitHub PR: manifest/asset resolution failed for $repoPath#$prNumber: ${e.message}") Result.failure(e) } } - private suspend fun getGitHubPat(): String? { - val config = getConfig() - return config.gitHubPat.trim().takeIf { it.isNotEmpty() } - ?: System.getenv("GITHUB_TOKEN")?.trim()?.takeIf { it.isNotEmpty() } - ?: System.getenv("GH_TOKEN")?.trim()?.takeIf { it.isNotEmpty() } - } + private suspend fun getGitHubPat(): String? = patProvider() private suspend fun resolvePrRelease(): Release { val pat = getGitHubPat() @@ -87,7 +72,7 @@ class PullRequestPatchSource( id = 0L, name = assetName, downloadUrl = prAsset.downloadUrl, - size = 0L, // 0L prevents PatchRepository cache checks from failing against unzipped file size + size = 0L, // 0L prevents cache checks from failing against unzipped file size contentType = "application/zip", ) @@ -119,8 +104,27 @@ class PullRequestPatchSource( logger.info("GitHub PR: downloaded ${file.length()} bytes to ${file.absolutePath}") Result.success(file) } catch (e: Exception) { - logger.warning("GitHub PR download failed: ${e.message}") Result.failure(e) } } + + companion object { + /** + * Reads the GitHub PAT from `EngineConfigRepository` (if configured) + * or falls back to `GITHUB_TOKEN` / `GH_TOKEN` environment variables. + */ + suspend fun defaultGitHubPatProvider(): String? = withContext(Dispatchers.IO) { + val fromConfig = try { + EngineConfigRepository.shared.getGitHubPat().trim().takeIf { it.isNotEmpty() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + fromConfig + ?: System.getenv("GITHUB_TOKEN")?.trim()?.takeIf { it.isNotEmpty() } + ?: System.getenv("GH_TOKEN")?.trim()?.takeIf { it.isNotEmpty() } + } + } } diff --git a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt index 6536ea55..1e52bb61 100644 --- a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt @@ -21,10 +21,9 @@ import java.io.File * - Multi-source orchestration * - UI / progress reporting style * - * This is the engine layer's heart for remote patches. Both the GUI's - * PatchRepository and the CLI's PatchFileResolver call into a - * RemotePatchSource to do the real work, while owning their own caching - * and surface-specific concerns on top. + * This is the engine layer's heart for remote patches. Both the unified + * [PatchRepository] and [PatchResolver] call into a [RemotePatchSource] to + * fetch and download releases across both GUI and CLI surfaces. */ interface RemotePatchSource { /** Which remote provider this source talks to. */ diff --git a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt index 6dd4adcd..424df220 100644 --- a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt +++ b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt @@ -12,12 +12,13 @@ import io.ktor.client.HttpClient * Centralized URL parsing + provider detection for remote patch sources. * * Single source of truth for "given some user input, figure out the - * provider, owner, and repo." Both GUI (PatchSourceManager, - * PatchSourceDialogs) and CLI (PatchFileResolver) call into this — never - * roll their own URL parsing. + * provider, owner, repo, and optional pinned release tag." Both GUI and CLI + * call into this — never roll their own URL parsing. * * Accepted inputs: * - Full URL: `https://github.com/owner/repo[/…]`, `https://gitlab.com/owner/repo[/…]` + * - Release tag URL: `https://github.com/owner/repo/releases/tag/v1.0.0`, `https://gitlab.com/owner/repo/-/releases/v1.0.0` + * - Pull request URL: `https://github.com/owner/repo/pull/123` * - Bare host path: `github.com/owner/repo`, `gitlab.com/owner/repo` * - Deep-link: `morphe.software/add-source?github=owner/repo` (or `?gitlab=owner/repo`) * - Bare `owner/repo` — defaults to GitHub for backwards compatibility @@ -26,31 +27,51 @@ import io.ktor.client.HttpClient */ object RemotePatchSourceFactory { + const val DEFAULT_REPO_PATH = "MorpheApp/morphe-patches" + + private val githubTagRegex = Regex("""github\.com/[^/]+/[^/]+/releases?/tag/([^/?#]+)""") + private val gitlabTagRegex = Regex("""gitlab\.com/[^/]+/[^/]+(?:/-)?/releases/([^/?#]+)""") + /** - * Parse a user-entered URL and return a [Parsed] descriptor on success, + * Normalize a raw path or URL string, restoring `https://` / `http://` when + * `java.io.File.invariantSeparatorsPath` collapses `https://` into `https:/`. + */ + fun normalizeInput(input: String): String { + val trimmed = input.trim() + return when { + trimmed.startsWith("https:/") && !trimmed.startsWith("https://") -> + "https://" + trimmed.removePrefix("https:/") + trimmed.startsWith("http:/") && !trimmed.startsWith("http://") -> + "http://" + trimmed.removePrefix("http:/") + else -> trimmed + } + } + + /** + * Parse a user-entered URL or specifier and return a [Parsed] descriptor on success, * null when the input can't be classified. * - * Use [instantiate] to turn the descriptor into a working [RemotePatchSource]. + * Use [Parsed.instantiate] to turn the descriptor into a working [RemotePatchSource]. * Splitting parse from instantiation lets callers validate URLs in - * dialogs without needing an HttpClient handy. + * dialogs or CLI option validators without needing an [HttpClient] handy. */ fun parse(input: String): Parsed? { - val trimmed = input.trim() - if (trimmed.isBlank()) return null + val normalized = normalizeInput(input) + if (normalized.isBlank()) return null // Deep-link form - if (trimmed.contains("morphe.software/add-source")) { - Regex("[?&]github=([^&]+)").find(trimmed)?.let { match -> + if (normalized.contains("morphe.software/add-source")) { + Regex("[?&]github=([^&]+)").find(normalized)?.let { match -> return buildParsed(match.groupValues[1], PatchProvider.GITHUB) } - Regex("[?&]gitlab=([^&]+)").find(trimmed)?.let { match -> + Regex("[?&]gitlab=([^&]+)").find(normalized)?.let { match -> return buildParsed(match.groupValues[1], PatchProvider.GITLAB) } return null } // GitHub PR form: github.com/owner/repo/pull/123 - val prMatch = Regex("github\\.com/([^/]+)/([^/]+)/pull/(\\d+)").find(trimmed) + val prMatch = Regex("github\\.com/([^/]+)/([^/]+)/pull/(\\d+)").find(normalized) if (prMatch != null) { val owner = prMatch.groupValues[1] val repo = prMatch.groupValues[2] @@ -58,20 +79,25 @@ object RemotePatchSourceFactory { return Parsed(PatchProvider.GITHUB_PR, "$owner/$repo", prNumber = prNumber) } - if (trimmed.contains("github.com/")) { - val match = Regex("github\\.com/([^/]+/[^/?#]+)").find(trimmed) ?: return null - return buildParsed(match.groupValues[1], PatchProvider.GITHUB) + if (normalized.contains("github.com/")) { + val match = Regex("github\\.com/([^/]+/[^/?#]+)").find(normalized) ?: return null + val pinnedTag = githubTagRegex.find(normalized)?.groupValues?.get(1) + return buildParsed(match.groupValues[1], PatchProvider.GITHUB, pinnedTag = pinnedTag) } - if (trimmed.contains("gitlab.com/")) { - val match = Regex("gitlab\\.com/([^/]+/[^/?#]+)").find(trimmed) ?: return null - return buildParsed(match.groupValues[1], PatchProvider.GITLAB) + if (normalized.contains("gitlab.com/")) { + val match = Regex("gitlab\\.com/([^/]+/[^/?#]+)").find(normalized) ?: return null + val pinnedTag = gitlabTagRegex.find(normalized)?.groupValues?.get(1) + return buildParsed(match.groupValues[1], PatchProvider.GITLAB, pinnedTag = pinnedTag) } // Bare "owner/repo" — assume GitHub for backwards compatibility with - // the historical default behavior. - if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { - return buildParsed(trimmed, PatchProvider.GITHUB) + // the historical default behavior. Exclude file paths (.mpp/.jar or relative path prefixes). + if (!normalized.startsWith("./") && !normalized.startsWith("../") && + !normalized.endsWith(".mpp", ignoreCase = true) && !normalized.endsWith(".jar", ignoreCase = true) && + normalized.matches(Regex("""[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9._-]+""")) + ) { + return buildParsed(normalized, PatchProvider.GITHUB) } return null @@ -92,28 +118,31 @@ object RemotePatchSourceFactory { if (provider == PatchProvider.GITHUB_PR) { val match = Regex("([^/]+)/([^/]+)/pull/(\\d+)").find(repoPath) if (match != null) { - return Parsed(PatchProvider.GITHUB_PR, "${match.groupValues[1]}/${match.groupValues[2]}", prNumber = match.groupValues[3]).instantiate(httpClient) + return Parsed( + PatchProvider.GITHUB_PR, + "${match.groupValues[1]}/${match.groupValues[2]}", + prNumber = match.groupValues[3], + ).instantiate(httpClient) } } return Parsed(provider, repoPath).instantiate(httpClient) } - private fun buildParsed(rawPath: String, provider: PatchProvider): Parsed? { + private fun buildParsed(rawPath: String, provider: PatchProvider, pinnedTag: String? = null): Parsed? { val clean = rawPath.trimEnd('/').removeSuffix(".git") if (!clean.contains('/') || clean.split('/').size != 2) return null - return Parsed(provider, clean) + return Parsed(provider, clean, pinnedTag = pinnedTag) } /** - * Result of parsing — provider + repoPath are all the engine needs to - * spin up a working source. The canonical URL is reconstructed from - * provider + repoPath via [canonicalUrl] for surface code that needs - * to persist or display it. + * Result of parsing — provider + repoPath (+ optional prNumber / pinnedTag) + * are all the engine needs to spin up a working source and resolve a release. */ data class Parsed( val provider: PatchProvider, val repoPath: String, val prNumber: String? = null, + val pinnedTag: String? = null, ) { val canonicalUrl: String get() = when (provider) { @@ -123,9 +152,6 @@ object RemotePatchSourceFactory { } fun instantiate(httpClient: HttpClient): RemotePatchSource { - // Wrap the shared client in the centralized service so all sources - // get identical request/stream/retry behavior. Call sites still pass - // an HttpClient, so nothing downstream changes. val service = HttpService(httpClient) return when (provider) { PatchProvider.GITHUB -> GitHubPatchSource(service, repoPath) diff --git a/src/main/kotlin/app/morphe/engine/patches/SupportedAppCatalog.kt b/src/main/kotlin/app/morphe/engine/patches/SupportedAppCatalog.kt new file mode 100644 index 00000000..9a85f8e1 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/SupportedAppCatalog.kt @@ -0,0 +1,360 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.CompatiblePackage +import app.morphe.engine.model.PatchMetadata +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.options.toPatchOption +import app.morphe.patcher.patch.Patch +import java.io.File +import java.io.FileNotFoundException + +typealias VersionMap = LinkedHashMap +typealias CompatibleVersionsMap = Map + +/** + * Unified catalog and metadata query service for supported applications and patches. + */ +object SupportedAppCatalog { + + /** + * Convert library [Patch] to agnostic [PatchMetadata] model. + * + * Reads both the modern [Patch.compatibility] API and the deprecated + * [Patch.compatiblePackages] field for legacy bundle compatibility. + */ + @Suppress("DEPRECATION") + fun Patch<*>.toMetadata(): PatchMetadata { + val fromNewApi: List = this.compatibility + ?.mapNotNull { compatibility -> + val packageName = compatibility.packageName ?: return@mapNotNull null + val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + CompatiblePackage( + name = packageName, + displayName = compatibility.name, + versions = stable.mapNotNull { it.version }, + experimentalVersions = experimental.mapNotNull { it.version }, + appIconColor = compatibility.appIconColor + ?.let { "#%06X".format(it and 0xFFFFFF) }, + versionBuildCodes = compatibility.targets + .mapNotNull { target -> + val version = target.version ?: return@mapNotNull null + version to target.versionCodes?.values?.toSet().orEmpty() + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, sets) -> + if (sets.any { it.isEmpty() }) emptySet() else sets.flatten().toSet() + }, + ) + } + ?: emptyList() + + val fromLegacyApi: List = if (fromNewApi.isEmpty()) { + this.compatiblePackages + ?.map { (pkgName, versions) -> + CompatiblePackage( + name = pkgName, + displayName = null, + versions = versions?.toList() ?: emptyList(), + experimentalVersions = emptyList(), + ) + } + ?: emptyList() + } else emptyList() + + return PatchMetadata( + name = this.name ?: "Unknown", + description = this.description ?: "", + compatiblePackages = fromNewApi.ifEmpty { fromLegacyApi }, + options = this.options.values.map { it.toPatchOption() }, + isEnabled = this.use, + category = this.category?.takeIf { it.isNotBlank() } + ) + } + + /** + * Extract supported apps from a collection of library [Patch] objects. + */ + fun extractSupportedApps(patches: Iterable>): List = + extractSupportedAppsFromMetadata(patches.map { it.toMetadata() }) + + /** + * Extract supported apps from loaded bundle list. + */ + fun extractSupportedAppsFromBundles(bundles: List): List = + extractSupportedApps(bundles.flatMap { it.patches }) + + /** + * Extract all supported apps from a list of patch metadata models. + * Groups patches by package name and collects stable + experimental versions. + */ + fun extractSupportedAppsFromMetadata(patches: Iterable): List { + val packageVersionsMap = mutableMapOf>() + val packageExperimentalMap = mutableMapOf>() + val packageDisplayNames = mutableMapOf() + val packageIconColors = mutableMapOf() + val packageBuildCodes = mutableMapOf>>() + + for (patch in patches) { + for ((packageName, displayName, versions, experimentalVersions, appIconColor, versionBuildCodes) in patch.compatiblePackages) { + if (packageName.isNotBlank()) { + packageVersionsMap.getOrPut(packageName) { mutableSetOf() } + .addAll(versions) + packageExperimentalMap.getOrPut(packageName) { mutableSetOf() } + .addAll(experimentalVersions) + displayName + ?.takeIf { it.isNotBlank() } + ?.let { packageDisplayNames.putIfAbsent(packageName, it) } + appIconColor + ?.takeIf { it.isNotBlank() } + ?.let { packageIconColors.putIfAbsent(packageName, it) } + if (versionBuildCodes.isNotEmpty()) { + val perVersion = packageBuildCodes.getOrPut(packageName) { mutableMapOf() } + versionBuildCodes.forEach { (version, codes) -> + val existing = perVersion[version] + when { + codes.isEmpty() -> perVersion[version] = mutableSetOf() + existing == null -> perVersion[version] = codes.toMutableSet() + existing.isEmpty() -> Unit + else -> existing.addAll(codes) + } + } + } + } + } + } + + return packageVersionsMap.map { (packageName, versions) -> + val versionList = versions.toList().sortedDescending() + val experimentalList = (packageExperimentalMap[packageName] ?: emptySet()) + .minus(versions) + .toList().sortedDescending() + val recommendedVersion = SupportedApp.getRecommendedVersion(versionList) + val latestExperimental = experimentalList.firstOrNull() + SupportedApp( + packageName = packageName, + displayName = SupportedApp.resolveDisplayName( + packageName = packageName, + providedName = packageDisplayNames[packageName] + ), + supportedVersions = versionList, + experimentalVersions = experimentalList, + recommendedVersion = recommendedVersion, + apkDownloadUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion ?: "any"), + experimentalDownloadUrl = SupportedApp.getDownloadUrl(packageName, latestExperimental), + appIconColor = packageIconColors[packageName], + versionBuildCodes = packageBuildCodes[packageName] + ?.mapValues { (_, codes) -> codes.toSet() } + .orEmpty() + ) + }.sortedBy { it.displayName } + } + + /** + * Get a supported app by package name. + */ + fun getSupportedApp(packageName: String, patches: Iterable>): SupportedApp? = + extractSupportedApps(patches).find { it.packageName == packageName } + + /** + * Get a supported app by package name from pre-converted metadata. + */ + fun getSupportedAppFromMetadata(packageName: String, patches: Iterable): SupportedApp? = + extractSupportedAppsFromMetadata(patches).find { it.packageName == packageName } + + /** + * Get recommended version for a package from patches. + */ + fun getRecommendedVersion(packageName: String, patches: Iterable>): String? = + getSupportedApp(packageName, patches)?.recommendedVersion + + fun getRecommendedVersion(patches: Iterable>, packageName: String): String? = + getSupportedApp(packageName, patches)?.recommendedVersion + + /** + * Get recommended version for a package from patch metadata. + */ + fun getRecommendedVersionFromMetadata(packageName: String, patches: Iterable): String? = + getSupportedAppFromMetadata(packageName, patches)?.recommendedVersion + + fun getRecommendedVersionFromMetadata(patches: Iterable, packageName: String): String? = + getSupportedAppFromMetadata(packageName, patches)?.recommendedVersion + + /** + * Find patches compatible with a given package and version/versionCode constraints. + */ + fun findCompatiblePatches( + packageName: String, + versionName: String? = null, + versionCode: Int? = null, + includeExperimental: Boolean = false, + includeUniversal: Boolean = true, + patches: Iterable>, + ): List> { + return patches.filter { patch -> + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + compat.any { entry -> + when { + entry.packageName == null -> includeUniversal + entry.packageName != packageName -> false + versionName == null -> entry.targets.any { includeExperimental || !it.isExperimental } + else -> entry.targets.any { target -> + (includeExperimental || !target.isExperimental) && + target.version == versionName && + (versionCode == null || target.versionCodes.isNullOrEmpty() || versionCode in target.versionCodes!!.values) + } + } + } + } else { + @Suppress("DEPRECATION") + val legacy = patch.compatiblePackages + legacy?.any { (name, versions) -> + name == packageName && ( + versionName == null || + versions.isNullOrEmpty() || + versions.contains(versionName) + ) + } ?: includeUniversal + } + } + } + + /** + * Calculate frequency map of compatible versions per package across patches. + * Preserves the frequency count behavior used by CLI `list-versions`. + */ + @Suppress("DEPRECATION") + fun getCompatibilityMap( + packageNames: Set? = null, + countUnusedPatches: Boolean = false, + includeExperimental: Boolean = false, + patches: Iterable>, + ): CompatibleVersionsMap { + val allPackageNames: Set = buildSet { + for (patch in patches) { + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + for ((packageName) in compat) { + packageName?.let { add(it) } + } + } else { + patch.compatiblePackages?.forEach { (name, _) -> add(name) } + } + } + } + + val targetPackages = if (packageNames != null) { + allPackageNames.intersect(packageNames) + } else { + allPackageNames + } + + val result: MutableMap = LinkedHashMap() + + for (pkgName in targetPackages) { + val versionCount = VersionMap() + + for (patch in patches) { + if (!countUnusedPatches && !patch.default) continue + + val compat = patch.compatibility + + if (!compat.isNullOrEmpty()) { + val matchingEntries = compat.filter { entry -> + entry.packageName == null || entry.packageName == pkgName + } + + if (matchingEntries.isEmpty()) continue + + val versions = matchingEntries.flatMap { entry -> + entry.targets + .filter { includeExperimental || !it.isExperimental } + .mapNotNull { it.version } + } + + if (versions.isEmpty()) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + } else { + for (version in versions) { + versionCount[version] = (versionCount[version] ?: 0) + 1 + } + } + } else { + val legacyPackages = patch.compatiblePackages + if (legacyPackages == null) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + continue + } + val matching = legacyPackages.filter { (name, _) -> name == pkgName } + if (matching.isEmpty()) continue + for ((_, versions) in matching) { + if (versions.isNullOrEmpty()) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + } else { + for (version in versions) { + versionCount[version] = (versionCount[version] ?: 0) + 1 + } + } + } + } + } + + if (versionCount.isNotEmpty()) { + val sorted = versionCount.entries + .sortedByDescending { it.value } + .associateTo(LinkedHashMap()) { it.key to it.value } + sorted.remove("") + result[pkgName] = sorted + } + } + + return result.entries + .sortedBy { it.key } + .associateTo(LinkedHashMap()) { it.key to it.value } + } + + /** + * Load patches from an .mpp file and convert to [PatchMetadata], + * optionally filtered by target package name. + */ + fun loadPatches( + patchFile: File, + packageName: String? = null, + ): List { + if (!patchFile.exists()) { + throw FileNotFoundException("Patch file not found: ${patchFile.absolutePath}") + } + val patches = PatchBundleLoader.loadFlat(setOf(patchFile)).map { it.toMetadata() } + if (packageName.isNullOrBlank()) return patches + return patches.filter { patch -> + patch.isUniversal || patch.compatiblePackages.any { it.name == packageName } + } + } + + /** + * Match an APK filename to a [SupportedApp] using the filename's leading token. + * Examples: + * "soundcloud_2026.04.27.apkm" -> leading token "soundcloud" -> matches "SoundCloud" + * "YouTube Music_4.81.apkm" -> leading token "youtube music" -> matches "YouTube Music" + */ + fun fuzzyMatchApp( + filename: String, + supportedApps: List, + ): SupportedApp? { + val noExt = filename.substringBeforeLast('.').lowercase() + val leadingToken = noExt + .substringBefore('_') + .substringBefore('-') + .replace(" ", "") + if (leadingToken.isBlank()) return null + return supportedApps.firstOrNull { app -> + val name = app.displayName.lowercase().replace(" ", "") + name == leadingToken || name.startsWith(leadingToken) || leadingToken.startsWith(name) + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/engine/util/AdbManager.kt similarity index 57% rename from src/main/kotlin/app/morphe/gui/util/AdbManager.kt rename to src/main/kotlin/app/morphe/engine/util/AdbManager.kt index eb1150eb..8af58c77 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/engine/util/AdbManager.kt @@ -3,29 +3,34 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.gui.util +package app.morphe.engine.util -import app.morphe.engine.util.AppLinkCommands -import app.morphe.engine.util.SignatureIdentity -import app.morphe.morphe_desktop.generated.resources.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import app.morphe.library.installation.installer.AdbRootInstaller +import app.morphe.library.installation.installer.DeviceNotFoundException +import app.morphe.library.installation.installer.Installer +import app.morphe.library.installation.installer.RootInstallerResult import java.io.File import java.net.InetSocketAddress import java.net.Socket -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.getString +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** - * Manages ADB (Android Debug Bridge) operations for installing APKs. Works across macOS, Linux, and Windows. + * Manages ADB (Android Debug Bridge) operations shared across the CLI and GUI. + * Works across macOS, Linux, and Windows. */ class AdbManager { + private val logger = Logger.getLogger(AdbManager::class.java.name) private var adbPath: String? = null - private companion object { + companion object { + const val AUTO_SPOOF = "__AUTO_SPOOF__" + /* Popular store packages. The spoof installer is the first one NOT on the device — see [resolveSpoofInstaller]. */ - val SPOOF_STORE_CANDIDATES = listOf( + internal val SPOOF_STORE_CANDIDATES = listOf( "com.amazon.venezia", // Amazon Appstore "com.sec.android.app.samsungapps", // Samsung Galaxy Store "com.huawei.appmarket", // Huawei AppGallery @@ -33,6 +38,9 @@ class AdbManager { "com.aurora.store", // Aurora Store "org.fdroid.fdroid", // F-Droid ) + + internal fun selectSpoofInstaller(installedPackages: Set): String = + SPOOF_STORE_CANDIDATES.firstOrNull { it !in installedPackages } ?: SPOOF_STORE_CANDIDATES.first() } /** @@ -43,7 +51,7 @@ class AdbManager { * * Updated on every [startServer] call: if we attach to a pre-existing * daemon and that daemon later dies + our next [startServer] tick - * respawns it, ownership flips from false → true. Without that, the + * respawns it, ownership correctly flips from false → true. Without that, the * polling loop's implicit respawns would leak a daemon Morphe is * actively maintaining. */ @@ -131,7 +139,7 @@ class AdbManager { for (path in searchPaths) { val file = File(path) if (file.exists() && file.canExecute()) { - Logger.info("Found ADB at: $path") + logger.info("Found ADB at: $path") adbPath = path return@withContext path } @@ -149,16 +157,16 @@ class AdbManager { if (process.exitValue() == 0 && result.isNotEmpty()) { val path = result.lines().first() if (File(path).exists()) { - Logger.info("Found ADB in PATH: $path") + logger.info("Found ADB in PATH: $path") adbPath = path return@withContext path } } } catch (e: Exception) { - Logger.debug("Could not find ADB in PATH: ${e.message}") + logger.fine("Could not find ADB in PATH: ${e.message}") } - Logger.warn("ADB not found") + logger.warning("ADB not found") null } @@ -184,7 +192,7 @@ class AdbManager { */ suspend fun startServer(): Result = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) ) try { @@ -193,7 +201,7 @@ class AdbManager { // Don't touch the ownership flag (a prior tick may have set // it to true and the daemon is still ours). if (!weStartedDaemon && !loggedAttachOnce) { - Logger.info("ADB daemon was already running - leaving it alone on shutdown") + logger.info("ADB daemon was already running - leaving it alone on shutdown") loggedAttachOnce = true } return@withContext Result.success(Unit) @@ -207,23 +215,34 @@ class AdbManager { val exitCode = process.waitFor() if (exitCode != 0) { return@withContext Result.failure( - AdbException("Failed to start ADB server (exit code $exitCode)", Res.string.adb_error_start_server, listOf("exit code $exitCode")) + AdbException( + "Failed to start ADB server (exit code $exitCode)", + AdbErrorCode.START_SERVER_FAILED, + listOf("exit code $exitCode"), + ) ) } if (isDaemonAlive()) { if (!weStartedDaemon) { - Logger.info("ADB daemon spawned by Morphe - will kill on shutdown") + logger.info("ADB daemon spawned by Morphe - will kill on shutdown") } weStartedDaemon = true loggedAttachOnce = false } else { - Logger.warn("adb start-server returned success but port 5037 is still closed") + logger.warning("adb start-server returned success but port 5037 is still closed") } Result.success(Unit) } catch (e: Exception) { - Logger.error("Failed to start ADB server", e) - Result.failure(AdbException("Failed to start ADB server: ${e.message ?: ""}", Res.string.adb_error_start_server, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Failed to start ADB server", e) + Result.failure( + AdbException( + "Failed to start ADB server: ${e.message ?: ""}", + AdbErrorCode.START_SERVER_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -236,7 +255,7 @@ class AdbManager { */ suspend fun killServerIfOwned(): Result = withContext(Dispatchers.IO) { if (!weStartedDaemon) { - Logger.debug("Skipping adb kill-server - daemon wasn't started by Morphe") + logger.fine("Skipping adb kill-server - daemon wasn't started by Morphe") return@withContext Result.success(false) } val adb = findAdb() ?: return@withContext Result.success(false) @@ -248,18 +267,29 @@ class AdbManager { val output = process.inputStream.bufferedReader().readText() val exitCode = process.waitFor() if (exitCode != 0) { - Logger.warn("adb kill-server exited with code $exitCode: $output") + logger.warning("adb kill-server exited with code $exitCode: $output") return@withContext Result.failure( - AdbException("Failed to kill ADB server (exit code $exitCode)", Res.string.adb_error_kill_server, listOf("exit code $exitCode")) + AdbException( + "Failed to kill ADB server (exit code $exitCode)", + AdbErrorCode.KILL_SERVER_FAILED, + listOf("exit code $exitCode"), + ) ) } weStartedDaemon = false - loggedAttachOnce = false // next attach (if any) re-logs once - Logger.info("ADB daemon killed by Morphe") + loggedAttachOnce = false // next attach (if any) re-logs once + logger.info("ADB daemon killed by Morphe") Result.success(true) } catch (e: Exception) { - Logger.error("Failed to kill ADB server", e) - Result.failure(AdbException("Failed to kill ADB server: ${e.message ?: ""}", Res.string.adb_error_kill_server, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Failed to kill ADB server", e) + Result.failure( + AdbException( + "Failed to kill ADB server: ${e.message ?: ""}", + AdbErrorCode.KILL_SERVER_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -269,7 +299,7 @@ class AdbManager { */ suspend fun getConnectedDevices(): Result> = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) ) try { @@ -283,7 +313,11 @@ class AdbManager { if (exitCode != 0) { return@withContext Result.failure( - AdbException("Failed to get connected devices: $output", Res.string.adb_error_get_devices, listOf(output)) + AdbException( + "Failed to get connected devices: $output", + AdbErrorCode.GET_DEVICES_FAILED, + listOf(output), + ) ) } @@ -294,43 +328,23 @@ class AdbManager { // DeviceMonitor.refreshDevices instead. Result.success(devices) } catch (e: Exception) { - Logger.error("Error getting devices", e) - Result.failure(AdbException("Error getting devices: ${e.message ?: ""}", Res.string.adb_error_get_devices, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Error getting devices", e) + Result.failure( + AdbException( + "Error getting devices: ${e.message ?: ""}", + AdbErrorCode.GET_DEVICES_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } /** - * Install an APK on the specified device (or default device if only one connected). - */ - /** - * Pick an installer-source package to spoof for [deviceId]: the most-popular - * store NOT installed on the device, so nothing real tries to manage the app - * (and the Play Store won't auto-update it). Falls back to Amazon if all present. + * Resolve a target ready [AdbDevice] by [deviceId] (or the sole connected + * ready device when [deviceId] is null or blank). */ - suspend fun resolveSpoofInstaller(deviceId: String): String { - val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() - return SPOOF_STORE_CANDIDATES.firstOrNull { it !in installed } ?: SPOOF_STORE_CANDIDATES.first() - } - - suspend fun installApk( - apkPath: String, - deviceId: String? = null, - allowDowngrade: Boolean = true, - /** Set the recorded installer package (`pm install -i`). A non-Play value - * stops the Play Store from auto-updating (and clobbering) the patched app. */ - installerPackage: String? = null, - onProgress: (String) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { - val adb = findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) - ) - - val apkFile = File(apkPath) - if (!apkFile.exists()) { - return@withContext Result.failure(AdbException("APK file not found: $apkPath", Res.string.adb_error_apk_not_found, listOf(apkPath))) - } - - // Check connected devices + suspend fun resolveTargetDevice(deviceId: String? = null): Result = withContext(Dispatchers.IO) { val devicesResult = getConnectedDevices() if (devicesResult.isFailure) { return@withContext Result.failure(devicesResult.exceptionOrNull()!!) @@ -343,17 +357,23 @@ class AdbManager { val unauthorized = devices.filter { it.status == DeviceStatus.UNAUTHORIZED } return@withContext Result.failure( if (unauthorized.isNotEmpty()) { - AdbException("Device is unauthorized", Res.string.adb_error_unauthorized) + AdbException("Device is unauthorized", AdbErrorCode.UNAUTHORIZED_DEVICE) } else { - AdbException("No connected devices found", Res.string.adb_error_no_devices) + AdbException("No connected devices found", AdbErrorCode.NO_DEVICES) } ) } - // Determine target device - val targetDevice = if (deviceId != null) { - authorizedDevices.find { it.id == deviceId } - ?: return@withContext Result.failure(AdbException("Device not found: $deviceId", Res.string.adb_error_device_not_found, listOf(deviceId))) + val normalizedId = deviceId?.takeIf { it.isNotBlank() } + val targetDevice = if (normalizedId != null) { + authorizedDevices.find { it.id == normalizedId } + ?: return@withContext Result.failure( + AdbException( + "Device not found: $normalizedId", + AdbErrorCode.DEVICE_NOT_FOUND, + listOf(normalizedId), + ) + ) } else if (authorizedDevices.size == 1) { authorizedDevices.first() } else { @@ -361,28 +381,77 @@ class AdbManager { AdbMultipleDevicesException( "Multiple devices connected: ${authorizedDevices.size} devices found", authorizedDevices, - Res.string.adb_error_multiple_devices, ) ) } + Result.success(targetDevice) + } + + /** + * Pick an installer-source package to spoof for [deviceId]: the most-popular + * store NOT installed on the device, so nothing real tries to manage the app + * (and the Play Store won't auto-update it). Falls back to Amazon if all present. + */ + suspend fun resolveSpoofInstaller(deviceId: String): String { + val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() + return selectSpoofInstaller(installed) + } + + /** + * Install an APK on the specified device (or default device if only one connected). + * + * [installerPackage] specifies the recorded installer source (`pm install -i`). + * Defaults to [AUTO_SPOOF], which automatically resolves a non-Play store package + * for [deviceId] to stop the Play Store from auto-updating the patched app. + * Pass explicit `null` to disable installer attribution. + */ + suspend fun installApk( + apkPath: String, + deviceId: String? = null, + allowDowngrade: Boolean = true, + installerPackage: String? = AUTO_SPOOF, + onProgress: (String) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) + ) + + val apkFile = File(apkPath) + if (!apkFile.exists()) { + val msg = "APK file not found: $apkPath" + logger.severe(msg) + return@withContext Result.failure( + AdbException(msg, AdbErrorCode.APK_NOT_FOUND, listOf(apkPath)) + ) + } + + val targetDevice = resolveTargetDevice(deviceId).getOrElse { + return@withContext Result.failure(it) + } + + val effectiveInstaller = if (installerPackage == AUTO_SPOOF) { + resolveSpoofInstaller(targetDevice.id) + } else { + installerPackage + } + // Build + run the install, factored so we can transparently retry // without installer attribution. Stricter Android builds could reject an // `-i` pointing at a store the user doesn't have; if that's what failed, // we'd rather install without Play-update blocking than not install at // all. (Validated on Android 12 that an absent `-i` is accepted; this is // a safety net for versions we haven't tested.) - suspend fun attemptInstall(withInstaller: Boolean): Result { + fun attemptInstall(withInstaller: Boolean): Result { val command = mutableListOf(adb, "-s", targetDevice.id, "install", "-r") if (allowDowngrade) command.add("-d") // Allow downgrade - if (withInstaller && !installerPackage.isNullOrBlank()) { + if (withInstaller && !effectiveInstaller.isNullOrBlank()) { command.add("-i") // Record installer source (blocks Play auto-update) - command.add(installerPackage) + command.add(effectiveInstaller) } command.add(apkPath) - onProgress(getString(Res.string.adb_status_installing, targetDevice.displayName)) - Logger.info("Running: ${command.joinToString(" ")}") + logger.info("Running: ${command.joinToString(" ")}") return try { val process = ProcessBuilder(command) @@ -394,71 +463,223 @@ class AdbManager { process.inputStream.bufferedReader().forEachLine { line -> output.appendLine(line) onProgress(line) - Logger.debug("ADB: $line") + logger.fine("ADB: $line") } val exitCode = process.waitFor() val outputStr = output.toString() if (exitCode == 0 && outputStr.contains("Success")) { - Logger.info("APK installed successfully") + logger.info("APK installed successfully") Result.success(Unit) } else { val errorInfo = parseInstallError(outputStr) - Logger.error("Installation failed: ${errorInfo.technicalMessage}") - Result.failure(AdbException(errorInfo.technicalMessage, errorInfo.stringRes, errorInfo.formatArgs)) + Result.failure(AdbException(errorInfo.technicalMessage, errorInfo.errorCode, errorInfo.formatArgs)) } } catch (e: Exception) { - Logger.error("Error installing APK", e) - Result.failure(AdbException("Error installing APK: ${e.message ?: ""}", Res.string.adb_error_generic, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Error installing APK", e) + Result.failure( + AdbException( + "Error installing APK: ${e.message ?: ""}", + AdbErrorCode.INSTALL_GENERIC, + listOf(e.message ?: ""), + cause = e, + ) + ) } } - val withInstaller = !installerPackage.isNullOrBlank() + val withInstaller = !effectiveInstaller.isNullOrBlank() val first = attemptInstall(withInstaller = withInstaller) - if (first.isFailure && withInstaller) { - Logger.info("Install with '-i $installerPackage' failed; retrying without installer attribution") + val finalResult = if ( + first.isFailure && + withInstaller && + (first.exceptionOrNull() as? AdbException)?.errorCode == AdbErrorCode.INSTALL_GENERIC + ) { + logger.info("Install with '-i $effectiveInstaller' failed; retrying without installer attribution") attemptInstall(withInstaller = false) } else { first } + finalResult.onFailure { e -> + if (e is AdbException && e.cause == null) { + logger.severe("Installation failed: ${e.message}") + } + } + finalResult + } + + /** + * Verify that the target device (or sole connected device) is reachable and + * ready for root mount installation when [mount] is true. + */ + suspend fun verifyTargetDevice( + deviceId: String? = null, + mount: Boolean = false, + ): Result = withContext(Dispatchers.IO) { + val targetDevice = resolveTargetDevice(deviceId).getOrElse { + return@withContext Result.failure(it) + } + if (mount) { + try { + AdbRootInstaller(targetDevice.id) + } catch (e: DeviceNotFoundException) { + return@withContext Result.failure( + AdbException( + "Device not found: ${targetDevice.id}", + AdbErrorCode.DEVICE_NOT_FOUND, + listOf(targetDevice.id), + cause = e, + ) + ) + } catch (e: Exception) { + return@withContext Result.failure( + AdbException( + "Root mount check failed on ${targetDevice.id}: ${e.message ?: e}", + AdbErrorCode.ROOT_MOUNT_FAILED, + listOf(e.message ?: e.toString()), + cause = e, + ) + ) + } + } + Result.success(targetDevice) + } + + /** + * Mount [apkFile] over [packageName] on a rooted device using [AdbRootInstaller]. + */ + suspend fun mountApk( + apkFile: File, + packageName: String, + deviceId: String? = null, + ): Result = withContext(Dispatchers.IO) { + if (!apkFile.exists()) { + val msg = "APK file not found: ${apkFile.path}" + logger.severe(msg) + return@withContext Result.failure( + AdbException(msg, AdbErrorCode.APK_NOT_FOUND, listOf(apkFile.path)) + ) + } + val targetDevice = resolveTargetDevice(deviceId).getOrElse { + return@withContext Result.failure(it) + } + try { + val result = AdbRootInstaller(targetDevice.id).install(Installer.Apk(apkFile, packageName)) + if (result == RootInstallerResult.FAILURE) { + logger.severe("Failed to mount the APK file") + Result.failure(AdbException("Failed to mount the APK file", AdbErrorCode.ROOT_MOUNT_FAILED)) + } else { + logger.info("Mounted the APK file on ${targetDevice.id}") + Result.success(Unit) + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Error mounting APK", e) + Result.failure( + AdbException( + "Error mounting APK: ${e.message ?: e}", + AdbErrorCode.ROOT_MOUNT_FAILED, + listOf(e.message ?: e.toString()), + cause = e, + ) + ) + } + } + + /** + * Unmount a root-mounted patched APK for [packageName] using [AdbRootInstaller]. + */ + suspend fun unmountApk( + packageName: String, + deviceId: String? = null, + ): Result = withContext(Dispatchers.IO) { + val targetDevice = resolveTargetDevice(deviceId).getOrElse { + return@withContext Result.failure(it) + } + try { + val result = AdbRootInstaller(targetDevice.id).uninstall(packageName) + if (result == RootInstallerResult.FAILURE) { + logger.severe("Failed to unmount the patched APK file") + Result.failure(AdbException("Failed to unmount the patched APK file", AdbErrorCode.ROOT_UNMOUNT_FAILED)) + } else { + logger.info("Unmounted the patched APK file from ${targetDevice.id}") + Result.success(Unit) + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Error unmounting $packageName", e) + Result.failure( + AdbException( + "Error unmounting $packageName: ${e.message ?: e}", + AdbErrorCode.ROOT_UNMOUNT_FAILED, + listOf(e.message ?: e.toString()), + cause = e, + ) + ) + } } /** - * Uninstall [packageName] from [deviceId]. Used by the "Your apps" cards so a - * patched app can be removed through Morphe (keeping our recall state accurate) - * instead of from the launcher behind our back. + * Uninstall [packageName] from [deviceId] (or default device if only one connected). + * Used by the "Your apps" cards and CLI `utility uninstall`. * * Treats "package not installed" as success — the desired end state (app gone) * already holds, so the caller can refresh and move on. */ suspend fun uninstallApk( packageName: String, - deviceId: String, + deviceId: String? = null, ): Result = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) ) + val targetDeviceId = if (!deviceId.isNullOrBlank()) { + deviceId + } else { + resolveTargetDevice(null).getOrElse { return@withContext Result.failure(it) }.id + } + try { - val process = ProcessBuilder(adb, "-s", deviceId, "uninstall", packageName) + val process = ProcessBuilder(adb, "-s", targetDeviceId, "uninstall", packageName) .redirectErrorStream(true) .start() val output = process.inputStream.bufferedReader().readText().trim() val exitCode = process.waitFor() - Logger.info("uninstall $packageName on $deviceId -> exit $exitCode: $output") + logger.fine("uninstall $packageName on $targetDeviceId -> exit $exitCode: $output") when { - exitCode == 0 && output.contains("Success") -> Result.success(Unit) + exitCode == 0 && output.contains("Success") -> { + logger.info("Uninstalled $packageName from $targetDeviceId") + Result.success(Unit) + } // Already gone is the end state we wanted. output.contains("not installed", ignoreCase = true) || output.contains("DELETE_FAILED_INTERNAL_ERROR", ignoreCase = true) && - listInstalledPackages(deviceId).getOrNull()?.contains(packageName) == false -> + listInstalledPackages(targetDeviceId).getOrNull()?.contains(packageName) == false -> { + logger.info("$packageName is already uninstalled on $targetDeviceId") Result.success(Unit) - else -> Result.failure(AdbException("Uninstall failed: ${output.ifBlank { "exit $exitCode" }}", Res.string.adb_error_uninstall, listOf(output.ifBlank { "exit $exitCode" }))) + } + else -> { + val detail = output.ifBlank { "exit $exitCode" } + logger.severe("Uninstall failed: $detail") + Result.failure( + AdbException( + "Uninstall failed: $detail", + AdbErrorCode.UNINSTALL_FAILED, + listOf(detail), + ) + ) + } } } catch (e: Exception) { - Logger.error("Error uninstalling $packageName", e) - Result.failure(AdbException("Error uninstalling $packageName: ${e.message ?: ""}", Res.string.adb_error_uninstall, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Error uninstalling $packageName", e) + Result.failure( + AdbException( + "Error uninstalling $packageName: ${e.message ?: ""}", + AdbErrorCode.UNINSTALL_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -468,7 +689,7 @@ class AdbManager { */ suspend fun clearLogcat(deviceId: String): Result = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) ) try { @@ -477,7 +698,13 @@ class AdbManager { .start() val mainOutput = main.inputStream.bufferedReader().readText() if (main.waitFor() != 0) { - return@withContext Result.failure(AdbException("Failed to clear logcat: $mainOutput", Res.string.adb_error_clear_logs, listOf(mainOutput))) + return@withContext Result.failure( + AdbException( + "Failed to clear logcat: $mainOutput", + AdbErrorCode.CLEAR_LOGS_FAILED, + listOf(mainOutput), + ) + ) } // Best-effort: also clear the crash buffer. Ignore failure. @@ -489,11 +716,18 @@ class AdbManager { crash.waitFor() } catch (_: Exception) { /* older devices may not have crash buffer */ } - Logger.info("Cleared logcat on $deviceId") + logger.info("Cleared logcat on $deviceId") Result.success(Unit) } catch (e: Exception) { - Logger.error("Error clearing logcat", e) - Result.failure(AdbException("Error clearing logcat: ${e.message ?: ""}", Res.string.adb_error_clear_logs, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Error clearing logcat", e) + Result.failure( + AdbException( + "Error clearing logcat: ${e.message ?: ""}", + AdbErrorCode.CLEAR_LOGS_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -504,7 +738,7 @@ class AdbManager { */ suspend fun captureLogcat(deviceId: String, outputFile: File): Result = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) ) try { @@ -522,20 +756,33 @@ class AdbManager { } val exitCode = process.waitFor() if (exitCode != 0) { - return@withContext Result.failure(AdbException("Failed to capture logcat (exit code $exitCode)", Res.string.adb_error_capture_logs, listOf("exit code $exitCode"))) + return@withContext Result.failure( + AdbException( + "Failed to capture logcat (exit code $exitCode)", + AdbErrorCode.CAPTURE_LOGS_FAILED, + listOf("exit code $exitCode"), + ) + ) } if (kept.isEmpty()) { - Logger.info("No matching logcat lines on $deviceId - skipping file write") + logger.info("No matching logcat lines on $deviceId - skipping file write") } else { outputFile.parentFile?.mkdirs() outputFile.writeText(kept.joinToString("\n") + "\n") - Logger.info("Captured ${kept.size} logcat line(s) to ${outputFile.absolutePath}") + logger.info("Captured ${kept.size} logcat line(s) to ${outputFile.absolutePath}") } Result.success(kept.size) } catch (e: Exception) { - Logger.error("Error capturing logcat", e) - Result.failure(AdbException("Error capturing logcat: ${e.message ?: ""}", Res.string.adb_error_capture_logs, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Error capturing logcat", e) + Result.failure( + AdbException( + "Error capturing logcat: ${e.message ?: ""}", + AdbErrorCode.CAPTURE_LOGS_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -561,10 +808,10 @@ class AdbManager { patchedPackage: String, stockPackage: String? = null, enable: Boolean = true, - onProgress: (String) -> Unit = {}, + onProgress: suspend (LinkHandlingProgress) -> Unit = {}, ): Result = withContext(Dispatchers.IO) { findAdb() ?: return@withContext Result.failure( - AdbException("ADB binary not found", Res.string.adb_error_not_found) + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) ) // Stock OFF only applies to a genuinely different, installed package. @@ -573,10 +820,7 @@ class AdbManager { stockPackage != patchedPackage && stockPackage in installed - onProgress( - if (enable) getString(Res.string.adb_status_routing_links, patchedPackage) - else getString(Res.string.adb_status_restoring_links) - ) + onProgress(LinkHandlingProgress.Patched(patchedPackage, enable)) val patchedCommands = if (enable) AppLinkCommands.enablePatched(patchedPackage) else AppLinkCommands.restorePatched(patchedPackage) @@ -588,17 +832,14 @@ class AdbManager { if (stockEligible) { val stockCommands = if (enable) AppLinkCommands.disableStock(stockPackage) else AppLinkCommands.restoreStock(stockPackage) - onProgress( - if (enable) getString(Res.string.adb_status_disabling_links, stockPackage) - else getString(Res.string.adb_status_enabling_links, stockPackage) - ) + onProgress(LinkHandlingProgress.Stock(stockPackage, enable)) runShellCommands(deviceId, stockCommands).onFailure { return@withContext Result.failure(it) } stockChanged = true } - Logger.info( + logger.info( "Link handling ${if (enable) "enabled" else "restored"} for $patchedPackage" + (if (stockChanged) " (stock $stockPackage toggled)" else "") ) @@ -614,7 +855,9 @@ class AdbManager { deviceId: String, commands: List>, ): Result = withContext(Dispatchers.IO) { - val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB binary not found", Res.string.adb_error_not_found)) + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) + ) for (argv in commands) { try { val process = ProcessBuilder(listOf(adb, "-s", deviceId, "shell") + argv) @@ -622,22 +865,30 @@ class AdbManager { .start() val output = process.inputStream.bufferedReader().readText().trim() val exitCode = process.waitFor() - Logger.debug("ADB shell ${argv.joinToString(" ")} -> exit $exitCode${if (output.isNotBlank()) ": $output" else ""}") + logger.fine("ADB shell ${argv.joinToString(" ")} -> exit $exitCode${if (output.isNotBlank()) ": $output" else ""}") if (exitCode != 0 || output.contains("Error", ignoreCase = true) || output.contains("Failure", ignoreCase = true) ) { + val detail = "pm ${argv.getOrNull(1) ?: ""} - ${output.ifBlank { "exit $exitCode" }}" return@withContext Result.failure( AdbException( - "ADB shell command failed: pm ${argv.getOrNull(1) ?: ""} - ${output.ifBlank { "exit $exitCode" }}", - Res.string.adb_error_run_command, - listOf("pm ${argv.getOrNull(1) ?: ""} - ${output.ifBlank { "exit $exitCode" }}"), + "ADB shell command failed: $detail", + AdbErrorCode.RUN_COMMAND_FAILED, + listOf(detail), ) ) } } catch (e: Exception) { - Logger.error("Error running adb shell ${argv.joinToString(" ")}", e) - return@withContext Result.failure(AdbException("Error running ADB shell command: ${e.message ?: ""}", Res.string.adb_error_run_command, listOf(e.message ?: ""), cause = e)) + logger.log(Level.SEVERE, "Error running adb shell ${argv.joinToString(" ")}", e) + return@withContext Result.failure( + AdbException( + "Error running ADB shell command: ${e.message ?: ""}", + AdbErrorCode.RUN_COMMAND_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } Result.success(Unit) @@ -647,13 +898,23 @@ class AdbManager { /** Package names installed on [deviceId] (`pm list packages`). */ suspend fun listInstalledPackages(deviceId: String): Result> = withContext(Dispatchers.IO) { - val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB binary not found", Res.string.adb_error_not_found)) + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB binary not found", AdbErrorCode.ADB_NOT_FOUND) + ) try { val process = ProcessBuilder(adb, "-s", deviceId, "shell", "pm", "list", "packages") .redirectErrorStream(true).start() val out = process.inputStream.bufferedReader().readText() process.waitFor() - if (process.exitValue() != 0) return@withContext Result.failure(AdbException("Failed to list packages: exit code ${process.exitValue()}", Res.string.adb_error_pm_list_packages, listOf("exit code ${process.exitValue()}"))) + if (process.exitValue() != 0) { + return@withContext Result.failure( + AdbException( + "Failed to list packages: exit code ${process.exitValue()}", + AdbErrorCode.PM_LIST_PACKAGES_FAILED, + listOf("exit code ${process.exitValue()}"), + ) + ) + } val packages = out.lineSequence() .map { it.trim() } .filter { it.startsWith("package:") } @@ -662,7 +923,14 @@ class AdbManager { .toSet() Result.success(packages) } catch (e: Exception) { - Result.failure(AdbException("Error listing packages: ${e.message ?: ""}", Res.string.adb_error_pm_list_packages, listOf(e.message ?: ""), cause = e)) + Result.failure( + AdbException( + "Error listing packages: ${e.message ?: ""}", + AdbErrorCode.PM_LIST_PACKAGES_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -687,7 +955,7 @@ class AdbManager { val out = process.inputStream.bufferedReader().readText() process.waitFor() out.ifBlank { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -696,12 +964,12 @@ class AdbManager { * Parse output from 'adb devices -l' command. * Example line: "XXXXXXXX device usb:1-1 product:flame model:Pixel_4 device:flame transport_id:1" */ - private fun parseDeviceList(output: String, adbPath: String): List { + internal fun parseDeviceList(output: String, adbPath: String?): List { return output.lines() .drop(1) // Skip "List of devices attached" header .filter { it.isNotBlank() } .mapNotNull { line -> - val parts = line.split("\\s+".toRegex()) + val parts = line.trim().split("\\s+".toRegex()) if (parts.size >= 2) { val id = parts[0] val status = when (parts[1]) { @@ -722,13 +990,13 @@ class AdbManager { } // If device is authorized, try to get friendly device name and architecture - val deviceName = if (status == DeviceStatus.DEVICE) { + val deviceName = if (status == DeviceStatus.DEVICE && adbPath != null) { model ?: product ?: getDeviceName(adbPath, id) } else { model ?: product } - val architecture = if (status == DeviceStatus.DEVICE) { + val architecture = if (status == DeviceStatus.DEVICE && adbPath != null) { getDeviceArchitecture(adbPath, id) } else null @@ -748,7 +1016,7 @@ class AdbManager { val result = process.inputStream.bufferedReader().readText().trim() process.waitFor() if (process.exitValue() == 0 && result.isNotBlank()) result else null - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -764,43 +1032,75 @@ class AdbManager { val result = process.inputStream.bufferedReader().readText().trim() process.waitFor() if (process.exitValue() == 0 && result.isNotBlank()) result else null - } catch (e: Exception) { + } catch (_: Exception) { null } } - private data class InstallErrorInfo( + internal data class InstallErrorInfo( val technicalMessage: String, - val stringRes: StringResource, + val errorCode: AdbErrorCode, val formatArgs: List = emptyList(), ) - private fun parseInstallError(output: String): InstallErrorInfo { + internal fun parseInstallError(output: String): InstallErrorInfo { // Common ADB install errors return when { output.contains("INSTALL_FAILED_VERSION_DOWNGRADE") -> - InstallErrorInfo("Version downgrade not allowed (INSTALL_FAILED_VERSION_DOWNGRADE)", Res.string.adb_error_downgrade) + InstallErrorInfo( + "Version downgrade not allowed (INSTALL_FAILED_VERSION_DOWNGRADE)", + AdbErrorCode.INSTALL_DOWNGRADE, + ) output.contains("INSTALL_FAILED_ALREADY_EXISTS") -> - InstallErrorInfo("Application already exists with different signature (INSTALL_FAILED_ALREADY_EXISTS)", Res.string.adb_error_already_exists) + InstallErrorInfo( + "Application already exists with different signature (INSTALL_FAILED_ALREADY_EXISTS)", + AdbErrorCode.INSTALL_ALREADY_EXISTS, + ) output.contains("INSTALL_FAILED_INSUFFICIENT_STORAGE") -> - InstallErrorInfo("Insufficient storage on device (INSTALL_FAILED_INSUFFICIENT_STORAGE)", Res.string.adb_error_storage) + InstallErrorInfo( + "Insufficient storage on device (INSTALL_FAILED_INSUFFICIENT_STORAGE)", + AdbErrorCode.INSTALL_STORAGE, + ) output.contains("INSTALL_FAILED_INVALID_APK") -> - InstallErrorInfo("Invalid APK file (INSTALL_FAILED_INVALID_APK)", Res.string.adb_error_invalid_apk) + InstallErrorInfo( + "Invalid APK file (INSTALL_FAILED_INVALID_APK)", + AdbErrorCode.INSTALL_INVALID_APK, + ) output.contains("INSTALL_PARSE_FAILED_NO_CERTIFICATES") -> - InstallErrorInfo("APK is not signed or certificates are missing (INSTALL_PARSE_FAILED_NO_CERTIFICATES)", Res.string.adb_error_no_certificates) + InstallErrorInfo( + "APK is not signed or certificates are missing (INSTALL_PARSE_FAILED_NO_CERTIFICATES)", + AdbErrorCode.INSTALL_NO_CERTIFICATES, + ) output.contains("INSTALL_FAILED_UPDATE_INCOMPATIBLE") -> - InstallErrorInfo("Update is incompatible with currently installed version (INSTALL_FAILED_UPDATE_INCOMPATIBLE)", Res.string.adb_error_update_incompatible) + InstallErrorInfo( + "Update is incompatible with currently installed version (INSTALL_FAILED_UPDATE_INCOMPATIBLE)", + AdbErrorCode.INSTALL_UPDATE_INCOMPATIBLE, + ) output.contains("INSTALL_FAILED_USER_RESTRICTED") -> - InstallErrorInfo("Installation restricted by user or policy (INSTALL_FAILED_USER_RESTRICTED)", Res.string.adb_error_user_restricted) + InstallErrorInfo( + "Installation restricted by user or policy (INSTALL_FAILED_USER_RESTRICTED)", + AdbErrorCode.INSTALL_USER_RESTRICTED, + ) output.contains("INSTALL_FAILED_VERIFICATION_FAILURE") -> - InstallErrorInfo("Package verification failed (INSTALL_FAILED_VERIFICATION_FAILURE)", Res.string.adb_error_verification_failure) + InstallErrorInfo( + "Package verification failed (INSTALL_FAILED_VERIFICATION_FAILURE)", + AdbErrorCode.INSTALL_VERIFICATION_FAILURE, + ) output.contains("Failure") -> { // Extract the failure reason val match = Regex("Failure \\[(.+)]").find(output) val failureMessage = match?.groupValues?.get(1) ?: output - InstallErrorInfo("Installation failed: $failureMessage", Res.string.adb_error_generic, listOf(failureMessage)) + InstallErrorInfo( + failureMessage, + AdbErrorCode.INSTALL_GENERIC, + listOf(failureMessage), + ) } - else -> InstallErrorInfo("Installation failed: $output", Res.string.adb_error_generic, listOf(output)) + else -> InstallErrorInfo( + output, + AdbErrorCode.INSTALL_GENERIC, + listOf(output), + ) } } } @@ -821,10 +1121,18 @@ data class AdbDevice( } enum class DeviceStatus { - DEVICE, // Connected and authorized + DEVICE, // Connected and authorized UNAUTHORIZED, // Connected but not authorized for debugging - OFFLINE, // Device offline - UNKNOWN // Unknown status + OFFLINE, // Device offline + UNKNOWN // Unknown status +} + +/** + * Progress step emitted by [AdbManager.setLinkHandling]. + */ +sealed interface LinkHandlingProgress { + data class Patched(val packageName: String, val enable: Boolean) : LinkHandlingProgress + data class Stock(val packageName: String, val enable: Boolean) : LinkHandlingProgress } /** @@ -836,19 +1144,42 @@ data class LinkHandlingResult( val stockChanged: Boolean, ) +enum class AdbErrorCode { + ADB_NOT_FOUND, + START_SERVER_FAILED, + KILL_SERVER_FAILED, + GET_DEVICES_FAILED, + APK_NOT_FOUND, + UNAUTHORIZED_DEVICE, + NO_DEVICES, + DEVICE_NOT_FOUND, + MULTIPLE_DEVICES, + INSTALL_DOWNGRADE, + INSTALL_ALREADY_EXISTS, + INSTALL_STORAGE, + INSTALL_INVALID_APK, + INSTALL_NO_CERTIFICATES, + INSTALL_UPDATE_INCOMPATIBLE, + INSTALL_USER_RESTRICTED, + INSTALL_VERIFICATION_FAILURE, + INSTALL_GENERIC, + ROOT_MOUNT_FAILED, + UNINSTALL_FAILED, + ROOT_UNMOUNT_FAILED, + CLEAR_LOGS_FAILED, + CAPTURE_LOGS_FAILED, + RUN_COMMAND_FAILED, + PM_LIST_PACKAGES_FAILED, +} + open class AdbException( message: String, - val stringRes: StringResource? = null, + val errorCode: AdbErrorCode, val formatArgs: List = emptyList(), cause: Throwable? = null, -) : Exception(message, cause) { - suspend fun getUserMessage(): String = - stringRes?.let { getString(it, *formatArgs.toTypedArray()) } ?: (message ?: "") -} +) : Exception(message, cause) class AdbMultipleDevicesException( message: String, val devices: List, - stringRes: StringResource? = null, - formatArgs: List = emptyList(), -) : AdbException(message, stringRes = stringRes, formatArgs = formatArgs) +) : AdbException(message, errorCode = AdbErrorCode.MULTIPLE_DEVICES) diff --git a/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt b/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt deleted file mode 100644 index 46dc04a4..00000000 --- a/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.engine.util - -import com.reandroid.arsc.chunk.xml.AndroidManifestBlock -import java.io.File -import java.util.logging.Logger -import java.util.zip.ZipFile - -/** - * Read structural metadata from an APK's `AndroidManifest.xml` using ARSCLib. - * - * This is the **only** manifest reader we use across the project. It replaces - * `net.dongliu:apk-parser`, which is unmaintained and crashes on split APKs - * whose base.apk references resources living in other splits (SoundCloud, - * Spotify, every large modular Play Store app). ARSCLib is the same library - * morphe-patcher uses internally — so anything the patcher can read, we can - * read. - * - * Only **direct string attributes** are exposed (packageName, versionName, - * minSdkVersion). The application label is included only when it's a literal - * string in the manifest — when it's a `@string/app_name` resource reference, - * resolving it would require the full resource table (and the split tables - * for split APKs), which we deliberately don't do. Callers that need a - * friendly display name should look it up against their supported-apps list - * by packageName. - * - * Caller is responsible for extracting `base.apk` from bundle formats - * (.apkm/.xapk/.apks) before passing to [read]. - */ -object ApkManifestReader { - private val logger = Logger.getLogger(ApkManifestReader::class.java.name) - - /** - * Read the manifest of an APK file. Returns null on failure (corrupt, - * not an APK, missing AndroidManifest.xml). - */ - fun read(apkFile: File): ApkManifest? { - return try { - ZipFile(apkFile).use { zip -> - val entry = zip.getEntry("AndroidManifest.xml") ?: run { - logger.warning("No AndroidManifest.xml in ${apkFile.name}") - return null - } - val block = zip.getInputStream(entry).use { input -> - AndroidManifestBlock.load(input) - } - val packageName = block.packageName ?: run { - logger.warning("Manifest has no package name in ${apkFile.name}") - return null - } - ApkManifest( - packageName = packageName, - versionName = block.versionName, - versionCode = block.versionCode, - minSdkVersion = block.minSdkVersion, - applicationLabel = block.applicationLabelString, - ) - } - } catch (e: Exception) { - logger.warning("Failed to read manifest from ${apkFile.name}: ${e.message}") - null - } - } -} - -/** - * Direct attributes from `AndroidManifest.xml`. None of these require resource - * resolution — they're plain strings/integers stored inline in the manifest. - * - * @property packageName always present (manifest is rejected without it) - * @property versionName may be null for APKs that omit it (rare) - * @property versionCode may be null for APKs that omit it (rare) - * @property minSdkVersion from `` - * @property applicationLabel the app's display name, only when stored as a - * literal string. Null when stored as a resource - * reference (`@string/app_name`) — callers should - * fall back to a supported-apps lookup by package. - */ -data class ApkManifest( - val packageName: String, - val versionName: String?, - val versionCode: Int?, - val minSdkVersion: Int?, - val applicationLabel: String?, -) diff --git a/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt index d47c0f90..3f51e004 100644 --- a/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt +++ b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt @@ -10,10 +10,8 @@ package app.morphe.engine.util * patched app and, optionally, stop the stock app from grabbing those same * links. * - * Pure argv construction only — no process execution. Each frontend runs these - * through its own adb path (the GUI's `AdbManager`, the CLI's installer), so the - * "what commands to run" decision lives in one tested place while "how to exec" - * stays per-frontend. + * Pure argv construction only — no process execution. Command execution is + * centralized in [AdbManager.setLinkHandling], shared across CLI and GUI. * * Each returned entry is one `adb shell` invocation's arguments (i.e. everything * after `adb -s shell`). Run them in order. diff --git a/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt b/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt deleted file mode 100644 index c4d38af0..00000000 --- a/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.engine.util - -import java.io.File - -/** - * Split-APK bundle container formats — ZIP archives that hold a base APK plus - * config/density/abi split APKs (`.apkm` from APKMirror, `.xapk` from APKPure, - * `.apks` from bundletool/SAI). - * - * Single source of truth for "is this a bundle?" so the engine, GUI, and CLI - * all agree. Lives in `engine.util` because it's a pure data check with no UI - * or CLI dependencies, and the GUI/CLI both depend on the engine (not the - * reverse). - */ -object BundleFormats { - /** Bundle file extensions, lowercase, without the leading dot. */ - val EXTENSIONS = setOf("apkm", "xapk", "apks") - - /** True if [file]'s extension is a split-APK bundle format. */ - fun isBundle(file: File): Boolean = file.extension.lowercase() in EXTENSIONS -} diff --git a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt b/src/main/kotlin/app/morphe/engine/util/DownloadUrlResolver.kt similarity index 83% rename from src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt rename to src/main/kotlin/app/morphe/engine/util/DownloadUrlResolver.kt index cb282550..be75013f 100644 --- a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt +++ b/src/main/kotlin/app/morphe/engine/util/DownloadUrlResolver.kt @@ -3,20 +3,21 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.gui.util +package app.morphe.engine.util -import app.morphe.gui.data.constants.AppConstants.MORPHE_API_URL +import app.morphe.engine.MorpheConstants.MORPHE_API_URL import io.ktor.http.encodeURLParameter import java.net.HttpURLConnection import java.net.SocketTimeoutException import java.net.URI -import java.net.URL +import java.util.logging.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext object DownloadUrlResolver { + private val logger = Logger.getLogger(DownloadUrlResolver::class.java.name) fun getWebSearchDownloadLink(packageName: String, version: String, architecture: String? = null): String { val architectureString = architecture ?: "all" @@ -34,7 +35,7 @@ object DownloadUrlResolver { } } - fun resolveRedirects(url: String, maxRedirectsToFollow : Int = 5): String { + fun resolveRedirects(url: String, maxRedirectsToFollow: Int = 5): String { if (maxRedirectsToFollow <= 0) return url try { @@ -50,7 +51,7 @@ object DownloadUrlResolver { val location = connection.getHeaderField("Location") if (location.isNullOrBlank()) { - Logger.info("Location tag is blank: ${connection.responseMessage}") + logger.info("Location tag is blank: ${connection.responseMessage}") return url } @@ -68,12 +69,10 @@ object DownloadUrlResolver { return resolveRedirects(resolved, maxRedirectsToFollow - 1) } - - //Log.d("Unexpected response code: $responseCode") } catch (ex: SocketTimeoutException) { - Logger.info("Timeout while resolving search redirect: $ex") + logger.info("Timeout while resolving search redirect: $ex") } catch (ex: Exception) { - Logger.info("Exception while resolving search redirect: $ex") + logger.info("Exception while resolving search redirect: $ex") } return url diff --git a/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt b/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt index 16b4b457..b1d2815b 100644 --- a/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt +++ b/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt @@ -9,10 +9,7 @@ import java.io.File import java.security.MessageDigest /** - * Canonical file-hashing used across the engine, CLI, and GUI. Lives in the - * engine layer so non-GUI callers (CLI patch recording, patched-app integrity - * checks) don't have to depend on `gui.util`. The GUI's `ChecksumUtils` - * delegates here so there's one implementation. + * Canonical file-hashing used across the engine, CLI, and GUI. */ object FileChecksum { diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreService.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreService.kt new file mode 100644 index 00000000..4fdeea1b --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreService.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.util + +import app.morphe.engine.MorpheData +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.config.EngineConfigRepository +import app.morphe.engine.util.SignatureIdentity +import app.morphe.patcher.apk.ApkSigner +import app.morphe.patcher.apk.ApkUtils +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.security.Provider +import java.security.Security +import java.security.cert.X509Certificate +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +class KeystoreService( + private val configRepository: EngineConfigRepository = EngineConfigRepository.shared, +) { + /** + * Resolves the keystore details for signing: + * 1. [explicitDetails] if non-null (e.g., CLI --keystore flag). + * 2. Configured keystore from [EngineConfigRepository] if valid and file exists. + * 3. Fallback to [MorpheData.defaultKeystoreFile] with default alias and password. + */ + suspend fun resolveSigningDetails(explicitDetails: ApkUtils.KeyStoreDetails? = null): ApkUtils.KeyStoreDetails { + if (explicitDetails != null) return explicitDetails + + val config = configRepository.loadConfig() + val userDetails = config.toKeyStoreDetails() + if (userDetails != null && userDetails.keyStore.exists()) { + return userDetails + } + + return ApkUtils.KeyStoreDetails( + keyStore = MorpheData.defaultKeystoreFile, + keyStorePassword = null, + alias = DEFAULT_KEYSTORE_ALIAS, + password = DEFAULT_KEYSTORE_PASSWORD, + ) + } + + /** + * Generates a new BKS keystore with a self-signed certificate and stores it at [destination]. + */ + fun generateKeystore( + destination: File, + alias: String = DEFAULT_KEYSTORE_ALIAS, + storePassword: String? = null, + entryPassword: String = DEFAULT_KEYSTORE_PASSWORD, + validityDays: Long = 8L * 365, + ) { + destination.parentFile?.mkdirs() + val keyPair = ApkSigner.newPrivateKeyCertificatePair( + "Morphe", + Date(System.currentTimeMillis() + validityDays * 24 * 60 * 60 * 1000), + ) + val ks = ApkSigner.newKeyStore(setOf( + ApkSigner.KeyStoreEntry( + alias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, + entryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD }, + keyPair, + ), + )) + destination.outputStream().use { + ks.store(it, storePassword?.ifEmpty { null }?.toCharArray()) + } + } + + /** + * Inspects a keystore file and extracts certificate metadata, fingerprints, and warnings. + */ + fun inspectKeystore( + keystoreFile: File, + password: String?, + alias: String, + entryPassword: String? = null, + locale: Locale = Locale.getDefault(), + ): KeystoreInspectionResult? { + if (!keystoreFile.exists()) return null + ensureBouncyCastleProvider() + + val passwordChars = password?.toCharArray() ?: charArrayOf() + val types = listOf("BKS" to "BC", "BKS" to null, "JKS" to null, "PKCS12" to null) + val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) + + for ((type, provider) in types) { + try { + val ks = if (provider != null) KeyStore.getInstance(type, provider) else KeyStore.getInstance(type) + keystoreFile.inputStream().use { ks.load(it, passwordChars) } + + if (!ks.containsAlias(alias)) { + return KeystoreInspectionResult( + alias = alias, + issuer = "", + validFrom = "", + validTo = "", + sha256Fingerprint = "", + sha1Fingerprint = "", + warnings = listOf(KeystoreWarning.AliasNotFound(alias)), + ) + } + + val cert = ks.getCertificate(alias) as? X509Certificate ?: continue + + try { + ks.getKey(alias, entryPassword?.toCharArray() ?: charArrayOf()) + } catch (_: Exception) { + return KeystoreInspectionResult( + alias = alias, + issuer = "", + validFrom = "", + validTo = "", + sha256Fingerprint = "", + sha1Fingerprint = "", + warnings = listOf(KeystoreWarning.KeyPasswordIncorrect(alias)), + ) + } + + val sha256 = MessageDigest.getInstance("SHA-256") + .digest(cert.encoded) + .joinToString(":") { "%02X".format(it) } + + val sha1 = MessageDigest.getInstance("SHA-1") + .digest(cert.encoded) + .joinToString(":") { "%02X".format(it) } + + return KeystoreInspectionResult( + alias = alias, + issuer = cert.issuerX500Principal.name, + validFrom = dateFormat.format(cert.notBefore), + validTo = dateFormat.format(cert.notAfter), + sha256Fingerprint = sha256, + sha1Fingerprint = sha1, + warnings = emptyList(), + ) + } catch (_: Exception) { + continue + } + } + return null + } + + /** + * Returns known Morphe signature hashes for both the default keystore and the configured keystore. + */ + suspend fun getKnownSignatureIds(): Set = buildSet { + SignatureIdentity.idForKeystore( + MorpheData.defaultKeystoreFile, + storePassword = null, + alias = DEFAULT_KEYSTORE_ALIAS, + )?.let { add(it) } + + val config = configRepository.loadConfig() + config.resolvedKeystorePath()?.let { ks -> + SignatureIdentity.idForKeystore(ks, config.keystorePassword, config.keystoreAlias)?.let { add(it) } + } + } + + private fun ensureBouncyCastleProvider() { + if (Security.getProvider("BC") != null) return + try { + val provider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor().newInstance() as Provider + Security.addProvider(provider) + } catch (_: Exception) {} + } + + companion object { + val shared: KeystoreService by lazy { KeystoreService() } + } +} + +sealed interface KeystoreWarning { + data class AliasNotFound(val alias: String) : KeystoreWarning + data class KeyPasswordIncorrect(val alias: String) : KeystoreWarning +} + +data class KeystoreInspectionResult( + val alias: String, + val issuer: String, + val validFrom: String, + val validTo: String, + val sha256Fingerprint: String, + val sha1Fingerprint: String, + val warnings: List = emptyList(), +) diff --git a/src/main/kotlin/app/morphe/engine/util/VersionUtils.kt b/src/main/kotlin/app/morphe/engine/util/VersionUtils.kt new file mode 100644 index 00000000..f83fda0a --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/VersionUtils.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.util + +import app.morphe.engine.model.Release + +/** + * Strips a leading `v` and surrounding whitespace. GitHub tags, JSON metadata + * and APK manifests MUST compare equal after this. + */ +fun String.normalizeVersion(): String = removePrefix("v").removePrefix("V").trim() + +/** + * Compare two version strings. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2. + * Handles dotted app versions ("20.40.45") and semver-ish tags ("v1.39.0-dev.10"). + * Stable outranks a pre-release of the same base. Pre-release ordinals compare + * numerically, so dev.9 sorts below dev.10. + * + * Ported from morphe-manager's VersionUtils. Both projects MUST rank alike. + */ +fun compareVersions(v1: String?, v2: String?): Int { + if (v1 == null && v2 == null) return 0 + if (v1 == null) return -1 + if (v2 == null) return 1 + + val version1 = v1.normalizeVersion() + val version2 = v2.normalizeVersion() + if (version1 == version2) return 0 + + data class VersionParts(val base: String, val preRelease: String?) + + fun extractParts(version: String): VersionParts { + val preReleasePattern = + """^([\d.]+)[-._]?(dev|beta|rc|alpha|preview)""".toRegex(RegexOption.IGNORE_CASE) + val match = preReleasePattern.find(version) + return if (match != null) { + VersionParts(match.groupValues[1], match.groupValues[2]) + } else { + val numMatch = """^([\d.]+)(.*)$""".toRegex().find(version) + if (numMatch != null && numMatch.groupValues[2].isNotEmpty()) { + VersionParts(numMatch.groupValues[1], numMatch.groupValues[2]) + } else { + VersionParts(version, null) + } + } + } + + val parts1 = extractParts(version1) + val parts2 = extractParts(version2) + + val base1 = parts1.base.split(".").map { it.toIntOrNull() ?: 0 } + val base2 = parts2.base.split(".").map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(base1.size, base2.size)) { + val a = base1.getOrNull(i) ?: 0 + val b = base2.getOrNull(i) ?: 0 + when { + a < b -> return -1 + a > b -> return 1 + } + } + + return when { + parts1.preRelease == null && parts2.preRelease == null -> 0 + parts1.preRelease == null && parts2.preRelease != null -> 1 // stable beats pre-release + parts1.preRelease != null && parts2.preRelease == null -> -1 + else -> { + val num1 = version1.substringAfterLast('.').toLongOrNull() + val num2 = version2.substringAfterLast('.').toLongOrNull() + if (num1 != null && num2 != null) num1.compareTo(num2) else version1.compareTo(version2) + } + } +} + +fun isNewerVersion(current: String?, baseline: String?): Boolean { + if (current.isNullOrBlank() || baseline.isNullOrBlank()) return false + if (current.equals("unknown", true) || baseline.equals("unknown", true)) return false + return compareVersions(current, baseline) > 0 +} + +fun String?.isDevTag(): Boolean { + val tag = this ?: return false + return tag.contains("dev", ignoreCase = true) || + tag.contains("alpha", ignoreCase = true) || + tag.contains("beta", ignoreCase = true) +} + +fun newerRelease(dev: Release?, stable: Release?): Release? = when { + dev == null -> stable + stable == null -> dev + compareVersions(dev.tagName, stable.tagName) >= 0 -> dev + else -> stable +} diff --git a/src/main/kotlin/app/morphe/engine/workspace/SessionWorkspace.kt b/src/main/kotlin/app/morphe/engine/workspace/SessionWorkspace.kt new file mode 100644 index 00000000..a8f0b015 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/workspace/SessionWorkspace.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.workspace + +import app.morphe.engine.util.deleteRecursivelyInParallel +import java.io.File + +/** + * An active, isolated session workspace for temporary patching/scratch operations. + */ +class SessionWorkspace( + val root: File, + val isCustom: Boolean, +) { + /** + * When true, this workspace will not be deleted on exit, preserving files for debugging. + */ + var retain: Boolean = false + + /** + * Resolves a child file within this workspace root. + */ + fun file(name: String): File = File(root, name) + + /** + * Creates and returns a subdirectory within this workspace root. + */ + fun subDir(name: String): File = File(root, name).also { it.mkdirs() } + + /** + * Cleans up the workspace unless [retain] is true. + * For auto-generated sessions, deletes the session directory recursively. + * For user-specified custom directories, clears the contents to preserve the container. + */ + fun cleanup(): Boolean { + if (retain) return false + return try { + if (isCustom) { + root.listFiles()?.forEach { it.deleteRecursivelyInParallel() } + true + } else { + root.deleteRecursivelyInParallel() + } + } catch (_: Exception) { + false + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/workspace/WorkspaceManager.kt b/src/main/kotlin/app/morphe/engine/workspace/WorkspaceManager.kt new file mode 100644 index 00000000..af3e3bce --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/workspace/WorkspaceManager.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.workspace + +import app.morphe.engine.CacheManager +import app.morphe.engine.MorpheData +import app.morphe.engine.util.deleteRecursivelyInParallel +import java.io.File + +/** + * Manager for session workspaces, scratch files, and shadow copies. + * All temporary and scratch files are rooted strictly under [MorpheData.tmpDir]. + */ +object WorkspaceManager { + val scratchDir: File get() = MorpheData.tmpDir + private val shadowDir: File get() = File(scratchDir, "shadow").also { it.mkdirs() } + + /** + * Executes [block] within an isolated [SessionWorkspace]. + * + * The workspace is guaranteed to be cleaned up on completion unless [disablePurge] is true + * or the workspace is explicitly marked to retain. + */ + inline fun useSession( + prefix: String = "patching", + customDir: File? = null, + disablePurge: Boolean = false, + block: (SessionWorkspace) -> T, + ): T { + val sessionRoot = customDir ?: File(scratchDir, "$prefix-${System.currentTimeMillis()}").also { it.mkdirs() } + val workspace = SessionWorkspace(sessionRoot, isCustom = customDir != null) + if (disablePurge) { + workspace.retain = true + } + + return try { + block(workspace) + } finally { + if (!workspace.retain) { + workspace.cleanup() + } + } + } + + /** + * Creates a safe shadow copy of an .mpp file in `morphe-data/tmp/shadow/` + * to avoid Windows `URLClassLoader` file-locking bugs. + */ + fun createShadowCopy(sourceFile: File, prefix: String = "bundle"): File { + val copy = File(shadowDir, "$prefix-${System.nanoTime()}.mpp") + copy.deleteOnExit() + sourceFile.copyTo(copy, overwrite = true) + return copy + } + + /** + * Returns the total byte size of all scratch and temporary files in [MorpheData.tmpDir]. + */ + fun getScratchSize(): Long = CacheManager.getDirectorySize(scratchDir) + + /** + * Returns true if there are any scratch files or directories in [MorpheData.tmpDir]. + */ + fun hasScratchFiles(): Boolean = try { + scratchDir.exists() && (scratchDir.listFiles()?.isNotEmpty() == true) + } catch (_: Exception) { + false + } + + /** + * Deletes all scratch and temporary contents under [MorpheData.tmpDir]. + */ + fun clearScratch(): CacheManager.DirResult = + CacheManager.clearDirectoryContents("Temp", scratchDir) + + /** + * Reaps stale session directories and shadow files left behind by past crashed runs. + * Default threshold: 12 hours. + */ + fun reapStaleWorkspaces(maxAgeMs: Long = 12 * 3600 * 1000L): Int { + val now = System.currentTimeMillis() + var reaped = 0 + scratchDir.listFiles()?.forEach { entry -> + if (entry.isDirectory) { + if (entry.name == "shadow") { + entry.listFiles()?.forEach { shadowFile -> + if (now - shadowFile.lastModified() > maxAgeMs) { + if (shadowFile.delete()) reaped++ + } + } + } else if (entry.name.startsWith("patching-")) { + val timestamp = entry.name.removePrefix("patching-").toLongOrNull() + if (timestamp != null && (now - timestamp > maxAgeMs)) { + if (entry.deleteRecursivelyInParallel()) reaped++ + } + } + } + } + return reaped + } +} diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index eee5959c..672352a1 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -29,8 +29,9 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import app.morphe.engine.PatchEngine +import app.morphe.engine.config.EngineConfigRepository import app.morphe.gui.data.model.MorpheFill -import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.data.repository.ActiveMode import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager @@ -232,6 +233,7 @@ private fun appContent( initialSimplifiedMode: Boolean ) { val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() val patchSourceManager: PatchSourceManager = koinInject() val scope = rememberCoroutineScope() val enableParallaxState = remember { mutableStateOf(true) } @@ -273,7 +275,7 @@ private fun appContent( appLanguage = config.language applyLocale(config.language) - autoStartAdb = config.autoStartAdb + autoStartAdb = engineConfigRepository.loadConfig().autoStartAdb // Publish the initial active mode BEFORE the VMs subscribe so their // activeMode listener fires with the correct value on first emit. patchSourceManager.setActiveMode( @@ -320,7 +322,7 @@ private fun appContent( val onAutoStartAdbChange: (Boolean) -> Unit = { enabled -> autoStartAdb = enabled scope.launch { - configRepository.setAutoStartAdb(enabled) + engineConfigRepository.setAutoStartAdb(enabled) if (enabled) { DeviceMonitor.startMonitoring() } else { @@ -505,7 +507,7 @@ private fun appContent( } } ) { backStackEntry -> - val config = backStackEntry.getComplexArg() + val config = backStackEntry.getComplexArg() if (config != null) { PatchingScreen(config = config) } diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index cca62ca4..480b58ad 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -16,10 +16,11 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import app.morphe.engine.MorpheData +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.gui.data.model.AppConfig import app.morphe.gui.ui.components.LocalFrameWindowScope import app.morphe.gui.util.DeviceMonitor -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import io.github.vinceglb.filekit.FileKit import java.awt.Dimension @@ -36,6 +37,7 @@ import org.jetbrains.skia.Image */ fun launchGui(args: Array) { Logger.init() + runCatching { WorkspaceManager.reapStaleWorkspaces() } // FileKit backs the native OS file/folder pickers (JNA on Windows/macOS, // XDG Desktop Portal on Linux). Must run once before any picker is used; @@ -133,7 +135,7 @@ fun launchGui(args: Array) { */ private fun loadConfigSync(): AppConfig { return try { - val configFile = FileUtils.getConfigFile() + val configFile = MorpheData.configFile if (configFile.exists()) { val json = Json { ignoreUnknownKeys = true } json.decodeFromString(configFile.readText()) diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt index 80f5e8f2..efc00a66 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -27,7 +27,7 @@ object AppConstants { } // ==================== API ==================== - const val MORPHE_API_URL = "https://api.morphe.software" + const val MORPHE_API_URL = app.morphe.engine.MorpheConstants.MORPHE_API_URL const val WEBSITE_URL = "https://morphe.software" /** A dev build carries a prerelease suffix, and an unpackaged run reports "dev". */ @@ -37,13 +37,6 @@ object AppConstants { val branch = if (IS_DEV_BUILD) "dev" else "main" "https://github.com/MorpheApp/morphe-desktop/blob/$branch/docs/documentation.md" } - - val FALLBACK_PACKAGES = listOf( - "com.google.android.youtube", - "com.google.android.apps.youtube.music", - "com.reddit.frontpage", - ) - // TODO: Checksum verification will be re-enabled when checksums are added to .mpp files // For now, checksums are not validated. See ChecksumUtils.kt for the verification logic. } diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 609c8ac4..c89c3f94 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -5,13 +5,8 @@ package app.morphe.gui.data.model -import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS -import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD -import app.morphe.engine.util.PortablePaths import app.morphe.gui.ui.theme.ThemePreference -import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import kotlinx.serialization.Serializable -import java.io.File /** * Application configuration stored in config.json @@ -89,19 +84,9 @@ data class AppConfig( val groupPatchesByCategory: Boolean = true, val homeAppSortMode: String = "RECOMMENDED", val preferredPatchChannel: String = PatchChannel.STABLE.name, - val defaultOutputDirectory: String? = null, - val autoCleanupTempFiles: Boolean = true, // Default ON val useSimplifiedMode: Boolean = true, // Default to Quick/Simplified mode val patchSource: List = listOf(DEFAULT_PATCH_SOURCE), val activePatchSourceId: String = "morphe-default", - val keystorePath: String? = null, - val keystorePassword: String? = null, - val keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, - val keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD, - // User's global keep-list for strip libs. Defaults to all common modern arches - // (equivalent to no stripping). Stripping is only applied when the APK contains - // an arch NOT in this set. See PatchSelectionViewModel.computeStripLibsStatus. - val keepArchitectures: Set = ANDROID_ARCHITECTURES, // Persisted expand/collapse state for each section in the Settings dialog. // Keyed by section title (e.g. "STRIP LIBS"). Missing key = section starts collapsed. val collapsibleSectionStates: Map = emptyMap(), @@ -118,34 +103,9 @@ data class AppConfig( // after upgrading to multi-source builds. Flips to true once the user dismisses // the banner, never resets. val multiSourceHintDismissed: Boolean = false, - // Whether Morphe should auto-start the ADB daemon at GUI launch to monitor - // connected devices. Default OFF. Many users never push patched APKs to a - // device, so spawning a long-lived adb server unprompted is unwanted noise. - // When ON, DeviceMonitor polls devices. If Morphe was the one that started - // the daemon, it's killed on toggle-OFF and on window close. - val autoStartAdb: Boolean = false, - // Patch-developer options. Enables a suite of developers options that patch developers - // can use. - val developerOptions: Boolean = false, - // Last folder browsed when picking a local .mpp, so the file picker reopens there - // instead of a system default. Set after a successful pick. - val lastLocalPatchDir: String? = null, - // Extra glob patterns (e.g. "*-debug.mpp") for .mpp files to skip when a developer - // folder source auto-loads the newest .mpp. Layered on top of the always-excluded - // build classifiers (*-sources.mpp, *-javadoc.mpp). Patch-developer option. - val excludedMppPatterns: List = emptyList(), // Which home apps tab the user last viewed ("ALL" or "YOURS"), restored on // next launch. Stored as a string so this data layer stays free of UI enums. val homeAppListFilter: String = "ALL", - // After an ADB install, automatically route the patched app's web links to it - // ("open with"). Default OFF. It changes how the device opens links, so it's - // opt-in. See AppLinkCommands / AdbManager.setLinkHandling. - val autoRouteLinksAfterInstall: Boolean = false, - // When auto-routing links, also stop the stock app from opening those links - // (only applies when a rename patch was used and stock is installed). Default - // OFF. It reaches into a stock app's behavior. - val disableStockLinksAfterInstall: Boolean = false, - val gitHubPat: String = "", ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { @@ -164,20 +124,6 @@ data class AppConfig( ThemePreference.SYSTEM } } - - /** - * Resolved live [File] for [defaultOutputDirectory]. Goes through - * [PortablePaths.resolve] so a stored relative value is anchored to the - * bundle, not the JVM's CWD. Use this instead of `File(...)` at call sites. - */ - fun resolvedDefaultOutputDirectory(): File? = - defaultOutputDirectory?.let(PortablePaths::resolve) - - /** - * Resolved live [File] for [keystorePath]. See [resolvedDefaultOutputDirectory]. - */ - fun resolvedKeystorePath(): File? = - keystorePath?.let(PortablePaths::resolve) } @Serializable diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt deleted file mode 100644 index 4723abc7..00000000 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.data.model - -import app.morphe.engine.model.PatchedAppRecord -import app.morphe.patcher.resource.CpuArchitecture -import kotlinx.serialization.Serializable -import kotlin.reflect.KType -import kotlinx.serialization.Transient - -/** - * Represents a single patch from Morphe patches bundle. - */ -@Serializable -data class Patch( - val name: String, - val description: String = "", - val compatiblePackages: List = emptyList(), - val options: List = emptyList(), - val isEnabled: Boolean = true, - val category: String? = null -) { - /** Whether this patch targets no specific package (applies universally / system-wide). */ - val isUniversal: Boolean - get() = compatiblePackages.isEmpty() - - /** - * Unique identifier for this patch. - * Combines name, packages, and description hash for true uniqueness. - */ - val uniqueId: String - get() { - val packages = compatiblePackages.joinToString(",") { it.name } - val descHash = description.hashCode().toString(16) - return "$name|$packages|$descHash" - } - - /** - * Check if patch is compatible with a given package. - * Patches with no compatible packages listed are NOT shown (they're system patches). - */ - fun isCompatibleWith(packageName: String, versionName: String? = null): Boolean { - return compatiblePackages.any { pkg -> - pkg.name == packageName && ( - versionName == null || - pkg.versions.isEmpty() || - pkg.versions.contains(versionName) - ) - } - } -} - -@Serializable -data class CompatiblePackage( - val name: String, - val displayName: String? = null, - val versions: List = emptyList(), - val experimentalVersions: List = emptyList(), - val appIconColor: String? = null, - val versionBuildCodes: Map> = emptyMap() -) - -/** - * Semantic UI hint produced by a typed patcher option subclass. - * Null when the underlying option is a plain untyped option. - */ -enum class ExplicitOptionKind { - Folder, FilePath, Files, Image, Color, IntSlider, FloatSlider, IntRange, FloatRange -} - -/** Recommended pixel dimensions for an [ExplicitOptionKind.Image] option. */ -data class ImageSize(val width: Int, val height: Int) - -/** - * Bounds declared by a slider option, normalized so one carrier serves the integer and the - * floating point kinds alike. - */ -data class SliderBounds(val min: Float, val max: Float, val step: Float?) - -@Serializable -data class PatchOption( - val key: String, - val title: String, - val description: String = "", - val type: PatchOptionType = PatchOptionType.STRING, - val default: String? = null, - val required: Boolean = false, - /** The type the patch declared. [type] cannot express a list's element type. */ - @Transient val valueType: KType? = null, - @Transient val explicitKind: ExplicitOptionKind? = null, - @Transient val allowedExtensions: List? = null, - @Transient val recommendedSize: ImageSize? = null, - @Transient val sliderBounds: SliderBounds? = null, - @Transient val presets: Map? = null, - @Transient val rawDefault: Any? = null, -) - -@Serializable -enum class PatchOptionType { - STRING, - BOOLEAN, - INT, - LONG, - FLOAT, - LIST, - FILE -} - -/** - * Configuration for a patching session. - */ -@Serializable -data class PatchConfig( - val inputApkPath: String, - val outputApkPath: String, - /** One or more .mpp file paths. Multiple = union of patches across sources. */ - val patchesFilePaths: List, - val enabledPatches: List = emptyList(), - val disabledPatches: List = emptyList(), - val patchOptions: Map = emptyMap(), - val useExclusiveMode: Boolean = false, - val keepArchitectures: Set = emptySet(), - val continueOnError: Boolean = false, - - // ── Recall metadata ── - // Carried from the selection screen down to the patching screen so the - // success path can record a PatchedAppRecord (see PatchedAppStore). All - // default-empty, so callers that don't populate them still work. - val packageName: String = "", - val appDisplayName: String = "", - /** Source name → set of selected patch unique ids. */ - val patchSelectionByBundle: Map> = emptyMap(), - /** Sources + versions enabled at patch time (drives "update available"). */ - val sourcesSnapshot: List = emptyList(), - val appVersion: String? = null, - val patchesSourceName: String? = null, - val patchesVersion: String? = null, -) diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 56eb4803..ff7f8978 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -5,7 +5,8 @@ package app.morphe.gui.data.repository -import app.morphe.engine.util.PortablePaths +import app.morphe.engine.MorpheData +import app.morphe.engine.util.isDevTag import app.morphe.gui.data.model.AppConfig import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE import app.morphe.gui.data.model.FollowMode @@ -15,12 +16,14 @@ import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.UpdateChannelPreference import app.morphe.gui.data.model.MorpheFill import app.morphe.gui.ui.theme.ThemePreference -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger -import app.morphe.gui.util.isDevTag +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject /** * Repository for managing app configuration (config.json) @@ -46,7 +49,7 @@ open class ConfigRepository { // unified morphe-data location. Runs once and is a no-op thereafter. ConfigMigration.runIfNeeded() - val configFile = FileUtils.getConfigFile() + val configFile = MorpheData.configFile try { if (configFile.exists()) { @@ -61,6 +64,8 @@ open class ConfigRepository { saveConfig(default) default } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.error("Failed to load config, using defaults", e) AppConfig() @@ -72,9 +77,18 @@ open class ConfigRepository { */ suspend fun saveConfig(config: AppConfig) = withContext(Dispatchers.IO) { try { - val configFile = FileUtils.getConfigFile() - val content = json.encodeToString(AppConfig.serializer(), config) - configFile.writeText(content) + val configFile = MorpheData.configFile + val existingObj = if (configFile.exists()) { + runCatching { json.parseToJsonElement(configFile.readText()).jsonObject }.getOrDefault(JsonObject(emptyMap())) + } else JsonObject(emptyMap()) + + val guiObj = json.encodeToJsonElement(AppConfig.serializer(), config).jsonObject + val mergedMap = existingObj.toMutableMap() + guiObj.forEach { (k, v) -> mergedMap[k] = v } + val mergedJson = json.encodeToString(JsonObject.serializer(), JsonObject(mergedMap)) + + configFile.parentFile?.mkdirs() + configFile.writeText(mergedJson) cachedConfig = config Logger.info("Config saved to ${configFile.absolutePath}") } catch (e: Exception) { @@ -278,38 +292,6 @@ open class ConfigRepository { return derived } - /** - * Update default output directory. - */ - suspend fun setDefaultOutputDirectory(path: String?) { - val current = loadConfig() - saveConfig(current.copy(defaultOutputDirectory = path?.let(PortablePaths::storableForm))) - } - - /** - * Update auto-cleanup temp files setting. - */ - suspend fun setAutoCleanupTempFiles(enabled: Boolean) { - val current = loadConfig() - saveConfig(current.copy(autoCleanupTempFiles = enabled)) - } - - /** - * Update the "route links to patched app after install" setting. - */ - suspend fun setAutoRouteLinksAfterInstall(enabled: Boolean) { - val current = loadConfig() - saveConfig(current.copy(autoRouteLinksAfterInstall = enabled)) - } - - /** - * Update the "also disable stock app's links" sub-setting. - */ - suspend fun setDisableStockLinksAfterInstall(enabled: Boolean) { - val current = loadConfig() - saveConfig(current.copy(disableStockLinksAfterInstall = enabled)) - } - /** * Update simplified mode setting. */ @@ -318,14 +300,6 @@ open class ConfigRepository { saveConfig(current.copy(useSimplifiedMode = enabled)) } - /** - * Update the user's global keep-architectures list for strip libs. - */ - suspend fun setKeepArchitectures(keep: Set) { - val current = loadConfig() - saveConfig(current.copy(keepArchitectures = keep)) - } - /** * Persist the expand/collapse state of a Settings dialog section so it restores * on next open instead of resetting to collapsed. @@ -336,32 +310,6 @@ open class ConfigRepository { saveConfig(current.copy(collapsibleSectionStates = updated)) } - /** - * Update keystore path only (used for auto-remember on first creation). - */ - suspend fun setKeystorePath(path: String?) { - val current = loadConfig() - saveConfig(current.copy(keystorePath = path?.let(PortablePaths::storableForm))) - } - - /** - * Update all keystore details at once. - */ - suspend fun setKeystoreDetails( - path: String?, - password: String?, - alias: String, - entryPassword: String - ) { - val current = loadConfig() - saveConfig(current.copy( - keystorePath = path?.let(PortablePaths::storableForm), - keystorePassword = password, - keystoreAlias = alias, - keystoreEntryPassword = entryPassword - )) - } - /** * Get the currently active patch source. */ @@ -427,31 +375,7 @@ open class ConfigRepository { saveConfig(current.copy(patchSource = reordered)) } - /** - * Update whether Morphe auto-starts the ADB daemon at GUI launch. - */ - suspend fun setAutoStartAdb(enabled: Boolean) { - val current = loadConfig() - saveConfig(current.copy(autoStartAdb = enabled)) - } - - /** Toggle patch-developer options (unlocks folder-based local sources). */ - suspend fun setDeveloperOptions(enabled: Boolean) { - val current = loadConfig() - saveConfig(current.copy(developerOptions = enabled)) - } - /** Remember the last folder used in the local .mpp picker. */ - suspend fun setLastLocalPatchDir(path: String?) { - val current = loadConfig() - saveConfig(current.copy(lastLocalPatchDir = path)) - } - - /** User-added glob patterns for .mpp files a developer folder source should skip. */ - suspend fun setExcludedMppPatterns(patterns: List) { - val current = loadConfig() - saveConfig(current.copy(excludedMppPatterns = patterns)) - } /** * Mark the multi-source upgrade hint as dismissed. One-shot, never resets. @@ -516,18 +440,7 @@ open class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources, activePatchSourceId = newActiveId)) } - /** - * Update GitHub Personal Access Token (PAT). - */ - suspend fun setGitHubPat(pat: String) { - val current = loadConfig() - saveConfig(current.copy(gitHubPat = pat)) - } - /** - * Get the configured GitHub Personal Access Token (PAT). - */ - suspend fun getGitHubPat(): String = loadConfig().gitHubPat /** * Clear cached config (for testing). diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt deleted file mode 100644 index 8d8f9432..00000000 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.data.repository - -import app.morphe.engine.model.Release -import app.morphe.engine.model.ReleaseAsset -import app.morphe.engine.patches.PatchBundleLoader -import app.morphe.engine.patches.PatchCache -import app.morphe.engine.patches.RemotePatchSource -import app.morphe.engine.patches.findPatchAsset -import app.morphe.gui.util.Logger -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * GUI-side wrapper around an engine [RemotePatchSource]. Adds: - * - 5-minute in-memory TTL on the release listing (so repeated UI calls - * don't re-hit the API every time) - * - Disk cache for downloaded .mpp files keyed by source's repoPath - * - Filter helpers (stable/dev) and cache lookup helpers tailored to the - * GUI's needs - * - * The remote provider logic itself (URL construction, HTTP, JSON shape) is - * NOT here — it lives in the engine. This class is purely a caching + - * convenience layer. - */ -class PatchRepository( - val remoteSource: RemotePatchSource, -) { - val repoPath: String get() = remoteSource.repoPath - - companion object { - private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes - - /** - * Per-release filename used in the disk cache. - * - * Many patch source maintainers (including MorpheApp/morphe-patches) - * name their `.mpp` release asset the SAME string across versions, - * e.g. `morphe-patches.mpp`. Storing them by their bare asset name - * means each new download overwrites the previous version — only ONE - * file ever lives in the cache. Worse, the size-match check made - * `checkCachedPatches` return a "hit" for the latest version (whose - * size happened to match the on-disk file) while older versions - * correctly returned a miss — so the patches-screen UI showed - * SELECT for the latest and DOWNLOAD for everything else, even - * right after a Clear Cache. - * - * Prepending the release tag (`v1.5.0__morphe-patches.mpp`) gives - * each version its own file. Cache hits are now per-version exactly. - * The double-underscore is a deliberate visual delimiter — easier - * to eyeball when grepping the cache directory than a single dash. - */ - fun cachedFileName(release: Release, asset: ReleaseAsset): String = - PatchCache.cachedFileName(release, asset) - } - - // In-memory cache so multiple callers don't re-fetch from the remote API - private var cachedReleases: List? = null - private var cacheTimestamp: Long = 0L - - /** - * Fetch all releases. Returns cached result if still fresh. - * @param forceRefresh bypass the in-memory cache - */ - suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = - withContext(Dispatchers.IO) { - val cached = cachedReleases - if (!forceRefresh && cached != null && - (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS - ) { - Logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") - return@withContext Result.success(cached) - } - - val result = remoteSource.listReleases() - result.onSuccess { fresh -> - cachedReleases = fresh - cacheTimestamp = System.currentTimeMillis() - } - // If fetch failed but we still have stale data, prefer the stale - // data over a hard error. Matches the previous behavior — keeps - // offline / flaky-network sessions usable. - if (result.isFailure) { - val stale = cachedReleases - if (stale != null) { - Logger.info("Returning stale cached releases after fetch failure") - return@withContext Result.success(stale) - } - } - result - } - - /** Stable releases only (non-prerelease). */ - suspend fun fetchStableReleases(): Result> = - fetchReleases().map { releases -> releases.filter { !it.isDevRelease() } } - - /** Dev / prerelease versions only. */ - suspend fun fetchDevReleases(): Result> = - fetchReleases().map { releases -> releases.filter { it.isDevRelease() } } - - suspend fun getLatestStableRelease(): Result = - latestFromManifestOrApi(prerelease = false) - - suspend fun getLatestDevRelease(): Result = - latestFromManifestOrApi(prerelease = true) - - /** - * Resolve the latest release for a channel via the repo's `patches-bundle.json` - * (raw CDN, no API rate limit); fall back to the releases API only when the source - * publishes no manifest. This is the common path, so it keeps the tight 60/hr GitHub - * API budget for the rarer "list older versions" case. - */ - private suspend fun latestFromManifestOrApi(prerelease: Boolean): Result = - withContext(Dispatchers.IO) { - remoteSource.fetchLatestFromManifest(prerelease).getOrNull()?.let { - Logger.info("Latest ${if (prerelease) "dev" else "stable"} via patches-bundle.json: ${it.tagName}") - return@withContext Result.success(it) - } - if (prerelease) fetchDevReleases().map { it.firstOrNull() } - else fetchStableReleases().map { it.firstOrNull() } - } - - /** Find the patch .mpp asset in a release. */ - fun findPatchAsset(release: Release): ReleaseAsset? = release.findPatchAsset() - - /** - * Download the patch .mpp file from a release. Handles the disk cache — - * if a matching file is already present, skips the network call entirely. - */ - suspend fun downloadPatches( - release: Release, - onProgress: (Float) -> Unit = {}, - ): Result = withContext(Dispatchers.IO) { - val asset = release.findPatchAsset() - ?: return@withContext Result.failure( - Exception("No .mpp patch files found in release ${release.tagName}") - ) - - val patchesDir = PatchCache.sourceDir(repoPath) - val targetFile = File(patchesDir, cachedFileName(release, asset)) - - // Cache hit rules: - // - If we know the asset's expected size (GitHub provides it), - // the cached file must match exactly. - // - If size is unknown (some GitLab cases), fall back to "file - // exists and is non-empty". A zero-byte file is always treated - // as a miss so a previously-failed download doesn't masquerade - // as a cache hit. - val isCached = when { - !targetFile.exists() -> false - targetFile.length() == 0L -> false - asset.size > 0L -> targetFile.length() == asset.size - else -> true - } - if (isCached) { - Logger.info("Using cached patches: ${targetFile.absolutePath} (${targetFile.length()} bytes)") - onProgress(1f) - return@withContext Result.success(targetFile) - } - - // Delegate the actual network IO to the engine source, translating byte - // progress into a 0..1 fraction for the UI. When the server omits - // Content-Length we can't compute a fraction, so the bar stays put until - // completion — GitHub/GitLab CDN downloads do send it, so this is rare. - val result = remoteSource.downloadAsset(asset, targetFile) { bytesRead, contentLength -> - if (contentLength != null && contentLength > 0L) { - onProgress((bytesRead.toFloat() / contentLength).coerceIn(0f, 1f)) - } - } - if (result.isSuccess) onProgress(1f) - result - } - - /** Get cached patch file for a specific version. */ - fun getCachedPatches(version: String): File? { - val patchesDir = PatchCache.sourceDir(repoPath) - return patchesDir.listFiles()?.find { - (it.name.contains(version) || PatchBundleLoader.extractVersion(it) == version) && isPatchFileName(it.name) - } - } - - private fun isPatchFileName(name: String): Boolean = - name.endsWith(".mpp", ignoreCase = true) - - /** List all cached patch versions. */ - fun listCachedPatches(): List { - val patchesDir = PatchCache.sourceDir(repoPath) - return patchesDir.listFiles()?.filter { isPatchFileName(it.name) } ?: emptyList() - } - - /** Get the per-source cache directory for this repository. */ - fun getCacheDir(): File = PatchCache.sourceDir(repoPath) - - /** Delete cached patches (both in-memory release list and on-disk files). */ - fun clearCache(): Boolean { - cachedReleases = null - cacheTimestamp = 0L - return try { - val patchesDir = PatchCache.sourceDir(repoPath) - var failedCount = 0 - patchesDir.listFiles()?.forEach { file -> - try { - if (!file.deleteRecursively()) throw Exception("Could not delete") - } catch (e: Exception) { - failedCount++ - Logger.error("Failed to delete ${file.name}: ${e.message}") - } - } - if (failedCount > 0) { - Logger.error("Patches cache clear incomplete: $failedCount file(s) locked") - false - } else { - Logger.info("Patches cache cleared for $repoPath") - true - } - } catch (e: Exception) { - Logger.error("Failed to clear patches cache", e) - false - } - } -} diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt index ecb191fb..9a018349 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -6,11 +6,12 @@ package app.morphe.gui.data.repository import app.morphe.engine.patches.PatchProvider +import app.morphe.engine.patches.PatchRepository import app.morphe.engine.patches.RemotePatchSourceFactory import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.util.Logger -import io.ktor.client.* +import io.ktor.client.HttpClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -142,7 +143,7 @@ class PatchSourceManager( private fun defaultMorpheRepository(): PatchRepository { val remote = RemotePatchSourceFactory.build( PatchProvider.GITHUB, - "MorpheApp/morphe-patches", + RemotePatchSourceFactory.DEFAULT_REPO_PATH, httpClient, ) return PatchRepository(remote) @@ -170,14 +171,17 @@ class PatchSourceManager( } else null val finalRemote = remote ?: run { - val repoPath = extractRepoPath(source) // Map the GUI's persisted source type to the engine's provider // enum. DEFAULT inherits GitHub (Morphe Patches lives there). val provider = when (source.type) { PatchSourceType.GITLAB -> PatchProvider.GITLAB else -> PatchProvider.GITHUB } - RemotePatchSourceFactory.build(provider, repoPath, httpClient) + RemotePatchSourceFactory.build( + provider, + RemotePatchSourceFactory.DEFAULT_REPO_PATH, + httpClient, + ) } Logger.info("Creating PatchRepository for source '${source.name}' (repo=${finalRemote.repoPath}, provider=${finalRemote.provider})") PatchRepository(finalRemote) @@ -191,22 +195,6 @@ class PatchSourceManager( return configRepository.getActivePatchSource() } - /** - * Extract "owner/repo" from a PatchSource's URL. Works for both GitHub - * and GitLab hosts. Falls back to the built-in default repo when no URL - * is configured (e.g. for the DEFAULT source on first launch). - */ - private fun extractRepoPath(source: PatchSource): String { - val url = source.url ?: return "MorpheApp/morphe-patches" - return url - .removePrefix("https://github.com/") - .removePrefix("http://github.com/") - .removePrefix("https://gitlab.com/") - .removePrefix("http://gitlab.com/") - .removeSuffix("/") - .removeSuffix(".git") - } - /** * Notify that cached patch files were deleted (e.g. via "Clear Cache" in settings). * Clears cached repo state and bumps [sourceVersion] so ViewModels reload. diff --git a/src/main/kotlin/app/morphe/gui/data/repository/SeenPatchesRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/SeenPatchesRepository.kt index f54303ea..5fb8b641 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/SeenPatchesRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/SeenPatchesRepository.kt @@ -5,7 +5,7 @@ package app.morphe.gui.data.repository -import app.morphe.gui.util.FileUtils +import app.morphe.engine.MorpheData import app.morphe.gui.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -19,7 +19,7 @@ class SeenPatchesRepository { private val mutex = Mutex() private val json = Json { ignoreUnknownKeys = true; prettyPrint = true } - private fun file(): File = File(FileUtils.getAppDataDir(), "seen-patches.json") + private fun file(): File = File(MorpheData.root, "seen-patches.json") private fun load(): MutableMap>> { val f = file() diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index dfec9234..f5625d71 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -6,10 +6,13 @@ package app.morphe.gui.di import app.morphe.engine.PatchedAppStore +import app.morphe.engine.config.EngineConfigRepository +import app.morphe.engine.network.createHttpClient +import app.morphe.engine.options.PatchPreferencesRepository +import app.morphe.engine.util.KeystoreService import app.morphe.gui.data.repository.ChangelogRepository import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.LanguageRepository -import app.morphe.gui.data.repository.PatchPreferencesRepository import app.morphe.gui.data.repository.SeenPatchesRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository @@ -19,17 +22,7 @@ import app.morphe.gui.ui.screens.patches.PatchesViewModel import app.morphe.gui.ui.screens.patching.PatchingViewModel import app.morphe.gui.ui.screens.quick.QuickPatchViewModel import app.morphe.gui.util.Logger as MorpheLogger -import app.morphe.gui.util.PatchService -import io.ktor.client.* -import io.ktor.client.engine.okhttp.* -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.logging.* -import io.ktor.serialization.kotlinx.json.* -import java.net.Inet4Address import kotlinx.serialization.json.Json -import okhttp3.Dns -import okhttp3.Protocol import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -50,52 +43,19 @@ val appModule = module { // Ktor HTTP Client single { - HttpClient(OkHttp) { - engine { - config { - // Prefer IPv4. A host with an advertised but unroutable IPv6 - // address otherwise burns the whole connect timeout before - // anything tries the working A record. - dns { hostname -> - val all = Dns.SYSTEM.lookup(hostname) - all.filterIsInstance().ifEmpty { all } - } - // Pin HTTP/1.1. Avoids intermittent HTTP/2 PROTOCOL_ERROR stream - // resets seen against GitHub-backed download endpoints. - protocols(listOf(Protocol.HTTP_1_1)) - followRedirects(true) - followSslRedirects(true) - } - } - install(ContentNegotiation) { - json(get()) - } - install(Logging) { - level = LogLevel.INFO - logger = object : Logger { - override fun log(message: String) { - MorpheLogger.debug("HTTP: $message") - } - } - } - // Socket-based (idle) timeouts, not a wall-clock cap. A large but flowing - // patch download is never killed just for being big. - // requestTimeoutMillis is left unset (infinite). - install(HttpTimeout) { - connectTimeoutMillis = 30_000 - socketTimeoutMillis = 60_000 - } - // Retry/429 handling lives in HttpService (single layer). Not a client plugin, to avoid compounding retries. + createHttpClient(json = get()) { message -> + MorpheLogger.debug("HTTP: $message") } } // Repositories and Services + single { EngineConfigRepository.shared } + single { KeystoreService.shared } single { ConfigRepository() } single { LanguageRepository() } single { PatchPreferencesRepository() } single { SeenPatchesRepository() } single { PatchSourceManager(get(), get()) } - single { PatchService() } single { UpdateCheckRepository(get()) } single { ChangelogRepository(get()) } single { PatchedAppStore.shared } @@ -103,10 +63,10 @@ val appModule = module { // ViewModels // ViewModels observe PatchSourceManager.sourceVersion and reload on source changes. viewModel { - HomeViewModel(get(), get(), get(), get(), get(), get()) + HomeViewModel(get(), get(), get(), get(), get()) } viewModel { - QuickPatchViewModel(get(), get(), get(), get()) + QuickPatchViewModel(get(), get(), get()) } viewModel { params -> val psm = get() @@ -127,7 +87,6 @@ val appModule = module { params.get(), params.get(), params.get(), - get(), psm.getActiveRepositorySync(), get(), get(), @@ -145,8 +104,6 @@ val appModule = module { PatchingViewModel( params.get(), get(), - get(), - get() ) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt index b1ebde95..07b08f6b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt @@ -27,13 +27,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.util.DeviceStatus import app.morphe.gui.LocalAdbPreference import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.util.DeviceMonitor -import app.morphe.gui.util.DeviceStatus import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/src/main/kotlin/app/morphe/gui/ui/components/MorphePathInput.kt b/src/main/kotlin/app/morphe/gui/ui/components/MorphePathInput.kt index d19baeca..fc8e4ceb 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/MorphePathInput.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/MorphePathInput.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.gui.data.model.ImageSize +import app.morphe.engine.options.ImageSize import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners diff --git a/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt index 82e4021e..30b1ce88 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt @@ -22,9 +22,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.engine.patches.PatchProvider import app.morphe.engine.patches.RemotePatchSourceFactory +import app.morphe.engine.config.EngineConfigRepository import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType -import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheDimens @@ -55,12 +55,12 @@ internal fun AddPatchSourceDialog( var error by remember { mutableStateOf(null) } var usePreRelease by remember { mutableStateOf(false) } var useExperimentalVersions by remember { mutableStateOf(false) } - val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() val scope = rememberCoroutineScope() var developerOptions by remember { mutableStateOf(false) } var lastLocalPatchDir by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - val cfg = configRepository.loadConfig() + val cfg = engineConfigRepository.loadConfig() developerOptions = cfg.developerOptions lastLocalPatchDir = cfg.lastLocalPatchDir } @@ -232,7 +232,7 @@ internal fun AddPatchSourceDialog( filePath = path if (name.isBlank()) name = suggested error = null - scope.launch { configRepository.setLastLocalPatchDir(dirToRemember(path)) } + scope.launch { engineConfigRepository.setLastLocalPatchDir(dirToRemember(path)) } }, font = font, accents = accents, @@ -346,12 +346,12 @@ internal fun EditPatchSourceDialog( var error by remember { mutableStateOf(null) } var usePreRelease by remember { mutableStateOf(source.usePreRelease) } var useExperimentalVersions by remember { mutableStateOf(source.useExperimentalVersions) } - val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() val scope = rememberCoroutineScope() var developerOptions by remember { mutableStateOf(false) } var lastLocalPatchDir by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - val cfg = configRepository.loadConfig() + val cfg = engineConfigRepository.loadConfig() developerOptions = cfg.developerOptions lastLocalPatchDir = cfg.lastLocalPatchDir } @@ -421,7 +421,7 @@ internal fun EditPatchSourceDialog( onPicked = { path, _ -> filePath = path error = null - scope.launch { configRepository.setLastLocalPatchDir(dirToRemember(path)) } + scope.launch { engineConfigRepository.setLastLocalPatchDir(dirToRemember(path)) } }, font = font, accents = accents, diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index 1a81ed83..87785d56 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.UpdateChecker +import app.morphe.engine.config.EngineConfigRepository import app.morphe.gui.LocalAdbPreference import app.morphe.gui.LocalCustomAccentColor import app.morphe.gui.LocalIsPatching @@ -62,6 +63,7 @@ fun SettingsDialogHost() { val languageState = LocalLanguageState.current val adbPreference = LocalAdbPreference.current val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() val updateCheckRepository: UpdateCheckRepository = koinInject() val scope = rememberCoroutineScope() @@ -83,21 +85,22 @@ fun SettingsDialogHost() { LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { val config = configRepository.loadConfig() - autoCleanupTempFiles = config.autoCleanupTempFiles + val engineConfig = engineConfigRepository.loadConfig() + autoCleanupTempFiles = engineConfig.autoCleanupTempFiles // Display the resolved absolute form even though storage may be // bundle-relative. Users expect to see a real filesystem path in // the field, not a cryptic basename. - defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath - keystorePath = config.resolvedKeystorePath()?.absolutePath - keystorePassword = config.keystorePassword - keystoreAlias = config.keystoreAlias - keystoreEntryPassword = config.keystoreEntryPassword - keepArchitectures = config.keepArchitectures + defaultOutputDirectory = engineConfig.resolvedDefaultOutputDirectory()?.absolutePath + keystorePath = engineConfig.resolvedKeystorePath()?.absolutePath + keystorePassword = engineConfig.keystorePassword + keystoreAlias = engineConfig.keystoreAlias + keystoreEntryPassword = engineConfig.keystoreEntryPassword + keepArchitectures = engineConfig.keepArchitectures collapsibleSectionStates = config.collapsibleSectionStates - autoRouteLinksAfterInstall = config.autoRouteLinksAfterInstall - disableStockLinksAfterInstall = config.disableStockLinksAfterInstall - developerOptions = config.developerOptions - gitHubPat = config.gitHubPat + autoRouteLinksAfterInstall = engineConfig.autoRouteLinksAfterInstall + disableStockLinksAfterInstall = engineConfig.disableStockLinksAfterInstall + developerOptions = engineConfig.developerOptions + gitHubPat = engineConfig.gitHubPat // Resolve the smart-default if the user has never picked a channel // (returns DEV when the running build is dev, STABLE otherwise). updateChannelPreference = configRepository.getOrInitUpdateChannelPreference( @@ -119,13 +122,13 @@ fun SettingsDialogHost() { onAutoCleanupChange = { enabled -> autoCleanupTempFiles = enabled scope.launch { - configRepository.setAutoCleanupTempFiles(enabled) + engineConfigRepository.setAutoCleanupTempFiles(enabled) } }, defaultOutputDirectory = defaultOutputDirectory, onDefaultOutputDirectoryChange = { path -> defaultOutputDirectory = path - scope.launch { configRepository.setDefaultOutputDirectory(path) } + scope.launch { engineConfigRepository.setDefaultOutputDirectory(path) } }, useExpertMode = !modeState.isSimplified, onExpertModeChange = { enabled -> @@ -134,7 +137,7 @@ fun SettingsDialogHost() { developerOptions = developerOptions, onDeveloperOptionsChange = { enabled -> developerOptions = enabled - scope.launch { configRepository.setDeveloperOptions(enabled) } + scope.launch { engineConfigRepository.setDeveloperOptions(enabled) } }, onDismiss = { showSettingsDialog = false @@ -147,14 +150,14 @@ fun SettingsDialogHost() { keystoreEntryPassword = keystoreEntryPassword, onKeystorePathChange = { path -> keystorePath = path - scope.launch { configRepository.setKeystorePath(path) } + scope.launch { engineConfigRepository.setKeystorePath(path) } }, onKeystoreCredentialsChange = { pwd, alias, entryPwd -> keystorePassword = pwd keystoreAlias = alias - keystoreEntryPassword = entryPwd + entryPwd.let { keystoreEntryPassword = it } scope.launch { - configRepository.setKeystoreDetails( + engineConfigRepository.setKeystoreDetails( path = keystorePath, password = pwd, alias = alias, @@ -165,7 +168,7 @@ fun SettingsDialogHost() { keepArchitectures = keepArchitectures, onKeepArchitecturesChange = { updated -> keepArchitectures = updated - scope.launch { configRepository.setKeepArchitectures(updated) } + scope.launch { engineConfigRepository.setKeepArchitectures(updated) } }, updateChannelPreference = updateChannelPreference, onUpdateChannelChange = { pref -> @@ -187,12 +190,12 @@ fun SettingsDialogHost() { autoRouteLinksAfterInstall = autoRouteLinksAfterInstall, onAutoRouteLinksChange = { enabled -> autoRouteLinksAfterInstall = enabled - scope.launch { configRepository.setAutoRouteLinksAfterInstall(enabled) } + scope.launch { engineConfigRepository.setAutoRouteLinksAfterInstall(enabled) } }, disableStockLinksAfterInstall = disableStockLinksAfterInstall, onDisableStockLinksChange = { enabled -> disableStockLinksAfterInstall = enabled - scope.launch { configRepository.setDisableStockLinksAfterInstall(enabled) } + scope.launch { engineConfigRepository.setDisableStockLinksAfterInstall(enabled) } }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> @@ -202,7 +205,7 @@ fun SettingsDialogHost() { gitHubPat = gitHubPat, onGitHubPatChange = { pat -> gitHubPat = pat - scope.launch { configRepository.setGitHubPat(pat) } + scope.launch { engineConfigRepository.setGitHubPat(pat) } }, customAccentColorArgb = customAccentColorArgb, onCustomAccentColorChange = { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt index 70776777..2ef11364 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -44,9 +44,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import app.morphe.engine.patches.PatchResolver.Channel import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType -import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.engine.config.EngineConfigRepository import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners @@ -101,7 +102,7 @@ fun SourceManagementSheet( /** sourceId → resolved version label (e.g. "v1.27.0-dev.2"). Empty when not loaded. */ sourceVersions: Map = emptyMap(), /** sourceId → channel classification of the resolved release. Drives the badge. */ - sourceChannels: Map = emptyMap(), + sourceChannels: Map = emptyMap(), /** True while patches are being (re)loaded. Drives the per-row spinner shown * in place of the version/badge for enabled sources whose data isn't yet * in [sourceVersions]. */ @@ -357,7 +358,7 @@ fun SourceManagementSheet( private fun SourceRow( source: PatchSource, version: String?, - channel: EnabledSourcesLoader.Channel?, + channel: Channel?, isLoading: Boolean, error: String? = null, accentColor: Color, @@ -700,14 +701,14 @@ private fun ReorderArrow( @Composable private fun ChannelBadge( - channel: EnabledSourcesLoader.Channel?, + channel: Channel?, ) { val label = when (channel) { - EnabledSourcesLoader.Channel.STABLE_LATEST -> stringResource(Res.string.version_label_latest_stable) - EnabledSourcesLoader.Channel.STABLE_OLDER -> stringResource(Res.string.source_sheet_channel_older_stable) - EnabledSourcesLoader.Channel.DEV_LATEST -> stringResource(Res.string.version_label_latest_dev) - EnabledSourcesLoader.Channel.DEV_OLDER -> stringResource(Res.string.source_sheet_channel_older_dev) - EnabledSourcesLoader.Channel.LOCAL -> stringResource(Res.string.source_sheet_local_label) + Channel.STABLE_LATEST -> stringResource(Res.string.version_label_latest_stable) + Channel.STABLE_OLDER -> stringResource(Res.string.source_sheet_channel_older_stable) + Channel.DEV_LATEST -> stringResource(Res.string.version_label_latest_dev) + Channel.DEV_OLDER -> stringResource(Res.string.source_sheet_channel_older_dev) + Channel.LOCAL -> stringResource(Res.string.source_sheet_local_label) else -> stringResource(Res.string.version_label_latest_stable) } val color = channelColor(channel) @@ -727,14 +728,14 @@ private fun ChannelBadge( */ @Composable private fun DeveloperMppExclusionsSection(enabled: Boolean) { - val configRepository = koinInject() + val engineConfigRepository = koinInject() val scope = rememberCoroutineScope() var developerOptions by remember { mutableStateOf(false) } var patterns by remember { mutableStateOf>(emptyList()) } var loaded by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - val cfg = configRepository.loadConfig() + val cfg = engineConfigRepository.loadConfig() developerOptions = cfg.developerOptions patterns = cfg.excludedMppPatterns loaded = true @@ -762,7 +763,7 @@ private fun DeveloperMppExclusionsSection(enabled: Boolean) { patterns = patterns, onChange = { next -> patterns = next - scope.launch { configRepository.setExcludedMppPatterns(next) } + scope.launch { engineConfigRepository.setExcludedMppPatterns(next) } }, enabled = enabled, ) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt index 9ceb5523..f137ba83 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.patches.PatchResolver.Channel import app.morphe.gui.data.model.PatchSource import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheAccents @@ -120,11 +121,11 @@ fun SourcesCountPill( private fun SourceLed(state: SourceLedState) { val color = when (state) { SourceLedState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - SourceLedState.STABLE_LATEST -> channelColor(EnabledSourcesLoader.Channel.STABLE_LATEST) - SourceLedState.STABLE_OLDER -> channelColor(EnabledSourcesLoader.Channel.STABLE_OLDER) - SourceLedState.DEV_LATEST -> channelColor(EnabledSourcesLoader.Channel.DEV_LATEST) - SourceLedState.DEV_OLDER -> channelColor(EnabledSourcesLoader.Channel.DEV_OLDER) - SourceLedState.LOCAL -> channelColor(EnabledSourcesLoader.Channel.LOCAL) + SourceLedState.STABLE_LATEST -> channelColor(Channel.STABLE_LATEST) + SourceLedState.STABLE_OLDER -> channelColor(Channel.STABLE_OLDER) + SourceLedState.DEV_LATEST -> channelColor(Channel.DEV_LATEST) + SourceLedState.DEV_OLDER -> channelColor(Channel.DEV_OLDER) + SourceLedState.LOCAL -> channelColor(Channel.LOCAL) SourceLedState.ERROR -> MaterialTheme.colorScheme.error } Box( @@ -137,7 +138,7 @@ private fun SourceLed(state: SourceLedState) { /** Map a [PatchSource] + its resolved channel to a UI LED state. */ fun sourceLedState( source: PatchSource, - channel: EnabledSourcesLoader.Channel?, + channel: Channel?, hasError: Boolean = false, ): SourceLedState { if (!source.enabled) return SourceLedState.DISABLED @@ -145,11 +146,11 @@ fun sourceLedState( // LOCAL) but failed to load should read red, not its channel color. if (hasError) return SourceLedState.ERROR return when (channel) { - EnabledSourcesLoader.Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST - EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.STABLE_OLDER - EnabledSourcesLoader.Channel.DEV_LATEST -> SourceLedState.DEV_LATEST - EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV_OLDER - EnabledSourcesLoader.Channel.LOCAL -> SourceLedState.LOCAL - null, EnabledSourcesLoader.Channel.UNKNOWN -> SourceLedState.STABLE_LATEST + Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST + Channel.STABLE_OLDER -> SourceLedState.STABLE_OLDER + Channel.DEV_LATEST -> SourceLedState.DEV_LATEST + Channel.DEV_OLDER -> SourceLedState.DEV_OLDER + Channel.LOCAL -> SourceLedState.LOCAL + null, Channel.UNKNOWN -> SourceLedState.STABLE_LATEST } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt index 89a40446..a7820ce7 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt @@ -19,11 +19,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.engine.CacheManager +import app.morphe.engine.MorpheData import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.MorpheColors -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.currentLocale @@ -79,7 +79,7 @@ fun ToolsDialog( borderColor = borderColor, onClick = { try { - val logsDir = FileUtils.getLogsDir() + val logsDir = MorpheData.logsDir if (Desktop.isDesktopSupported()) { Desktop.getDesktop().open(logsDir) } @@ -98,7 +98,7 @@ fun ToolsDialog( borderColor = borderColor, onClick = { try { - val appDataDir = FileUtils.getAppDataDir() + val appDataDir = MorpheData.root if (Desktop.isDesktopSupported()) { Desktop.getDesktop().open(appDataDir) } @@ -121,7 +121,7 @@ fun ToolsDialog( Spacer(Modifier.height(6.dp)) // Clear cache - val cacheBytes = remember { getCacheSizeBytes() } + val cacheBytes = remember { CacheManager.getCacheSizeBytes() } val cacheColor = when { cacheCleared -> MorpheColors.Teal cacheClearFailed -> MaterialTheme.colorScheme.error @@ -236,12 +236,6 @@ fun ToolsDialog( } } -private fun getCacheSizeBytes(): Long { - val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - val logsSize = FileUtils.getLogsDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - return patchesSize + logsSize -} - @Composable private fun formatCacheSize(totalSize: Long): String = FormatUtils.formatFileSize(totalSize, currentLocale()) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/settings/KeystoreInfoDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/settings/KeystoreInfoDialog.kt index 7d974b91..e0e5c926 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/settings/KeystoreInfoDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/settings/KeystoreInfoDialog.kt @@ -23,20 +23,15 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.util.KeystoreService +import app.morphe.engine.util.KeystoreWarning import app.morphe.gui.ui.components.MorpheAlertDialog import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont -import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.currentLocale import app.morphe.morphe_desktop.generated.resources.* import java.io.File -import java.security.KeyStore -import java.security.MessageDigest -import java.security.Provider -import java.security.Security -import java.security.cert.X509Certificate -import java.util.Locale import org.jetbrains.compose.resources.stringResource @Composable @@ -53,7 +48,7 @@ internal fun KeystoreInfoDialog( val locale = currentLocale() val info = remember(keystorePath, password, alias, entryPassword, locale) { - readKeystoreInfo(keystorePath, password, alias, entryPassword, locale) + KeystoreService.shared.inspectKeystore(File(keystorePath), password, alias, entryPassword, locale) } MorpheAlertDialog( @@ -189,110 +184,3 @@ private fun CertInfoRow( ) } } - -internal sealed interface KeystoreWarning { - data class AliasNotFound(val alias: String) : KeystoreWarning - data class KeyPasswordIncorrect(val alias: String) : KeystoreWarning -} - -internal data class KeystoreInfoResult( - val alias: String, - val issuer: String, - val validFrom: String, - val validTo: String, - val sha256Fingerprint: String, - val sha1Fingerprint: String, - val warnings: List = emptyList() -) - -internal fun readKeystoreInfo( - keystorePath: String, - password: String?, - alias: String, - entryPassword: String? = null, - locale: Locale = Locale.getDefault() -): KeystoreInfoResult? { - val file = File(keystorePath) - if (!file.exists()) return null - - val passwordChars = password?.toCharArray() ?: charArrayOf() - - // Ensure BouncyCastle provider is registered (needed for BKS keystores) - try { - if (Security.getProvider("BC") == null) { - Security.addProvider( - Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") - .getDeclaredConstructor().newInstance() as Provider - ) - } - } catch (_: Exception) { - } - - // Try multiple keystore types: BKS (what Morphe generates), then JKS, then PKCS12 - // BKS requires the BouncyCastle provider. Try with the name, fall back without - val types = listOf("BKS" to "BC", "BKS" to null, "JKS" to null, "PKCS12" to null) - for ((type, provider) in types) { - try { - val ks = if (provider != null) { - KeyStore.getInstance(type, provider) - } else { - KeyStore.getInstance(type) - } - - file.inputStream().use { ks.load(it, passwordChars) } - - val warnings = mutableListOf() - - // Alias must match exactly - if (!ks.containsAlias(alias)) { - return KeystoreInfoResult( - alias = alias, - issuer = "", - validFrom = "", - validTo = "", - sha256Fingerprint = "", - sha1Fingerprint = "", - warnings = listOf(KeystoreWarning.AliasNotFound(alias)) - ) - } - - val cert = ks.getCertificate(alias) as? X509Certificate ?: continue - - // Verify the entry password actually works - try { - ks.getKey(alias, entryPassword?.toCharArray() ?: charArrayOf()) - } catch (_: Exception) { - return KeystoreInfoResult( - alias = alias, - issuer = "", - validFrom = "", - validTo = "", - sha256Fingerprint = "", - sha1Fingerprint = "", - warnings = listOf(KeystoreWarning.KeyPasswordIncorrect(alias)) - ) - } - - val sha256 = MessageDigest.getInstance("SHA-256") - .digest(cert.encoded) - .joinToString(":") { "%02X".format(it) } - - val sha1 = MessageDigest.getInstance("SHA-1") - .digest(cert.encoded) - .joinToString(":") { "%02X".format(it) } - - return KeystoreInfoResult( - alias = alias, - issuer = cert.issuerX500Principal.name, - validFrom = FormatUtils.formatDate(cert.notBefore, locale), - validTo = FormatUtils.formatDate(cert.notAfter, locale), - sha256Fingerprint = sha256, - sha1Fingerprint = sha1, - warnings = warnings - ) - } catch (_: Exception) { - continue - } - } - return null -} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/settings/PatchedAppRuntimeLogsSection.kt b/src/main/kotlin/app/morphe/gui/ui/components/settings/PatchedAppRuntimeLogsSection.kt index 918ece45..77a3f258 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/settings/PatchedAppRuntimeLogsSection.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/settings/PatchedAppRuntimeLogsSection.kt @@ -19,15 +19,16 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.MorpheData +import app.morphe.engine.util.AdbException +import app.morphe.engine.util.AdbManager import app.morphe.gui.ui.components.ActionButton import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheCorners -import app.morphe.gui.util.AdbException -import app.morphe.gui.util.AdbManager import app.morphe.gui.util.DeviceMonitor -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import app.morphe.gui.util.getUserMessage import app.morphe.morphe_desktop.generated.resources.* import java.awt.Desktop import java.io.File @@ -137,7 +138,7 @@ internal fun PatchedAppRuntimeLogsSection( scope.launch { val defaultErrMsg = getString(Res.string.settings_runtime_logs_failed_to_save) val timestamp = SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(Date()) - val outFile = File(FileUtils.getLogsDir(), "device-logcat-$timestamp.txt") + val outFile = File(MorpheData.logsDir, "device-logcat-$timestamp.txt") val result = adbManager.captureLogcat(device.id, outFile) status = result.fold( onSuccess = { count -> RuntimeLogsStatus.Saved(outFile, count) }, diff --git a/src/main/kotlin/app/morphe/gui/ui/components/settings/SigningSection.kt b/src/main/kotlin/app/morphe/gui/ui/components/settings/SigningSection.kt index f633eac3..04f7232f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/settings/SigningSection.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/settings/SigningSection.kt @@ -32,6 +32,8 @@ import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.util.KeystoreImporter +import app.morphe.engine.util.KeystoreService +import app.morphe.engine.util.KeystoreWarning import app.morphe.engine.util.PortablePaths import app.morphe.gui.ui.components.LabeledField import app.morphe.gui.ui.components.SlimTextField @@ -43,10 +45,8 @@ import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.Logger import app.morphe.gui.util.MorpheFilePicker -import app.morphe.patcher.apk.ApkSigner import app.morphe.morphe_desktop.generated.resources.* import java.io.File -import java.util.Date import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @@ -396,8 +396,8 @@ internal fun SigningSection( verifyResult = null verifySuccess = false scope.launch { - val result = readKeystoreInfo( - keystorePath, + val result = KeystoreService.shared.inspectKeystore( + File(keystorePath), localPassword.ifEmpty { null }, localAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, localEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD } @@ -485,20 +485,12 @@ internal fun SigningSection( try { val file = File(path) - file.parentFile?.mkdirs() - val keyPair = ApkSigner.newPrivateKeyCertificatePair( - "Morphe", - Date(System.currentTimeMillis() + 8L * 365 * 24 * 60 * 60 * 1000)) - val ks = ApkSigner.newKeyStore(setOf( - ApkSigner.KeyStoreEntry( - localAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, - localEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD }, - keyPair - ) - )) - file.outputStream().use { - ks.store(it, localPassword.ifEmpty { null }?.toCharArray()) - } + KeystoreService.shared.generateKeystore( + destination = file, + alias = localAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, + storePassword = localPassword.ifEmpty { null }, + entryPassword = localEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD }, + ) // Save credentials to config onCredentialsChange( localPassword.ifEmpty { null }, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 9103a8f7..9462354e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.compose.currentBackStackEntryAsState import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.util.AdbException import app.morphe.gui.LocalNavController import app.morphe.gui.PatchSelectionParams import app.morphe.gui.PatchSelectionScreenRoute @@ -39,9 +41,9 @@ import app.morphe.gui.ui.screens.home.components.handleContinue import app.morphe.gui.ui.screens.home.components.openFilePicker import app.morphe.gui.ui.screens.patches.PatchSelectionScreen import app.morphe.gui.ui.screens.patches.PatchesScreen -import app.morphe.gui.util.AdbException import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.PatchException +import app.morphe.gui.util.getUserMessage import app.morphe.gui.util.humanizePatchLoadError import app.morphe.gui.util.sourceChannelMap import app.morphe.gui.util.sourceErrorMap @@ -408,7 +410,7 @@ fun HomeScreenContent( // ?.resolved // ?.associate { it.source.id to it.resolvedVersion } // ?: emptyMap() - val channelsBySource: Map = + val channelsBySource: Map = resolvedSnapshot ?.resolved ?.associate { it.source.id to it.channel } @@ -417,7 +419,7 @@ fun HomeScreenContent( // Used by ApkInfoCard's "FROM" row to surface multi-source provenance. val patchSourcesForSelectedApk: List = uiState.apkInfo?.let { info -> val snapshot = resolvedSnapshot ?: return@let null - snapshot.guiPatchesBySource.entries + snapshot.patchesBySource.entries .filter { (_, patches) -> patches.any { p -> p.compatiblePackages.any { it.name == info.packageName } } } @@ -433,7 +435,7 @@ fun HomeScreenContent( } else { val sourceIdToName = allSources.associate { it.id to it.name } val accum = mutableMapOf>() - resolvedSnapshot.guiPatchesBySource.forEach { (sourceId, patches) -> + resolvedSnapshot.patchesBySource.forEach { (sourceId, patches) -> val name = sourceIdToName[sourceId] ?: return@forEach val packages = patches.flatMap { it.compatiblePackages.map { p -> p.name } } .filter { it.isNotBlank() } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 8dea1e24..36e60e86 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -7,45 +7,48 @@ package app.morphe.gui.ui.screens.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.morphe.engine.MorpheData import app.morphe.engine.MultiSourceLoader -import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchedAppStore import app.morphe.engine.UpdateInfo +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.apk.ApkOutputNaming +import app.morphe.engine.apk.BundleFormats +import app.morphe.engine.config.EngineConfigRepository import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.patches.PatchCache +import app.morphe.engine.patches.PatchRepository +import app.morphe.engine.patches.PatchResolver import app.morphe.engine.readableMessage -import app.morphe.engine.util.ApkManifestReader -import app.morphe.engine.util.SignatureIdentity +import app.morphe.engine.util.AdbException +import app.morphe.engine.util.AdbManager +import app.morphe.engine.util.KeystoreService +import app.morphe.engine.util.isNewerVersion +import app.morphe.engine.model.PatchMetadata +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.model.VersionResolution +import app.morphe.engine.model.VersionStatus +import app.morphe.engine.patches.SupportedAppCatalog +import app.morphe.engine.patches.resolveVersionStatus import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.FollowMode -import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SourceVersionPref -import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ActiveMode import app.morphe.gui.data.repository.ChangelogRepository import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository import app.morphe.gui.ui.screens.home.components.AppListFilter import app.morphe.gui.ui.screens.home.components.HomeAppSortMode -import app.morphe.gui.util.AdbException -import app.morphe.gui.util.AdbManager import app.morphe.gui.util.ChangelogParser import app.morphe.gui.util.ChecksumStatus import app.morphe.gui.util.DeviceMonitor +import app.morphe.engine.MorpheConstants import app.morphe.gui.util.EnabledSourcesLoader -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchException -import app.morphe.gui.util.PatchService -import app.morphe.gui.util.SupportedAppExtractor -import app.morphe.gui.util.VersionResolution -import app.morphe.gui.util.VersionStatus +import app.morphe.gui.util.getUserMessage import app.morphe.gui.util.humanizePatchLoadError -import app.morphe.gui.util.isNewerVersion -import app.morphe.gui.util.resolveVersionStatus import app.morphe.morphe_desktop.generated.resources.* import java.io.File import kotlinx.coroutines.CancellationException @@ -65,12 +68,13 @@ import org.jetbrains.compose.resources.getString class HomeViewModel( private val patchSourceManager: PatchSourceManager, - private val patchService: PatchService, private val configRepository: ConfigRepository, private val updateCheckRepository: UpdateCheckRepository, private val patchedAppStore: PatchedAppStore, private val changelogRepository: ChangelogRepository, private val adbManager: AdbManager = AdbManager(), + private val engineConfigRepository: EngineConfigRepository = EngineConfigRepository.shared, + private val keystoreService: KeystoreService = KeystoreService.shared, ) : ViewModel() { private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() @@ -81,7 +85,7 @@ class HomeViewModel( val uiState: StateFlow = _uiState.asStateFlow() // Cached patches and supported apps - private var cachedPatches: List = emptyList() + private var cachedPatches: List = emptyList() private var cachedPatchesFile: File? = null /** All resolved patch files across enabled sources. Single-element in * single-source mode. Exposed via [getAllResolvedPatchFiles] for screens @@ -240,28 +244,24 @@ class HomeViewModel( if (!device.isReady || _uiState.value.installingPackage != null) return _uiState.value = _uiState.value.copy(installingPackage = packageName) viewModelScope.launch { - // Always record a non-Play installer so the Play Store won't clobber - // the patched app with an official update. - val installer = adbManager.resolveSpoofInstaller(device.id) - val result = adbManager.installApk(record.outputApkPath, device.id, installerPackage = installer) + val result = adbManager.installApk(record.outputApkPath, device.id) // Mirror ResultScreen: if the user opted into auto-routing links, // point the patched app at its web links right after a good install. if (result.isSuccess) { - val config = configRepository.loadConfig() - if (config.autoRouteLinksAfterInstall) { + val engineConfig = engineConfigRepository.loadConfig() + if (engineConfig.autoRouteLinksAfterInstall) { adbManager.setLinkHandling( deviceId = device.id, patchedPackage = record.installedPackageName, - stockPackage = if (config.disableStockLinksAfterInstall) record.packageName else null, + stockPackage = if (engineConfig.disableStockLinksAfterInstall) record.packageName else null, enable = true, ) } } val installError = result.exceptionOrNull()?.let { - val detail = (it as? AdbException)?.getUserMessage() ?: it.message ?: "" - getString(Res.string.home_install_failed, detail) + (it as? AdbException)?.getUserMessage() ?: getString(Res.string.home_install_failed, it.message ?: "") } ?: _uiState.value.error _uiState.value = _uiState.value.copy( installingPackage = null, @@ -292,8 +292,7 @@ class HomeViewModel( patchedAppStore.delete(packageName) } val uninstallError = result.exceptionOrNull()?.let { - val detail = (it as? AdbException)?.getUserMessage() ?: it.message ?: "" - getString(Res.string.home_uninstall_failed, detail) + (it as? AdbException)?.getUserMessage() ?: getString(Res.string.home_uninstall_failed, it.message ?: "") } ?: _uiState.value.error _uiState.value = _uiState.value.copy( uninstallingPackage = null, @@ -360,18 +359,22 @@ class HomeViewModel( // no cross-source contamination. val prefs = configRepository.getSourceVersionPrefs() lastLoadedVersionsBySource = prefs - val result = EnabledSourcesLoader.loadAll(enabled, patchService, prefs, configRepository.loadConfig().excludedMppPatterns) + val result = EnabledSourcesLoader.loadAll(enabled, prefs, engineConfigRepository.loadConfig().excludedMppPatterns) if (!result.anyLoaded) { val firstThrowable = result.loaded.perSource.firstNotNullOfOrNull { it.error } val rawTechnicalError = firstThrowable?.message ?: result.resolved.firstNotNullOfOrNull { it.error } - ?: "Failed to load any patches" + val logMessage = when { + rawTechnicalError.isNullOrBlank() -> "Failed to load any patches" + rawTechnicalError.startsWith("Failed to load", ignoreCase = true) -> rawTechnicalError + else -> "Failed to load any patches: $rawTechnicalError" + } // Log the real throwable (full stack). Never only a null/blank .message. if (firstThrowable != null) { - Logger.error("Failed to load any patches: $rawTechnicalError", firstThrowable) + Logger.error(logMessage, firstThrowable) } else { - Logger.warn("Failed to load any patches: $rawTechnicalError") + Logger.warn(logMessage) } val firstError = result.resolved.firstNotNullOfOrNull { it.getUserErrorMessage() } @@ -401,7 +404,7 @@ class HomeViewModel( return@launch } - cachedPatches = result.unionGuiPatches + cachedPatches = result.unionPatches // Preserve existing single-file API for downstream navigation. In // multi-source mode this points at the first resolved source. The // full list is exposed via [getAllResolvedPatchFiles] and the @@ -412,7 +415,7 @@ class HomeViewModel( lastLoadedVersion = firstResolved?.resolvedVersion cachedSourcesResult = result - val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + val supportedApps = SupportedAppCatalog.extractSupportedAppsFromMetadata(result.unionPatches) Logger.info( "Loaded ${supportedApps.size} supported apps from " + "${result.resolved.count { it.patchFile != null }} source(s): " + @@ -553,8 +556,8 @@ class HomeViewModel( viewModelScope.launch { try { val enabled = patchSourceManager.getEnabledRepositories() - val result = EnabledSourcesLoader.loadAll(enabled, patchService, emptyMap(), configRepository.loadConfig().excludedMppPatterns) - val apps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + val result = EnabledSourcesLoader.loadAll(enabled, emptyMap(), engineConfigRepository.loadConfig().excludedMppPatterns) + val apps = SupportedAppCatalog.extractSupportedAppsFromMetadata(result.unionPatches) latestResolvedApps = apps _uiState.value = _uiState.value.copy(updateInfoByPackage = buildUpdateInfoMap(apps)) } catch (e: CancellationException) { @@ -716,13 +719,21 @@ class HomeViewModel( } relevantSourceUpdates = relevant states + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.error("Failed to compute patched-app states", e) emptyMap() } suspend fun apkVersionOf(path: String): String? = withContext(Dispatchers.IO) { - runCatching { parseApkManifest(File(path))?.versionName }.getOrNull() + try { + parseApkManifest(File(path))?.versionName + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } } fun activePatchSources(): List = @@ -746,7 +757,7 @@ class HomeViewModel( .firstOrNull { (source, _) -> source.name == sourceName } ?.second ?: return true - return repo.getCachedPatches(tag) != null + return PatchCache.findCachedByVersion(repo.repoPath, tag) != null } private val bundleSupportCache = mutableMapOf>() @@ -768,7 +779,7 @@ class HomeViewModel( when (val choice = overrides[source.name]) { is BundleChoice.Version -> { if (choice.tag != resolved?.resolvedVersion) isCurrent = false - val cached = repo?.getCachedPatches(choice.tag) + val cached = repo?.let { PatchCache.findCachedByVersion(it.repoPath, choice.tag) } when { cached != null -> inputs += MultiSourceLoader.SourceInput(source.id, source.name, cached) @@ -802,9 +813,7 @@ class HomeViewModel( val key = inputs.map { it.patchFile.absolutePath }.sorted().joinToString("|") val apps = bundleSupportCache.getOrPut(key) { - SupportedAppExtractor.extractSupportedApps( - patchService.convertToGuiPatches(MultiSourceLoader.load(inputs).allPatches) - ) + SupportedAppCatalog.extractSupportedApps(MultiSourceLoader.load(inputs).allPatches) } return BundleSupport( app = apps.firstOrNull { it.packageName == packageName }, @@ -846,9 +855,8 @@ class HomeViewModel( val result = EnabledSourcesLoader.loadAll( enabled.filterNot { (source, _) -> source.name in localFiles }, - patchService, prefs, - configRepository.loadConfig().excludedMppPatterns, + engineConfigRepository.loadConfig().excludedMppPatterns, onDownloadProgress, ) val resolvedOk = result.resolved.filter { it.patchFile != null } @@ -895,8 +903,8 @@ class HomeViewModel( val resolved = resolvedByName[sourceName] if (resolved == null) { add(sourceName); continue } - val prerelease = resolved.channel == EnabledSourcesLoader.Channel.DEV_LATEST || - resolved.channel == EnabledSourcesLoader.Channel.DEV_OLDER + val prerelease = resolved.channel == PatchResolver.Channel.DEV_LATEST || + resolved.channel == PatchResolver.Channel.DEV_OLDER val entries = changelogRepository.entriesFor(resolved.source, prerelease) if (entries == null) { add(sourceName); continue } if (names.isEmpty()) { add(sourceName); continue } @@ -961,17 +969,7 @@ class HomeViewModel( * the user's configured keystore (if any). An installed app whose device * signature id is in this set was signed by Morphe. */ - private suspend fun morpheSignatureIds(): Set = buildSet { - SignatureIdentity.idForKeystore( - MorpheData.defaultKeystoreFile, - storePassword = null, - alias = DEFAULT_KEYSTORE_ALIAS, - )?.let { add(it) } - val config = configRepository.loadConfig() - config.resolvedKeystorePath()?.let { ks -> - SignatureIdentity.idForKeystore(ks, config.keystorePassword, config.keystoreAlias)?.let { add(it) } - } - } + private suspend fun morpheSignatureIds(): Set = keystoreService.getKnownSignatureIds() /** * Snapshot of the most recent multi-source load. Used by 9d's @@ -1017,13 +1015,6 @@ class HomeViewModel( */ fun getCachedPatchesFile(): File? = cachedPatchesFile - /** - * Get recommended version for a package from loaded patches. - */ - fun getRecommendedVersion(packageName: String): String? { - return SupportedAppExtractor.getRecommendedVersion(cachedPatches, packageName) - } - fun onFileSelected(file: File) { viewModelScope.launch { Logger.info("File selected: ${file.absolutePath}") @@ -1057,7 +1048,7 @@ class HomeViewModel( } fun onFilesDropped(files: List) { - val apkFile = files.firstOrNull { FileUtils.isApkFile(it) } + val apkFile = files.firstOrNull { BundleFormats.isApkOrBundle(it) } if (apkFile != null) { onFileSelected(apkFile) } else { @@ -1112,7 +1103,7 @@ class HomeViewModel( ) } - if (!FileUtils.isApkFile(file)) { + if (!BundleFormats.isApkOrBundle(file)) { return ApkValidationResult( isValid = false, errorMessage = "Invalid APK file extension: ${file.name}", @@ -1147,34 +1138,24 @@ class HomeViewModel( * This works with APKs from any source, not just APKMirror. */ private suspend fun parseApkManifest(file: File): ApkInfo? { - // For split APK bundles (.apkm, .xapk, .apks), extract base.apk first - val isBundleFormat = FileUtils.isBundleFormat(file) - val apkToParse = if (isBundleFormat) { - FileUtils.extractBaseApkFromBundle(file) ?: run { - Logger.error("Failed to extract base APK from bundle: ${file.name}") - return null - } - } else { - file + val inspection = ApkInspector.inspect(file) ?: run { + Logger.warn( + "Full APK manifest parse failed for ${file.name}. " + + "Falling back to limited-info mode (filename heuristics + fuzzy match)." + ) + return parseApkManifestMinimal(file) } return try { - // ARSCLib reader (in engine). Same library morphe-patcher uses. - // Handles split APKs cleanly because we only read direct string - // attributes (no resource resolution that crashes apk-parser on - // cross-split references). - val manifest = ApkManifestReader.read(apkToParse) - ?: throw IllegalStateException("ARSCLib couldn't read manifest") - - val packageName = manifest.packageName - val versionName = manifest.versionName ?: getString(Res.string.unknown) - val versionCode = manifest.versionCode - val minSdk = manifest.minSdkVersion + val packageName = inspection.packageName + val versionName = inspection.versionName ?: getString(Res.string.unknown) + val versionCode = inspection.versionCode + val minSdk = inspection.minSdkVersion val loadedApps = _uiState.value.supportedApps val dynamicSupportedApp = loadedApps.find { it.packageName == packageName } val isSupported = dynamicSupportedApp != null || - (loadedApps.isEmpty() && packageName in AppConstants.FALLBACK_PACKAGES) + (loadedApps.isEmpty() && packageName in MorpheConstants.FALLBACK_PACKAGES) if (!isSupported) { Logger.warn("Unsupported package: $packageName — no compatible patches found") @@ -1184,7 +1165,7 @@ class HomeViewModel( // literal label (null for resource-referenced labels like SoundCloud's // `@string/app_name`). Last resort: derived from package. val appName = dynamicSupportedApp?.displayName - ?: SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) + ?: SupportedApp.resolveDisplayName(packageName, inspection.applicationLabel) val versionResolution = if (dynamicSupportedApp != null) { resolveVersionStatus(versionName, dynamicSupportedApp, versionCode) @@ -1194,15 +1175,13 @@ class HomeViewModel( val suggestedVersion = versionResolution.suggestedVersion val versionStatus = versionResolution.status - // Get supported architectures from native libraries. - // For split bundles, scan the original bundle (splits hold native libs, not base.apk). - val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) + val architectures = inspection.architectures.toList() // TODO: Re-enable when checksums are provided via .mpp files val checksumStatus = ChecksumStatus.NotConfigured Logger.info( - "Parsed APK: $packageName v${manifest.versionName ?: "unknown"}" + + "Parsed APK: $packageName v${inspection.versionName ?: "unknown"}" + (versionCode?.let { " build $it" } ?: "") + " (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)" ) @@ -1222,25 +1201,14 @@ class HomeViewModel( checksumStatus = checksumStatus, isUnsupportedApp = !isSupported ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - // apk-parser commonly chokes on split-APK base.apks whose resource - // references point into other splits (SoundCloud and similar). The - // base.apk is structurally valid. Android installs it fine, the - // patcher merges + patches it fine. But apk-parser can't resolve - // cross-split references from an isolated file. - // - // Fall back to a "limited info" parse: extract package/version from - // the filename (APKMirror naming convention), fuzzy-match supported - // apps by display name, and let the user proceed to patching - // regardless. ApkInfo.hasLimitedInfo=true so the UI can warn that - // card details may be approximate. Logger.warn( "Full APK manifest parse failed for ${file.name}: ${e.message}. " + "Falling back to limited-info mode (filename heuristics + fuzzy match)." ) - parseApkManifestMinimal(file, isBundleFormat) - } finally { - if (isBundleFormat) apkToParse.delete() + parseApkManifestMinimal(file) } } @@ -1253,15 +1221,15 @@ class HomeViewModel( * Patching still works regardless. The patcher merges splits first and reads * the manifest from the merged APK via its own (working) reader. */ - private suspend fun parseApkManifestMinimal(file: File, isBundleFormat: Boolean): ApkInfo { - val (packageFromName, versionFromName) = parseFromApkMirrorFilename(file.name) + private suspend fun parseApkManifestMinimal(file: File): ApkInfo { + val (packageFromName, versionFromName) = ApkOutputNaming.extractPackageAndVersionFromFilename(file.name) val supportedApps = _uiState.value.supportedApps // Match against supported apps: by exact package first, then fuzzy name // on the filename's leading token (handles "soundcloud_..." → "SoundCloud"). val matched = packageFromName ?.let { pkg -> supportedApps.firstOrNull { it.packageName == pkg } } - ?: fuzzyMatchSupportedApp(file.name, supportedApps) + ?: SupportedAppCatalog.fuzzyMatchApp(file.name, supportedApps) val packageName = packageFromName ?: matched?.packageName.orEmpty() val displayName = matched?.displayName @@ -1276,7 +1244,7 @@ class HomeViewModel( VersionResolution(VersionStatus.UNKNOWN, null) } - val architectures = FileUtils.extractArchitectures(file) + val architectures = ApkInspector.extractArchitectures(file).toList() Logger.info( "Limited-info parse for ${file.name}: package=$packageName, " + @@ -1300,65 +1268,6 @@ class HomeViewModel( ) } - /** - * Best-effort package + version extraction from APKMirror-style filenames: - * com.google.android.youtube_19.20.30-12345.apk - * → ("com.google.android.youtube", "19.20.30") - * - * Returns (null, null) when the filename doesn't look like a package_version - * pattern. The version-only path also tries a generic semver / date regex - * against the whole filename for files like `soundcloud_2026.04.27.apkm`. - */ - private fun parseFromApkMirrorFilename(filename: String): Pair { - val noExt = filename.substringBeforeLast('.') - val splitOnUnderscore = noExt.split('_', limit = 2) - - val packageCandidate = splitOnUnderscore.getOrNull(0) - val afterUnderscore = splitOnUnderscore.getOrNull(1) - - // A package name has at least one dot + only lowercase/digits/underscore in - // each segment. Filters out "soundcloud" while accepting "com.foo.bar". - val looksLikePackage = packageCandidate != null && - packageCandidate.contains('.') && - packageCandidate.split('.').all { segment -> - segment.isNotEmpty() && segment.all { c -> c.isLowerCase() || c.isDigit() || c == '_' } - } - - val packageName = if (looksLikePackage) packageCandidate else null - - // Version: prefer the token right after "_" (APKMirror convention), else - // scan the whole filename for a semver / date pattern. - val versionAfterUnderscore = afterUnderscore?.substringBefore('-')?.takeIf { it.isNotBlank() } - val version = versionAfterUnderscore - ?: Regex("""\d+\.\d+\.\d+(?:-dev\.\d+)?""").find(noExt)?.value - ?: Regex("""\d+\.\d+(?:\.\d+)?""").find(noExt)?.value - - return packageName to version - } - - /** - * Fuzzy-match the filename's leading token against supported apps' display names. - * Used when APKMirror-style filename inference fails to give us a package name. - * Examples: - * "soundcloud_2026.04.27.apkm" → leading token "soundcloud" → matches "SoundCloud" - * "YouTube Music_4.81.apkm" → leading token "youtube music" → matches "YouTube Music" - */ - private fun fuzzyMatchSupportedApp( - filename: String, - supportedApps: List, - ): SupportedApp? { - val noExt = filename.substringBeforeLast('.').lowercase() - val leadingToken = noExt - .substringBefore('_') - .substringBefore('-') - .replace(" ", "") - if (leadingToken.isBlank()) return null - return supportedApps.firstOrNull { app -> - val name = app.displayName.lowercase().replace(" ", "") - name == leadingToken || name.startsWith(leadingToken) || leadingToken.startsWith(name) - } - } - // TODO: Re-enable checksum verification when checksums are provided via .mpp files // private fun verifyChecksum( // file: File, packageName: String, version: String, @@ -1474,7 +1383,7 @@ data class HomeUiState( val uninstallingPackage: String? = null, val deviceAppInfo: Map = emptyMap(), val patchesVersion: String? = null, - val patchesChannel: EnabledSourcesLoader.Channel? = null, + val patchesChannel: PatchResolver.Channel? = null, val patchSourceName: String? = null, val patchLoadError: String? = null, val updateInfo: UpdateInfo? = null, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt index ff7bd702..2731be76 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -38,8 +38,8 @@ import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.panelFill import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.FormatUtils +import app.morphe.engine.model.VersionStatus import app.morphe.gui.util.StatusColorType -import app.morphe.gui.util.VersionStatus import app.morphe.gui.util.currentLocale import app.morphe.gui.util.resolveStatusColorType import app.morphe.gui.util.resolveVersionStatusDisplay diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkStagePane.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkStagePane.kt index fd1f0c56..7f633058 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkStagePane.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkStagePane.kt @@ -36,8 +36,8 @@ import app.morphe.gui.ui.theme.contrastingForeground import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.engine.model.VersionStatus import app.morphe.gui.util.StatusColorType -import app.morphe.gui.util.VersionStatus import app.morphe.gui.util.resolveStatusColorType import app.morphe.gui.util.toColor import app.morphe.morphe_desktop.generated.resources.* diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeAppSortMode.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeAppSortMode.kt index 89f1ab52..871cd35f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeAppSortMode.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeAppSortMode.kt @@ -7,7 +7,7 @@ package app.morphe.gui.ui.screens.home.components import androidx.compose.runtime.Composable import app.morphe.engine.model.PatchedAppRecord -import app.morphe.gui.data.model.SupportedApp +import app.morphe.engine.model.SupportedApp import app.morphe.gui.ui.screens.home.PatchedAppState import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.StringResource diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeDialogs.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeDialogs.kt index ca8aee75..4d220f15 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeDialogs.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeDialogs.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.apk.BundleFormats import app.morphe.engine.model.PatchedAppRecord import app.morphe.gui.ui.components.MorpheDialogButton import app.morphe.gui.ui.components.MorpheDialogCard @@ -26,8 +27,8 @@ import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.engine.model.VersionStatus import app.morphe.gui.util.MorpheFilePicker -import app.morphe.gui.util.VersionStatus import app.morphe.gui.util.resolveVersionWarningContent import app.morphe.gui.util.toColor import app.morphe.morphe_desktop.generated.resources.* @@ -161,7 +162,7 @@ internal fun RepatchMissingApkDialog( scope.launch { val picked = MorpheFilePicker.pickFile( title = getString(Res.string.home_dialog_select_apk_to_repatch), - extensions = listOf("apk", "apkm", "xapk", "apks"), + extensions = BundleFormats.SUPPORTED_EXTENSIONS, ) onDismiss() if (picked != null && picked.exists()) onApkPicked(picked.absolutePath) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeNavigationHelpers.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeNavigationHelpers.kt index 704adef2..d8a6f7b8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeNavigationHelpers.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/HomeNavigationHelpers.kt @@ -6,13 +6,14 @@ package app.morphe.gui.ui.screens.home.components import androidx.navigation.NavController +import app.morphe.engine.apk.BundleFormats +import app.morphe.engine.model.VersionStatus import app.morphe.gui.PatchSelectionParams import app.morphe.gui.PatchSelectionScreenRoute import app.morphe.gui.navigateComplex import app.morphe.gui.ui.screens.home.HomeUiState import app.morphe.gui.ui.screens.home.HomeViewModel import app.morphe.gui.util.MorpheFilePicker -import app.morphe.gui.util.VersionStatus import app.morphe.morphe_desktop.generated.resources.* import java.io.File import org.jetbrains.compose.resources.getString @@ -49,5 +50,5 @@ internal fun handleContinue( internal suspend fun openFilePicker(): File? = MorpheFilePicker.pickFile( title = getString(Res.string.home_select_apk_file), - extensions = listOf("apk", "apkm", "xapk", "apks"), + extensions = BundleFormats.SUPPORTED_EXTENSIONS, ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailDialog.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailDialog.kt index 8bd8656f..6ec0a1e0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailDialog.kt @@ -45,8 +45,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.model.SupportedApp import app.morphe.gui.data.model.PatchSource -import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.ui.components.LocalCardFills import app.morphe.gui.ui.components.MorpheDialogSurface import app.morphe.gui.ui.components.morpheScrollbarStyle diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailSections.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailSections.kt index 9c8eefc9..97344879 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailSections.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/PatchedAppDetailSections.kt @@ -45,8 +45,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.model.SupportedApp import app.morphe.gui.data.model.PatchSource -import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.ui.components.MorpheCardChip import app.morphe.gui.ui.components.MorpheSwitch import app.morphe.gui.ui.components.handCursor diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt index b28c7137..c6f79e3e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -33,19 +33,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.util.DownloadUrlResolver.openUrlAndFollowRedirects import app.morphe.gui.ui.components.MorpheBadge import app.morphe.gui.ui.components.MorpheBadgeTone import app.morphe.gui.ui.components.cardChipInk import app.morphe.gui.ui.components.MorpheCardChip import app.morphe.gui.ui.components.LocalCardFills import app.morphe.gui.ui.components.AppCard -import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.screens.home.DeviceAppInfo import app.morphe.gui.ui.screens.home.PatchedAppState import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont -import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppsListPane.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppsListPane.kt index c7d811bf..e891876f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppsListPane.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppsListPane.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.engine.model.PatchedAppRecord -import app.morphe.gui.data.model.SupportedApp +import app.morphe.engine.model.SupportedApp import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.screens.home.DeviceAppInfo diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index 4bcfd8ec..82f0d3e2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -33,12 +33,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.config.EngineConfigRepository +import app.morphe.engine.model.PatchMetadata import app.morphe.gui.LocalGroupPatchesByCategory import app.morphe.gui.LocalNavController import app.morphe.gui.PatchSelectionParams import app.morphe.gui.PatchingScreenRoute -import app.morphe.gui.data.model.Patch -import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.navigateComplex import app.morphe.gui.ui.components.ErrorDialog import app.morphe.gui.ui.components.MorpheBadge @@ -84,7 +84,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { val font = LocalMorpheFont.current val accents = LocalMorpheAccents.current val navController = LocalNavController.current - val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() val uiState by viewModel.uiState.collectAsState() val targetPackage = viewModel.targetPackage() @@ -94,7 +94,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { var keystoreAlias by remember { mutableStateOf(null) } var keystoreEntryPassword by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - val config = configRepository.loadConfig() + val config = engineConfigRepository.loadConfig() keystorePath = config.resolvedKeystorePath()?.absolutePath keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias @@ -568,5 +568,5 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { /** New patches float to the top, the rest keep the bundle's own order. */ -private fun List.newestFirst(newIds: Set): List = +private fun List.newestFirst(newIds: Set): List = if (newIds.isEmpty()) this else sortedByDescending { it.uniqueId in newIds } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index 5a1aaa1b..24fbb28e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -7,23 +7,23 @@ package app.morphe.gui.ui.screens.patches import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.morphe.engine.PatchEngine import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.apk.AndroidArchitectures +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.apk.ApkOutputNaming +import app.morphe.engine.config.EngineConfigRepository import app.morphe.engine.model.PatchedAppRecord.PatchedSourceSnapshot -import app.morphe.engine.util.ApkOutputNaming -import app.morphe.gui.data.model.Patch -import app.morphe.gui.data.model.PatchConfig -import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchPreferencesRepository -import app.morphe.gui.data.repository.PatchRepository +import app.morphe.engine.options.PatchPreferencesRepository +import app.morphe.engine.options.groupFlatOptionsToJson +import app.morphe.engine.options.optionValueFromJson +import app.morphe.engine.patches.PatchRepository +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.patches.SupportedAppCatalog +import app.morphe.engine.model.PatchMetadata import app.morphe.gui.data.repository.SeenPatchesRepository -import app.morphe.gui.util.FileUtils -import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import app.morphe.gui.util.Logger -import app.morphe.gui.util.PatchService -import app.morphe.gui.util.optionValueFromJson -import app.morphe.gui.util.optionValueOrNull -import app.morphe.gui.util.optionValueToJson import app.morphe.morphe_desktop.generated.resources.* import app.morphe.patcher.resource.CpuArchitecture import java.io.File @@ -56,7 +56,7 @@ import org.jetbrains.compose.resources.getString data class BundlePatches( val bundleId: String, val bundleName: String, - val patches: List, + val patches: List, ) class PatchSelectionViewModel( @@ -65,9 +65,8 @@ class PatchSelectionViewModel( private val patchesFilePath: String, private val packageName: String, private val apkArchitectures: List, - private val patchService: PatchService, private val patchRepository: PatchRepository, - private val configRepository: ConfigRepository, + private val engineConfigRepository: EngineConfigRepository = EngineConfigRepository.shared, private val preferencesRepository: PatchPreferencesRepository, private val patchSourceName: String, private val localPatchFilePath: String? = null, @@ -97,11 +96,12 @@ class PatchSelectionViewModel( private var actualPatchesFilePaths: List = patchesFilePaths private var defaultOutputDirectory: String? = null + private var autoCleanupTempFiles: Boolean = true private val _uiState = MutableStateFlow( PatchSelectionUiState( apkArchitectures = apkArchitectures, - stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES), + stripLibsStatus = computeStripLibsStatus(apkArchitectures, AndroidArchitectures.ALL), ) ) val uiState: StateFlow = _uiState.asStateFlow() @@ -113,7 +113,8 @@ class PatchSelectionViewModel( private fun loadStripLibsPreference() { viewModelScope.launch { - val config = configRepository.loadConfig() + val config = engineConfigRepository.loadConfig() + autoCleanupTempFiles = config.autoCleanupTempFiles // Store the resolved absolute path so the lookup at line ~487 can // pass it straight into File(...) without re-resolving. defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath @@ -122,7 +123,7 @@ class PatchSelectionViewModel( // option isn't lost. Also correct for an Update's freshly-downloaded APK. val arches = apkArchitectures.ifEmpty { withContext(Dispatchers.IO) { - runCatching { FileUtils.extractArchitectures(File(apkPath)) } + runCatching { ApkInspector.extractArchitectures(File(apkPath)).toList() } .getOrDefault(emptyList()) } } @@ -226,7 +227,7 @@ class PatchSelectionViewModel( // When an APK version is supported by at least one bundle, do not auto-restore // selections from a bundle that has zero compatible patches for that version. - val bundleIncompatible = anyBundleHasCompatible && compatiblePatchCount(bundleId, patches) == 0 + val bundleIncompatible = anyBundleHasCompatible && compatiblePatchCount(patches) == 0 if (bundleIncompatible) { savedByBundle[bundleId] = combined } else { @@ -300,13 +301,13 @@ class PatchSelectionViewModel( } } - fun isPatchCompatible(patch: Patch): Boolean = + fun isPatchCompatible(patch: PatchMetadata): Boolean = patch.isUniversal || patch.isCompatibleWith(packageName, apkVersion.ifBlank { null }) fun compatiblePatchCount(bundle: BundlePatches): Int = - compatiblePatchCount(bundle.bundleId, bundle.patches) + compatiblePatchCount(bundle.patches) - fun compatiblePatchCount(bundleId: String, patches: List): Int = + fun compatiblePatchCount(patches: List): Int = patches.count { !it.isUniversal && it.isCompatibleWith(packageName, apkVersion.ifBlank { null }) } private fun computeBundleDefaults(bundle: BundlePatches, hasAnyCompatibleBundle: Boolean): Set { @@ -331,7 +332,7 @@ class PatchSelectionViewModel( val pkgFilter = packageName.ifEmpty { null } val perFile = actualPatchesFilePaths.mapIndexed { idx, path -> async { - val result = patchService.listPatches(path, pkgFilter) + val result = runCatching { SupportedAppCatalog.loadPatches(File(path), pkgFilter) } Triple(idx, path, result) } }.awaitAll() @@ -518,7 +519,7 @@ class PatchSelectionViewModel( fun runInfo(): RunInfo = RunInfo( appName = apkName, appVersion = apkVersion.takeIf { it.isNotBlank() } - ?: extractVersionFromFilename(File(apkPath).name).orEmpty(), + ?: ApkOutputNaming.extractApkVersionFromFilename(File(apkPath).name).orEmpty(), apkFileName = File(apkPath).name, apkPath = apkPath, packageName = packageName, @@ -526,7 +527,7 @@ class PatchSelectionViewModel( val file = File(path) RunInfo.Bundle( name = patchSourceNames.getOrNull(i) ?: file.nameWithoutExtension, - version = extractPatchesVersion(file.name), + version = ApkOutputNaming.extractPatchesVersion(file.name), fileName = file.name, ) }, @@ -545,17 +546,7 @@ class PatchSelectionViewModel( val declaredTypes = state.bundles .flatMap { it.patches } .associate { patch -> patch.name to patch.options.associate { it.key to it.valueType } } - val groupedOptions = mutableMapOf>() - for ((compoundKey, value) in state.patchOptionValues) { - val dotIdx = compoundKey.indexOf('.') - if (dotIdx <= 0) continue - val patchName = compoundKey.substring(0, dotIdx) - val optKey = compoundKey.substring(dotIdx + 1) - val type = declaredTypes[patchName]?.get(optKey) - val typed = type?.let { optionValueOrNull(value, it) } - groupedOptions.getOrPut(patchName) { mutableMapOf() }[optKey] = - if (typed == null) JsonPrimitive(value) else optionValueToJson(typed) - } + val groupedOptions = groupFlatOptionsToJson(state.patchOptionValues, declaredTypes) viewModelScope.launch { for ((bundleId, bundleName, patches) in state.bundles) { @@ -596,20 +587,20 @@ class PatchSelectionViewModel( // ── Patcher integration ───────────────────────────────────────────────── - fun createPatchConfig(continueOnError: Boolean = false): PatchConfig { + fun createPatchConfig(continueOnError: Boolean = false): PatchEngine.Config { saveCurrentSelection() // Delegate to the shared engine helper. Same path the CLI computes. // Passing apkName as the display name preserves the friendly label // (e.g. "Youtube") instead of falling back to the filename. val inputFile = File(apkPath) - val outputPath = ApkOutputNaming.outputApkPath( + val outputFile = ApkOutputNaming.outputApkPath( inputApk = inputFile, patchesFile = File(actualPatchesFilePath), baseOutputDir = defaultOutputDirectory?.let { File(it) }, appDisplayName = apkName, appVersion = apkVersion, - ).absolutePath + ) // Flatten across bundles: the engine takes a single flat enable/disable // list and dedups identical patches at apply time, so the union of @@ -633,33 +624,35 @@ class PatchSelectionViewModel( PatchedSourceSnapshot( sourceId = sourceIdsByName[name] ?: name, sourceName = name, - version = extractPatchesVersion(File(path).name) ?: "unknown", + version = ApkOutputNaming.extractPatchesVersion(File(path).name) ?: "unknown", ) } val activeSources = fullSourcesSnapshot.filter { snapshot -> selectionByBundle[snapshot.sourceName]?.isNotEmpty() == true } - - val displaySources = activeSources.takeIf { it.isNotEmpty() } ?: fullSourcesSnapshot.take(1) - - return PatchConfig( - inputApkPath = apkPath, - outputApkPath = outputPath, - patchesFilePaths = actualPatchesFilePaths, - enabledPatches = selectedPatchNames, - disabledPatches = disabledPatchNames, - patchOptions = _uiState.value.patchOptionValues, - useExclusiveMode = true, - keepArchitectures = keepArches, - continueOnError = continueOnError, - packageName = packageName, + + return PatchEngine.Config( + inputApk = inputFile, + outputApk = outputFile, + patchFiles = actualPatchesFilePaths.map { File(it) }, + enabledPatches = selectedPatchNames.toSet(), + disabledPatches = disabledPatchNames.toSet(), + flatPatchOptions = _uiState.value.patchOptionValues, + exclusiveMode = true, + forceCompatibility = true, + architecturesToKeep = keepArches, + failOnError = !continueOnError, + disablePurge = !autoCleanupTempFiles, + recordHistory = true, appDisplayName = apkName, - patchSelectionByBundle = selectionByBundle, - sourcesSnapshot = activeSources, - appVersion = apkVersion.takeIf { it.isNotBlank() }, - patchesSourceName = displaySources.joinToString(", ") { it.sourceName }, - patchesVersion = displaySources.joinToString(", ") { it.version } + historyMetadata = PatchEngine.HistoryMetadata( + originalPackageName = packageName, + displayName = apkName, + patchSelectionByBundle = selectionByBundle, + patchOptionValues = _uiState.value.patchOptionValues, + sourcesSnapshot = activeSources, + ), ) } @@ -685,15 +678,6 @@ class PatchSelectionViewModel( return selected.toList() to disabled.toList() } - // Delegate to the shared engine helper so GUI and CLI agree on filename - // parsing. Returning these as instance methods (not direct calls) keeps - // existing call sites in this file unchanged. - private fun extractVersionFromFilename(fileName: String): String? = - ApkOutputNaming.extractApkVersionFromFilename(fileName) - - private fun extractPatchesVersion(patchesFileName: String): String? = - ApkOutputNaming.extractPatchesVersion(patchesFileName) - /** * Resolve the filename of the running Morphe Desktop JAR. Falls back to * "morphe-desktop.jar" if running unpackaged in a development environment. @@ -739,18 +723,19 @@ class PatchSelectionViewModel( .filter { it.name.isNotBlank() } .ifEmpty { listOf(File(actualPatchesFilePath)) } val primaryPatchesFile = patchesFiles.first() - val appFolderName = apkName.replace(" ", "-") - val version = extractVersionFromFilename(inputFile.name) ?: "patched" - val patchesVersion = extractPatchesVersion(primaryPatchesFile.name) - val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" - val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" + val outputFileName = ApkOutputNaming.outputApkPath( + inputApk = inputFile, + patchesFile = primaryPatchesFile, + appDisplayName = apkName, + appVersion = apkVersion, + ).name val state = uiState.value // 1. Collect selected and default-enabled patch names across bundles val selectedPatchNames = mutableSetOf() val defaultEnabledPatchNames = mutableSetOf() - val allCompatiblePatches = mutableMapOf() + val allCompatiblePatches = mutableMapOf() for ((bundleId, _, patches) in state.bundles) { val bundleSelected = state.selectedByBundle[bundleId].orEmpty() @@ -885,37 +870,24 @@ class PatchSelectionViewModel( */ private suspend fun downloadMissingPatches(expectedFilename: String): Result { if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - return if (localFile.exists()) Result.success(localFile) - else Result.failure(Exception("Local patch file not found: ${localFile.name}")) + val localRes = PatchResolver.resolveLocal(localPatchFilePath) + return localRes.patchFile?.let { Result.success(it) } + ?: Result.failure(Exception(localRes.error?.message ?: "Local patch file not found")) } - val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") - val versionMatch = versionRegex.find(expectedFilename) - val expectedVersion = versionMatch?.groupValues?.get(1) + val expectedVersion = expectedFilename.substringBefore("__").takeIf { expectedFilename.contains("__") } + ?: Regex("""v?(\d+\.\d+\.\d+(?:-dev\.\d+)?)""").find(expectedFilename)?.value Logger.info("Looking for patches version: ${expectedVersion ?: "latest"}") - val targetRelease = if (expectedVersion != null) { - // A specific version is expected (repatch/update) → the full release list - // (API) is the only way to locate that exact tag. - val releasesResult = patchRepository.fetchReleases() - if (releasesResult.isFailure) { - return Result.failure(releasesResult.exceptionOrNull() ?: Exception("Failed to fetch releases")) - } - val releases = releasesResult.getOrNull() ?: emptyList() - if (releases.isEmpty()) return Result.failure(Exception("No releases found")) - releases.find { it.tagName.contains(expectedVersion) } - ?: releases.firstOrNull { !it.isDevRelease() } - ?: return Result.failure(Exception("No suitable release found")) - } else { - // Just need the latest stable → resolve via the raw manifest (no API call). - patchRepository.getLatestStableRelease().getOrNull() - ?: return Result.failure(Exception("No suitable release found")) - } - - Logger.info("Downloading patches from release: ${targetRelease.tagName}") - return patchRepository.downloadPatches(targetRelease) + val remoteRes = PatchResolver.resolveRemote( + repo = patchRepository, + usePreRelease = expectedVersion?.contains("dev", ignoreCase = true) == true, + pinnedTag = expectedVersion, + strictPin = false, + ) + return remoteRes.patchFile?.let { Result.success(it) } + ?: Result.failure(Exception(remoteRes.error?.message ?: "No suitable release found")) } } @@ -971,18 +943,6 @@ data class PatchSelectionUiState( /** Total count of patches across all bundles. */ val totalCount: Int get() = bundles.sumOf { it.patches.size } - // ── Legacy flat shims ──────────────────────────────────────────────── - // - // These let the existing PatchSelectionScreen render against the new - // per-bundle state without changes. Deleted once the screen renders - // collapsible bundle boxes. - - @Deprecated("Use bundles directly", ReplaceWith("bundles")) - val allPatches: List get() = bundles.flatMap { it.patches } - - @Deprecated("Use filteredBundles directly", ReplaceWith("filteredBundles")) - val filteredPatches: List get() = filteredBundles.flatMap { it.patches } - /** Which preset (if any) the SPECIFIED bundle's selection matches. Each box renders * its own chip highlighting independently. */ fun selectionModeFor(bundleId: String): SelectionMode { diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt index a30040df..63122bae 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -7,20 +7,23 @@ package app.morphe.gui.ui.screens.patches import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.morphe.desktop.command.model.toPatchBundle +import app.morphe.engine.ReleaseChannel import app.morphe.engine.model.Release -import app.morphe.engine.model.ReleaseAsset +import app.morphe.engine.options.toPatchBundle +import app.morphe.engine.options.writePatchBundles +import app.morphe.engine.patches.PatchCache +import app.morphe.engine.patches.PatchRepository +import app.morphe.engine.patches.PullRequestPatchSource +import app.morphe.engine.util.newerRelease import app.morphe.gui.data.model.FollowMode import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager +import app.morphe.engine.patches.PatchBundleLoader import app.morphe.gui.util.Logger -import app.morphe.gui.util.newerRelease import app.morphe.morphe_desktop.generated.resources.* -import app.morphe.patcher.patch.loadPatchesFromJar import java.io.File -import java.time.Instant +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,7 +31,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import org.jetbrains.compose.resources.getString class PatchesViewModel( @@ -123,11 +125,11 @@ class PatchesViewModel( } // Check if patches for the initial release are already cached - val cachedFile = initialRelease?.let { checkCachedPatches(it) } + val cachedFile = initialRelease?.let { PatchCache.getCachedFile(patchRepository.repoPath, it) } // Build set of all cached release versions val cachedVersions = releases - .filter { checkCachedPatches(it) != null } + .filter { PatchCache.getCachedFile(patchRepository.repoPath, it) != null } .map { it.tagName } .toSet() @@ -145,15 +147,9 @@ class PatchesViewModel( Logger.info("Loaded ${stableReleases.size} stable and ${devReleases.size} dev releases, saved=$savedVersion, selected=${initialRelease?.tagName}, cached: ${cachedFile != null}") }, onFailure = { e -> - // Even when offline, check for cached .mpp files - val cachedFiles = findAllCachedPatchFiles() - if (cachedFiles.isNotEmpty()) { - val offlineReleases = cachedFiles.mapNotNull { buildOfflineRelease(it) } - .sortedByDescending { rel -> - val version = rel.tagName.removePrefix("v") - parseVersionParts(version) - .fold(0L) { acc, part -> acc * 10000 + part } - } + val prNumber = (patchRepository.remoteSource as? PullRequestPatchSource)?.prNumber + val offlineReleases = PatchCache.listOfflineReleases(patchRepository.repoPath, prNumber) + if (offlineReleases.isNotEmpty()) { val activeSource = patchSourceManager?.getActiveSource() val activeSourceId = activeSource?.id val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] } @@ -184,11 +180,7 @@ class PatchesViewModel( offlineReleases.firstOrNull { !it.isDevRelease() } ?: offlineReleases.firstOrNull() } - // Find the cached file for the selected release - val cachedFile = selected?.let { rel -> - val assetName = rel.assets.firstOrNull()?.name - cachedFiles.find { it.name == assetName } - } + val cachedFile = selected?.let { PatchCache.getCachedFile(patchRepository.repoPath, it) } _uiState.value = _uiState.value.copy( isLoading = false, @@ -199,7 +191,7 @@ class PatchesViewModel( cachedReleaseVersions = offlineReleases.map { it.tagName }.toSet(), error = null ) - Logger.info("Offline - found ${cachedFiles.size} cached patch file(s), selected=${selected?.tagName}") + Logger.info("Offline - found ${offlineReleases.size} cached release(s), selected=${selected?.tagName}") } else { _uiState.value = _uiState.value.copy( isLoading = false, @@ -214,17 +206,7 @@ class PatchesViewModel( } fun selectRelease(release: Release) { - val cachedFile = if (_uiState.value.isOffline) { - // In offline mode, find the cached file by matching the asset name - val assetName = release.assets.firstOrNull()?.name - if (assetName != null) { - val patchesDir = patchRepository.getCacheDir() - val file = File(patchesDir, assetName) - if (file.exists()) file else null - } else null - } else { - checkCachedPatches(release) - } + val cachedFile = PatchCache.getCachedFile(patchRepository.repoPath, release) _uiState.value = _uiState.value.copy( selectedRelease = release, @@ -233,76 +215,6 @@ class PatchesViewModel( Logger.info("Selected release: ${release.tagName}, cached: ${cachedFile != null}") } - /** - * Find all cached .mpp files in the per-source cache directory. - */ - private fun findAllCachedPatchFiles(): List { - val patchesDir = patchRepository.getCacheDir() - return patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: emptyList() - } - - private val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") - - /** - * Parse semantic version parts for comparison. - * "1.13.0" -> [1, 13, 0], "1.4.0-dev.5" -> [1, 4, 0, 5] - */ - private fun parseVersionParts(version: String): List { - return version.replace("-dev.", ".").split(".").mapNotNull { it.toIntOrNull() } - } - - /** - * Build a synthetic Release from a cached .mpp file for offline display. - * Extracts version from the filename (e.g. "patches-1.13.0.mpp" -> "v1.13.0"). - * publishedAt is left empty since we don't know the actual release date offline. - */ - private fun buildOfflineRelease(file: File): Release? { - val match = versionRegex.find(file.name) ?: return null - val version = match.groupValues[1] - - return Release( - id = file.name.hashCode().toLong(), - tagName = "v$version", - name = "v$version", - isPrerelease = version.contains("dev"), - publishedAt = Instant.ofEpochMilli(file.lastModified()).toString(), - assets = listOf( - ReleaseAsset( - id = file.name.hashCode().toLong(), - name = file.name, - downloadUrl = "", - size = file.length(), - contentType = "application/octet-stream" - ) - ) - ) - } - - /** - * Check if patches for a release are already downloaded and valid. - */ - private fun checkCachedPatches(release: Release): File? { - val asset = patchRepository.findPatchAsset(release) ?: return null - val patchesDir = patchRepository.getCacheDir() - // Match the version-prefixed filename PatchRepository.downloadPatches writes. - // Looking up by bare asset.name would falsely "find" the latest version's - // file for every other version's check (since maintainers commonly reuse - // the asset filename across releases). That was the cause of the - // "latest stable shows SELECT after Clear Cache" bug. - val cachedFile = File(patchesDir, PatchRepository.cachedFileName(release, asset)) - - // Verify file exists and size matches (size check acts as basic integrity verification) - return if (cachedFile.exists() && cachedFile.length() == asset.size) { - Logger.info("Found cached patches: ${cachedFile.absolutePath}") - cachedFile - } else { - null - } - } - fun setChannel(channel: ReleaseChannel) { val newRelease = when (channel) { ReleaseChannel.STABLE -> _uiState.value.stableReleases.firstOrNull() @@ -310,7 +222,7 @@ class PatchesViewModel( } // Check if patches for the new release are already cached - val cachedFile = newRelease?.let { checkCachedPatches(it) } + val cachedFile = newRelease?.let { PatchCache.getCachedFile(patchRepository.repoPath, it) } _uiState.value = _uiState.value.copy( selectedChannel = channel, @@ -440,13 +352,13 @@ class PatchesViewModel( _uiState.value = _uiState.value.copy(isExporting = true) try { withContext(Dispatchers.IO) { - val patches = loadPatchesFromJar(setOf(patchFile)) + val patches = PatchBundleLoader.loadFlat(setOf(patchFile)) val bundle = patches.toPatchBundle(sourceFiles = setOf(patchFile)) - val json = Json { prettyPrint = true } - outputFile.parentFile?.mkdirs() - outputFile.writeText(json.encodeToString(listOf(bundle))) + writePatchBundles(outputFile, listOf(bundle)) } Logger.info("Exported ${_uiState.value.downloadedPatchFile?.name} options to ${outputFile.path}") + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _uiState.value = _uiState.value.copy( error = getString(Res.string.patches_error_failed_to_export_options, e.message ?: "") @@ -462,11 +374,6 @@ class PatchesViewModel( fun getApkName(): String = apkName } -enum class ReleaseChannel { - STABLE, - DEV -} - data class PatchesUiState( val isLoading: Boolean = false, val isOffline: Boolean = false, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/patches/ChannelSelector.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/patches/ChannelSelector.kt index ac7a5125..e419b030 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/patches/ChannelSelector.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/patches/ChannelSelector.kt @@ -27,8 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.ReleaseChannel import app.morphe.gui.ui.components.handCursor -import app.morphe.gui.ui.screens.patches.ReleaseChannel import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchListItem.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchListItem.kt index 17cf1566..8130d958 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchListItem.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchListItem.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.gui.data.model.Patch +import app.morphe.engine.model.PatchMetadata import app.morphe.gui.ui.components.MorpheBadge import app.morphe.gui.ui.components.MorpheBadgeTone import app.morphe.gui.ui.components.handCursor @@ -67,7 +67,7 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun PatchListItem( - patch: Patch, + patch: PatchMetadata, isSelected: Boolean, onToggle: () -> Unit, isNew: Boolean = false, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionEditor.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionEditor.kt index 0e5267d2..f58ba2f3 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionEditor.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionEditor.kt @@ -10,9 +10,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -31,11 +28,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.gui.data.model.PatchOption -import app.morphe.gui.data.model.PatchOptionType +import app.morphe.engine.options.PatchOption +import app.morphe.engine.options.coerceOptionValue import app.morphe.gui.icon.IconExporter import app.morphe.gui.icon.IconStudioDialog import app.morphe.gui.ui.components.* @@ -44,10 +40,9 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.util.MorpheFilePicker -import app.morphe.gui.util.expectedValueHint -import app.morphe.gui.util.optionValueOrNull import app.morphe.morphe_desktop.generated.resources.* import kotlin.math.roundToInt +import kotlin.reflect.KType import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -417,7 +412,7 @@ internal fun PatchOptionEditor( // unless the patch demands one. val missing = option.required && localText.isBlank() val badType = localText.isNotBlank() && option.valueType?.let { - optionValueOrNull(localText, it) == null + coerceOptionValue(it, localText) == null } == true val invalid = missing || badType @@ -488,3 +483,22 @@ internal fun PatchOptionEditor( } } } + +/** What [type] will accept, phrased for someone typing into a field. */ +@Composable +private fun expectedValueHint(type: KType): String { + if (type.classifier == List::class) { + val element = type.arguments.firstOrNull()?.type + val each = element?.let { scalarHint(it) } ?: stringResource(Res.string.patch_selection_option_hint_value) + return stringResource(Res.string.patch_selection_option_hint_list, each) + } + return scalarHint(type) +} + +@Composable +private fun scalarHint(type: KType): String = when (type.classifier) { + Boolean::class -> stringResource(Res.string.patch_selection_option_hint_boolean) + Int::class, Long::class -> stringResource(Res.string.patch_selection_option_hint_whole_number) + Float::class, Double::class -> stringResource(Res.string.patch_selection_option_hint_number) + else -> stringResource(Res.string.patch_selection_option_hint_text) +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionKind.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionKind.kt index efa204fb..55d73cac 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionKind.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchOptionKind.kt @@ -5,10 +5,11 @@ package app.morphe.gui.ui.screens.patches.components.selection -import app.morphe.gui.data.model.ExplicitOptionKind -import app.morphe.gui.data.model.PatchOption -import app.morphe.gui.data.model.PatchOptionType -import app.morphe.gui.data.model.SliderBounds +import app.morphe.engine.options.ExplicitOptionKind +import app.morphe.engine.options.OptionKeywords +import app.morphe.engine.options.PatchOption +import app.morphe.engine.options.PatchOptionType +import app.morphe.engine.options.SliderBounds /** * Represents the resolved UI kind of a patch option. @@ -78,11 +79,8 @@ fun resolveOptionKind(option: PatchOption, value: Any?): OptionKind { isList && (typeStr.contains("String") || option.type == PatchOptionType.LIST) -> OptionKind.StringList // Path/folder string with presets: combined dropdown + path picker - isString && option.presets?.isNotEmpty() == true && ( - desc.contains("folder") || - desc.contains("mipmap") || - desc.contains("drawable") - ) -> OptionKind.PathWithPresets + isString && option.presets?.isNotEmpty() == true && + OptionKeywords.ASSET_FOLDER.any { it in desc } -> OptionKind.PathWithPresets // Color: string whose key/title hints "color" or value looks like a color literal isString && ( @@ -95,30 +93,18 @@ fun resolveOptionKind(option: PatchOption, value: Any?): OptionKind { // Image file detected by heuristics (e.g. "custom wallpaper", "image", "logo", "banner") isString && option.presets == null && ( - combinedMeta.contains("image") || - combinedMeta.contains("wallpaper") || - combinedMeta.contains("banner") || - combinedMeta.contains("logo") || - desc.contains("image") || - desc.contains("wallpaper") + OptionKeywords.IMAGE.any { it in combinedMeta || it in desc } ) -> OptionKind.Image // Individual file path string: file picker (not a folder) isString && option.presets == null && ( - desc.contains("file path") || - combinedMeta.contains("file path") || - option.type == PatchOptionType.FILE - ) && !desc.contains("folder") && !combinedMeta.contains("folder") && !combinedMeta.contains("customicon") -> OptionKind.FilePath + "file path" in desc || "file path" in combinedMeta || option.type == PatchOptionType.FILE + ) && !OptionKeywords.ASSET_FOLDER.any { it in desc || it in combinedMeta } && "customicon" !in combinedMeta -> OptionKind.FilePath // Path/folder string without presets: folder picker isString && option.key != "customName" && ( - combinedMeta.contains("icon") || - combinedMeta.contains("header") || - combinedMeta.contains("custom") || - combinedMeta.contains("folder") || - desc.contains("folder") || - desc.contains("mipmap") || - desc.contains("drawable") + listOf("icon", "header", "custom").any { it in combinedMeta } || + OptionKeywords.ASSET_FOLDER.any { it in combinedMeta || it in desc } ) -> OptionKind.Path // Comma-separated string: detected by value content or explicit description hint diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt index da3de629..60093614 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -27,11 +27,11 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.PatchEngine import app.morphe.gui.HomeScreenRoute import app.morphe.gui.LocalNavController import app.morphe.gui.LocalPatchingCompleted import app.morphe.gui.ResultScreenRoute -import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.screens.patching.components.* import app.morphe.gui.ui.screens.result.ResultScreen @@ -56,7 +56,7 @@ import org.koin.core.parameter.parametersOf */ @Composable fun PatchingScreen( - config: PatchConfig, + config: PatchEngine.Config, viewModel: PatchingViewModel = koinViewModel { parametersOf(config) } ) { PatchingScreenContent(viewModel = viewModel) @@ -245,24 +245,3 @@ fun PatchingScreenContent(viewModel: PatchingViewModel) { } } } - -/** - * Re-export of [LogFileViewerDialog] for external callers (e.g., QuickPatchScreen) - * to maintain backwards compatibility. - */ -@Composable -fun LogFileViewerDialog( - file: File, - corners: MorpheCornerStyle, - font: FontFamily, - borderColor: Color, - onDismiss: () -> Unit, -) { - LogFileViewerDialog( - file = file, - corners = corners, - font = font, - borderColor = borderColor, - onDismiss = onDismiss - ) -} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index c273ce85..6aae52bb 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -8,19 +8,14 @@ package app.morphe.gui.ui.screens.patching import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.morphe.engine.MorpheComponents -import app.morphe.engine.MorpheData -import app.morphe.engine.PatchedAppStore +import app.morphe.engine.PatchEngine import app.morphe.engine.UpdateChecker -import app.morphe.engine.model.PatchedAppRecord -import app.morphe.engine.util.ApkManifestReader -import app.morphe.engine.util.FileChecksum -import app.morphe.gui.data.model.PatchConfig +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.apk.BundleFormats +import app.morphe.engine.config.EngineConfigRepository import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.Logger -import app.morphe.gui.util.PatchResult -import app.morphe.gui.util.PatchService -import app.morphe.gui.util.PatcherLogInterceptor import app.morphe.gui.util.PatcherState import app.morphe.morphe_desktop.generated.resources.* import java.io.File @@ -34,15 +29,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.getString import oshi.SystemInfo class PatchingViewModel( - private val config: PatchConfig, - private val patchService: PatchService, + private val config: PatchEngine.Config, private val configRepository: ConfigRepository, - private val patchedAppStore: PatchedAppStore, + private val engineConfigRepository: EngineConfigRepository = EngineConfigRepository.shared, ) : ViewModel() { private val _uiState = MutableStateFlow(PatchingUiState()) @@ -65,20 +58,21 @@ class PatchingViewModel( val osName = System.getProperty("os.name") ?: "Unknown OS" val osArch = System.getProperty("os.arch") ?: "Unknown Arch" val maxMemoryMb = (Runtime.getRuntime().maxMemory() / (1024 * 1024)).toInt() - val inputApkFile = File(config.inputApkPath) + val inputApkFile = config.inputApk val apkSizeMb = if (inputApkFile.exists()) FormatUtils.formatFileSize(inputApkFile.length(), locale) else "?" - val appVersion = config.appVersion ?: ApkManifestReader.read(inputApkFile)?.versionName ?: "?" - val patchesSourceName = config.patchesSourceName ?: "MORPHE PATCHES" - val patchesVersion = config.patchesVersion ?: config.sourcesSnapshot.firstOrNull()?.version ?: "?" - val isSplit = config.inputApkPath.endsWith(".apkm", true) || config.inputApkPath.endsWith(".xapk", true) || config.inputApkPath.endsWith(".apks", true) || inputApkFile.isDirectory + val appVersion = ApkInspector.inspect(inputApkFile)?.versionName ?: "?" + val sourcesSnapshot = config.historyMetadata?.sourcesSnapshot + val patchesSourceName = sourcesSnapshot?.firstOrNull()?.sourceName ?: "MORPHE PATCHES" + val patchesVersion = sourcesSnapshot?.firstOrNull()?.version ?: "?" + val isSplit = BundleFormats.isBundle(inputApkFile) || inputApkFile.isDirectory - val parentFile = File(config.outputApkPath).parentFile ?: File(System.getProperty("user.home")) + val parentFile = config.outputApk?.parentFile ?: config.inputApk.parentFile ?: File(System.getProperty("user.home")) val storageFreeInfo = "${FormatUtils.formatFileSize(parentFile.usableSpace, locale)} / ${FormatUtils.formatFileSize(parentFile.totalSpace, locale)}" val desktopVersion = UpdateChecker.currentVersion() ?: "?" val patcherVersion = MorpheComponents.patcherVersion ?: "?" - val nativeLibs = if (config.keepArchitectures.isNotEmpty()) getString(Res.string.patching_banner_native_libs_kept) else getString(Res.string.patching_banner_native_libs_stripped) + val nativeLibs = if (config.architecturesToKeep.isNotEmpty()) getString(Res.string.patching_banner_native_libs_kept) else getString(Res.string.patching_banner_native_libs_stripped) val osBean = ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean val ramFreeInfo = "${FormatUtils.formatFileSize(osBean.freeMemorySize, locale)} / ${FormatUtils.formatFileSize(osBean.totalMemorySize, locale)}" @@ -134,84 +128,67 @@ class PatchingViewModel( progress = 0f ) - // Resolve keystore. Two modes: - // - User configured one in Settings, so use it and fail loudly if the - // file is missing (don't silently swap in our default, that - // would produce APKs signed by a different identity than the - // user picked, breaking on-device updates without explanation). - // - Otherwise → use the shared MorpheData default keystore. The - // patcher library creates it on first sign if missing, reused - // every patch session so all Morphe-patched apps share one - // signing identity. - val userKeystore = appConfig.resolvedKeystorePath() - if (userKeystore != null && !userKeystore.exists()) { - val rawMsg = "Keystore file not found at ${userKeystore.absolutePath}" - val uiMsg = getString(Res.string.settings_signing_error_not_found, userKeystore.absolutePath) - addLog(rawMsg, LogLevel.ERROR) - _uiState.value = _uiState.value.copy(status = PatchingStatus.FAILED, error = uiMsg) - Logger.error("Patching aborted: $rawMsg") - return@launch - } - val resolvedKeystorePath = (userKeystore ?: MorpheData.defaultKeystoreFile).absolutePath - - // Attach a custom handler to capture standard patcher logs for parsing - val logHandler = PatcherLogInterceptor.attach { message -> - launch(Dispatchers.Main) { - parseAndAddLog(message) - } + // Keystore: pass user-configured keystore if present, or null to let PatchEngine + // use the shared MorpheData default keystore. + val userKeystoreDetails = engineConfigRepository.loadConfig().toKeyStoreDetails() + val engineConfig = if (config.keystoreDetails == null && userKeystoreDetails != null) { + config.copy(keystoreDetails = userKeystoreDetails) + } else { + config } val result = try { - patchService.patch( - patchesFilePaths = config.patchesFilePaths, - inputApkPath = config.inputApkPath, - outputApkPath = config.outputApkPath, - enabledPatches = config.enabledPatches, - disabledPatches = config.disabledPatches, - options = config.patchOptions, - exclusiveMode = config.useExclusiveMode, - keepArchitectures = config.keepArchitectures, - continueOnError = config.continueOnError, - keystorePath = resolvedKeystorePath, - keystorePassword = appConfig.keystorePassword, - keystoreAlias = appConfig.keystoreAlias, - keystoreEntryPassword = appConfig.keystoreEntryPassword, - onProgress = { message -> - parseAndAddLog(message) - } - ) + runCatching { + PatchEngine.patch( + config = engineConfig, + onProgress = { message -> + val (cleanMessage, level) = when { + message.startsWith("ERROR: ", ignoreCase = true) -> message.substring(7) to LogLevel.ERROR + message.startsWith("WARNING: ", ignoreCase = true) -> message.substring(9) to LogLevel.WARNING + message.startsWith("ERROR:", ignoreCase = true) -> message.substring(6).trimStart() to LogLevel.ERROR + message.startsWith("WARNING:", ignoreCase = true) -> message.substring(8).trimStart() to LogLevel.WARNING + else -> message to LogLevel.INFO + } + when (level) { + LogLevel.ERROR -> Logger.error(cleanMessage) + LogLevel.WARNING -> Logger.warn(cleanMessage) + LogLevel.INFO -> Logger.info(cleanMessage) + } + viewModelScope.launch(Dispatchers.Main) { + parseAndAddLog(message) + } + } + ) + } } finally { - PatcherLogInterceptor.detach(logHandler) memoryJob.cancel() } result.fold( - onSuccess = { patchResult -> - if (patchResult.success) { - // Distinguish clean success from "continue-on-error" partial success: - // the APK was built, but some patches were skipped. Log the skipped - // ones as a warning so the user sees what didn't apply. - + onSuccess = { engineResult -> + if (engineResult.success) { val elapsedMs = System.currentTimeMillis() - startTime - val outputApkFile = File(config.outputApkPath) + val outputApkFile = config.outputApk ?: File(engineResult.outputPath) val outSizeMb = if (outputApkFile.exists()) FormatUtils.formatFileSize(outputApkFile.length(), locale) else "?" _uiState.value = _uiState.value.copy( status = PatchingStatus.COMPLETED, - outputPath = config.outputApkPath, + outputPath = engineResult.outputPath, progress = 1f, outputSizeMb = outSizeMb, elapsedSec = formatElapsed(elapsedMs) ) - Logger.info("Patching completed: ${config.outputApkPath}") - recordPatchedApp(patchResult) + Logger.info("Patching completed: ${engineResult.outputPath}") } else { - val reason = patchResult.failureDetail - ?: patchResult.getLocalizedFailureReason() - ?: if (patchResult.failedPatches.isNotEmpty()) - getString(Res.string.patching_error_failed_patches, patchResult.failedPatches.joinToString(", ")) + val reason = engineResult.failureDetail + ?: engineResult.stepResults.lastOrNull { !it.success && it.error != null }?.let { + val stepDisplay = it.step.name.lowercase().replaceFirstChar { c -> c.uppercase() } + getString(Res.string.error_step_failed, stepDisplay, it.error ?: "") + } + ?: if (engineResult.failedPatches.isNotEmpty()) + getString(Res.string.patching_error_failed_patches, engineResult.failedPatches.joinToString(", ") { it.name }) else getString(Res.string.error_patching_unknown) - addLog("Patching failed: ${patchResult.failureReason ?: "Unknown reason"}", LogLevel.ERROR) + addLog("Patching failed: ${engineResult.failureReason ?: "Unknown reason"}", LogLevel.ERROR) _uiState.value = _uiState.value.copy( status = PatchingStatus.FAILED, error = reason, @@ -242,48 +219,6 @@ class PatchingViewModel( } } - /** - * Record this patch in the shared patched-app history (see [PatchedAppStore]). - * Best-effort: a history-write failure must never disrupt the success UX. - */ - private suspend fun recordPatchedApp(patchResult: PatchResult) { - try { - val pkg = config.packageName.ifEmpty { patchResult.packageName } - if (pkg.isEmpty()) return // nothing useful to key on - val (sha, size) = withContext(Dispatchers.IO) { - FileChecksum.fingerprintOrNull(config.outputApkPath) - } - // Read the output APK's manifest once: post-rename package (for device - // matching) + versionName (fallback so the APK version number always shows). - val manifest = withContext(Dispatchers.IO) { - runCatching { ApkManifestReader.read(File(config.outputApkPath)) }.getOrNull() - } - patchedAppStore.upsert( - PatchedAppRecord( - packageName = pkg, - currentPackageName = manifest?.packageName, - displayName = config.appDisplayName.ifEmpty { pkg }, - // Prefer the manifest's versionName (e.g. "21.20.400"). The patch - // result's packageVersion can be the numeric versionCode, which breaks - // version comparisons for update detection. - apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: patchResult.packageVersion, - apkVersionCode = manifest?.versionCode, - inputApkPath = config.inputApkPath, - outputApkPath = config.outputApkPath, - outputApkSha256 = sha, - outputApkSize = size, - patchSelectionByBundle = config.patchSelectionByBundle, - patchOptionValues = config.patchOptions, - sourcesSnapshot = config.sourcesSnapshot, - patchedAt = System.currentTimeMillis(), - patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", - ) - ) - } catch (e: Exception) { - Logger.error("Failed to record patched app", e) - } - } - private fun addLog(message: String, level: LogLevel) { val entry = LogEntry(message, level) _uiState.update { it.copy( @@ -292,18 +227,22 @@ class PatchingViewModel( } private fun parseAndAddLog(line: String) { - val level = when { - line.contains("error", ignoreCase = true) -> LogLevel.ERROR - line.contains("warning", ignoreCase = true) -> LogLevel.WARNING + val (cleanMessage, level) = when { + line.startsWith("ERROR: ", ignoreCase = true) -> line.substring(7) to LogLevel.ERROR + line.startsWith("WARNING: ", ignoreCase = true) -> line.substring(9) to LogLevel.WARNING + line.startsWith("ERROR:", ignoreCase = true) -> line.substring(6).trimStart() to LogLevel.ERROR + line.startsWith("WARNING:", ignoreCase = true) -> line.substring(8).trimStart() to LogLevel.WARNING + line.contains("error", ignoreCase = true) -> line to LogLevel.ERROR + line.contains("warning", ignoreCase = true) -> line to LogLevel.WARNING line.contains("success", ignoreCase = true) || line.contains("completed", ignoreCase = true) || line.contains("done", ignoreCase = true) || line.contains("patching", ignoreCase = true) || - line.contains("applying", ignoreCase = true) -> LogLevel.INFO - else -> LogLevel.INFO + line.contains("applying", ignoreCase = true) -> line to LogLevel.INFO + else -> line to LogLevel.INFO } - if (!line.startsWith("FAILED: ", ignoreCase = true)) { - addLog(line, level) + if (!cleanMessage.startsWith("FAILED: ", ignoreCase = true)) { + addLog(cleanMessage, level) } // Extract progress information using State Machine @@ -311,7 +250,7 @@ class PatchingViewModel( val total = if (_uiState.value.totalPatches > 0) _uiState.value.totalPatches else config.enabledPatches.size stateMachine = PatcherState(total, _uiState.value.isSplit) } - stateMachine?.processLogLine(line) + stateMachine?.processLogLine(cleanMessage) _uiState.update { it.copy( progress = maxOf(it.progress, stateMachine?.currentProgress ?: 0f), @@ -322,7 +261,7 @@ class PatchingViewModel( ) } } - fun getConfig(): PatchConfig = config + fun getConfig(): PatchEngine.Config = config private fun formatElapsed(ms: Long): String { val totalSec = ms / 1000 diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/PatchingFailureContent.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/PatchingFailureContent.kt index 5fab391e..b8f16d53 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/PatchingFailureContent.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/PatchingFailureContent.kt @@ -32,7 +32,8 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.gui.data.model.PatchConfig +import app.morphe.engine.PatchEngine +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.screens.patching.PatchingUiState @@ -41,7 +42,6 @@ import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheMono import app.morphe.gui.ui.theme.panelFill -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.currentLocale @@ -58,7 +58,7 @@ private fun formatFileSize(bytes: Long): String = @Composable internal fun ExpertFailureContent( uiState: PatchingUiState, - config: PatchConfig, + config: PatchEngine.Config, onBackToHome: () -> Unit ) { val corners = LocalMorpheCorners.current @@ -68,8 +68,8 @@ internal fun ExpertFailureContent( val borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) val logFile = remember { Logger.getLogFile() } - val hasTempFiles = remember { FileUtils.hasTempFiles() } - val tempFilesSize = remember { FileUtils.getTempDirSize() } + val hasTempFiles = remember { WorkspaceManager.hasScratchFiles() } + val tempFilesSize = remember { WorkspaceManager.getScratchSize() } var tempFilesCleared by remember { mutableStateOf(false) } var showLogViewer by remember { mutableStateOf(false) } @@ -107,7 +107,7 @@ internal fun ExpertFailureContent( } Spacer(Modifier.height(8.dp)) Text( - text = config.packageName, + text = config.historyMetadata?.originalPackageName ?: config.appDisplayName ?: "", fontSize = 11.sp, fontWeight = FontWeight.Normal, fontFamily = mono, @@ -292,7 +292,7 @@ internal fun ExpertFailureContent( .clip(RoundedCornerShape(corners.small)) .background(cleanBg) .clickable { - FileUtils.cleanupAllTempDirs() + WorkspaceManager.clearScratch() tempFilesCleared = true Logger.info("Cleaned temp files after failed patching") } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 6b44ad06..015d7be6 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import app.morphe.engine.MorpheData +import app.morphe.engine.apk.BundleFormats import app.morphe.gui.LocalBackgroundSpeed import app.morphe.gui.LocalPatchingCompleted import app.morphe.gui.data.repository.PatchSourceManager @@ -29,7 +30,7 @@ import app.morphe.gui.ui.components.SourceSheetMode import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.UpdateBanner import app.morphe.gui.ui.screens.home.components.FullScreenDropZone -import app.morphe.gui.ui.screens.patching.LogFileViewerDialog +import app.morphe.gui.ui.screens.patching.components.LogFileViewerDialog import app.morphe.gui.ui.screens.patching.LogLevel import app.morphe.gui.ui.screens.quick.components.BrandingLogo import app.morphe.gui.ui.screens.quick.components.CompletedContent @@ -365,7 +366,7 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { private suspend fun openFilePicker(title: String): File? = MorpheFilePicker.pickFile( title = title, - extensions = listOf("apk", "apkm", "xapk", "apks"), + extensions = BundleFormats.SUPPORTED_EXTENSIONS, ) @Composable diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index 2c3adabd..1a610958 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -7,21 +7,27 @@ package app.morphe.gui.ui.screens.quick import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.morphe.engine.MorpheData -import app.morphe.engine.PatchedAppStore +import app.morphe.engine.MorpheConstants +import app.morphe.engine.PatchEngine import app.morphe.engine.UpdateChecker import app.morphe.engine.UpdateInfo +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.apk.ApkOutputNaming +import app.morphe.engine.apk.BundleFormats +import app.morphe.engine.config.EngineConfigRepository import app.morphe.engine.model.PatchedAppRecord -import app.morphe.engine.util.ApkManifestReader -import app.morphe.engine.util.ApkOutputNaming -import app.morphe.engine.util.FileChecksum +import app.morphe.engine.patches.PatchRepository +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.model.PatchMetadata +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.model.VersionResolution +import app.morphe.engine.model.VersionStatus +import app.morphe.engine.patches.SupportedAppCatalog +import app.morphe.engine.patches.resolveVersionStatus import app.morphe.gui.data.constants.AppConstants -import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchSource -import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ActiveMode import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.SeenPatchesRepository import app.morphe.gui.data.repository.UpdateCheckRepository @@ -29,19 +35,12 @@ import app.morphe.gui.ui.screens.patching.LogEntry import app.morphe.gui.ui.screens.patching.LogLevel import app.morphe.gui.util.ChecksumStatus import app.morphe.gui.util.EnabledSourcesLoader -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.Logger -import app.morphe.gui.util.PatchResult -import app.morphe.gui.util.PatchService -import app.morphe.gui.util.PatcherLogInterceptor import app.morphe.gui.util.PatcherState -import app.morphe.gui.util.SupportedAppExtractor -import app.morphe.gui.util.VersionResolution -import app.morphe.gui.util.VersionStatus import app.morphe.gui.util.humanizePatchLoadError -import app.morphe.gui.util.resolveVersionStatus import app.morphe.morphe_desktop.generated.resources.* +import app.morphe.patcher.apk.ApkUtils import java.io.File import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CancellationException @@ -63,11 +62,10 @@ import org.jetbrains.compose.resources.getString */ class QuickPatchViewModel( private val patchSourceManager: PatchSourceManager, - private val patchService: PatchService, private val configRepository: ConfigRepository, private val updateCheckRepository: UpdateCheckRepository, - private val patchedAppStore: PatchedAppStore = PatchedAppStore.shared, private val seenPatchesRepository: SeenPatchesRepository = SeenPatchesRepository(), + private val engineConfigRepository: EngineConfigRepository = EngineConfigRepository.shared, ) : ViewModel() { private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() @@ -82,7 +80,7 @@ class QuickPatchViewModel( private var isSplitApk = false // Cached dynamic data from patches - private var cachedPatches: List = emptyList() + private var cachedPatches: List = emptyList() private var cachedSupportedApps = emptyList() private var stateMachine: PatcherState? = null private var cachedPatchesFile: File? = null @@ -215,19 +213,23 @@ class QuickPatchViewModel( val result = EnabledSourcesLoader.loadAll( listOf(pair), - patchService, - excludedMppPatterns = configRepository.loadConfig().excludedMppPatterns, + excludedMppPatterns = engineConfigRepository.loadConfig().excludedMppPatterns, ) if (!result.anyLoaded) { val firstThrowable = result.loaded.perSource.firstNotNullOfOrNull { it.error } val technicalError = firstThrowable?.message ?: result.resolved.firstNotNullOfOrNull { it.error } - ?: "Failed to load any patches" + val logMessage = when { + technicalError.isNullOrBlank() -> "Quick mode: Failed to load any patches" + technicalError.startsWith("Quick mode:", ignoreCase = true) -> technicalError + technicalError.startsWith("Failed to load", ignoreCase = true) -> "Quick mode: $technicalError" + else -> "Quick mode: Failed to load any patches: $technicalError" + } if (firstThrowable != null) { - Logger.error("Quick mode: Failed to load any patches: $technicalError", firstThrowable) + Logger.error(logMessage, firstThrowable) } else { - Logger.warn("Quick mode: Failed to load any patches: $technicalError") + Logger.warn(logMessage) } val firstError = result.resolved.firstNotNullOfOrNull { it.getUserErrorMessage() } ?: firstThrowable?.let { humanizePatchLoadError(it) } @@ -247,8 +249,8 @@ class QuickPatchViewModel( return@launch } - val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) - cachedPatches = result.unionGuiPatches + val supportedApps = SupportedAppCatalog.extractSupportedAppsFromMetadata(result.unionPatches) + cachedPatches = result.unionPatches cachedSupportedApps = supportedApps val firstResolved = result.resolved.firstOrNull { it.patchFile != null } cachedPatchesFile = firstResolved?.patchFile @@ -312,7 +314,7 @@ class QuickPatchViewModel( * Handle file drop or selection. */ fun onFilesDropped(files: List) { - val apkFile = files.firstOrNull { FileUtils.isApkFile(it) } + val apkFile = files.firstOrNull { BundleFormats.isApkOrBundle(it) } if (apkFile != null) { onFileSelected(apkFile) } else { @@ -353,28 +355,19 @@ class QuickPatchViewModel( * Analyze the APK file using dynamic data from patches. */ private suspend fun analyzeApk(file: File): QuickApkInfo? = withContext(Dispatchers.IO) { - if (!file.exists() || !FileUtils.isApkFile(file)) { + if (!file.exists() || !BundleFormats.isApkOrBundle(file)) { _uiState.value = _uiState.value.copy(error = getString(Res.string.error_drop_valid_apk)) return@withContext null } - // For split APK bundles (.apkm, .xapk, .apks), extract base.apk first - val isBundleFormat = FileUtils.isBundleFormat(file) - val apkToParse = if (isBundleFormat) { - FileUtils.extractBaseApkFromBundle(file) ?: run { - _uiState.value = _uiState.value.copy(error = getString(Res.string.quick_patch_extract_base_apk_failed)) - return@withContext null - } - } else { - file + val inspection = ApkInspector.inspect(file) ?: run { + _uiState.value = _uiState.value.copy(error = getString(Res.string.quick_patch_analyze_apk_failed)) + return@withContext null } try { - val manifest = ApkManifestReader.read(apkToParse) - ?: throw IllegalStateException("ARSCLib couldn't read manifest") - - val packageName = manifest.packageName - val versionName = manifest.versionName ?: getString(Res.string.unknown) + val packageName = inspection.packageName + val versionName = inspection.versionName ?: getString(Res.string.unknown) // Check if supported using dynamic data val dynamicAppInfo = cachedSupportedApps.find { it.packageName == packageName } @@ -382,15 +375,15 @@ class QuickPatchViewModel( if (dynamicAppInfo == null) { // Fallback to hardcoded check if patches not loaded yet val supportedPackages = if (cachedSupportedApps.isEmpty()) { - AppConstants.FALLBACK_PACKAGES + MorpheConstants.FALLBACK_PACKAGES } else { cachedSupportedApps.map { it.packageName } } if (packageName !in supportedPackages) { - val appName = SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) + val appName = SupportedApp.resolveDisplayName(packageName, inspection.applicationLabel) val supportedNames = cachedSupportedApps.map { it.displayName } - .ifEmpty { AppConstants.FALLBACK_PACKAGES.map(SupportedApp::getDisplayName) } + .ifEmpty { MorpheConstants.FALLBACK_PACKAGES.map(SupportedApp::getDisplayName) } .joinToString(", ") _uiState.value = _uiState.value.copy( error = getString(Res.string.quick_patch_unsupported_app_error, appName, supportedNames), @@ -403,7 +396,7 @@ class QuickPatchViewModel( // Get display name and recommended version from dynamic data, fallback to constants val displayName = dynamicAppInfo?.displayName - ?: SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) + ?: SupportedApp.resolveDisplayName(packageName, inspection.applicationLabel) val useExperimental = patchSourceManager.getActiveSource().useExperimentalVersions val hasExperimental = dynamicAppInfo?.experimentalVersions?.isNotEmpty() == true @@ -417,7 +410,7 @@ class QuickPatchViewModel( // Resolve version status against the supported app's stable + // experimental version lists. val versionResolution = if (dynamicAppInfo != null) { - resolveVersionStatus(versionName, dynamicAppInfo, manifest.versionCode) + resolveVersionStatus(versionName, dynamicAppInfo, inspection.versionCode) } else { VersionResolution(VersionStatus.UNKNOWN, null) } @@ -449,16 +442,16 @@ class QuickPatchViewModel( // TODO: Re-enable when checksums are provided via .mpp files val checksumStatus = ChecksumStatus.NotConfigured - val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) - val minSdk = manifest.minSdkVersion + val architectures = inspection.architectures.toList() + val minSdk = inspection.minSdkVersion - Logger.info("Quick mode: Analyzed $displayName v${manifest.versionName ?: "unknown"} (recommended: $recommendedVersion, status: $versionStatus, archs: $architectures)") + Logger.info("Quick mode: Analyzed $displayName v${inspection.versionName ?: "unknown"} (recommended: $recommendedVersion, status: $versionStatus, archs: $architectures)") QuickApkInfo( fileName = file.name, packageName = packageName, versionName = versionName, - versionCode = manifest.versionCode, + versionCode = inspection.versionCode, fileSize = file.length(), displayName = displayName, recommendedVersion = recommendedVersion, @@ -470,12 +463,12 @@ class QuickPatchViewModel( architectures = architectures, minSdk = minSdk ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.error("Quick mode: Failed to analyze APK", e) _uiState.value = _uiState.value.copy(error = getString(Res.string.quick_patch_read_apk_failed, e.message ?: "")) null - } finally { - if (isBundleFormat) apkToParse.delete() } } @@ -547,70 +540,92 @@ class QuickPatchViewModel( // Generate output path via the shared engine helper, the same path // the CLI and Expert mode compute. Passing apkInfo.displayName // as the display name preserves the friendly label. - val appConfig = configRepository.loadConfig() + val engineConfigData = engineConfigRepository.loadConfig() val outputPath = ApkOutputNaming.outputApkPath( inputApk = apkFile, patchesFile = patchFile, - baseOutputDir = appConfig.resolvedDefaultOutputDirectory(), + baseOutputDir = engineConfigData.resolvedDefaultOutputDirectory(), appDisplayName = apkInfo.displayName, appVersion = apkInfo.versionName, ).absolutePath - // Resolve keystore. See PatchingViewModel for the full rationale. - // User-configured: use it, and fail loudly if missing. - // Default: shared MorpheData keystore, auto-created on first sign. - val userKeystore = appConfig.resolvedKeystorePath() - if (userKeystore != null && !userKeystore.exists()) { - val rawMsg = "Keystore file not found at ${userKeystore.absolutePath}" - val uiMsg = getString(Res.string.settings_dialog_error_keystore_not_found, userKeystore.absolutePath) - _uiState.value = _uiState.value.copy(phase = QuickPatchPhase.READY, error = uiMsg) - Logger.error("Quick patching aborted: $rawMsg") - return@launch - } - val resolvedKeystorePath = (userKeystore ?: MorpheData.defaultKeystoreFile).absolutePath - - // Intercept internal library logs to drive the Rebuild phase progress bar - val logHandler = PatcherLogInterceptor.attach { message -> - val level = when { - message.startsWith("ERROR:") -> LogLevel.ERROR - message.startsWith("WARNING:") -> LogLevel.WARNING - else -> LogLevel.INFO + // Keystore: pass user-configured keystore if present, or null to let PatchEngine + // use the shared MorpheData default keystore. + val keystoreDetails = engineConfigData.toKeyStoreDetails() + + // Resolve sources snapshot for history recording + val resolvedSources = cachedSourcesResult?.resolved?.filter { it.patchFile != null } + val sources = if (!resolvedSources.isNullOrEmpty()) { + resolvedSources.map { r -> + PatchedAppRecord.PatchedSourceSnapshot( + sourceId = r.source.id, + sourceName = r.source.name, + version = r.resolvedVersion + ?: r.patchFile?.name?.let { ApkOutputNaming.extractPatchesVersion(it) } + ?: "unknown", + ) + } + } else { + currentResolvedPatchFiles().map { f -> + PatchedAppRecord.PatchedSourceSnapshot( + sourceId = f.nameWithoutExtension, + sourceName = f.nameWithoutExtension, + version = ApkOutputNaming.extractPatchesVersion(f.name) ?: "unknown", + ) } - val entry = LogEntry(message, level) - _uiState.value = _uiState.value.copy(logs = _uiState.value.logs + entry) - parseProgress(message) } - val patchResult = try { - // exclusiveMode = false means the library's patch.use field determines defaults - patchService.patch( - patchesFilePaths = currentResolvedPatchFiles().map { it.absolutePath }, - inputApkPath = apkFile.absolutePath, - outputApkPath = outputPath, - enabledPatches = emptyList(), - disabledPatches = emptyList(), - options = emptyMap(), - exclusiveMode = false, - keystorePath = resolvedKeystorePath, - keystorePassword = appConfig.keystorePassword, - keystoreAlias = appConfig.keystoreAlias, - keystoreEntryPassword = appConfig.keystoreEntryPassword, + val engineConfig = PatchEngine.Config( + inputApk = apkFile, + outputApk = File(outputPath), + patchFiles = currentResolvedPatchFiles(), + enabledPatches = emptySet(), + disabledPatches = emptySet(), + exclusiveMode = false, + forceCompatibility = true, + failOnError = true, + disablePurge = !engineConfigData.autoCleanupTempFiles, + keystoreDetails = keystoreDetails, + recordHistory = true, + appDisplayName = apkInfo.displayName, + historyMetadata = PatchEngine.HistoryMetadata( + originalPackageName = apkInfo.packageName, + displayName = apkInfo.displayName, + sourcesSnapshot = sources, + ), + ) + + val patchResult = runCatching { + PatchEngine.patch( + config = engineConfig, onProgress = { message -> - val entry = LogEntry(message, LogLevel.INFO) - _uiState.value = _uiState.value.copy( - statusMessage = message.take(60), - logs = _uiState.value.logs + entry - ) - parseProgress(message) + val (cleanMessage, level) = when { + message.startsWith("ERROR: ", ignoreCase = true) -> message.substring(7) to LogLevel.ERROR + message.startsWith("WARNING: ", ignoreCase = true) -> message.substring(9) to LogLevel.WARNING + message.startsWith("ERROR:", ignoreCase = true) -> message.substring(6).trimStart() to LogLevel.ERROR + message.startsWith("WARNING:", ignoreCase = true) -> message.substring(8).trimStart() to LogLevel.WARNING + else -> message to LogLevel.INFO + } + when (level) { + LogLevel.ERROR -> Logger.error(cleanMessage) + LogLevel.WARNING -> Logger.warn(cleanMessage) + LogLevel.INFO -> Logger.info(cleanMessage) + } + viewModelScope.launch(Dispatchers.Main) { + val entry = LogEntry(cleanMessage, level) + _uiState.value = _uiState.value.copy( + statusMessage = cleanMessage.take(60), + logs = _uiState.value.logs + entry + ) + parseProgress(cleanMessage) + } } ) - } finally { - PatcherLogInterceptor.detach(logHandler) } patchResult.fold( - onSuccess = { result -> - if (result.success) { + onSuccess = { engineResult -> + if (engineResult.success) { // Force 100% progress immediately upon engine success _uiState.value = _uiState.value.copy( progress = 1.0f, @@ -627,11 +642,15 @@ class QuickPatchViewModel( progress = 1f, statusMessage = "" ) - Logger.info("Quick mode: Patching completed - $outputPath (${result.appliedPatches.size} patches)") - recordPatchedApp(result, apkFile.absolutePath, outputPath, apkInfo.displayName) + Logger.info("Quick mode: Patching completed - $outputPath (${engineResult.appliedPatches.size} patches)") recordSeenPatches(apkInfo.packageName) } else { - val errorMsg = result.failureDetail ?: result.getLocalizedFailureReason() + val errorMsg = engineResult.failureDetail + ?: engineResult.stepResults.lastOrNull { !it.success && it.error != null }?.let { + val stepDisplay = it.step.name.lowercase().replaceFirstChar { c -> c.uppercase() } + getString(Res.string.error_step_failed, stepDisplay, it.error ?: "") + } + ?: engineResult.failureReason ?: getString(Res.string.error_patching_unknown) _uiState.value = _uiState.value.copy( phase = QuickPatchPhase.ERROR, @@ -652,11 +671,6 @@ class QuickPatchViewModel( } } - /** - * Record this quick-mode patch in the shared patched-app history. - * Best-effort: a write failure must never disrupt the success UX. Quick mode - * uses the default patch set, so no per-bundle selection is captured. - */ /** * Snapshot what each source offered for this app, so expert mode can tell a * genuinely new patch from one the user has already seen. @@ -665,73 +679,13 @@ class QuickPatchViewModel( val sources = cachedSourcesResult?.resolved ?: return sources.forEach { resolved -> val path = resolved.patchFile?.absolutePath ?: return@forEach - val names = patchService.listPatches(path, packageName).getOrNull() + val names = runCatching { SupportedAppCatalog.loadPatches(File(path), packageName) }.getOrNull() ?.mapTo(mutableSetOf()) { it.name } ?: return@forEach seenPatchesRepository.save(packageName, resolved.source.name, names) } } - private suspend fun recordPatchedApp( - result: PatchResult, - inputApkPath: String, - outputApkPath: String, - displayName: String, - ) { - try { - val pkg = result.packageName - if (pkg.isEmpty()) return - val (sha, size) = withContext(Dispatchers.IO) { - FileChecksum.fingerprintOrNull(outputApkPath) - } - val manifest = withContext(Dispatchers.IO) { - runCatching { ApkManifestReader.read(File(outputApkPath)) }.getOrNull() - } - // Must be the configured source name: every update lookup keys off it, - // and a bundle filename never matches one. - val resolvedSources = cachedSourcesResult?.resolved?.filter { it.patchFile != null } - val sources = if (!resolvedSources.isNullOrEmpty()) { - resolvedSources.map { r -> - PatchedAppRecord.PatchedSourceSnapshot( - sourceId = r.source.id, - sourceName = r.source.name, - version = r.resolvedVersion - ?: r.patchFile?.name?.let { ApkOutputNaming.extractPatchesVersion(it) } - ?: "unknown", - ) - } - } else { - currentResolvedPatchFiles().map { f -> - PatchedAppRecord.PatchedSourceSnapshot( - sourceId = f.nameWithoutExtension, - sourceName = f.nameWithoutExtension, - version = ApkOutputNaming.extractPatchesVersion(f.name) ?: "unknown", - ) - } - } - patchedAppStore.upsert( - PatchedAppRecord( - packageName = pkg, - currentPackageName = manifest?.packageName, - displayName = displayName.ifEmpty { pkg }, - // Prefer the manifest's versionName (e.g. "21.20.400") over the numeric - // versionCode so update-detection version comparisons work. - apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: result.packageVersion, - apkVersionCode = manifest?.versionCode, - inputApkPath = inputApkPath, - outputApkPath = outputApkPath, - outputApkSha256 = sha, - outputApkSize = size, - sourcesSnapshot = sources, - patchedAt = System.currentTimeMillis(), - patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", - ) - ) - } catch (e: Exception) { - Logger.error("Failed to record patched app (quick mode)", e) - } - } - /** * Parse progress from patcher logs. * Synchronized because internal library logs can arrive from multiple parallel threads. @@ -874,12 +828,12 @@ data class QuickPatchUiState( val isLoadingPatches: Boolean = true, val supportedApps: List = emptyList(), val patchesVersion: String? = null, - val patchesChannel: EnabledSourcesLoader.Channel? = null, + val patchesChannel: PatchResolver.Channel? = null, val patchSourceName: String? = null, val patchLoadError: String? = null, val isOffline: Boolean = false, // Compatible patches for the loaded APK - val compatiblePatches: List = emptyList(), + val compatiblePatches: List = emptyList(), val updateInfo: UpdateInfo? = null, val dismissedUpdateVersion: String? = null, val updateBannerSessionDismissed: Boolean = false, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/CompletedContent.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/CompletedContent.kt index c93cdfed..effd4aec 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/CompletedContent.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/CompletedContent.kt @@ -7,9 +7,6 @@ package app.morphe.gui.ui.screens.quick.components import androidx.compose.animation.* import androidx.compose.animation.core.tween -import app.morphe.gui.ui.components.MorpheActionButton -import app.morphe.gui.ui.components.handCursor -import app.morphe.gui.ui.components.morpheScrollbarStyle import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter @@ -31,16 +28,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.engine.PatchedAppStore +import app.morphe.engine.util.AdbException +import app.morphe.engine.util.AdbManager +import app.morphe.engine.config.EngineConfigRepository import app.morphe.gui.LocalAdbPreference -import app.morphe.gui.data.model.Patch -import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.ui.components.MorpheActionButton +import app.morphe.gui.ui.components.handCursor +import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.screens.quick.QuickApkInfo import app.morphe.gui.ui.screens.quick.formatFileSize import app.morphe.gui.ui.theme.* -import app.morphe.gui.util.AdbException -import app.morphe.gui.util.AdbManager import app.morphe.gui.util.DeviceMonitor +import app.morphe.gui.util.getUserMessage import app.morphe.morphe_desktop.generated.resources.* import java.awt.Desktop import java.io.File @@ -65,7 +65,7 @@ internal fun CompletedContent( val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } - val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() val monitorState by DeviceMonitor.state.collectAsState() val adbPreference = LocalAdbPreference.current val isAdbDisabledByUser = !adbPreference.enabled @@ -294,12 +294,12 @@ internal fun CompletedContent( installError = null val result = adbManager.installApk( apkPath = outputPath, - deviceId = device.id + deviceId = device.id, ) result.fold( onSuccess = { installSuccess = true - val config = configRepository.loadConfig() + val config = engineConfigRepository.loadConfig() if (config.autoRouteLinksAfterInstall) { val record = PatchedAppStore.shared.getAll() .firstOrNull { it.outputApkPath == outputPath } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/QuickBranding.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/QuickBranding.kt index ba0ba38c..d422e4b5 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/QuickBranding.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/QuickBranding.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.patches.PatchResolver.Channel import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.theme.* import app.morphe.gui.util.EnabledSourcesLoader @@ -49,7 +50,7 @@ internal fun PatchesVersionBadge( patchesVersion: String?, isLoading: Boolean, patchSourceName: String? = null, - patchesChannel: EnabledSourcesLoader.Channel? = null, + patchesChannel: Channel? = null, onClick: (() -> Unit)? = null, ) { val font = LocalMorpheFont.current @@ -124,8 +125,8 @@ internal fun PatchesVersionBadge( color = accents.primary ) val latestLabel = when (patchesChannel) { - EnabledSourcesLoader.Channel.STABLE_LATEST -> stringResource(Res.string.version_label_latest_stable) - EnabledSourcesLoader.Channel.DEV_LATEST -> stringResource(Res.string.version_label_latest_dev) + Channel.STABLE_LATEST -> stringResource(Res.string.version_label_latest_stable) + Channel.DEV_LATEST -> stringResource(Res.string.version_label_latest_dev) else -> null } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/ReadyContent.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/ReadyContent.kt index acc7c0fb..915b7d4b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/ReadyContent.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/ReadyContent.kt @@ -8,8 +8,6 @@ package app.morphe.gui.ui.screens.quick.components import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import app.morphe.gui.ui.components.handCursor -import app.morphe.gui.ui.components.morpheScrollbarStyle import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter @@ -38,7 +36,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.gui.data.model.Patch +import app.morphe.engine.model.PatchMetadata +import app.morphe.engine.model.VersionStatus +import app.morphe.gui.ui.components.handCursor +import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.screens.quick.QuickApkInfo import app.morphe.gui.ui.theme.* @@ -46,7 +47,6 @@ import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.StatusColorType import app.morphe.gui.util.currentLocale -import app.morphe.gui.util.VersionStatus import app.morphe.gui.util.resolveStatusColorType import app.morphe.gui.util.resolveVersionStatusDisplay import app.morphe.gui.util.toColor @@ -61,7 +61,7 @@ import org.jetbrains.compose.resources.stringResource @Composable internal fun ReadyContent( apkInfo: QuickApkInfo, - compatiblePatches: List, + compatiblePatches: List, onPatch: () -> Unit, onClear: () -> Unit ) { diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/SupportedAppsRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/SupportedAppsRow.kt index 8401865d..6f542b9e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/SupportedAppsRow.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/components/SupportedAppsRow.kt @@ -30,14 +30,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.util.DownloadUrlResolver.openUrlAndFollowRedirects import app.morphe.gui.ui.components.LocalCardFills import app.morphe.gui.ui.components.AppCard import app.morphe.gui.ui.components.MorpheCardChip -import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.* -import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index b968cb95..f4abc8a8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -21,25 +21,28 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.engine.PatchedAppStore -import app.morphe.engine.util.ApkManifestReader +import app.morphe.engine.apk.ApkInspector +import app.morphe.engine.config.EngineConfigRepository +import app.morphe.engine.util.AdbException +import app.morphe.engine.util.AdbManager +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.gui.HomeScreenRoute import app.morphe.gui.LocalAdbPreference import app.morphe.gui.LocalNavController -import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.screens.result.components.* import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.screenScrim -import app.morphe.gui.util.AdbException -import app.morphe.gui.util.AdbManager import app.morphe.gui.util.DeviceMonitor -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.FormatUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.currentLocale +import app.morphe.gui.util.getUserMessage +import app.morphe.gui.util.toUserMessage import app.morphe.morphe_desktop.generated.resources.* import java.io.File +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -67,7 +70,7 @@ fun ResultScreenContent(outputPath: String) { val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } - val configRepository: ConfigRepository = koinInject() + val engineConfigRepository: EngineConfigRepository = koinInject() // ADB state from DeviceMonitor val monitorState by DeviceMonitor.state.collectAsState() @@ -84,7 +87,7 @@ fun ResultScreenContent(outputPath: String) { var alreadyInstalled by remember { mutableStateOf(false) } LaunchedEffect(outputPath) { outputPackage = withContext(Dispatchers.IO) { - runCatching { ApkManifestReader.read(outputFile)?.packageName }.getOrNull() + runCatching { ApkInspector.inspect(outputFile)?.packageName }.getOrNull() } } LaunchedEffect(monitorState.selectedDevice?.id, monitorState.selectedDevice?.isReady, outputPackage) { @@ -106,11 +109,15 @@ fun ResultScreenContent(outputPath: String) { var autoRouteLinks by remember { mutableStateOf(false) } LaunchedEffect(outputPath, outputPackage) { stockPackage = withContext(Dispatchers.IO) { - runCatching { + try { val records = PatchedAppStore.shared.getAll() records.firstOrNull { it.outputApkPath == outputPath }?.packageName ?: outputPackage?.let { pkg -> records.firstOrNull { it.installedPackageName == pkg }?.packageName } - }.getOrNull() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } } } @@ -121,15 +128,15 @@ fun ResultScreenContent(outputPath: String) { var autoCleanupEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - val config = configRepository.loadConfig() + val config = engineConfigRepository.loadConfig() autoCleanupEnabled = config.autoCleanupTempFiles autoRouteLinks = config.autoRouteLinksAfterInstall disableStockLinks = config.disableStockLinksAfterInstall - hasTempFiles = FileUtils.hasTempFiles() - tempFilesSize = FileUtils.getTempDirSize() + hasTempFiles = WorkspaceManager.hasScratchFiles() + tempFilesSize = WorkspaceManager.getScratchSize() if (autoCleanupEnabled && hasTempFiles) { - FileUtils.cleanupAllTempDirs() + WorkspaceManager.clearScratch() hasTempFiles = false tempFilesCleared = true Logger.info("Auto-cleaned temp files after successful patching") @@ -143,13 +150,9 @@ fun ResultScreenContent(outputPath: String) { installError = null installProgress = if (alreadyInstalled) getString(Res.string.result_adb_updating_on_device, device.displayName) else getString(Res.string.adb_status_installing, device.displayName) - // Always record a non-Play installer so the Play Store won't clobber - // the patched app with an official update. - val installer = adbManager.resolveSpoofInstaller(device.id) val result = adbManager.installApk( apkPath = outputPath, deviceId = device.id, - installerPackage = installer, onProgress = { installProgress = it } ) @@ -178,7 +181,7 @@ fun ResultScreenContent(outputPath: String) { patchedPackage = patched, stockPackage = if (disableStockLinks) stockPackage else null, enable = enable, - onProgress = { linkProgress = it }, + onProgress = { linkProgress = it.toUserMessage() }, ) result.fold( onSuccess = { outcome -> @@ -303,7 +306,7 @@ fun ResultScreenContent(outputPath: String) { font = font, borderColor = borderColor, onCleanupClick = { - FileUtils.cleanupAllTempDirs() + WorkspaceManager.clearScratch() hasTempFiles = false tempFilesCleared = true Logger.info("Manually cleaned temp files after patching") diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/components/AdbInstallSection.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/components/AdbInstallSection.kt index cf80e1a2..cba15d39 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/components/AdbInstallSection.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/components/AdbInstallSection.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.util.AdbDevice +import app.morphe.engine.util.DeviceStatus import app.morphe.gui.ui.components.MorpheActionButton import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.icons.MorpheIcons @@ -32,8 +34,6 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.MorpheCornerStyle -import app.morphe.gui.util.AdbDevice -import app.morphe.gui.util.DeviceStatus import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/components/LinkHandlingSection.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/components/LinkHandlingSection.kt index e5d2617d..522e90d8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/components/LinkHandlingSection.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/components/LinkHandlingSection.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.gui.data.model.SupportedApp +import app.morphe.engine.model.SupportedApp import app.morphe.gui.ui.components.MorpheActionButton import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.icons.MorpheIcons @@ -38,7 +38,7 @@ import org.jetbrains.compose.resources.stringResource * Route the patched app's web links to it (and optionally stop the stock app * from grabbing them). Shown only once the patched app is installed on a ready * device. The stock-disable checkbox appears only when a rename patch was used - * (a distinct [stockPackage]). On-device, [app.morphe.gui.util.AdbManager.setLinkHandling] still + * (a distinct [stockPackage]). On-device, [app.morphe.engine.util.AdbManager.setLinkHandling] still * verifies the stock app is actually installed before touching it. */ @Composable diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt index cc30f518..7d9b6193 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt @@ -9,7 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color -import app.morphe.gui.util.EnabledSourcesLoader.Channel +import app.morphe.engine.patches.PatchResolver.Channel /** * Semantic color for a patch source's release channel. Single source of truth so diff --git a/src/main/kotlin/app/morphe/gui/util/AdbErrorMessages.kt b/src/main/kotlin/app/morphe/gui/util/AdbErrorMessages.kt new file mode 100644 index 00000000..b1229a72 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/AdbErrorMessages.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.gui.util + +import app.morphe.engine.util.AdbErrorCode +import app.morphe.engine.util.AdbException +import app.morphe.engine.util.LinkHandlingProgress +import app.morphe.morphe_desktop.generated.resources.* +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString + +fun AdbErrorCode.toStringResource(): StringResource = when (this) { + AdbErrorCode.ADB_NOT_FOUND -> Res.string.adb_error_not_found + AdbErrorCode.START_SERVER_FAILED -> Res.string.adb_error_start_server + AdbErrorCode.KILL_SERVER_FAILED -> Res.string.adb_error_kill_server + AdbErrorCode.GET_DEVICES_FAILED -> Res.string.adb_error_get_devices + AdbErrorCode.APK_NOT_FOUND -> Res.string.adb_error_apk_not_found + AdbErrorCode.UNAUTHORIZED_DEVICE -> Res.string.adb_error_unauthorized + AdbErrorCode.NO_DEVICES -> Res.string.adb_error_no_devices + AdbErrorCode.DEVICE_NOT_FOUND -> Res.string.adb_error_device_not_found + AdbErrorCode.MULTIPLE_DEVICES -> Res.string.adb_error_multiple_devices + AdbErrorCode.INSTALL_DOWNGRADE -> Res.string.adb_error_downgrade + AdbErrorCode.INSTALL_ALREADY_EXISTS -> Res.string.adb_error_already_exists + AdbErrorCode.INSTALL_STORAGE -> Res.string.adb_error_storage + AdbErrorCode.INSTALL_INVALID_APK -> Res.string.adb_error_invalid_apk + AdbErrorCode.INSTALL_NO_CERTIFICATES -> Res.string.adb_error_no_certificates + AdbErrorCode.INSTALL_UPDATE_INCOMPATIBLE -> Res.string.adb_error_update_incompatible + AdbErrorCode.INSTALL_USER_RESTRICTED -> Res.string.adb_error_user_restricted + AdbErrorCode.INSTALL_VERIFICATION_FAILURE -> Res.string.adb_error_verification_failure + AdbErrorCode.INSTALL_GENERIC, + AdbErrorCode.ROOT_MOUNT_FAILED -> Res.string.adb_error_generic + AdbErrorCode.UNINSTALL_FAILED, + AdbErrorCode.ROOT_UNMOUNT_FAILED -> Res.string.adb_error_uninstall + AdbErrorCode.CLEAR_LOGS_FAILED -> Res.string.adb_error_clear_logs + AdbErrorCode.CAPTURE_LOGS_FAILED -> Res.string.adb_error_capture_logs + AdbErrorCode.RUN_COMMAND_FAILED -> Res.string.adb_error_run_command + AdbErrorCode.PM_LIST_PACKAGES_FAILED -> Res.string.adb_error_pm_list_packages +} + +suspend fun AdbException.getUserMessage(): String = + getString(errorCode.toStringResource(), *formatArgs.toTypedArray()) + +suspend fun LinkHandlingProgress.toUserMessage(): String = when (this) { + is LinkHandlingProgress.Patched -> + if (enable) getString(Res.string.adb_status_routing_links, packageName) + else getString(Res.string.adb_status_restoring_links) + is LinkHandlingProgress.Stock -> + if (enable) getString(Res.string.adb_status_disabling_links, packageName) + else getString(Res.string.adb_status_enabling_links, packageName) +} diff --git a/src/main/kotlin/app/morphe/gui/util/ChangelogParser.kt b/src/main/kotlin/app/morphe/gui/util/ChangelogParser.kt index fb515344..e4b1a670 100644 --- a/src/main/kotlin/app/morphe/gui/util/ChangelogParser.kt +++ b/src/main/kotlin/app/morphe/gui/util/ChangelogParser.kt @@ -5,6 +5,9 @@ package app.morphe.gui.util +import app.morphe.engine.util.isNewerVersion +import app.morphe.engine.util.normalizeVersion + data class ChangelogEntry( val version: String, val date: String?, diff --git a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt index ea315147..daa49b1e 100644 --- a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt @@ -5,30 +5,6 @@ package app.morphe.gui.util -import app.morphe.engine.util.FileChecksum -import java.io.File - -/** - * Utility for calculating and verifying file checksums. - */ -object ChecksumUtils { - - /** - * Calculate SHA-256 checksum of a file. - * @return Lowercase hex string of the checksum - */ - fun calculateSha256(file: File): String = FileChecksum.sha256(file) - - /** - * Verify a file's checksum against expected value. - * @return true if checksums match (case-insensitive comparison) - */ - fun verifyChecksum(file: File, expectedChecksum: String): Boolean { - val actualChecksum = calculateSha256(file) - return actualChecksum.equals(expectedChecksum, ignoreCase = true) - } -} - /** * Result of checksum verification. */ diff --git a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt index ed155ee2..6e327f41 100644 --- a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt +++ b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt @@ -5,6 +5,8 @@ package app.morphe.gui.util +import app.morphe.engine.util.AdbDevice +import app.morphe.engine.util.AdbManager import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt index 178d9a0d..706a60a9 100644 --- a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -6,24 +6,21 @@ package app.morphe.gui.util import androidx.compose.runtime.Composable -import app.morphe.engine.GitHubPatMissingException import app.morphe.engine.MultiSourceLoader -import app.morphe.engine.model.Release -import app.morphe.engine.patches.PatchBundleLoader -import app.morphe.engine.patches.PullRequestPatchSource +import app.morphe.engine.model.PatchMetadata +import app.morphe.engine.patches.PatchRepository +import app.morphe.engine.patches.PatchResolver +import app.morphe.engine.patches.SupportedAppCatalog.toMetadata import app.morphe.gui.data.model.FollowMode -import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.data.model.SourceVersionPref -import app.morphe.gui.data.repository.PatchRepository import app.morphe.morphe_desktop.generated.resources.* import java.io.File import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource @@ -32,40 +29,19 @@ import org.jetbrains.compose.resources.stringResource /** * GUI-side orchestrator that resolves each enabled patch source to a downloaded - * `.mpp` file (LOCAL = read filePath, GITHUB = fetch latest release + download) - * in parallel, then hands the resulting files to [MultiSourceLoader] for the - * actual patch loading + union. - * - * The single-source case (one enabled source) produces output equivalent to the - * pre-multi-source per-ViewModel flow. Per-source version pinning via - * [preferredVersionsBySource] keeps each source independent. Picking a tag in - * one source's PatchesScreen does NOT contaminate other sources. + * `.mpp` file in parallel via [PatchResolver], then hands the resulting files to + * [MultiSourceLoader] for patch loading + union. */ object EnabledSourcesLoader { - /** - * Per-source resolution result before patch-loading. Successful sources have - * a [patchFile]. Failed ones have an [error] message and the UI can render - * the failure inline. - */ - /** What channel the resolved release is on. Used by the home pill LEDs and - * the sheet's channel badge so we don't keep re-deriving from tag strings. */ - enum class Channel { STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER, LOCAL, UNKNOWN } - data class ResolvedSource( val source: PatchSource, val patchFile: File? = null, val resolvedVersion: String? = null, - /** - * Newest available release tag in the resolved channel (stable/dev), - * regardless of what's currently downloaded. Lets the UI flag "a newer - * patch file is available" without the user having to select it first. - * Null when unknown (offline / cache fallback). - */ val latestAvailableVersion: String? = null, val isOffline: Boolean = false, val error: String? = null, - val channel: Channel = Channel.UNKNOWN, + val channel: PatchResolver.Channel = PatchResolver.Channel.UNKNOWN, val errorRes: StringResource? = null, val errorArgs: List = emptyList(), ) { @@ -78,10 +54,10 @@ object EnabledSourcesLoader { val resolved: List, /** MultiSourceLoader output across the successfully-resolved sources. */ val loaded: MultiSourceLoader.Result, - /** Union of GUI patches across all sources, for SupportedAppExtractor / UI. */ - val unionGuiPatches: List, - /** GUI patches grouped by sourceId, for badging UI in PatchSelectionScreen. */ - val guiPatchesBySource: Map>, + /** Union of patches across all sources, for SupportedAppCatalog / UI. */ + val unionPatches: List, + /** Patches grouped by sourceId, for badging UI in PatchSelectionScreen. */ + val patchesBySource: Map>, ) { val anyLoaded: Boolean get() = loaded.allPatches.isNotEmpty() } @@ -95,16 +71,10 @@ object EnabledSourcesLoader { */ suspend fun loadAll( enabled: List>, - patchService: PatchService, prefsBySource: Map = emptyMap(), excludedMppPatterns: List = emptyList(), onDownloadProgress: ((String, Float) -> Unit)? = null, ): Result = supervisorScope { - // supervisorScope (not coroutineScope) so a single source's failure - // doesn't cancel the other in-flight resolves. Each async catches its - // own exceptions and returns a failed ResolvedSource. Failures - // become data, not control flow. Cancellation still propagates from - // the caller (e.g. ViewModel cancelling its loadJob). val resolved = enabled.map { (source, repo) -> async(Dispatchers.IO) { try { @@ -136,19 +106,17 @@ object EnabledSourcesLoader { MultiSourceLoader.load(inputs) } - // Convert library patches → GUI patches once. Both the union and per-source - // groupings are derived from this single conversion. - val unionGui = patchService.convertToGuiPatches(loaded.allPatches) - val guiBySource: Map> = + val unionPatches = loaded.allPatches.map { it.toMetadata() } + val patchesBySource: Map> = loaded.perSource.associate { src -> - src.sourceId to patchService.convertToGuiPatches(src.patches) + src.sourceId to src.patches.map { it.toMetadata() } } Result( resolved = resolved, loaded = loaded, - unionGuiPatches = unionGui, - guiPatchesBySource = guiBySource, + unionPatches = unionPatches, + patchesBySource = patchesBySource, ) } @@ -159,289 +127,58 @@ object EnabledSourcesLoader { excludedMppPatterns: List, onDownloadProgress: ((String, Float) -> Unit)? = null, ): ResolvedSource = withContext(Dispatchers.IO) { - when (source.type) { - PatchSourceType.LOCAL -> resolveLocal(source, excludedMppPatterns) - // GitHub / GitLab / built-in default all flow through the same - // remote-fetch path. The PatchRepository instance itself knows - // which API to talk to based on the source's provider type. + val engineResult = when (source.type) { + PatchSourceType.LOCAL -> PatchResolver.resolveLocal(source.filePath, excludedMppPatterns) PatchSourceType.DEFAULT, PatchSourceType.GITHUB, - PatchSourceType.GITLAB -> resolveRemote(source, repo, pref, onDownloadProgress) - } - } - - private fun resolveLocal(source: PatchSource, excludedMppPatterns: List): ResolvedSource { - val path = source.filePath - if (path.isNullOrBlank()) { - return ResolvedSource( - source = source, - error = "Local source path is empty", - errorRes = Res.string.source_error_local_no_path, - ) - } - val target = File(path) - if (!target.exists()) { - return ResolvedSource( - source = source, - error = "Local source file not found: ${target.name}", - errorRes = Res.string.source_error_local_not_found, - errorArgs = listOf(target.name), - ) - } - // Folder source (patch-developer mode): auto-resolve the NEWEST .mpp in the - // directory. This is re-evaluated on every load, so rebuilding a patch is - // picked up on the next (re)load without touching the file picker. - val file = if (target.isDirectory) { - newestMppIn(target, excludedMppPatterns) - ?: return ResolvedSource( - source = source, - error = "No .mpp patch file found in ${target.name}", - errorRes = Res.string.source_error_local_no_mpp, - errorArgs = listOf(target.name), - ) - } else { - target - } - val manifestVersion = PatchBundleLoader.extractVersion(file) - return ResolvedSource( - source = source, - patchFile = file, - resolvedVersion = manifestVersion ?: file.nameWithoutExtension, - isOffline = false, - // A local file has no release channel, so tag it LOCAL rather than letting - // it fall through to the STABLE_LATEST default used for remote sources. - channel = Channel.LOCAL, - ) - } - - /** - * Build-output classifiers a patch build emits *alongside* the real bundle (same - * convention as Maven's `-sources.jar` / `-javadoc.jar`). Never the patch file we - * want, yet often the newest files in the folder. So a naive "newest .mpp" would - * wrongly pick one. Always excluded, on top of any user-configured patterns. - */ - private val DEFAULT_EXCLUDED_MPP_GLOBS = listOf("*-sources.mpp", "*-javadoc.mpp") - - /** - * Newest *loadable* `.mpp` file directly inside [dir] (by last-modified time), or null - * when the folder contains none. Backs developer folder sources so a freshly-built - * patch is picked up without re-selecting the file. Non-`.mpp` files, build-output - * classifiers ([DEFAULT_EXCLUDED_MPP_GLOBS]), and any [extraExcludedPatterns] the user - * configured are ignored. See [toExclusionMatcher] for how a pattern is interpreted. - */ - private fun newestMppIn(dir: File, extraExcludedPatterns: List): File? { - val matchers = (DEFAULT_EXCLUDED_MPP_GLOBS + extraExcludedPatterns) - .mapNotNull { p -> p.trim().takeIf { it.isNotEmpty() }?.let(::toExclusionMatcher) } - return dir.listFiles { f -> - f.isFile && - f.extension.equals("mpp", ignoreCase = true) && - matchers.none { it(f.name) } - }?.maxByOrNull { it.lastModified() } - } - - /** - * Interpret one exclusion pattern into a filename predicate. A pattern with a wildcard - * (`*` or `?`) is a glob matched against the whole name, so `*-sources.mpp` ignores any - * file ending that way. A plain word (no wildcard) is a "contains" match, so `debug` - * ignores every file with "debug" anywhere in its name. Both are case-insensitive. - */ - private fun toExclusionMatcher(pattern: String): (String) -> Boolean { - if ('*' in pattern || '?' in pattern) { - val regex = globToRegex(pattern) - return { name -> regex.matches(name) } - } - return { name -> name.contains(pattern, ignoreCase = true) } - } - - /** Translate a shell-style glob (`*` = any run, `?` = one char) into a case-insensitive [Regex]. */ - private fun globToRegex(glob: String): Regex { - val pattern = buildString { - for (c in glob) when (c) { - '*' -> append(".*") - '?' -> append('.') - '.', '(', ')', '[', ']', '{', '}', '+', '^', '$', '|', '\\' -> { append('\\'); append(c) } - else -> append(c) - } - } - return Regex(pattern, RegexOption.IGNORE_CASE) - } - - private suspend fun resolveRemote( - source: PatchSource, - repo: PatchRepository?, - pref: SourceVersionPref?, - onDownloadProgress: ((String, Float) -> Unit)? = null, - ): ResolvedSource { - if (repo == null) { - return ResolvedSource( - source = source, - error = "No repository configured for source ${source.name}", - errorRes = Res.string.source_error_no_repository, - ) - } - - // Resolve the target release WITHOUT the releases API where possible: - // - Not pinned → latest via the raw patches-bundle.json, with the channel - // coming from PatchSource.usePreRelease. getLatest*Release is manifest-first - // (it only touches the API if the source ships no manifest), so following - // sources cost 0 API calls on startup. - // - PINNED → needs the full release list (API) to locate the exact old tag. - val isPrSource = repo.remoteSource is PullRequestPatchSource - val release: Release? - val latestStableTag: String? - val latestDevTag: String? - - if (pref?.mode == FollowMode.PINNED) { - val releasesResult = repo.fetchReleases() - val releases = releasesResult.getOrNull() - if (releases.isNullOrEmpty()) { - if (isPrSource) { - val ex = releasesResult.exceptionOrNull() - val errorRes = when { - ex is GitHubPatMissingException || ex?.message?.contains("A GitHub PAT is required", ignoreCase = true) == true -> - Res.string.source_error_github_pat_required - ex?.message?.contains("No artifacts found", ignoreCase = true) == true || - ex?.message?.contains("No GitHub Actions run found", ignoreCase = true) == true -> - Res.string.source_error_pr_no_artifact - else -> null + PatchSourceType.GITLAB -> { + if (repo == null) { + PatchResolver.ResolutionResult(error = PatchResolver.Error.NoRepository(source.name)) + } else { + val pinnedTag = if (pref?.mode == FollowMode.PINNED) pref.pinnedTag else null + PatchResolver.resolveRemote( + repo = repo, + usePreRelease = source.usePreRelease, + pinnedTag = pinnedTag, + strictPin = false, + ) { pct -> + onDownloadProgress?.invoke(source.name, pct) } - return ResolvedSource( - source = source, - error = ex?.message ?: "No artifacts found for pull request", - errorRes = errorRes, - ) - } - return offlineOrError(source, repo) - } - val latestStable = releases.firstOrNull { !it.isDevRelease() } - release = releases.find { it.tagName == pref.pinnedTag } ?: latestStable ?: releases.firstOrNull() - latestStableTag = latestStable?.tagName - latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName - } else { - val (stableResult, devResult) = coroutineScope { - val stableAsync = async { repo.getLatestStableRelease() } - val devAsync = async { repo.getLatestDevRelease() } - stableAsync.await() to devAsync.await() - } - val stable = stableResult.getOrNull() - val dev = devResult.getOrNull() - if (isPrSource && dev == null && stable == null) { - val ex = devResult.exceptionOrNull() ?: stableResult.exceptionOrNull() - val errorRes = when { - ex is GitHubPatMissingException || ex?.message?.contains("A GitHub PAT is required", ignoreCase = true) == true -> - Res.string.source_error_github_pat_required - ex?.message?.contains("No artifacts found", ignoreCase = true) == true || - ex?.message?.contains("No GitHub Actions run found", ignoreCase = true) == true -> - Res.string.source_error_pr_no_artifact - else -> null - } - return ResolvedSource( - source = source, - error = ex?.message ?: "No artifacts found for pull request", - errorRes = errorRes, - ) - } - release = if (source.usePreRelease) newerRelease(dev, stable) else (stable ?: dev) - latestStableTag = stable?.tagName - latestDevTag = dev?.tagName - } - - // Manifest + API both unavailable (e.g. offline) → fall back to a cached file. - if (release == null) return offlineOrError(source, repo) - - // Classify where the resolved release sits (for the LED + badge), independent - // of which track it's following. - val channel = when { - release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST - release.isDevRelease() -> Channel.DEV_OLDER - release.tagName == latestStableTag -> Channel.STABLE_LATEST - else -> Channel.STABLE_OLDER - } - - val downloadResult = repo.downloadPatches(release) { pct -> - onDownloadProgress?.invoke(source.name, pct) - } - val patchFile = downloadResult.getOrNull() - ?: run { - val ex = downloadResult.exceptionOrNull() - val errorRes = when { - ex is GitHubPatMissingException || ex?.message?.contains("A GitHub PAT is required", ignoreCase = true) == true -> - Res.string.source_error_github_pat_required - ex?.message?.contains("No artifacts found", ignoreCase = true) == true || - ex?.message?.contains("No GitHub Actions run found", ignoreCase = true) == true -> - Res.string.source_error_pr_no_artifact - else -> null } - return ResolvedSource( - source = source, - error = ex?.message ?: "", - errorRes = errorRes, - ) } - - val manifestVersion = PatchBundleLoader.extractVersion(patchFile) - val resolvedVersion = if (isPrSource) { - manifestVersion ?: release.tagName - } else { - release.tagName } + engineResult.toResolvedSource(source) + } - return ResolvedSource( + private fun PatchResolver.ResolutionResult.toResolvedSource(source: PatchSource): ResolvedSource { + val err = error ?: return ResolvedSource( source = source, patchFile = patchFile, resolvedVersion = resolvedVersion, - latestAvailableVersion = if (isPrSource) resolvedVersion else (if (release.isDevRelease()) latestDevTag else latestStableTag), - isOffline = false, + latestAvailableVersion = latestAvailableVersion, + isOffline = isOffline, channel = channel, ) - } - /** Offline / no-releases fallback: use the newest cached .mpp/.jar for this source. */ - private fun offlineOrError(source: PatchSource, repo: PatchRepository): ResolvedSource { - val cached = findCachedPatchFile(repo) - return if (cached != null) { - val isPrSource = repo.remoteSource is PullRequestPatchSource - val manifestVersion = PatchBundleLoader.extractVersion(cached) - val resolvedVersion = if (isPrSource) { - manifestVersion ?: versionFromFilename(cached) - } else { - versionFromFilename(cached) - } - ResolvedSource( - source = source, - patchFile = cached, - resolvedVersion = resolvedVersion, - latestAvailableVersion = if (isPrSource) resolvedVersion else null, - isOffline = true, - ) - } else { - ResolvedSource( - source = source, - error = "Failed to fetch releases", - errorRes = Res.string.source_error_fetch_releases, - ) + val (res, args) = when (err) { + is PatchResolver.Error.LocalPathEmpty -> Res.string.source_error_local_no_path to emptyList() + is PatchResolver.Error.LocalFileNotFound -> Res.string.source_error_local_not_found to listOf(err.fileName) + is PatchResolver.Error.LocalNoMppInDirectory -> Res.string.source_error_local_no_mpp to listOf(err.dirName) + is PatchResolver.Error.NoRepository -> Res.string.source_error_no_repository to emptyList() + is PatchResolver.Error.GitHubPatRequired -> Res.string.source_error_github_pat_required to emptyList() + is PatchResolver.Error.PrNoArtifact -> Res.string.source_error_pr_no_artifact to emptyList() + is PatchResolver.Error.ReleaseFetchFailed -> Res.string.source_error_fetch_releases to emptyList() + is PatchResolver.Error.PinnedTagNotFound, + is PatchResolver.Error.DownloadFailed -> null to emptyList() } - } - - private fun findCachedPatchFile(repo: PatchRepository): File? { - val cacheDir = repo.getCacheDir() - val prSource = repo.remoteSource as? PullRequestPatchSource - val prPrefix = prSource?.let { "pr-${it.prNumber}-" } - return cacheDir.listFiles { file -> - val ext = file.extension.lowercase() - val isMpp = (ext == "mpp" || ext == "jar") && file.length() > 0 - if (!isMpp) return@listFiles false - if (prPrefix != null) { - file.name.startsWith(prPrefix) - } else { - !file.name.startsWith("pr-") - } - }?.maxByOrNull { it.lastModified() } - } - private fun versionFromFilename(file: File): String { - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(file.nameWithoutExtension) - return match?.value ?: file.nameWithoutExtension + return ResolvedSource( + source = source, + error = err.message, + errorRes = res, + errorArgs = args, + channel = channel, + ) } } @@ -449,18 +186,12 @@ object EnabledSourcesLoader { // SNAPSHOT PROJECTIONS FOR THE SOURCE-MANAGEMENT UI // ============================================================================ -/* - * Both Expert mode and Quick Patch open the same SourceManagementSheet, so these - * projections live here rather than being re-derived per screen. All three - * tolerate a null receiver because a snapshot only exists once a load has run. - */ - /** sourceId to resolved version label (e.g. "v1.27.0-dev.2"). */ fun EnabledSourcesLoader.Result?.sourceVersionMap(): Map = this?.resolved?.associate { it.source.id to it.resolvedVersion } ?: emptyMap() /** sourceId to the channel its resolved release sits on. Drives the sheet badge. */ -fun EnabledSourcesLoader.Result?.sourceChannelMap(): Map = +fun EnabledSourcesLoader.Result?.sourceChannelMap(): Map = this?.resolved?.associate { it.source.id to it.channel } ?: emptyMap() /** diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt deleted file mode 100644 index 9ed8e26a..00000000 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import app.morphe.engine.MorpheData -import app.morphe.engine.util.BundleFormats -import java.io.File -import java.util.zip.ZipFile - -/** - * Platform-agnostic file utilities. - * Handles app directories, temp files, and cross-platform path operations. - * - * Directory paths delegate to [MorpheData] (the engine-level single source of - * truth) so the GUI, CLI, and any future surface all agree on where data - * lives. The previous per-OS app-data folders (`%APPDATA%/morphe-gui`, - * `~/Library/Application Support/morphe-gui`, `~/.config/morphe-gui`) are - * superseded by `MorpheData.root` — see `unified-data-location-plan.md`. - */ -object FileUtils { - - /** - * All modern Android architectures. Obsolete architectures such as Mips are not included. - */ - val ANDROID_ARCHITECTURES = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") - - private val EXTENSION_APK_BUNDLES = BundleFormats.EXTENSIONS - private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" - - /** Returns the unified Morphe data root. Was: per-OS app-data folder. */ - fun getAppDataDir(): File = MorpheData.root - - /** Returns the patches cache directory. */ - fun getPatchesDir(): File = MorpheData.patchesDir - - /** Returns the logs directory. */ - fun getLogsDir(): File = MorpheData.logsDir - - /** Returns the GUI config file path. */ - fun getConfigFile(): File = MorpheData.configFile - - /** Returns the patcher-scratch directory shared with the CLI. */ - // TODO: This points at morphe-data/tmp, but the GUI's actual patching scratch - // goes to the system temp dir (PatchEngine uses Files.createTempDirectory - // when no tempDir is passed, and PatchService never passes one). So the - // cleanup/size helpers below (getTempDirSize, hasTempFiles, cleanupAllTempDirs) - // under-report — they miss the real per-run patching scratch. Either route GUI - // patching through this dir (createPatchingTempDir is currently dead code) or - // point these helpers at the actual system-temp location. Part of the - // unified-data-location cleanup. - fun getTempDir(): File = MorpheData.tmpDir - - /** - * Create a unique temp directory for a patching session. Session-scoped - * timestamp keeps concurrent CLI/GUI patches from stepping on each other - * (see Phase 6 of the unified-data-location plan). - */ - fun createPatchingTempDir(): File { - val timestamp = System.currentTimeMillis() - return File(getTempDir(), "patching-$timestamp").also { it.mkdirs() } - } - - /** - * Clean up a temp directory. - */ - fun cleanupTempDir(dir: File): Boolean { - return try { - dir.exists() && dir.startsWith(getTempDir()) && dir.deleteRecursively() - } catch (e: Exception) { - false - } - } - - /** - * Clean up all temp directories (call on app exit). - */ - fun cleanupAllTempDirs(): Boolean { - return try { - getTempDir().deleteRecursively() - true - } catch (e: Exception) { - false - } - } - - /** - * Get the size of all temp directories. - */ - fun getTempDirSize(): Long { - return try { - getTempDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - } catch (e: Exception) { - 0L - } - } - - /** - * Check if there are any temp files to clean. - */ - fun hasTempFiles(): Boolean { - return try { - val tempDir = getTempDir() - tempDir.exists() && (tempDir.listFiles()?.isNotEmpty() == true) - } catch (e: Exception) { - false - } - } - - /** - * Build a path using the system file separator. - */ - fun buildPath(vararg parts: String): String { - return parts.joinToString(File.separator) - } - - /** - * Get file extension. - */ - fun getExtension(file: File): String { - return file.extension.lowercase() - } - - /** - * Check if file is an APK or split APK bundle (APKM, XAPK, APKS). - */ - fun isApkFile(file: File): Boolean { - val ext = getExtension(file) - return file.isFile && ext in EXTENSION_APK_ANY - } - - /** - * Check if file is a split APK bundle (.apkm, .xapk, or .apks). - */ - fun isBundleFormat(file: File): Boolean { - return file.extension.lowercase() in EXTENSION_APK_BUNDLES - } - - /** - * Extract base.apk from a split APK bundle (.apkm, .xapk, or .apks) to a temp directory. - * For XAPK files, the base APK may not be named "base.apk" — falls back to the - * first non-split .apk entry or the largest by compressed size. - * Returns the extracted base.apk file, or null if extraction fails. - * Caller is responsible for cleaning up the returned temp file. - */ - fun extractBaseApkFromBundle(bundleFile: File): File? { - return try { - ZipFile(bundleFile).use { zip -> - val allEntries = zip.entries().asSequence().toList() - - // Try "base.apk" first (APKM format) - var baseEntry = zip.getEntry("base.apk") - - // For XAPK: find the base APK among all .apk entries. - // Splits are named like "config.arm64_v8a.apk", "split_config.en.apk", etc. - // The base APK is typically the package name (e.g., "com.google.android.youtube.apk"). - if (baseEntry == null) { - val apkEntries = allEntries - .filter { !it.isDirectory && it.name.endsWith(".apk", ignoreCase = true) } - - val splitPatterns = listOf("split_config", "config.", "split_") - baseEntry = apkEntries - .firstOrNull { entry -> - val name = entry.name.substringAfterLast('/').lowercase() - splitPatterns.none { name.startsWith(it) } - } - // Final fallback: largest .apk by compressed size - ?: apkEntries.maxByOrNull { it.compressedSize } - } - - if (baseEntry == null) return null - - val tempFile = File(getTempDir(), "base-${System.currentTimeMillis()}.apk") - zip.getInputStream(baseEntry).use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } - } - tempFile - } - } catch (e: Exception) { - null - } - } - - @Deprecated("Use extractBaseApkFromBundle instead", ReplaceWith("extractBaseApkFromBundle(apkmFile)")) - fun extractBaseApkFromApkm(apkmFile: File): File? = extractBaseApkFromBundle(apkmFile) - - /** - * Extract supported CPU architectures from native libraries in an APK or bundle. - * Scans for lib// directories, and for bundles also detects arch from split APK names. - */ - fun extractArchitectures(file: File): List { - return try { - ZipFile(file).use { zip -> - val archDirs = mutableSetOf() - - // Scan for lib// entries - zip.entries().asSequence() - .map { it.name } - .filter { it.startsWith("lib/") } - .mapNotNull { path -> - val parts = path.split("/") - if (parts.size >= 2) parts[1] else null - } - .forEach { archDirs.add(it) } - - // For bundles: detect arch from split APK names (e.g. split_config.arm64_v8a.apk) - if (archDirs.isEmpty()) { - zip.entries().asSequence() - .map { it.name } - .filter { it.endsWith(".apk") } - .forEach { name -> - val normalized = name.replace("_", "-") - ANDROID_ARCHITECTURES.filter { arch -> normalized.contains(arch) } - .forEach { archDirs.add(it) } - } - } - - archDirs.toList().ifEmpty { listOf("universal") } - } - } catch (e: Exception) { - Logger.warn("Could not extract architectures: ${e.message}") - emptyList() - } - } -} diff --git a/src/main/kotlin/app/morphe/gui/util/Logger.kt b/src/main/kotlin/app/morphe/gui/util/Logger.kt index fb14d290..50dfc8c3 100644 --- a/src/main/kotlin/app/morphe/gui/util/Logger.kt +++ b/src/main/kotlin/app/morphe/gui/util/Logger.kt @@ -6,6 +6,7 @@ package app.morphe.gui.util import app.morphe.engine.MorpheComponents +import app.morphe.engine.MorpheData import app.morphe.gui.data.constants.AppConstants import java.io.File import java.io.PrintWriter @@ -50,7 +51,7 @@ object Logger { // Install JUL bridge early so logs during startup (e.g. MorpheData) are unified installJulBridge() - val logsDir = FileUtils.getLogsDir() + val logsDir = MorpheData.logsDir logFile = File(logsDir, LOG_FILE_NAME) // Trim log file if it's too large (keep only last N lines) @@ -69,7 +70,7 @@ object Logger { info("Java: ${System.getProperty("java.version")} (${System.getProperty("java.vendor")}) ${System.getProperty("sun.arch.data.model")}-bit") info("Memory: ${Runtime.getRuntime().maxMemory() / 1024 / 1024} MB max") info("User: ${System.getProperty("user.name")}") - info("App Data: ${FileUtils.getAppDataDir().absolutePath}") + info("App Data: ${MorpheData.root.absolutePath}") info("Working Dir: ${System.getProperty("user.dir")}") info("=".repeat(60)) @@ -234,7 +235,7 @@ object Logger { * Get all log files for export. */ fun getAllLogFiles(): List { - val logsDir = FileUtils.getLogsDir() + val logsDir = MorpheData.logsDir return logsDir.listFiles() ?.filter { it.name.startsWith(LOG_FILE_NAME) } ?.sortedByDescending { it.lastModified() } @@ -266,33 +267,4 @@ object Logger { false } } - - /** - * Clear all log files. - */ - fun clearLogs(): Boolean { - return try { - val logsDir = FileUtils.getLogsDir() - logsDir.listFiles()?.forEach { it.delete() } - logFile?.createNewFile() - info("Logs cleared") - true - } catch (e: Exception) { - System.err.println("Failed to clear logs: ${e.message}") - false - } - } - - /** - * Get the total size of all log files. - */ - fun getLogsSize(): Long { - return try { - FileUtils.getLogsDir().walkTopDown() - .filter { it.isFile } - .sumOf { it.length() } - } catch (e: Exception) { - 0L - } - } } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchException.kt b/src/main/kotlin/app/morphe/gui/util/PatchException.kt new file mode 100644 index 00000000..707648f9 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/PatchException.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.gui.util + +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString + +open class PatchException( + message: String, + val stringRes: StringResource? = null, + val formatArgs: List = emptyList(), + cause: Throwable? = null, +) : Exception(message, cause) { + suspend fun getUserMessage(): String = + stringRes?.let { getString(it, *formatArgs.toTypedArray()) } ?: (message ?: "") +} diff --git a/src/main/kotlin/app/morphe/gui/util/PatchOptionValues.kt b/src/main/kotlin/app/morphe/gui/util/PatchOptionValues.kt deleted file mode 100644 index 294e2cd8..00000000 --- a/src/main/kotlin/app/morphe/gui/util/PatchOptionValues.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import androidx.compose.runtime.Composable -import app.morphe.desktop.command.model.deserializeOptionValue -import app.morphe.morphe_desktop.generated.resources.* -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.longOrNull -import org.jetbrains.compose.resources.stringResource -import kotlin.reflect.KType - -/** - * Returns [value] as [type], or null when it cannot stand for one, since the patcher aborts the - * run over a value of any other type. An unmodelled type is passed through untouched. - */ -fun coerceOptionValue(type: KType, value: Any?): Any? { - if (value == null) return null - - if (type.classifier == List::class) { - val elementType = type.arguments.firstOrNull()?.type ?: return value - val elements = when (value) { - is List<*> -> value - is Array<*> -> value.asList() - // How the list editor and imported profiles carry a list - is String -> value.split(',').map(String::trim).filter(String::isNotEmpty) - else -> return null - } - - return elements.map { element -> coerceOptionValue(elementType, element) ?: return null } - } - - return when (type.classifier) { - String::class -> when (value) { - is String -> value - is Number, is Boolean -> value.toString() - else -> null - } - - Boolean::class -> when (value) { - is Boolean -> value - is String -> value.trim().lowercase().toBooleanStrictOrNull() - else -> null - } - - Int::class -> value.integralOrNull()?.takeIf { it in INT_RANGE }?.toInt() - Long::class -> value.integralOrNull() - Float::class -> value.decimalOrNull()?.toFloat() - Double::class -> value.decimalOrNull() - else -> value - } -} - -private val INT_RANGE = Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() - -/** The whole number [this] stands for, or null when it is none. */ -private fun Any.integralOrNull(): Long? = when (this) { - is Long -> this - is Int, is Short, is Byte -> (this as Number).toLong() - is Float, is Double -> (this as Number).toDouble().toWholeOrNull() - is String -> trim().let { it.toLongOrNull() ?: it.toDoubleOrNull()?.toWholeOrNull() } - else -> null -} - -/** The decimal [this] stands for, or null when it is none. */ -private fun Any.decimalOrNull(): Double? = when (this) { - is Number -> toDouble() - is String -> trim().toDoubleOrNull() - else -> null -} - -/** Rejects fractions, NaN and infinities, which no integer option can hold. */ -private fun Double.toWholeOrNull(): Long? = takeIf { it == it.toLong().toDouble() }?.toLong() - -/** - * The JSON shape [text] stands for, ready for [deserializeOptionValue] to read as - * [type]. Only the shape is decided here. Element types stay that function's job, - * so the rules live in one place. - * - * A list option is typed into one field, so its elements are separated by commas. - */ -fun optionTextToJson(text: String, type: KType?): JsonElement = - if (type?.classifier == List::class) { - buildJsonArray { - text.split(',') - .map(String::trim) - .filter(String::isNotEmpty) - .forEach { add(JsonPrimitive(it)) } - } - } else { - JsonPrimitive(text) - } - -/** [text] as [type], or null when it cannot stand for one. */ -fun optionValueOrNull(text: String, type: KType): Any? = - coerceOptionValue(type, text) - -/** Decodes a [JsonElement] to its raw Kotlin primitive or list form. */ -fun JsonElement.decodePrimitiveOrArray(): Any? = when (this) { - is JsonNull -> null - is JsonArray -> mapNotNull { it.decodePrimitiveOrArray() } - is JsonPrimitive -> - if (isString) content - else booleanOrNull ?: longOrNull ?: doubleOrNull - else -> toString() -} - -/** The text an editor shows for a stored value, whatever JSON type it was saved as. */ -fun optionValueFromJson(element: JsonElement): String = when (element) { - is JsonNull -> "" - is JsonArray -> element.joinToString(", ") { optionValueFromJson(it) } - is JsonPrimitive -> element.content - else -> element.toString() -} - -/** The JSON for an already typed value, so a boolean saves as `true`, not `"true"`. */ -fun optionValueToJson(value: Any?): JsonElement = when (value) { - null -> JsonNull - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is List<*> -> buildJsonArray { value.forEach { add(optionValueToJson(it)) } } - else -> JsonPrimitive(value.toString()) -} - -/** What [type] will accept, phrased for someone typing into a field. */ -@Composable -fun expectedValueHint(type: KType): String { - if (type.classifier == List::class) { - val element = type.arguments.firstOrNull()?.type - val each = element?.let { scalarHint(it) } ?: stringResource(Res.string.patch_selection_option_hint_value) - return stringResource(Res.string.patch_selection_option_hint_list, each) - } - return scalarHint(type) -} - -@Composable -private fun scalarHint(type: KType): String = when (type.classifier) { - Boolean::class -> stringResource(Res.string.patch_selection_option_hint_boolean) - Int::class, Long::class -> stringResource(Res.string.patch_selection_option_hint_whole_number) - Float::class, Double::class -> stringResource(Res.string.patch_selection_option_hint_number) - else -> stringResource(Res.string.patch_selection_option_hint_text) -} diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt deleted file mode 100644 index f2ff3919..00000000 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import app.morphe.desktop.command.model.deserializeOptionValue -import app.morphe.engine.PatchEngine -import app.morphe.engine.patches.PatchBundleLoader -import app.morphe.gui.data.model.CompatiblePackage -import app.morphe.gui.data.model.ExplicitOptionKind -import app.morphe.gui.data.model.ImageSize -import app.morphe.gui.data.model.Patch -import app.morphe.gui.data.model.PatchOption -import app.morphe.gui.data.model.PatchOptionType -import app.morphe.gui.data.model.SliderBounds -import app.morphe.patcher.apk.ApkUtils -import app.morphe.patcher.patch.ColorOption -import app.morphe.patcher.patch.FilePathOption -import app.morphe.patcher.patch.FilesOption -import app.morphe.patcher.patch.FloatRangeOption -import app.morphe.patcher.patch.FloatSliderOption -import app.morphe.patcher.patch.FolderOption -import app.morphe.patcher.patch.ImageOption -import app.morphe.patcher.patch.IntRangeOption -import app.morphe.patcher.patch.IntSliderOption -import app.morphe.patcher.patch.Patch as LibraryPatch -import app.morphe.patcher.patch.loadPatchesFromJar -import app.morphe.patcher.resource.CpuArchitecture -import app.morphe.morphe_desktop.generated.resources.* -import java.io.File -import kotlin.reflect.KType -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.getString - -/** - * Bridge between GUI and morphe-patcher library. - * Replaces CliRunner with direct library calls. - */ -class PatchService { - - /** - * Load patches from an .mpp file and convert to GUI model. - * Optionally filter by package name. - */ - suspend fun listPatches( - patchesFilePath: String, - packageName: String? = null - ): Result> = withContext(Dispatchers.IO) { - try { - val patchFile = File(patchesFilePath) - if (!patchFile.exists()) { - return@withContext Result.failure(PatchException("Patch file not found: $patchesFilePath", Res.string.error_patch_file_not_found, listOf(patchesFilePath))) - } - - Logger.info("Loading patches from: $patchesFilePath") - - // Copy to temp file so URLClassLoader locks the copy, not the cached original. - // On Windows, the classloader holds the file locked and prevents deletion. - val tempCopy = File.createTempFile("morphe-patches-", ".mpp") - try { - patchFile.copyTo(tempCopy, overwrite = true) - val patches = loadPatchesFromJar(setOf(tempCopy)) - - // Convert library patches to GUI model - val guiPatches = patches.map { it.toGuiPatch() } - - // Filter by package name if specified - val filtered = if (packageName != null) { - guiPatches.filter { patch -> - patch.compatiblePackages.isEmpty() || // Universal patches - patch.compatiblePackages.any { it.name == packageName } - } - } else { - guiPatches - } - - Logger.info("Loaded ${filtered.size} patches" + (packageName?.let { " for $it" } ?: "")) - Result.success(filtered) - } finally { - tempCopy.deleteOnExit() - } - } catch (e: Exception) { - Logger.error("Failed to load patches", e) - Result.failure(e) - } - } - - /** - * Execute patching operation with progress callbacks. - * Delegates to PatchEngine for the actual pipeline. - */ - suspend fun patch( - patchesFilePaths: List, - inputApkPath: String, - outputApkPath: String, - enabledPatches: List = emptyList(), - disabledPatches: List = emptyList(), - options: Map = emptyMap(), - exclusiveMode: Boolean = false, - keepArchitectures: Set = emptySet(), - continueOnError: Boolean = false, - keystorePath: String? = null, - keystorePassword: String? = null, - keystoreAlias: String? = null, - keystoreEntryPassword: String? = null, - onProgress: (String) -> Unit = {} - ): Result = withContext(Dispatchers.IO) { - try { - if (patchesFilePaths.isEmpty()) { - return@withContext Result.failure(PatchException("No patch files supplied", Res.string.error_no_patches_files_supplied)) - } - val patchFiles = patchesFilePaths.map { File(it) } - val inputApk = File(inputApkPath) - val outputFile = File(outputApkPath) - - patchFiles.firstOrNull { !it.exists() }?.let { - return@withContext Result.failure(PatchException("Patch file not found: ${it.name}", Res.string.error_patch_file_not_found, listOf(it.name))) - } - if (!inputApk.exists()) { - return@withContext Result.failure(PatchException("Input APK file not found: $inputApkPath", Res.string.error_input_apk_not_found)) - } - - // Load patches (copy each to temp to avoid Windows file lock) - onProgress("Loading patches...") - val tempCopies = patchFiles.map { src -> - val tmp = File.createTempFile("morphe-patches-", ".mpp") - src.copyTo(tmp, overwrite = true) - tmp - } - try { - val loadedPatches = PatchBundleLoader.loadFlat(tempCopies) - - // Build a lookup: patchName -> (optionKey -> KType) for type-aware coercion. - val patchOptionTypes: Map> = loadedPatches - .filter { it.name != null } - .associate { patch -> - patch.name!! to patch.options.mapValues { (_, opt) -> opt.type } - } - - // Convert GUI's flat "patchName.optionKey" -> value map - // to engine's Map> format. - // String values are coerced to the option's native type (Boolean, Int, etc.) - // so the patcher receives the expected JVM type, not a raw String. - val patchOptions = enabledPatches.associateWith { patchName -> - options.filterKeys { it.startsWith("$patchName.") } - .mapKeys { it.key.removePrefix("$patchName.") } - .mapNotNull { (optKey, strValue) -> - val kType = patchOptionTypes[patchName]?.get(optKey) - val coerced = if (kType != null) { - coerceOptionValue(kType, strValue) ?: strValue - } else { - strValue - } - optKey to coerced - }.toMap() - }.filter { it.value.isNotEmpty() } - - val keystoreDetails = if (keystorePath != null) { - ApkUtils.KeyStoreDetails( - keyStore = File(keystorePath), - keyStorePassword = keystorePassword, - alias = keystoreAlias ?: PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS, - password = keystoreEntryPassword ?: PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD, - ) - } else null - - val config = PatchEngine.Config( - inputApk = inputApk, - patches = loadedPatches, - outputApk = outputFile, - enabledPatches = enabledPatches.toSet(), - disabledPatches = disabledPatches.toSet(), - exclusiveMode = exclusiveMode, - forceCompatibility = true, - patchOptions = patchOptions, - architecturesToKeep = keepArchitectures, - failOnError = !continueOnError, - keystoreDetails = keystoreDetails, - ) - - val engineResult = PatchEngine.patch(config, onProgress) - - val failureDetail: String? = if (engineResult.success) null else buildString { - if (engineResult.failedPatches.isNotEmpty()) { - engineResult.failedPatches.forEach { fp -> - appendLine("Patch '${fp.name}' failed:") - appendLine(fp.error) - } - } else { - engineResult.stepResults - .filter { !it.success && it.error != null } - .forEach { appendLine("Step ${it.step.name} failed:"); appendLine(it.error) } - } - }.takeIf { it.isNotBlank() } - failureDetail?.let { Logger.error("Patching failed - full detail:\n$it") } - - val (failureReason, failureReasonRes, failureReasonArgs) = if (engineResult.success) { - Triple(null, null, emptyList()) - } else { - // Prefer a specific failed-patch error, else the last failed - // step's error (rebuild/sign), else a generic fallback. - // First line only, since this is the short UI-banner summary. The - // full traces are already logged above. - engineResult.failedPatches.firstOrNull()?.let { fp -> - Triple("${fp.name}: ${fp.error.lineSequence().first()}", null, emptyList()) - } - ?: engineResult.stepResults.lastOrNull { !it.success && it.error != null } - ?.let { - val stepDisplay = it.step.name.lowercase().replaceFirstChar { c -> c.uppercase() } - val stepError = it.error ?: "" - Triple("Step $stepDisplay failed: $stepError", Res.string.error_step_failed, listOf(stepDisplay, stepError)) - } - ?: Triple("Unknown patching error", Res.string.error_patching_unknown, emptyList()) - } - Result.success(PatchResult( - success = engineResult.success, - outputPath = engineResult.outputPath, - appliedPatches = engineResult.appliedPatches, - failedPatches = engineResult.failedPatches.map { it.name }, - failureReason = failureReason, - failureDetail = failureDetail, - packageName = engineResult.packageName, - packageVersion = engineResult.packageVersion, - failureReasonRes = failureReasonRes, - failureReasonArgs = failureReasonArgs, - )) - } finally { - tempCopies.forEach { runCatching { it.delete() } } - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - // Catch Throwable, not just Exception: a mismatched patch bundle can - // throw java.lang.Error (e.g. NoSuchMethodError when two sources ship - // the same class compiled against different patcher versions). Those - // are Errors, not Exceptions, so catch(Exception) would let them escape and the UI - // would hang on "Loading patches" forever instead of surfacing a failure. - Logger.error("Patching failed", e) - Result.failure(e) - } - } - - /** - * Convert a set of already-loaded library patches into GUI patches. - * Used by EnabledSourcesLoader / MultiSourceLoader paths so we don't have to - * re-open the .mpp file just to convert. - */ - fun convertToGuiPatches(loaded: Set>): List = - loaded.map { it.toGuiPatch() } - - /** - * Convert library Patch to GUI Patch model. - * - * Reads BOTH the new [compatibility] API and the deprecated [compatiblePackages] - * field. Some forks (e.g. hoo-dles) compiled their patches against the older - * patcher API and only declare compatibility via the legacy field. Without the - * fallback, those patches would convert to a GUI Patch with empty - * compatiblePackages, which means SupportedAppExtractor under-counts apps and - * the per-source attribution map misses entire sources. - */ - @Suppress("DEPRECATION") - private fun LibraryPatch<*>.toGuiPatch(): Patch { - // Primary: new compatibility API (typed, with experimental flag, display name). - val fromNewApi: List = this.compatibility - ?.mapNotNull { compatibility -> - val packageName = compatibility.packageName ?: return@mapNotNull null - val (experimental, stable) = compatibility.targets.partition { it.isExperimental } - CompatiblePackage( - name = packageName, - displayName = compatibility.name, - versions = stable.mapNotNull { it.version }, - experimentalVersions = experimental.mapNotNull { it.version }, - appIconColor = compatibility.appIconColor - ?.let { "#%06X".format(it and 0xFFFFFF) }, - versionBuildCodes = compatibility.targets - .mapNotNull { target -> - val version = target.version ?: return@mapNotNull null - version to target.versionCodes?.values?.toSet().orEmpty() - } - .groupBy({ it.first }, { it.second }) - .mapValues { (_, sets) -> - if (sets.any { it.isEmpty() }) emptySet() else sets.flatten().toSet() - }, - ) - } - ?: emptyList() - - // Fallback: legacy compatiblePackages field (Set>). - // No display name or experimental flag in the legacy schema, so those stay null or empty. - val fromLegacyApi: List = if (fromNewApi.isEmpty()) { - this.compatiblePackages - ?.map { (pkgName, versions) -> - CompatiblePackage( - name = pkgName, - displayName = null, - versions = versions?.toList() ?: emptyList(), - experimentalVersions = emptyList(), - ) - } - ?: emptyList() - } else emptyList() - - return Patch( - name = this.name ?: "Unknown", - description = this.description ?: "", - compatiblePackages = fromNewApi.ifEmpty { fromLegacyApi }, - options = this.options.values.map { opt -> - val explicitKind = when (opt) { - is FolderOption -> ExplicitOptionKind.Folder - is FilePathOption -> ExplicitOptionKind.FilePath - is FilesOption -> ExplicitOptionKind.Files - is ImageOption -> ExplicitOptionKind.Image - is ColorOption -> ExplicitOptionKind.Color - is IntSliderOption -> ExplicitOptionKind.IntSlider - is FloatSliderOption -> ExplicitOptionKind.FloatSlider - is IntRangeOption -> ExplicitOptionKind.IntRange - is FloatRangeOption -> ExplicitOptionKind.FloatRange - else -> null - } - val allowedExtensions = when (opt) { - is FilePathOption -> opt.allowedExtensions - is FilesOption -> opt.allowedExtensions - is ImageOption -> opt.allowedExtensions - else -> null - } - val recommendedSize = when (opt) { - is ImageOption -> opt.recommendedSize?.let { ImageSize(it.width, it.height) } - else -> null - } - val sliderBounds = when (opt) { - is IntSliderOption -> SliderBounds(opt.min.toFloat(), opt.max.toFloat(), opt.step.toFloat()) - is FloatSliderOption -> SliderBounds(opt.min, opt.max, opt.step) - is IntRangeOption -> SliderBounds(opt.min.toFloat(), opt.max.toFloat(), opt.step.toFloat()) - is FloatRangeOption -> SliderBounds(opt.min, opt.max, opt.step) - else -> null - } - PatchOption( - key = opt.key, - title = opt.title ?: opt.key, - description = opt.description ?: "", - type = mapKTypeToOptionType(opt.type, opt.key, opt.title ?: opt.key), - default = when (val def = opt.default) { - null -> null - is List<*> -> def.joinToString(", ") - else -> def.toString() - }, - required = opt.required, - valueType = opt.type, - explicitKind = explicitKind, - allowedExtensions = allowedExtensions, - recommendedSize = recommendedSize, - sliderBounds = sliderBounds, - presets = opt.values, - rawDefault = opt.default, - ) - }, - isEnabled = this.use, - category = this.category?.takeIf { it.isNotBlank() } - ) - } - - /** - * Map Kotlin KType to GUI PatchOptionType. - */ - private fun mapKTypeToOptionType(kType: KType, key: String, title: String): PatchOptionType { - val typeName = kType.toString() - return when { - typeName.contains("Boolean") -> PatchOptionType.BOOLEAN - typeName.contains("Int") -> PatchOptionType.INT - typeName.contains("Long") -> PatchOptionType.LONG - typeName.contains("Float") || typeName.contains("Double") -> PatchOptionType.FLOAT - typeName.contains("List") || typeName.contains("Array") || typeName.contains("Set") -> PatchOptionType.LIST - typeName.contains("File") || typeName.contains("Path") || typeName.contains("InputStream") -> PatchOptionType.FILE - else -> { - val combined = "$key $title".lowercase() - val fileKeywords = listOf("icon", "image", "logo", "banner", "path", "file", "png", "jpg") - if (fileKeywords.any { it in combined }) PatchOptionType.FILE else PatchOptionType.STRING - } - } - } -} - -/** - * Result of a patching operation. - */ -data class PatchResult( - val success: Boolean, - val outputPath: String, - val appliedPatches: List, - val failedPatches: List, - // Human-readable reason for [success == false]. Populated from the first - // failed patch's error, or when patching succeeded but a later step - // (rebuild, sign) blew up, that step's error. Null on success. - val failureReason: String? = null, - // Full failure detail: complete stack traces (incl. nested "Caused by:" - // causes) for every failed patch and step. The expandable "Details" - // counterpart to the one-line [failureReason]. Null on success. - val failureDetail: String? = null, - // Surfaced from the engine so callers (e.g. patched-app history) can record - // what was actually patched. Empty when the patcher didn't report them. - val packageName: String = "", - val packageVersion: String = "", - val failureReasonRes: StringResource? = null, - val failureReasonArgs: List = emptyList(), -) { - suspend fun getLocalizedFailureReason(): String? = - failureReasonRes?.let { getString(it, *failureReasonArgs.toTypedArray()) } ?: failureReason -} - -open class PatchException( - message: String, - val stringRes: StringResource? = null, - val formatArgs: List = emptyList(), - cause: Throwable? = null, -) : Exception(message, cause) { - suspend fun getUserMessage(): String = - stringRes?.let { getString(it, *formatArgs.toTypedArray()) } ?: (message ?: "") -} diff --git a/src/main/kotlin/app/morphe/gui/util/PatcherLogInterceptor.kt b/src/main/kotlin/app/morphe/gui/util/PatcherLogInterceptor.kt deleted file mode 100644 index ec632918..00000000 --- a/src/main/kotlin/app/morphe/gui/util/PatcherLogInterceptor.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import java.util.logging.Handler -import java.util.logging.Level -import java.util.logging.LogRecord -import java.util.logging.Logger - -/** - * Utility to intercept internal `java.util.logging` output from the underlying - * patching library. This captures granular progress messages (e.g., "Writing classes") - * which are not emitted through standard progress callbacks. - */ -object PatcherLogInterceptor { - - private var activeLogger: Logger? = null - - /** - * Attaches a handler to the patcher logger and returns it so it can be detached later. - */ - fun attach(onLog: (String) -> Unit): Handler { - val handler = object : Handler() { - override fun publish(record: LogRecord) { - val prefix = when (record.level) { - Level.SEVERE -> "ERROR: " - Level.WARNING -> "WARNING: " - else -> "" - } - record.message?.takeIf { it.isNotBlank() }?.let { onLog("$prefix$it") } - } - - override fun flush() {} - - override fun close() {} - } - val logger = Logger.getLogger("app.morphe.patcher") - activeLogger = logger - logger.addHandler(handler) - return handler - } - - /** - * Detaches the given handler. - */ - fun detach(handler: Handler) { - activeLogger?.removeHandler(handler) - activeLogger = null - } -} diff --git a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt deleted file mode 100644 index 087aeb2d..00000000 --- a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import app.morphe.gui.data.model.Patch -import app.morphe.gui.data.model.SupportedApp - -/** - * Extracts supported apps from parsed patch data. - * This allows the app to dynamically determine which apps are supported - * based on the .mpp file contents rather than hardcoding. - */ -object SupportedAppExtractor { - - /** - * Extract all supported apps from a list of patches. - * Groups patches by package name and collects all supported versions. - */ - fun extractSupportedApps(patches: List): List { - // Collect all package names and their stable + experimental versions from all patches - val packageVersionsMap = mutableMapOf>() - val packageExperimentalMap = mutableMapOf>() - val packageDisplayNames = mutableMapOf() - val packageIconColors = mutableMapOf() - val packageBuildCodes = mutableMapOf>>() - - for ((_, _, compatiblePackages) in patches) { - for ((packageName, displayName, versions, experimentalVersions, appIconColor, versionBuildCodes) in compatiblePackages) { - if (packageName.isNotBlank()) { - packageVersionsMap.getOrPut(packageName) { mutableSetOf() } - .addAll(versions) - packageExperimentalMap.getOrPut(packageName) { mutableSetOf() } - .addAll(experimentalVersions) - displayName - ?.takeIf { it.isNotBlank() } - ?.let { packageDisplayNames.putIfAbsent(packageName, it) } - appIconColor - ?.takeIf { it.isNotBlank() } - ?.let { packageIconColors.putIfAbsent(packageName, it) } - if (versionBuildCodes.isNotEmpty()) { - val perVersion = packageBuildCodes.getOrPut(packageName) { mutableMapOf() } - versionBuildCodes.forEach { (version, codes) -> - val existing = perVersion[version] - when { - codes.isEmpty() -> perVersion[version] = mutableSetOf() - existing == null -> perVersion[version] = codes.toMutableSet() - existing.isEmpty() -> Unit - else -> existing.addAll(codes) - } - } - } - } - } - } - - // Convert to SupportedApp list - return packageVersionsMap.map { (packageName, versions) -> - val versionList = versions.toList().sortedDescending() - val experimentalList = (packageExperimentalMap[packageName] ?: emptySet()) - .minus(versions) // Remove any that are also stable - .toList().sortedDescending() - val recommendedVersion = SupportedApp.getRecommendedVersion(versionList) - val latestExperimental = experimentalList.firstOrNull() - SupportedApp( - packageName = packageName, - displayName = SupportedApp.resolveDisplayName( - packageName = packageName, - providedName = packageDisplayNames[packageName] - ), - supportedVersions = versionList, - experimentalVersions = experimentalList, - recommendedVersion = recommendedVersion, - apkDownloadUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion ?: "any"), - experimentalDownloadUrl = SupportedApp.getDownloadUrl(packageName, latestExperimental), - appIconColor = packageIconColors[packageName], - versionBuildCodes = packageBuildCodes[packageName] - ?.mapValues { (_, codes) -> codes.toSet() } - .orEmpty() - ) - }.sortedBy { it.displayName } - } - - /** - * Get supported app by package name. - */ - fun getSupportedApp(patches: List, packageName: String): SupportedApp? { - return extractSupportedApps(patches).find { it.packageName == packageName } - } - - /** - * Get recommended version for a package from patches. - */ - fun getRecommendedVersion(patches: List, packageName: String): String? { - return getSupportedApp(patches, packageName)?.recommendedVersion - } -} diff --git a/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt b/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt index 3db27246..71ce467d 100644 --- a/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt +++ b/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt @@ -8,6 +8,7 @@ package app.morphe.gui.util import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import app.morphe.engine.model.VersionStatus import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt deleted file mode 100644 index 49249e4e..00000000 --- a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import app.morphe.engine.model.Release -import app.morphe.gui.data.model.SupportedApp - -/** - * The "bucket" an APK's version falls into relative to a [SupportedApp]'s - * stable + experimental version lists. - */ -enum class VersionStatus { - /** Current version is the latest stable. Happy path. */ - LATEST_STABLE, - - /** In the stable list but older than the latest stable. */ - OLDER_STABLE, - - /** Current version is the latest experimental. */ - LATEST_EXPERIMENTAL, - - /** In the experimental list but older than the latest experimental. */ - OLDER_EXPERIMENTAL, - - /** Newer than every known version (stable + experimental). */ - TOO_NEW, - - /** Older than every known stable version. */ - TOO_OLD, - - /** Between supported versions but not in either list. */ - UNSUPPORTED_BETWEEN, - - BUILD_UNSUPPORTED, - - /** No patch metadata, can't determine. */ - UNKNOWN -} - -/** - * The result of resolving a current APK version against a [SupportedApp]. - * - * @param status which bucket the current version falls into. - * @param suggestedVersion the version most relevant to surface in UI for this - * status, e.g. the latest stable for [VersionStatus.OLDER_STABLE], the - * latest experimental for [VersionStatus.OLDER_EXPERIMENTAL], the newest - * known version for [VersionStatus.TOO_NEW], etc. - */ -data class VersionResolution( - val status: VersionStatus, - val suggestedVersion: String? -) - -/** - * Strips a leading `v` and surrounding whitespace. GitHub tags, JSON metadata - * and APK manifests MUST compare equal after this. - */ -fun String.normalizeVersion(): String = removePrefix("v").removePrefix("V").trim() - -/** - * Compare two version strings. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2. - * Handles dotted app versions ("20.40.45") and semver-ish tags ("v1.39.0-dev.10"). - * Stable outranks a pre-release of the same base. Pre-release ordinals compare - * numerically, so dev.9 sorts below dev.10. - * - * Ported from morphe-manager's VersionUtils. Both projects MUST rank alike. - */ -fun compareVersions(v1: String?, v2: String?): Int { - if (v1 == null && v2 == null) return 0 - if (v1 == null) return -1 - if (v2 == null) return 1 - - val version1 = v1.normalizeVersion() - val version2 = v2.normalizeVersion() - if (version1 == version2) return 0 - - data class VersionParts(val base: String, val preRelease: String?) - - fun extractParts(version: String): VersionParts { - val preReleasePattern = - """^([\d.]+)[-._]?(dev|beta|rc|alpha|preview)""".toRegex(RegexOption.IGNORE_CASE) - val match = preReleasePattern.find(version) - return if (match != null) { - VersionParts(match.groupValues[1], match.groupValues[2]) - } else { - val numMatch = """^([\d.]+)(.*)$""".toRegex().find(version) - if (numMatch != null && numMatch.groupValues[2].isNotEmpty()) { - VersionParts(numMatch.groupValues[1], numMatch.groupValues[2]) - } else { - VersionParts(version, null) - } - } - } - - val parts1 = extractParts(version1) - val parts2 = extractParts(version2) - - val base1 = parts1.base.split(".").map { it.toIntOrNull() ?: 0 } - val base2 = parts2.base.split(".").map { it.toIntOrNull() ?: 0 } - for (i in 0 until maxOf(base1.size, base2.size)) { - val a = base1.getOrNull(i) ?: 0 - val b = base2.getOrNull(i) ?: 0 - when { - a < b -> return -1 - a > b -> return 1 - } - } - - return when { - parts1.preRelease == null && parts2.preRelease == null -> 0 - parts1.preRelease == null && parts2.preRelease != null -> 1 // stable beats pre-release - parts1.preRelease != null && parts2.preRelease == null -> -1 - else -> { - val num1 = version1.substringAfterLast('.').toLongOrNull() - val num2 = version2.substringAfterLast('.').toLongOrNull() - if (num1 != null && num2 != null) num1.compareTo(num2) else version1.compareTo(version2) - } - } -} - -fun isNewerVersion(current: String?, baseline: String?): Boolean { - if (current.isNullOrBlank() || baseline.isNullOrBlank()) return false - if (current.equals("unknown", true) || baseline.equals("unknown", true)) return false - return compareVersions(current, baseline) > 0 -} - -/** - * Determine the status of [currentVersion] relative to the stable and - * experimental versions known for [app]. - */ -fun resolveVersionStatus( - currentVersion: String, - app: SupportedApp, - versionCode: Int? = null, -): VersionResolution { - val stableList = app.supportedVersions - val experimentalList = app.experimentalVersions - - val latestStable = stableList.firstOrNull() - val oldestStable = stableList.lastOrNull() - val latestExperimental = experimentalList.firstOrNull() - - if (latestStable == null && latestExperimental == null) { - return VersionResolution(VersionStatus.UNKNOWN, null) - } - - val versionIsKnown = currentVersion in stableList || currentVersion in experimentalList - if (versionIsKnown && !app.buildCodeSupported(currentVersion, versionCode)) { - return VersionResolution(VersionStatus.BUILD_UNSUPPORTED, currentVersion) - } - - // Exact matches in either bucket - if (latestStable != null && currentVersion == latestStable) { - return VersionResolution(VersionStatus.LATEST_STABLE, latestStable) - } - if (latestExperimental != null && currentVersion == latestExperimental) { - return VersionResolution(VersionStatus.LATEST_EXPERIMENTAL, latestExperimental) - } - if (currentVersion in stableList) { - return VersionResolution(VersionStatus.OLDER_STABLE, latestStable) - } - if (currentVersion in experimentalList) { - return VersionResolution(VersionStatus.OLDER_EXPERIMENTAL, latestExperimental) - } - - val newestKnown = when { - latestStable == null -> latestExperimental - latestExperimental == null -> latestStable - compareVersions(latestStable, latestExperimental) >= 0 -> latestStable - else -> latestExperimental - } - - if (compareVersions(currentVersion, newestKnown) > 0) { - return VersionResolution(VersionStatus.TOO_NEW, newestKnown) - } - if (oldestStable != null && compareVersions(currentVersion, oldestStable) < 0) { - return VersionResolution(VersionStatus.TOO_OLD, oldestStable) - } - - return VersionResolution( - VersionStatus.UNSUPPORTED_BETWEEN, - latestStable ?: latestExperimental - ) -} - -fun String?.isDevTag(): Boolean { - val tag = this ?: return false - return tag.contains("dev", ignoreCase = true) || - tag.contains("alpha", ignoreCase = true) || - tag.contains("beta", ignoreCase = true) -} - -fun newerRelease(dev: Release?, stable: Release?): Release? = when { - dev == null -> stable - stable == null -> dev - compareVersions(dev.tagName, stable.tagName) >= 0 -> dev - else -> stable -} diff --git a/src/test/kotlin/app/morphe/desktop/command/OptionValueConverterTest.kt b/src/test/kotlin/app/morphe/engine/options/OptionValueConverterTest.kt similarity index 62% rename from src/test/kotlin/app/morphe/desktop/command/OptionValueConverterTest.kt rename to src/test/kotlin/app/morphe/engine/options/OptionValueConverterTest.kt index 6d567115..08e9070f 100644 --- a/src/test/kotlin/app/morphe/desktop/command/OptionValueConverterTest.kt +++ b/src/test/kotlin/app/morphe/engine/options/OptionValueConverterTest.kt @@ -3,9 +3,15 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.desktop.command +package app.morphe.engine.options +import kotlin.reflect.typeOf import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive class OptionValueConverterTest { @Test @@ -76,7 +82,61 @@ class OptionValueConverterTest { "[.1,.2f,,true,FALSE]" convertsTo listOf(.1, .2f, "", true, false) because "Values in lists should be converted to the correct type" } - private val convert = OptionValueConverter()::convert + @Test + fun `coerceOptionValue coerces scalars and lists`() { + assertEquals(true, coerceOptionValue(typeOf(), "true")) + assertEquals(false, coerceOptionValue(typeOf(), "FALSE")) + assertNull(coerceOptionValue(typeOf(), "not-a-bool")) + + assertEquals(42, coerceOptionValue(typeOf(), "42")) + assertEquals(42, coerceOptionValue(typeOf(), 42.0)) + assertNull(coerceOptionValue(typeOf(), "42.5")) + + assertEquals(1.5f, coerceOptionValue(typeOf(), "1.5")) + assertEquals(listOf("a", "b", "c"), coerceOptionValue(typeOf>(), "a, b, c")) + assertEquals(listOf(1, 2, 3), coerceOptionValue(typeOf>(), "1, 2, 3")) + assertNull(coerceOptionValue(typeOf>(), "1, nope, 3")) + } + + @Test + fun `optionValueToJson and optionValueFromJson roundtrip`() { + assertEquals(JsonNull, optionValueToJson(null)) + assertEquals("", optionValueFromJson(JsonNull)) + + val boolJson = optionValueToJson(true) + assertEquals(JsonPrimitive(true), boolJson) + assertEquals("true", optionValueFromJson(boolJson)) + + val listJson = optionValueToJson(listOf("one", "two")) + assertEquals(JsonArray(listOf(JsonPrimitive("one"), JsonPrimitive("two"))), listJson) + assertEquals("one, two", optionValueFromJson(listJson)) + } + + @Test + fun `groupFlatOptionsToJson groups and coerces values`() { + val flat = mapOf( + "Custom branding.appIcon" to "play_black", + "Custom branding.iconSize" to "48", + "Theme.darkTheme" to "true", + "malformedKey" to "ignored", + ) + val declaredTypes = mapOf( + "Custom branding" to mapOf( + "iconSize" to typeOf(), + ), + "Theme" to mapOf( + "darkTheme" to typeOf(), + ), + ) + + val result = groupFlatOptionsToJson(flat, declaredTypes) + assertEquals(2, result.size) + assertEquals(JsonPrimitive("play_black"), result["Custom branding"]?.get("appIcon")) + assertEquals(JsonPrimitive(48), result["Custom branding"]?.get("iconSize")) + assertEquals(JsonPrimitive(true), result["Theme"]?.get("darkTheme")) + } + + private val convert = ::parseCliOptionValue private infix fun String.convertsTo(to: Any?) = convert(this) to to private infix fun Pair.because(reason: String) = assert(this.first == this.second) { reason } diff --git a/src/test/kotlin/app/morphe/desktop/command/PatchOptionsFileTest.kt b/src/test/kotlin/app/morphe/engine/options/PatchOptionsFileTest.kt similarity index 86% rename from src/test/kotlin/app/morphe/desktop/command/PatchOptionsFileTest.kt rename to src/test/kotlin/app/morphe/engine/options/PatchOptionsFileTest.kt index a1002074..12af72aa 100644 --- a/src/test/kotlin/app/morphe/desktop/command/PatchOptionsFileTest.kt +++ b/src/test/kotlin/app/morphe/engine/options/PatchOptionsFileTest.kt @@ -3,22 +3,19 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.desktop.command - -import app.morphe.desktop.command.model.PatchBundle -import app.morphe.desktop.command.model.PatchBundleMeta -import app.morphe.desktop.command.model.PatchEntry -import app.morphe.desktop.command.model.findMatchingBundle -import app.morphe.desktop.command.model.mergeWithBundle -import app.morphe.desktop.command.model.sha256 -import app.morphe.desktop.command.model.withUpdatedBundle +package app.morphe.engine.options + +import app.morphe.engine.util.FileChecksum import app.morphe.patcher.patch.option import app.morphe.patcher.patch.rawResourcePatch -import kotlinx.serialization.json.JsonPrimitive import java.io.File +import kotlin.reflect.typeOf import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive class PatchOptionsFileTest { // findMatchingBundle section ------------------ @@ -44,7 +41,7 @@ class PatchOptionsFileTest { val tmpFile = File.createTempFile("test", ".mpp") tmpFile.writeText("test content") tmpFile.deleteOnExit() - val hash = tmpFile.sha256() // We use SHA to get our exact patch options + val hash = FileChecksum.sha256(tmpFile) // We use SHA to get our exact patch options val bundle1 = PatchBundle( meta = PatchBundleMeta(sha256 = "some-wrong-hash"), @@ -180,4 +177,17 @@ class PatchOptionsFileTest { assertEquals(false, result.patches["Theme"]!!.enabled) assertNull(result.patches["AdBlocker"]) } -} \ No newline at end of file + + @Test + fun `deserializeOptionValue deserializes primitives and arrays`() { + assertEquals("hello", deserializeOptionValue(JsonPrimitive("hello"), typeOf())) + assertEquals(42, deserializeOptionValue(JsonPrimitive(42), typeOf())) + assertEquals(42L, deserializeOptionValue(JsonPrimitive(42L), typeOf())) + assertEquals(3.5f, deserializeOptionValue(JsonPrimitive(3.5f), typeOf())) + assertEquals(true, deserializeOptionValue(JsonPrimitive(true), typeOf())) + assertNull(deserializeOptionValue(JsonNull, typeOf())) + + val arr = JsonArray(listOf(JsonPrimitive("a"), JsonPrimitive("b"))) + assertEquals(listOf("a", "b"), deserializeOptionValue(arr, typeOf>())) + } +} diff --git a/src/test/kotlin/app/morphe/gui/util/VersionUtilsTest.kt b/src/test/kotlin/app/morphe/engine/util/VersionUtilsTest.kt similarity index 99% rename from src/test/kotlin/app/morphe/gui/util/VersionUtilsTest.kt rename to src/test/kotlin/app/morphe/engine/util/VersionUtilsTest.kt index 56d81df1..7b84d446 100644 --- a/src/test/kotlin/app/morphe/gui/util/VersionUtilsTest.kt +++ b/src/test/kotlin/app/morphe/engine/util/VersionUtilsTest.kt @@ -3,7 +3,7 @@ * https://github.com/MorpheApp/morphe-desktop */ -package app.morphe.gui.util +package app.morphe.engine.util import app.morphe.engine.model.Release import kotlin.test.Test diff --git a/src/test/kotlin/app/morphe/gui/util/BuildCodeSupportTest.kt b/src/test/kotlin/app/morphe/gui/util/BuildCodeSupportTest.kt index dc252a8a..31ac40f1 100644 --- a/src/test/kotlin/app/morphe/gui/util/BuildCodeSupportTest.kt +++ b/src/test/kotlin/app/morphe/gui/util/BuildCodeSupportTest.kt @@ -5,7 +5,9 @@ package app.morphe.gui.util -import app.morphe.gui.data.model.SupportedApp +import app.morphe.engine.model.SupportedApp +import app.morphe.engine.model.VersionStatus +import app.morphe.engine.patches.resolveVersionStatus import kotlin.test.Test class BuildCodeSupportTest {