diff --git a/build.gradle.kts b/build.gradle.kts index 78ee0665..08264c53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,9 +77,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/docs/documentation.md b/docs/documentation.md index 9ce42a94..4530714a 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -568,36 +568,36 @@ morphe patch [flag/s] Here is a quick lookup for all the flags under this subcommand: -| Flag | Description | -|--------------------------------|--------------------------------------------------------------------------------| -| `-p`, `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs (repeatable, one per bundle) | -| `--prerelease` | Fetch latest dev pre-release instead of stable release | -| *(positional arg)* | APK file to patch | -| `-o`, `--out` | Path to save the patched APK to | -| `-e`, `--enable` | Enable a patch by name | -| `--ei` | Enable a patch by index | -| `-d`, `--disable` | Disable a patch by name | -| `--di` | Disable a patch by index | -| `-O`, `--options` | Set patch option values (e.g.`-Okey=value`) | -| `--exclusive` | Disable all patches except explicitly enabled ones | -| `-f`, `--force` | Skip APK version compatibility check | -| `-i`, `--install` | Install to ADB device (optional serial) | -| `--mount` | Install by mounting over existing app (requires root) | -| `--keystore` | Path to keystore file for signing | -| `--keystore-password` | Keystore password | -| `--keystore-entry-alias` | Alias of the key pair in the keystore | -| `--keystore-entry-password` | Password for the keystore entry | -| `--signer` | Signer name in the APK signature | -| `--unsigned` | Skip signing the final APK | -| `-t`, `--temporary-files-path` | Path to store temp files | -| `--disable-purge` | Keep this run's scratch files (deleted after patching by default) | -| `--striplibs` | Architectures to keep, comma-separated (e.g.`arm64-v8a,x86`) | -| `--bytecode-mode` | Bytecode mode:`FULL`, `STRIP_SAFE`, or `STRIP_FAST` | -| `--verify-with-sdk` | Verify the patched DEX/APK using an Android SDK | -| `--continue-on-error` | Continue patching if a patch fails | -| `--options-file` | Path to options JSON file | -| `--options-update` | Auto-update options JSON file after patching | -| `-r`, `--result-file` | Path to save patching result JSON | +| Flag | Description | +|--------------------------------|------------------------------------------------------------------------------------| +| `-p`, `--patches` | Paths to .mpp files, directories, or remote repos/PRs (repeatable, one per bundle) | +| `--prerelease` | Fetch latest dev pre-release instead of stable release | +| *(positional arg)* | APK file to patch | +| `-o`, `--out` | Path to save the patched APK to | +| `-e`, `--enable` | Enable a patch by name | +| `--ei` | Enable a patch by index | +| `-d`, `--disable` | Disable a patch by name | +| `--di` | Disable a patch by index | +| `-O`, `--options` | Set patch option values (e.g.`-Okey=value`) | +| `--exclusive` | Disable all patches except explicitly enabled ones | +| `-f`, `--force` | Skip APK version compatibility check | +| `-i`, `--install` | Install to ADB device (optional serial) | +| `--mount` | Install by mounting over existing app (requires root) | +| `--keystore` | Path to keystore file for signing | +| `--keystore-password` | Keystore password | +| `--keystore-entry-alias` | Alias of the key pair in the keystore | +| `--keystore-entry-password` | Password for the keystore entry | +| `--signer` | Signer name in the APK signature | +| `--unsigned` | Skip signing the final APK | +| `-t`, `--temporary-files-path` | Path to store temp files | +| `--disable-purge` | Keep this run's scratch files (deleted after patching by default) | +| `--striplibs` | Architectures to keep, comma-separated (e.g.`arm64-v8a,x86`) | +| `--bytecode-mode` | Bytecode mode:`FULL`, `STRIP_SAFE`, or `STRIP_FAST` | +| `--verify-with-sdk` | Verify the patched DEX/APK using an Android SDK | +| `--continue-on-error` | Continue patching if a patch fails | +| `--options-file` | Path to options JSON file | +| `--options-update` | Auto-update options JSON file after patching | +| `-r`, `--result-file` | Path to save patching result JSON | > [!NOTE] > The examples used for each flag below only show the usage of that specific flag, but in practice, you'll almost always combine multiple flags together to customize your patching. Here's an example of a more complete command: @@ -612,10 +612,12 @@ Required: Yes Default: - -This flag specifies the patch file(s) to apply to your APK. You can pass local `.mpp` file paths or a GitHub or GitLab repository URL, and you can repeat `-p` to combine several bundles in one run (see [Using multiple patch bundles](#using-multiple-bundles) below). When a URL is provided, Morphe automatically downloads the `.mpp` file from the latest release and caches it for future runs. +This flag specifies the patch file(s) to apply to your APK. You can pass local `.mpp` file paths, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests), and you can repeat `-p` to combine several bundles in one run (see [Using multiple patch bundles](#using-multiple-bundles) below). When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. When a remote URL is provided, Morphe downloads the bundle and caches it on disk for future runs. ``` +# Local file or directory: java -jar morphe-desktop-*-all.jar patch --patches patches-*.mpp your_app.apk +java -jar morphe-desktop-*-all.jar patch --patches /path/to/patches/ your_app.apk ``` You can also pass a GitHub or GitLab repo URL: @@ -625,12 +627,21 @@ java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/ java -jar morphe-desktop-*-all.jar patch --patches https://gitlab.com/MorpheApp/morphe-patches your_app.apk ``` -Or a specific release URL: +Or a specific release tag or Pull Request URL: ``` java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches/releases/tag/v1.0.0 your_app.apk +java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches/pull/123 your_app.apk ``` +> [!NOTE] +> Downloading patch bundles from GitHub Pull Requests requires a GitHub Personal Access Token (PAT) with `public_repo` scope to access GitHub Actions artifacts. +> +> You can create a token at [github.com/settings/tokens](https://github.com/settings/tokens) (classic token with `public_repo` scope) and configure it via: +> +> - **Environment variable** (CLI): `export GITHUB_TOKEN="ghp_your_token"` (or `GH_TOKEN`) +> - **Settings UI / Config**: In the GUI under **Settings → GitHub Personal Access Token** +
Using multiple patch bundles
`-p` can be repeated to combine several `.mpp` bundles in a single run: @@ -645,10 +656,10 @@ The **name-based** selection flags (`-e`, `-d`, `-O`) apply to the bundle they f java -jar morphe-desktop-*-all.jar patch -p patches-a.mpp -e "A patch from bundle A" -p patches-b.mpp -d "A patch from bundle B" your_app.apk ``` -**Index-based** selections (`--ei`/`--di`) are the exception. Their numbers refer to the combined list across all supplied bundles, not a single one. Run `list-patches` with the same set of `-p` files to see the combined numbering: +**Index-based** selections (`--ei`/`--di`) are the exception. Their numbers refer to the combined list across all supplied bundles, not a single one. Run `list-patches` with the same set of `--patches` files to see the combined numbering: ``` -java -jar morphe-desktop-*-all.jar list-patches -p patches-a.mpp -p patches-b.mpp +java -jar morphe-desktop-*-all.jar list-patches --patches patches-a.mpp --patches patches-b.mpp ``` Then enable/disable by those combined indices. The index points to a patch no matter which bundle it came from, so its position relative to each `-p` doesn't matter: @@ -663,7 +674,7 @@ Required: No Default: `false` -When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. If no dev pre-release is found on the repository, Morphe falls back to the latest stable release automatically. ``` java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches --prerelease your_app.apk @@ -688,7 +699,7 @@ java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk Required: No -Default: a subfolder named after the app, created next to the input APK – `/-Morphe--patches-.apk` +Default: a subfolder named after the app, created next to the input APK – `/--patches-.apk` Specify a custom output path for the patched APK. @@ -773,9 +784,9 @@ java -jar morphe-desktop-*-all.jar patch -p patches.mpp -e "Patch name" -Okey1 y ``` > [!WARNING] -> Option values are typed. Setting a value with the wrong type can cause the patch to fail. Use `list-patches --with-options` to see the expected types. +> Option values are typed. Setting a value with the wrong type can cause the patch to fail. Use `list-patches --with-options` to inspect expected option types. > -> Common types: `string`, `true`/`false`, `123` (integer), `1.0` (double), `[item1,item2]` (list) +> Common types: `Boolean` (`true`/`false`), `String` (`"text"`), `Int` (`123`), `Long` (`123L`), `Float` (`1.0` / `1.0f`), `List` (`[item1,item2]`), and `File` (`/path/to/file`). #### `--exclusive`: @@ -813,13 +824,14 @@ Default: - Automatically install the patched APK to a connected ADB device after patching. -``` -java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i your_app.apk -``` - -If no serial is provided, it installs to the first connected device. You can optionally specify a device serial. +If no serial is provided, Morphe installs to the first connected device. You can optionally specify a device serial. ``` +# Install to default connected device: +java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk -i +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i --mount your_app.apk + +# Install to a specific device by serial: java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i SERIAL123 your_app.apk ``` @@ -945,7 +957,7 @@ Required: No Default: `false` -By default, this run's scratch files are deleted once patching finishes. Pass this flag to keep them instead — useful for debugging a failed patch. Does not affect cached patches, other sessions, or config. +By default, this run's scratch files are deleted once patching finishes. Pass this flag to keep them instead - useful for debugging a failed patch. Does not affect cached patches, other sessions, or config. ``` java -jar morphe-desktop-*-all.jar patch -p patches.mpp --disable-purge your_app.apk @@ -994,11 +1006,15 @@ Required: No Default: - (verification is skipped) -Verify the patched DEX and APK files using a local Android SDK after patching. Helpful for catching corrupt output before installing on a device. Pass a path to your SDK install directory, or pass the flag with no value to let Morphe auto-discover one. +Verify the patched DEX and APK files using a local Android SDK after patching. Helpful for catching corrupt output before installing on a device. Pass a path to your SDK install directory, or pass the flag without a path to let Morphe auto-discover one. ``` +# Specify SDK path explicitly: java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk /path/to/android-sdk your_app.apk -java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk your_app.apk + +# Auto-discover SDK (place the flag after the APK or before another flag): +java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk --verify-with-sdk +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk --unsigned your_app.apk ``` > [!NOTE] @@ -1040,6 +1056,9 @@ java -jar morphe-desktop-*-all.jar patch -p patches.mpp --options-file options.j > [!NOTE] > If the file you specify doesn't exist yet, Morphe will automatically generate one with default values at that path and use it for the current patch. This means you can skip `options-create` entirely - just pass a path to a non-existent file and Morphe will create it for you. +> [!NOTE] +> Command-line flags (`-e`, `-d`, `--ei`, `--di`, `-O`) take precedence over the options and selections defined in `--options-file`. + > [!TIP] > The options file is great for repeatable patching. Generate it once (either with `options-create` or by letting `--options-file` auto-generate it), tweak it, and reuse it every time you patch. @@ -1088,7 +1107,7 @@ Here is a quick lookup for all the flags under this subcommand: | Flag | Description | |----------------------------------|--------------------------------------------------------| -| `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs | +| `--patches` | Paths to .mpp files, directories, or remote repos/PRs | | `--prerelease` | Fetch latest dev pre-release instead of stable release | | `--out` | Write patch list to a file instead of stdout | | `-d`, `--with-descriptions` | Show patch descriptions | @@ -1106,10 +1125,11 @@ Required: Yes Default: - -One or more paths to .mpp patch files or GitHub or GitLab repository URLs to list patches from. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs. +One or more paths to `.mpp` patch files, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests) to list patches from. When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. When a remote URL is provided, Morphe downloads the bundle and caches it on disk for future runs. ``` java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp +java -jar morphe-desktop-*-all.jar list-patches --patches /path/to/patches/ java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches ``` @@ -1119,7 +1139,7 @@ Required: No Default: `false` -When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release (automatically falling back to the other channel if the preferred channel is not available). ``` java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches --prerelease @@ -1179,7 +1199,7 @@ Required: No Default: `false` -Show the configurable options for each patch, including their keys, types, default values, and possible values. +Show the configurable options for each patch, including their keys, types (`Boolean`, `String`, `Int`, `Long`, `Float`, `List`, `File`), default values, and possible values. ``` java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-options @@ -1248,7 +1268,7 @@ Here is a quick lookup for all the flags under this subcommand: | Flag | Description | |--------------------------------|--------------------------------------------------------| -| `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs | +| `--patches` | Paths to .mpp files, directories, or remote repos/PRs | | `--prerelease` | Fetch latest dev pre-release instead of stable release | | `-f`, `--filter-package-names` | Filter by package names | | `-u`, `--count-unused-patches` | Include unused patches in the version count | @@ -1260,10 +1280,11 @@ Required: Yes Default: - -One or more paths to .mpp patch files or GitHub or GitLab repository URLs. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs. +One or more paths to `.mpp` patch files, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests). When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. When a URL is provided, Morphe downloads the bundle and caches it on disk for future runs. ``` java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp +java -jar morphe-desktop-*-all.jar list-versions --patches /path/to/patches/ java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches ``` @@ -1273,7 +1294,7 @@ Required: No Default: `false` -When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release (automatically falling back to the other channel if the preferred channel is not available). ``` java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches --prerelease @@ -1285,10 +1306,11 @@ Required: No Default: - -Only show versions for the specified package names. Can be used to check versions for a specific app. +Only show versions for the specified package names. Can be used to check versions for a specific app, or specified multiple times to filter for multiple apps. ``` java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp -f com.google.android.youtube +java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp -f com.google.android.youtube -f com.google.android.apps.youtube.music ``` #### `-u`, `--count-unused-patches`: @@ -1330,7 +1352,7 @@ Here is a quick lookup for all the flags under this subcommand: | Flag | Description | |--------------------------------|--------------------------------------------------------| -| `-p`, `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs | +| `--patches` | Paths to .mpp files, directories, or remote repos/PRs | | `--prerelease` | Fetch latest dev pre-release instead of stable release | | `-o`, `--out` | Path to the output JSON file | | `-f`, `--filter-package-name` | Filter patches by package name | @@ -1341,10 +1363,11 @@ Required: Yes Default: - -One or more paths to .mpp patch files or GitHub or GitLab repository URLs to generate options from. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs. +One or more paths to `.mpp` patch files, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests) to generate options from. When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. Multiple `-p` flags can be supplied to include options from multiple bundles in a single file. When a URL is provided, Morphe downloads the bundle and caches it on disk for future runs. ``` java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json +java -jar morphe-desktop-*-all.jar options-create -p /path/to/patches/ -o options.json java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches -o options.json ``` @@ -1354,7 +1377,7 @@ Required: No Default: `false` -When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release (automatically falling back to the other channel if the preferred channel is not available). ``` java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches --prerelease -o options.json @@ -1366,7 +1389,7 @@ Required: Yes Default: - -Path to the output JSON file. If the file already exists, Morphe will merge the current patches into it - preserving your existing settings, adding new patches, and removing patches that no longer exist. +Path to the output JSON file. If the file already exists, Morphe merges the current patches into it - preserving your existing settings, adding new patches, and removing patches that no longer exist. If the existing file is corrupted or contains invalid JSON, Morphe warns and regenerates a fresh options file. ``` java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e65ef7ad..428d64d3 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" 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/MorpheLauncher.kt b/src/main/kotlin/app/morphe/MorpheLauncher.kt index f1ee4eb1..7758e947 100644 --- a/src/main/kotlin/app/morphe/MorpheLauncher.kt +++ b/src/main/kotlin/app/morphe/MorpheLauncher.kt @@ -7,10 +7,9 @@ package app.morphe import app.morphe.desktop.command.MainCommand import app.morphe.engine.BootstrapDownloader -import app.morphe.library.logging.Logger +import app.morphe.engine.util.Logger import java.awt.GraphicsEnvironment import java.io.File -import java.util.logging.Logger.getLogger as JVLogger import kotlin.system.exitProcess import picocli.CommandLine @@ -74,11 +73,8 @@ fun main(args: Array) { } } } else { - Logger.setDefault() - - if (GraphicsEnvironment.isHeadless() && args.isEmpty()){ - val logger = JVLogger("app.morphe.MorpheLauncher") - logger.info("Running in Headless environment, falling back to CLI mode.") + if (GraphicsEnvironment.isHeadless() && args.isEmpty()) { + Logger.info("Running in Headless environment, falling back to CLI mode.") } CommandLine(MainCommand) 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..b354461d 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 app.morphe.engine.util.Logger +import java.io.File 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", @@ -27,7 +27,6 @@ import java.util.logging.Logger ], ) internal class ListCompatibleVersions : Runnable { - private val logger = Logger.getLogger(this::class.java.name) @Option( names = ["--patches"], @@ -73,10 +72,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 +83,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 +110,17 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString(name).prependIndent("\t")) } - patches.mostCommonCompatibleVersions( - packageNames, - countUnusedPatches, - includeExperimental, - ).entries.joinToString("\n", transform = ::buildString).let(logger::info) + val compatibilityMap = SupportedAppCatalog.getCompatibilityMap( + packageNames = packageNames, + countUnusedPatches = countUnusedPatches, + includeExperimental = includeExperimental, + patches = patches, + ) + + if (compatibilityMap.isEmpty()) { + Logger.warn("No compatible versions found in: ${patchesFiles.joinToString(", ") { it.path }}") + } else { + Logger.info(compatibilityMap.entries.joinToString("\n", transform = ::buildString)) + } } } diff --git a/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt index 97a94293..53cdd4d7 100644 --- a/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt @@ -8,28 +8,28 @@ 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.options.toPatchOption +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.engine.util.Logger +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 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", description = ["List patches from supplied MPP files."], ) internal object ListPatchesCommand : Runnable { - private val logger = Logger.getLogger(this::class.java.name) // Patches is now flag based rather than position based @Option( @@ -131,7 +131,7 @@ internal object ListPatchesCommand : Runnable { append(values.map { "${it.value} (${it.key})" }.joinToString("\n").prependIndent("\t")) } - append("\nType: $type") + append("\nType: ${toPatchOption().type.displayName}") } fun getVersionCodesString(patch: Patch<*>, pkgName: String, versionName: String): String { @@ -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 @@ -229,12 +222,12 @@ internal object ListPatchesCommand : Runnable { val finalOutput = filtered.joinToString("\n\n") {it.buildString()} if (filtered.isEmpty()) { - logger.warning("No compatible patches found in: $patchesFiles") + Logger.warn("No compatible patches found in: ${patchesFiles.joinToString(", ") { it.path }}") } else { if (outputFile == null) { - logger.info(finalOutput) + Logger.info(finalOutput) } else { - logger.info("Created new output file at ${outputFile!!.path}") + Logger.info("Created new output file at ${outputFile!!.path}") outputFile!!.writeText(finalOutput) } } diff --git a/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt b/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt index ef3c7896..7f376bf7 100644 --- a/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt @@ -6,16 +6,16 @@ package app.morphe.desktop.command import app.morphe.desktop.command.utility.UtilityCommand -import app.morphe.library.logging.Logger +import app.morphe.engine.workspace.WorkspaceManager +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..ec8283e4 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 app.morphe.engine.util.Logger +import java.io.File +import java.util.concurrent.Callable 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", @@ -33,8 +34,6 @@ internal object OptionsCommand : Callable { private const val EXIT_CODE_SUCCESS = 0 private const val EXIT_CODE_ERROR = 1 - private val logger = Logger.getLogger(this::class.java.name) - @Spec private lateinit var spec: CommandSpec @@ -70,19 +69,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(), @@ -91,7 +83,7 @@ internal object OptionsCommand : Callable { } return try { - logger.info("Loading patches...") + Logger.info("Loading patches...") // Load each bundle separately so we produce one JSON entry per .mpp // matches the shape PatchCommand expects when reading --options-file. @@ -101,10 +93,10 @@ internal object OptionsCommand : Callable { val existingBundles: List = if (outputFile.exists()) { try { - Json.decodeFromString>(outputFile.readText()) - } catch (e: Exception) { - logger.warning( - "Could not parse existing file, creating fresh: ${e.message}" + readPatchBundles(outputFile) + } catch (_: Exception) { + Logger.warn( + "Could not parse existing '${outputFile.name}' (invalid JSON), creating fresh." ) emptyList() } @@ -138,24 +130,23 @@ internal object OptionsCommand : Callable { val removed = existingNames - newNames val kept = newNames.intersect(existingNames) - logger.info( + Logger.info( "Updated bundle for ${lb.sourceFile.name}: ${kept.size} preserved, ${added.size} added, ${removed.size} removed" ) } else { - logger.info( + Logger.info( "Created new bundle for ${lb.sourceFile.name} with ${updatedBundle.patches.size} patches" ) } } - outputFile.absoluteFile.parentFile?.mkdirs() - outputFile.writeText(json.encodeToString(updatedBundles)) + writePatchBundles(outputFile, updatedBundles) - logger.info("Options file saved to ${outputFile.path}") + Logger.info("Options file saved to ${outputFile.path}") EXIT_CODE_SUCCESS } catch (e: Exception) { - logger.severe("Failed to export options: ${e.message}") + Logger.error("Failed to export options: ${e.message}") EXIT_CODE_ERROR } } diff --git a/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt b/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt index 39e72f56..28bb55e3 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.engine.util.Logger 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 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 @@ -63,8 +49,6 @@ internal object PatchCommand : Callable { private const val EXIT_CODE_SUCCESS = 0 private const val EXIT_CODE_ERROR = 1 - private val logger = Logger.getLogger(this::class.java.name) - @Spec private lateinit var spec: CommandSpec @@ -113,7 +97,7 @@ internal object PatchCommand : Callable { mapFallbackValue = CommandLine.Option.NULL_VALUE, converter = [OptionKeyConverter::class, OptionValueConverter::class], ) - internal var options = mutableMapOf() + internal var options: MutableMap? = null } @ArgGroup(exclusive = false) @@ -388,665 +372,180 @@ internal object PatchCommand : Callable { override fun call(): Int { // 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) + UpdateChecker.check()?.let { Logger.info(it) } + + 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.error(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.error( + "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.error( + "No device has been found to install to. " + + "Ensure a device is connected when using the --install option.", + ) + else -> Logger.error(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.isNullOrEmpty() } + .associate { it.enabled!!.selector.name!! to it.enabled!!.options!! } + val patchOptionIndices = bundleArg.selections + .filter { it.enabled?.selector?.index != null && !it.enabled!!.options.isNullOrEmpty() } + .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.error(line) + line.startsWith("WARNING:", ignoreCase = true) -> Logger.warn(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) - } - 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}") + Json.encodeToStream(engineResult, outputStream) } + Logger.info("Patching result saved to $outputFile") } - 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.error("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..c257cf4a 100644 --- a/src/main/kotlin/app/morphe/desktop/command/utility/ClearCacheCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/utility/ClearCacheCommand.kt @@ -6,18 +6,16 @@ package app.morphe.desktop.command.utility import app.morphe.engine.CacheManager +import app.morphe.engine.util.Logger +import java.util.concurrent.Callable import picocli.CommandLine.Command import picocli.CommandLine.Option -import java.util.concurrent.Callable -import java.util.logging.Logger @Command( name = "clear-cache", description = ["Delete cached patch files, logs, and temporary files."], ) internal object ClearCacheCommand : Callable { - private val logger = Logger.getLogger(this::class.java.name) - private const val EXIT_CODE_SUCCESS = 0 private const val EXIT_CODE_ERROR = 1 @@ -31,7 +29,7 @@ internal object ClearCacheCommand : Callable { val result = CacheManager.clearCaches() if (info) { - logger.info( + Logger.info( buildString { appendLine("Cache cleared:") result.perDirectory.forEach { dir -> @@ -44,14 +42,14 @@ internal object ClearCacheCommand : Callable { } ) } else { - logger.info("Cache cleared.") + Logger.info("Cache cleared.") } // Deletion failures always shown, even without --info. return if (result.success) { EXIT_CODE_SUCCESS } else { - logger.warning("${result.failedFiles} file(s) could not be deleted (may be locked)") + Logger.warn("${result.failedFiles} file(s) could not be deleted (may be locked)") EXIT_CODE_ERROR } } 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..f6058987 100644 --- a/src/main/kotlin/app/morphe/desktop/command/utility/InstallCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/utility/InstallCommand.kt @@ -5,24 +5,20 @@ 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 app.morphe.engine.util.Logger +import java.io.File 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", description = ["Install an APK file."], ) internal object InstallCommand : Runnable { - private val logger = Logger.getLogger(this::class.java.name) - @Parameters( description = ["Serial of ADB devices. If not supplied, the first connected device will be used."], arity = "0..*", @@ -55,60 +51,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.error(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.error("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.error(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.error(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..26e1175f 100644 --- a/src/main/kotlin/app/morphe/desktop/command/utility/UninstallCommand.kt +++ b/src/main/kotlin/app/morphe/desktop/command/utility/UninstallCommand.kt @@ -5,24 +5,19 @@ 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 app.morphe.engine.util.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", description = ["Uninstall a patched app."], ) internal object UninstallCommand : Runnable { - private val logger = Logger.getLogger(this::class.java.name) - @Parameters( description = ["Serial of ADB devices. If not supplied, the first connected device will be used."], arity = "0..*", @@ -44,28 +39,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.error(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.error(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/BootstrapDownloader.kt b/src/main/kotlin/app/morphe/engine/BootstrapDownloader.kt index 4d28d069..394bd148 100644 --- a/src/main/kotlin/app/morphe/engine/BootstrapDownloader.kt +++ b/src/main/kotlin/app/morphe/engine/BootstrapDownloader.kt @@ -5,18 +5,16 @@ package app.morphe.engine +import app.morphe.engine.util.Logger import java.io.File import java.io.FileOutputStream import java.net.URI import java.security.MessageDigest -import java.util.logging.Logger /** * Handles deferred downloading of GUI dependencies to keep the base CLI as small as possible. */ object BootstrapDownloader { - private val logger = Logger.getLogger("app.morphe.engine.BootstrapDownloader") - // Remote Dependencies data class RemoteDependency(val fileName: String, val url: String, val expectedHash: String) @@ -62,7 +60,7 @@ object BootstrapDownloader { val expectedFileNames = dependencies.map { it.fileName }.toSet() binDir.listFiles()?.forEach { file -> if (file.isFile && file.name !in expectedFileNames) { - logger.info("Removing obsolete dependency: ${file.name}") + Logger.info("Removing obsolete dependency: ${file.name}") file.delete() } } @@ -85,11 +83,11 @@ object BootstrapDownloader { continue } if (target.exists()) { - logger.warning("Cache invalid for ${dep.fileName}, redownloading.") + Logger.warn("Cache invalid for ${dep.fileName}, redownloading.") target.delete() } - logger.info("Downloading ${dep.fileName}...") + Logger.info("Downloading ${dep.fileName}...") val current = index try { downloadWithProgress(dep, target) { done, total -> @@ -98,7 +96,7 @@ object BootstrapDownloader { } catch (e: Exception) { target.delete() val message = "Failed to download ${dep.fileName}: ${e.message}" - logger.severe(message) + Logger.error(message) listener?.onError(message) throw BootstrapException(message, e) } @@ -106,7 +104,7 @@ object BootstrapDownloader { if (!verifyHash(target, dep.expectedHash)) { target.delete() val message = "Checksum mismatch for ${dep.fileName}." - logger.severe(message) + Logger.error(message) listener?.onError(message) throw BootstrapException(message) } @@ -116,7 +114,7 @@ object BootstrapDownloader { } listener?.onComplete() - logger.info("All GUI dependencies ready.") + Logger.info("All GUI dependencies ready.") return downloadedFiles } 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/MorpheData.kt b/src/main/kotlin/app/morphe/engine/MorpheData.kt index ced304ae..f060db54 100644 --- a/src/main/kotlin/app/morphe/engine/MorpheData.kt +++ b/src/main/kotlin/app/morphe/engine/MorpheData.kt @@ -5,8 +5,8 @@ package app.morphe.engine +import app.morphe.engine.util.Logger import java.io.File -import java.util.logging.Logger /** * Single source of truth for where Morphe stores its runtime data on disk. @@ -44,8 +44,6 @@ import java.util.logging.Logger * resolution runs **at most once per JVM** — the lazy property caches. */ object MorpheData { - private val logger = Logger.getLogger(MorpheData::class.java.name) - private val resolution: Resolution by lazy { resolveRoot() } /** Root: JAR-adjacent `morphe-data/`, with fallback to `~/morphe/`. */ @@ -116,20 +114,20 @@ object MorpheData { // package-manager install can point the data root at a writable, XDG-compliant // location. No portable-bundle concept here for now (maybe change in the future?), paths stay absolute. envOverrideRoot()?.let { override -> - logger.info("Morphe data root: ${override.absolutePath} (MORPHE_DATA_DIR override)") + Logger.info("Morphe data root: ${override.absolutePath} (MORPHE_DATA_DIR override)") override.mkdirs() return Resolution(root = override, bundleRoot = null) } val (jarAdjacent, fallbackReason) = tryJarAdjacent() if (jarAdjacent != null) { - logger.info("Morphe data root: ${jarAdjacent.absolutePath} (JAR-adjacent)") + Logger.info("Morphe data root: ${jarAdjacent.absolutePath} (JAR-adjacent)") jarAdjacent.mkdirs() return Resolution(root = jarAdjacent, bundleRoot = jarAdjacent.parentFile) } val fallback = userHomeFallback() // WARNING level — users debugging "I can't find my patches" or "config // didn't persist" need to see this to know we fell back and why. - logger.warning( + Logger.warn( "Morphe data root falling back to ${fallback.absolutePath} — " + "primary (JAR-adjacent) unavailable: ${fallbackReason?.message ?: "unknown"}" ) @@ -184,7 +182,7 @@ object MorpheData { val dir = File(raw) val usable = runCatching { dir.mkdirs(); dir.isDirectory && dir.canWrite() }.getOrDefault(false) if (!usable) { - logger.warning("MORPHE_DATA_DIR is set to '$raw' but it isn't a writable directory — ignoring.") + Logger.warn("MORPHE_DATA_DIR is set to '$raw' but it isn't a writable directory — ignoring.") return null } return dir diff --git a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt index c5a4f133..fb171de4 100644 --- a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt +++ b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt @@ -5,6 +5,8 @@ package app.morphe.engine +import app.morphe.engine.util.Logger +import app.morphe.engine.workspace.WorkspaceManager import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar import kotlinx.coroutines.Dispatchers @@ -13,8 +15,6 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import java.io.File -import java.util.logging.Level -import java.util.logging.Logger /** * Loads patches from one or more `.mpp` files in parallel and tags each loaded @@ -23,13 +23,10 @@ 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 { - private val logger = Logger.getLogger(this::class.java.name) - data class SourceInput( val sourceId: String, val sourceName: String, @@ -59,8 +56,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,11 +81,10 @@ 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}'") + Logger.info("MultiSourceLoader: loaded ${patches.size} patches from '${input.sourceName}'") LoadedSource( sourceId = input.sourceId, sourceName = input.sourceName, @@ -112,8 +107,7 @@ object MultiSourceLoader { } else { PatchSourceLoadException(e.readableMessage(), e) } - logger.log( - Level.WARNING, + Logger.warn( "MultiSourceLoader: failed to load '${input.sourceName}': ${error.message}", e, ) @@ -126,8 +120,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..8e79f15b 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -8,108 +8,245 @@ 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.Logger 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.job 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.Logger +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger as JulLogger -/* - * 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 - val stepResults = mutableListOf() - val appliedPatches = mutableListOf() - val failedPatches = mutableListOf() - - 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. - val actualInputApk = if (BundleFormats.isBundle(config.inputApk)) { + 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() + + val scopeContext = coroutineContext + val workerThread = Thread.currentThread() + val cancelHandle = scopeContext.job.invokeOnCompletion { cause -> + if (cause is CancellationException) { + workerThread.interrupt() + } + } + + val reportProgress: (String) -> Unit = { message -> + scopeContext.ensureActive() + onProgress(message) + } + + // Capture internal logs from morphe-patcher and pipe to onProgress + val patcherLogger = JulLogger.getLogger("app.morphe.patcher") + val prevUseParentHandlers = patcherLogger.useParentHandlers + patcherLogger.useParentHandlers = false + val patcherLogHandler = object : Handler() { + override fun publish(record: LogRecord) { + scopeContext.ensureActive() + val rawMessage = record.message ?: return + if (rawMessage.isBlank()) return + val prefix = when (record.level) { + Level.SEVERE -> "ERROR: " + Level.WARNING -> "WARNING: " + else -> "" + } + reportProgress("$prefix$rawMessage") + } + override fun flush() {} + override fun close() {} + } + patcherLogger.addHandler(patcherLogHandler) + + try { + // 1. Handle split-APK bundles (.apkm/.xapk/.apks) + val actualInputApk = if (BundleFormats.isBundle(config.inputApk)) { + reportProgress("Merging split APK bundle...") val mergedApk = File(tempDir, "${config.inputApk.nameWithoutExtension}-merged.apk") - ApkMerger(Logger.getLogger("app.morphe.patcher.ApkMerger").toMorpheLogger()).merge( + ApkMerger(JulLogger.getLogger("app.morphe.patcher.ApkMerger").toMorpheLogger()).merge( inputFile = config.inputApk, outputFile = mergedApk, - cleanMetaInf = false + cleanMetaInf = false, ) mergedApkToCleanup = mergedApk mergedApk @@ -119,11 +256,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,56 +271,235 @@ 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.warn("Options file is out of date for ${lb.sourceFile.name}") + if (drift.oldPatches.isNotEmpty()) { + Logger.warn(" ${drift.oldPatches.size} patches in your options file are not compatible with the app:") + drift.oldPatches.forEach { Logger.warn(" - $it") } + } + if (drift.patchesWithNewOptions.isNotEmpty()) { + drift.patchesWithNewOptions.forEach { (patch, key) -> + Logger.warn(" \"$patch\" has new options: ${key.joinToString(", ")}") + } + } + if (drift.patchesWithOldOptions.isNotEmpty()) { + drift.patchesWithOldOptions.forEach { (patch, key) -> + Logger.warn(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") + } + } + Logger.warn(" 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.warn("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) + } + + 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.warn("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() - // 5. Execute patches - onProgress("Applying ${filteredPatches.size} patches...") + // 4. Execute patches + onProgress("Applying ${finalPatches.size} patches...") try { patcher().collect { patchResult -> + currentCoroutineContext().ensureActive() val patchName = patchResult.patch.name ?: "Unknown" patchResult.exception?.let { exception -> val error = StringWriter().use { writer -> @@ -191,10 +510,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,44 +520,85 @@ 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: CancellationException) { + throw e } 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, - logger = logger, + primary = finalKeystoreDetails, + allowLegacyFallback = config.keystoreDetails == null || + (finalKeystoreDetails.alias == Config.DEFAULT_KEYSTORE_ALIAS && + finalKeystoreDetails.password == Config.DEFAULT_KEYSTORE_PASSWORD), ) { details -> ApkUtils.signApk( rebuiltApk, @@ -251,51 +608,180 @@ object PatchEngine { ) } stepResults.add(StepResult(PatchStep.SIGNING, true)) + } catch (e: CancellationException) { + throw e } 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: CancellationException) { + throw e + } catch (e: Exception) { + Logger.warn("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.warn("Failed to record patched app to store: ${e.message ?: e::class.simpleName}") + } + } + 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() } + } - // 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, + 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) { + cancelHandle.dispose() + Thread.interrupted() + 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.warn("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 +792,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 +811,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/PatchedAppStore.kt b/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt index 8f4fa3e5..72dd2207 100644 --- a/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt +++ b/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt @@ -6,6 +6,7 @@ package app.morphe.engine import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -18,7 +19,6 @@ import kotlinx.serialization.json.Json import java.io.File import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.util.logging.Logger /** * Persistent store for [PatchedAppRecord]s — the patched-app history shared by @@ -35,8 +35,6 @@ import java.util.logging.Logger class PatchedAppStore( private val file: File = File(MorpheData.root, FILE_NAME), ) { - private val logger = Logger.getLogger(PatchedAppStore::class.java.name) - private val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -98,7 +96,7 @@ class PatchedAppStore( } catch (e: Exception) { // Corrupt/incompatible file: don't lose the user's ability to keep // patching — start fresh in memory and let the next write heal it. - logger.warning("Could not read patched-app history (${e.message}); starting empty") + Logger.warn("Could not read patched-app history (${e.message}); starting empty") emptyList() } } else { @@ -127,7 +125,7 @@ class PatchedAppStore( } cache = records } catch (e: Exception) { - logger.warning("Failed to write patched-app history: ${e.message}") + Logger.warn("Failed to write patched-app history: ${e.message}") } } diff --git a/src/main/kotlin/app/morphe/engine/PatcherCompatibility.kt b/src/main/kotlin/app/morphe/engine/PatcherCompatibility.kt index e9b4fa36..f2510a08 100644 --- a/src/main/kotlin/app/morphe/engine/PatcherCompatibility.kt +++ b/src/main/kotlin/app/morphe/engine/PatcherCompatibility.kt @@ -5,9 +5,9 @@ package app.morphe.engine +import app.morphe.engine.util.Logger import java.io.File import java.util.zip.ZipInputStream -import java.util.logging.Logger /** * Detects when a patch bundle (.mpp) was built against a newer morphe-patcher than this @@ -27,8 +27,6 @@ import java.util.logging.Logger */ object PatcherCompatibility { - private val logger = Logger.getLogger(PatcherCompatibility::class.java.name) - /** * The morphe-patcher version on the classpath (see [MorpheComponents.patcherVersion]). */ @@ -61,7 +59,7 @@ object PatcherCompatibility { val required = requiredPatcherVersion(mpp) ?: return null val current = currentPatcherVersion ?: return null if (!isNewer(required, current)) return null - logger.info("Bundle ${mpp.name} needs patcher $required, this build ships $current") + Logger.info("Bundle ${mpp.name} needs patcher $required, this build ships $current") return "This patch bundle needs Morphe patcher $required, but this build ships $current. " + "Update Morphe to use it." } diff --git a/src/main/kotlin/app/morphe/engine/UpdateChecker.kt b/src/main/kotlin/app/morphe/engine/UpdateChecker.kt index 701df576..76b90de9 100644 --- a/src/main/kotlin/app/morphe/engine/UpdateChecker.kt +++ b/src/main/kotlin/app/morphe/engine/UpdateChecker.kt @@ -5,11 +5,11 @@ package app.morphe.engine +import app.morphe.engine.util.Logger import java.net.HttpURLConnection import java.net.URI import java.net.URL import java.util.Properties -import java.util.logging.Logger /** * Resolved release channel that [UpdateChecker] should probe. The user-facing @@ -32,7 +32,7 @@ object UpdateChecker { * behavior — preserved so the CLI's existing call site keeps working). * Synchronous — call from a background dispatcher. */ - fun checkInfo(logger: Logger, channel: ReleaseChannel? = null): UpdateInfo? { + fun checkInfo(channel: ReleaseChannel? = null): UpdateInfo? { try { val currentVersion = javaClass.getResourceAsStream("/app/morphe/cli/version.properties") ?.use { stream -> @@ -75,7 +75,7 @@ object UpdateChecker { downloadLink = downloadLink, ) } catch (ex: Exception) { - logger.fine("Could not check for CLI update: $ex") + Logger.debug("Could not check for CLI update: $ex") return null } } @@ -96,8 +96,8 @@ object UpdateChecker { * Kept byte-identical so [app.morphe.desktop.command.PatchCommand]'s logger * output doesn't change. */ - fun check(logger: Logger): String? { - val info = checkInfo(logger) ?: return null + fun check(): String? { + val info = checkInfo() ?: return null val trackChangesMessage = if (info.crossesDevToStable) { "\nNotice: The latest CLI is a stable release. Updating to that will stop dev " + "update notifications. To keep receiving dev updates, skip stable update " + 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..5568b052 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/apk/ApkInspector.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.apk + +import app.morphe.engine.util.Logger +import com.reandroid.arsc.chunk.xml.AndroidManifestBlock +import java.io.ByteArrayInputStream +import java.io.File +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 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.warn("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.warn("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.warn("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.warn("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.warn("No AndroidManifest.xml found inside base APK of bundle ${file.name}") + return null + } + + val packageName = block.packageName ?: run { + Logger.warn("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..c6d53c34 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/config/EngineConfigRepository.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.config + +import app.morphe.engine.MorpheData +import app.morphe.engine.util.Logger +import app.morphe.engine.util.PortablePaths +import java.io.File +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 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.warn("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..a8bb0ebc 100644 --- a/src/main/kotlin/app/morphe/engine/network/HttpService.kt +++ b/src/main/kotlin/app/morphe/engine/network/HttpService.kt @@ -6,6 +6,7 @@ package app.morphe.engine.network import app.morphe.engine.GitHubPatMissingException +import app.morphe.engine.util.Logger import io.ktor.client.HttpClient import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.get @@ -24,7 +25,6 @@ import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.util.logging.Logger import java.util.zip.ZipInputStream import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CancellationException @@ -59,8 +59,6 @@ class HttpService( @PublishedApi internal val http: HttpClient, @PublishedApi internal val json: Json = Json { ignoreUnknownKeys = true }, ) { - @PublishedApi - internal val logger: Logger = Logger.getLogger(HttpService::class.java.name) /** * GET [url] and decode the body to [T]. @@ -370,17 +368,17 @@ class HttpService( throw HttpException(HttpStatusCode.TooManyRequests, operation, cause = t) } val wait = (t.retryAfterMillis ?: delayMs).coerceAtMost(MAX_RETRY_DELAY_MS) - logger.warning("$operation hit 429 (attempt $attempt/$MAX_RETRY_ATTEMPTS), waiting ${wait}ms") + Logger.warn("$operation hit 429 (attempt $attempt/$MAX_RETRY_ATTEMPTS), waiting ${wait}ms") delay(wait.milliseconds) delayMs = (delayMs * 2).coerceAtMost(MAX_RETRY_DELAY_MS) } catch (t: HttpException) { if (!t.isRetryable || attempt >= MAX_RETRY_ATTEMPTS) throw t - logger.warning("$operation attempt $attempt: retryable HTTP error: ${t.message}") + Logger.warn("$operation attempt $attempt: retryable HTTP error: ${t.message}") delay(delayMs.milliseconds) delayMs = (delayMs * 2).coerceAtMost(MAX_RETRY_DELAY_MS) } catch (t: Exception) { if (attempt >= MAX_RETRY_ATTEMPTS) throw t - logger.warning("$operation attempt $attempt failed: ${t::class.simpleName}: ${t.message}") + Logger.warn("$operation attempt $attempt failed: ${t::class.simpleName}: ${t.message}") delay(delayMs.milliseconds) delayMs = (delayMs * 2).coerceAtMost(MAX_RETRY_DELAY_MS) } @@ -417,7 +415,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..fd667e64 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/options/PatchOption.kt @@ -0,0 +1,163 @@ +/* + * 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; + + val displayName: String + get() = when (this) { + STRING -> "String" + BOOLEAN -> "Boolean" + INT -> "Int" + LONG -> "Long" + FLOAT -> "Float" + LIST -> "List" + FILE -> "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 + } + @Suppress("DEPRECATION") + val optionTitle = this.title?.takeIf { it.isNotBlank() } ?: this.name + return PatchOption( + key = this.name, + title = optionTitle, + 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 86% 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..3bb20666 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchPreferencesRepository.kt +++ b/src/main/kotlin/app/morphe/engine/options/PatchPreferencesRepository.kt @@ -3,13 +3,10 @@ * 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 app.morphe.engine.util.Logger import java.io.File import java.time.Instant import java.time.format.DateTimeFormatter @@ -19,17 +16,19 @@ 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 json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -39,11 +38,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 +49,7 @@ class PatchPreferencesRepository { mutableMapOf() } } catch (e: Exception) { - Logger.error("Failed to load patch preferences, starting fresh", e) + Logger.warn("Failed to load patch preferences, starting fresh", e) mutableMapOf() } cache = parsed @@ -124,7 +120,6 @@ 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)") @@ -133,4 +128,8 @@ class PatchPreferencesRepository { } } } + + 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..7eb673e8 100644 --- a/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt @@ -9,12 +9,12 @@ import app.morphe.engine.model.PatchesBundle import app.morphe.engine.model.Release import app.morphe.engine.model.ReleaseAsset import app.morphe.engine.network.HttpService +import app.morphe.engine.util.Logger import io.ktor.client.request.headers import io.ktor.http.HttpHeaders +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File -import java.util.logging.Logger /** * GitHub provider. Hits api.github.com/repos/{owner}/{repo}/releases for the @@ -31,36 +31,36 @@ open class GitHubPatchSource( override val provider = PatchProvider.GITHUB - protected val logger: Logger = Logger.getLogger(GitHubPatchSource::class.java.name) private val releasesEndpoint = "$API_BASE/repos/$repoPath/releases" override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { try { - logger.info("GitHub: fetching releases from $releasesEndpoint") + Logger.info("GitHub: fetching releases from $releasesEndpoint") val releases: List = http.request(releasesEndpoint) { headers { append(HttpHeaders.Accept, "application/vnd.github+json") append("X-GitHub-Api-Version", "2022-11-28") } } - logger.info("GitHub: fetched ${releases.size} releases from $repoPath") + Logger.info("GitHub: fetched ${releases.size} releases from $repoPath") Result.success(releases) } catch (e: Exception) { - logger.warning("GitHub releases fetch error for $repoPath: ${e.message}") + Logger.warn("GitHub releases fetch error for $repoPath: ${e.message}") Result.failure(e) } } 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") + 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}") + 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) } } @@ -71,14 +71,13 @@ open class GitHubPatchSource( onProgress: ((bytesRead: Long, contentLength: Long?) -> Unit)?, ): Result = withContext(Dispatchers.IO) { try { - logger.info("GitHub: downloading ${asset.name} from ${asset.downloadUrl}") + Logger.info("GitHub: downloading ${asset.name} from ${asset.downloadUrl}") val file = http.downloadToFile(asset.downloadUrl, targetFile, onProgress) { headers { append(HttpHeaders.Accept, "application/octet-stream") } } - logger.info("GitHub: wrote ${file.length()} bytes to ${file.absolutePath}") + 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..92c4b4f5 100644 --- a/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt @@ -9,10 +9,14 @@ import app.morphe.engine.model.PatchesBundle import app.morphe.engine.model.Release import app.morphe.engine.model.ReleaseAsset import app.morphe.engine.network.HttpService +import app.morphe.engine.util.Logger 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 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 @@ -47,8 +48,6 @@ class GitLabPatchSource( override val provider = PatchProvider.GITLAB - private val logger = Logger.getLogger(GitLabPatchSource::class.java.name) - // GitLab's projects API expects the path URL-encoded as `owner%2Frepo`. private val releasesEndpoint: String = run { val encoded = URLEncoder.encode(repoPath, "UTF-8") @@ -57,29 +56,30 @@ class GitLabPatchSource( override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { try { - logger.info("GitLab: fetching releases from $releasesEndpoint") + Logger.info("GitLab: fetching releases from $releasesEndpoint") val raw = http.request(releasesEndpoint) { headers { append(HttpHeaders.Accept, "application/json") } } val releases = parseReleases(raw) - logger.info("GitLab: fetched ${releases.size} releases from $repoPath") + Logger.info("GitLab: fetched ${releases.size} releases from $repoPath") Result.success(releases) } catch (e: Exception) { - logger.warning("GitLab releases fetch error for $repoPath: ${e.message}") + Logger.warn("GitLab releases fetch error for $repoPath: ${e.message}") Result.failure(e) } } 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") + 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}") + 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) } } @@ -90,14 +90,13 @@ class GitLabPatchSource( onProgress: ((bytesRead: Long, contentLength: Long?) -> Unit)?, ): Result = withContext(Dispatchers.IO) { try { - logger.info("GitLab: downloading ${asset.name} from ${asset.downloadUrl}") + Logger.info("GitLab: downloading ${asset.name} from ${asset.downloadUrl}") val file = http.downloadToFile(asset.downloadUrl, targetFile, onProgress) { headers { append(HttpHeaders.Accept, "application/octet-stream") } } - logger.info("GitLab: wrote ${file.length()} bytes to ${file.absolutePath}") + 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) } } @@ -182,12 +181,14 @@ class GitLabPatchSource( return try { val response: HttpResponse = http.head(url) if (!response.status.isSuccess()) { - logger.fine("HEAD $url returned ${response.status}") + Logger.debug("HEAD $url returned ${response.status}") return 0L } response.headers[HttpHeaders.ContentLength]?.toLongOrNull() ?: 0L + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - logger.fine("HEAD failed for $url: ${e.message}") + Logger.debug("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..55b89c49 100644 --- a/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt +++ b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt @@ -8,19 +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.Logger +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 /** - * 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 { + /** + * 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 = File(MorpheData.patchesDir, repoPath.replace("/", "-")).also { it.mkdirs() } @@ -33,5 +46,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.warn("Failed to delete ${file.name}") + } + } catch (e: Exception) { + failedCount++ + Logger.warn("Failed to delete ${file.name}: ${e.message ?: e::class.simpleName}") + } + } + if (failedCount > 0) { + Logger.warn("Patches cache clear incomplete for $repoPath: $failedCount file(s) locked") + false + } else { + Logger.info("Patches cache cleared for $repoPath") + true + } + } catch (e: Exception) { + Logger.warn("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..f81fe702 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchRepository.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.util.Logger +import java.io.File +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 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 + private var lastCacheHitLogTimestamp: 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 + val now = System.currentTimeMillis() + if (!forceRefresh && cached != null && + (now - cacheTimestamp) < CACHE_TTL_MS + ) { + val age = (now - cacheTimestamp) / 1000 + if (now - lastCacheHitLogTimestamp >= 2000L) { + lastCacheHitLogTimestamp = now + Logger.info("Using cached releases from $repoPath (${cached.size} releases, age=${age}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 && + (now - 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() + lastCacheHitLogTimestamp = cacheTimestamp + 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 from $repoPath 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 + lastCacheHitLogTimestamp = 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..dfe4d2bf --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchResolver.kt @@ -0,0 +1,323 @@ +/* + * 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.Logger +import app.morphe.engine.util.normalizeVersion +import io.ktor.client.HttpClient +import java.io.File +import kotlinx.coroutines.Dispatchers +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 { + + /** 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 primary = if (usePreRelease) repo.getLatestDevRelease() else repo.getLatestStableRelease() + val fallback = if (primary.getOrNull() == null) { + if (usePreRelease) repo.getLatestStableRelease() else repo.getLatestDevRelease() + } else null + + val chosen = primary.getOrNull() ?: fallback?.getOrNull() + ?: return@withContext offlineOrError(repo, primary.exceptionOrNull() ?: fallback?.exceptionOrNull()) + + release = chosen + latestDevTag = if (chosen.isDevRelease()) chosen.tagName else null + latestStableTag = if (!chosen.isDevRelease()) chosen.tagName else null + } + + 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.warn( + "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..5aad8eea 100644 --- a/src/main/kotlin/app/morphe/engine/patches/PullRequestPatchSource.kt +++ b/src/main/kotlin/app/morphe/engine/patches/PullRequestPatchSource.kt @@ -6,15 +6,15 @@ 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 app.morphe.engine.util.Logger 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 +27,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 +41,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,21 +52,15 @@ 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() - logger.info("GitHub PR: resolving artifact for $owner/$repo#$prNumber (auth=${pat != null})") + Logger.info("GitHub PR: resolving artifact for $owner/$repo#$prNumber (auth=${pat != null})") val prAsset = http.getAssetFromPullRequest(owner, repo, prNumber, pat) val assetName = if (prAsset.artifactName.endsWith(".mpp", ignoreCase = true)) { @@ -87,7 +73,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", ) @@ -114,13 +100,32 @@ class PullRequestPatchSource( throw GitHubPatMissingException("A GitHub PAT is required to download pull request sources") } - logger.info("GitHub PR: downloading ${asset.name} for $repoPath#$prNumber from ${asset.downloadUrl}") + Logger.info("GitHub PR: downloading ${asset.name} for $repoPath#$prNumber from ${asset.downloadUrl}") val file = http.downloadPrArtifactToFile(asset.downloadUrl, targetFile, pat, onProgress) - logger.info("GitHub PR: downloaded ${file.length()} bytes to ${file.absolutePath}") + 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 61% rename from src/main/kotlin/app/morphe/gui/util/AdbManager.kt rename to src/main/kotlin/app/morphe/engine/util/AdbManager.kt index eb1150eb..5d423bed 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/engine/util/AdbManager.kt @@ -3,29 +3,32 @@ * 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.engine.util.Logger +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 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 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 +36,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 +49,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. */ @@ -184,7 +190,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 { @@ -207,7 +213,11 @@ 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"), + ) ) } @@ -223,7 +233,14 @@ class AdbManager { 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)) + Result.failure( + AdbException( + "Failed to start ADB server: ${e.message ?: ""}", + AdbErrorCode.START_SERVER_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -250,16 +267,27 @@ class AdbManager { if (exitCode != 0) { Logger.warn("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 + 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)) + Result.failure( + AdbException( + "Failed to kill ADB server: ${e.message ?: ""}", + AdbErrorCode.KILL_SERVER_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -269,7 +297,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 +311,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), + ) ) } @@ -295,42 +327,22 @@ class AdbManager { 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)) + 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 +355,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,27 +379,76 @@ 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.error(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(" ")}") return try { @@ -405,60 +472,212 @@ class AdbManager { 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)) + 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.error("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.error(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.error("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.error("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.error("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.error("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.debug("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.error("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)) + Result.failure( + AdbException( + "Error uninstalling $packageName: ${e.message ?: ""}", + AdbErrorCode.UNINSTALL_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -468,7 +687,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 +696,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. @@ -493,7 +718,14 @@ class AdbManager { 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)) + Result.failure( + AdbException( + "Error clearing logcat: ${e.message ?: ""}", + AdbErrorCode.CLEAR_LOGS_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -504,7 +736,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,7 +754,13 @@ 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()) { @@ -535,7 +773,14 @@ class AdbManager { 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)) + Result.failure( + AdbException( + "Error capturing logcat: ${e.message ?: ""}", + AdbErrorCode.CAPTURE_LOGS_FAILED, + listOf(e.message ?: ""), + cause = e, + ) + ) } } @@ -561,10 +806,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 +818,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,10 +830,7 @@ 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) } @@ -614,7 +853,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) @@ -627,17 +868,25 @@ class AdbManager { 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)) + 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 +896,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 +921,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 +953,7 @@ class AdbManager { val out = process.inputStream.bufferedReader().readText() process.waitFor() out.ifBlank { null } - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -696,12 +962,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 +988,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 +1014,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 +1030,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 +1119,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 +1142,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 91% rename from src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt rename to src/main/kotlin/app/morphe/engine/util/DownloadUrlResolver.kt index cb282550..fdd5281d 100644 --- a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt +++ b/src/main/kotlin/app/morphe/engine/util/DownloadUrlResolver.kt @@ -3,14 +3,13 @@ * 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -34,7 +33,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 { @@ -68,8 +67,6 @@ object DownloadUrlResolver { return resolveRedirects(resolved, maxRedirectsToFollow - 1) } - - //Log.d("Unexpected response code: $responseCode") } catch (ex: SocketTimeoutException) { Logger.info("Timeout while resolving search redirect: $ex") } catch (ex: Exception) { 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/KeystoreSigner.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt index d2ba4ad0..de2b6233 100644 --- a/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt @@ -7,7 +7,6 @@ package app.morphe.engine.util import app.morphe.engine.PatchEngine import app.morphe.patcher.apk.ApkUtils -import java.util.logging.Logger /** * Signs an APK with [primary] credentials, falling back to the legacy ("Morphe Key" / empty password) entry. @@ -25,7 +24,6 @@ import java.util.logging.Logger fun signWithLegacyFallback( primary: ApkUtils.KeyStoreDetails, allowLegacyFallback: Boolean, - logger: Logger, sign: (ApkUtils.KeyStoreDetails) -> Unit, ) { try { @@ -34,7 +32,7 @@ fun signWithLegacyFallback( if (!allowLegacyFallback || !primary.keyStore.exists()) throw primaryError // Never silently swallow the real cause. Always log it before the back-compat path. - logger.info( + Logger.info( "Default keystore credentials failed (${primaryError.message}). Retrying with legacy credentials" ) diff --git a/src/main/kotlin/app/morphe/engine/util/Logger.kt b/src/main/kotlin/app/morphe/engine/util/Logger.kt new file mode 100644 index 00000000..2486a8b0 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/Logger.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.util + +import app.morphe.engine.MorpheComponents +import app.morphe.engine.MorpheData +import app.morphe.engine.UpdateChecker +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.MessageFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.logging.Handler +import java.util.logging.Level as JulLevel +import java.util.logging.LogRecord +import java.util.logging.Logger as JulLogger + +/** + * Unified logger for both CLI and GUI. + * + * Log file location: `/logs/morphe-.log` — JAR-adjacent + * `morphe-data/logs/` for shipped jars, `~/morphe/logs/` for IDE/dev runs. + * See [app.morphe.engine.MorpheData] for the full resolution + fallback rules. + */ +object Logger { + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + private val fileTimestampFormat = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US) + private var logFile: File? = null + private var initialized = false + + enum class Level { + DEBUG, INFO, WARN, ERROR + } + + /** + * Initialize the GUI logger. Call once at GUI application startup. + */ + fun init() { + if (initialized) return + + try { + // Install JUL bridge early so logs during startup (e.g. MorpheData) are unified + installJulBridge() + + val logsDir = MorpheData.logsDir + val timestamp = fileTimestampFormat.format(Date()) + logFile = File(logsDir, "morphe-$timestamp.log") + + // Write startup diagnostic banner to console and log file (without log markers) + val banner = buildString { + appendLine("=".repeat(60)) + appendLine("Morphe-GUI Started") + appendLine("Version: ${UpdateChecker.currentVersion() ?: "dev"}") + appendLine("morphe-patcher: ${MorpheComponents.patcherVersion ?: "unknown"}") + appendLine("morphe-library: ${MorpheComponents.libraryVersion ?: "unknown"}") + appendLine("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")} (${System.getProperty("os.arch")})") + appendLine("Java: ${System.getProperty("java.version")} (${System.getProperty("java.vendor")}) ${System.getProperty("sun.arch.data.model")}-bit") + appendLine("Memory: ${Runtime.getRuntime().maxMemory() / 1024 / 1024} MB max") + appendLine("User: ${System.getProperty("user.name")}") + appendLine("App Data: ${MorpheData.root.absolutePath}") + appendLine("Working Dir: ${System.getProperty("user.dir")}") + appendLine("=".repeat(60)) + } + print(banner) + logFile?.appendText(banner) + + initialized = true + } catch (e: Exception) { + System.err.println("Failed to initialize logger: ${e.message}") + } + } + + /** + * Bridges java.util.logging for `app.morphe` into this Logger. + * Sets useParentHandlers = false on "app.morphe" to isolate it from the root ConsoleHandler. + */ + private fun installJulBridge() { + val morpheLogger = JulLogger.getLogger("app.morphe") + morpheLogger.useParentHandlers = false + + for (handler in morpheLogger.handlers.toList()) { + if (handler is JulBridgeHandler) { + morpheLogger.removeHandler(handler) + } + } + + morpheLogger.addHandler(JulBridgeHandler()) + } + + private class JulBridgeHandler : Handler() { + override fun publish(record: LogRecord) { + val rawMessage = record.message ?: return + if (rawMessage.isBlank()) return + + val message = runCatching { + val params = record.parameters + if (params != null && params.isNotEmpty()) { + MessageFormat.format(rawMessage, *params) + } else rawMessage + }.getOrDefault(rawMessage) + + when { + record.level.intValue() >= JulLevel.SEVERE.intValue() -> { + if (record.thrown != null) { + error(message, record.thrown) + } else { + error(message) + } + } + record.level.intValue() >= JulLevel.WARNING.intValue() -> { + warn(message) + } + record.level.intValue() >= JulLevel.INFO.intValue() -> { + info(message) + } + else -> { + debug(message) + } + } + } + + override fun flush() {} + override fun close() {} + } + + fun debug(message: String) = log(Level.DEBUG, message) + fun info(message: String) = log(Level.INFO, message) + fun warn(message: String) = log(Level.WARN, message) + fun error(message: String) = log(Level.ERROR, message) + + fun warn(message: String, throwable: Throwable) { + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + log(Level.WARN, "$message\n$sw") + } + + fun error(message: String, throwable: Throwable) { + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + log(Level.ERROR, "$message\n$sw") + } + + /** + * Log a CLI command execution. + */ + fun logCliCommand(command: List) { + info("CLI Command: ${command.joinToString(" ")}") + } + + /** + * Log CLI output. + */ + fun logCliOutput(output: String) { + if (output.isNotBlank()) { + debug("CLI Output: $output") + } + } + + private fun log(level: Level, message: String) { + val logLine = if (logFile != null) { + val timestamp = dateFormat.format(Date()) + "[$timestamp] [${level.name}] $message" + } else { + "${level.name}: $message" + } + + // Print to console + when (level) { + Level.ERROR -> System.err.println(logLine) + else -> println(logLine) + } + + // Write to file + try { + logFile?.appendText("$logLine\n") + } catch (e: Exception) { + System.err.println("Failed to write to log file: ${e.message}") + } + } + + /** + * Get the current session's log file for export or viewing. + */ + fun getLogFile(): File? = logFile + + /** + * Get all log files in the logs directory. + */ + fun getAllLogFiles(): List { + val logsDir = MorpheData.logsDir + return logsDir.listFiles() + ?.filter { it.name.startsWith("morphe-") && it.name.endsWith(".log") } + ?.sortedByDescending { it.lastModified() } + ?: emptyList() + } + + /** + * Export logs to a specified location. + */ + fun exportLogs(destination: File): Boolean { + return try { + val logs = getAllLogFiles() + if (logs.isEmpty()) return false + + if (logs.size == 1) { + logs.first().copyTo(destination, overwrite = true) + } else { + // Combine all logs into one file + destination.writeText("") + logs.reversed().forEach { log -> + destination.appendText("=== ${log.name} ===\n") + destination.appendText(log.readText()) + destination.appendText("\n") + } + } + true + } catch (e: Exception) { + error("Failed to export logs", e) + false + } + } +} 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..eb492255 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -29,8 +29,10 @@ 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.engine.util.Logger 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 @@ -55,7 +57,6 @@ import app.morphe.gui.ui.theme.backgrounds.BackgroundType import app.morphe.gui.ui.theme.backgrounds.LocalParallaxState import app.morphe.gui.ui.theme.backgrounds.rememberParallaxState import app.morphe.gui.util.DeviceMonitor -import app.morphe.gui.util.Logger import app.morphe.gui.util.applyTitleBarTint import java.util.Locale import kotlinx.coroutines.launch @@ -202,10 +203,6 @@ val LocalAdbPreference = staticCompositionLocalOf { fun app( initialSimplifiedMode: Boolean = true ) { - LaunchedEffect(Unit) { - Logger.init() - } - KoinApplication(koinConfiguration { logger(KoinLoggerAdapter) modules(appModule) @@ -232,6 +229,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 +271,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 +318,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 +503,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..404ab6bb 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -16,11 +16,12 @@ 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.util.Logger +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 import java.awt.Taskbar @@ -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..adb74789 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -5,6 +5,7 @@ package app.morphe.gui.data.constants +import app.morphe.engine.MorpheConstants import java.util.Properties /** @@ -27,7 +28,7 @@ object AppConstants { } // ==================== API ==================== - const val MORPHE_API_URL = "https://api.morphe.software" + const val MORPHE_API_URL = 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 +38,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/ChangelogRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ChangelogRepository.kt index f2cf924a..0b9622ed 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ChangelogRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ChangelogRepository.kt @@ -8,12 +8,13 @@ package app.morphe.gui.data.repository import app.morphe.engine.network.HttpService import app.morphe.engine.patches.PatchProvider import app.morphe.engine.patches.RemotePatchSourceFactory +import app.morphe.engine.util.Logger import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.util.ChangelogEntry import app.morphe.gui.util.ChangelogParser -import app.morphe.gui.util.Logger import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -31,9 +32,11 @@ class ChangelogRepository( val url = changelogUrl(source, prerelease) ?: return@withContext null mutex.withLock { cache[url]?.let { return@withContext it } - val entries = runCatching { + val entries = try { ChangelogParser.parse(http.request(url)) - }.getOrElse { e -> + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { Logger.debug("Changelog unavailable for '${source.name}' at $url: ${e.message}") return@withContext null } diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt index 712d8b22..a07e3c2b 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt @@ -6,7 +6,7 @@ package app.morphe.gui.data.repository import app.morphe.engine.MorpheData -import app.morphe.gui.util.Logger +import app.morphe.engine.util.Logger import java.io.File import java.nio.file.Paths 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..69c9bbf2 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,9 @@ package app.morphe.gui.data.repository -import app.morphe.engine.util.PortablePaths +import app.morphe.engine.MorpheData +import app.morphe.engine.util.Logger +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 +17,13 @@ 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..81e25b2f 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.engine.util.Logger 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..470c1f0f 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/SeenPatchesRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/SeenPatchesRepository.kt @@ -5,8 +5,8 @@ package app.morphe.gui.data.repository -import app.morphe.gui.util.FileUtils -import app.morphe.gui.util.Logger +import app.morphe.engine.MorpheData +import app.morphe.engine.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -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/data/repository/UpdateCheckRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/UpdateCheckRepository.kt index f5508cc8..d92d18ad 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/UpdateCheckRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/UpdateCheckRepository.kt @@ -8,13 +8,12 @@ package app.morphe.gui.data.repository import app.morphe.engine.ReleaseChannel import app.morphe.engine.UpdateChecker import app.morphe.engine.UpdateInfo +import app.morphe.engine.util.Logger import app.morphe.gui.data.model.UpdateChannelPreference -import app.morphe.gui.util.Logger as MorpheLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.util.logging.Logger /** * Singleton wrapper around [UpdateChecker] so the network probe runs at most @@ -28,7 +27,6 @@ class UpdateCheckRepository( private val mutex = Mutex() private var cached: Result? = null - private val logger = Logger.getLogger(UpdateCheckRepository::class.java.name) /** * Fetch the latest update info per the user's channel preference. Returns @@ -61,9 +59,9 @@ class UpdateCheckRepository( } val info = try { - UpdateChecker.checkInfo(logger, channel) + UpdateChecker.checkInfo(channel) } catch (e: Exception) { - MorpheLogger.error("UpdateCheck: threw exception", e) + Logger.error("UpdateCheck: threw exception", e) null } cached = Result.success(info) diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index dfec9234..03b76cec 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -6,10 +6,14 @@ 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.engine.util.Logger 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 @@ -18,18 +22,7 @@ import app.morphe.gui.ui.screens.patches.PatchSelectionViewModel 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 -> + Logger.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/AppCard.kt b/src/main/kotlin/app/morphe/gui/ui/components/AppCard.kt index 5ce63016..7b843d7b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/AppCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/AppCard.kt @@ -5,48 +5,48 @@ package app.morphe.gui.ui.components -import app.morphe.gui.ui.theme.contrastingForeground -import app.morphe.gui.ui.icons.MorpheIcons -import app.morphe.gui.ui.components.color.toBrush -import app.morphe.gui.ui.theme.LocalMorpheAccents -import androidx.compose.ui.Alignment -import androidx.compose.material3.Icon -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.border -import androidx.compose.animation.fadeOut -import androidx.compose.animation.fadeIn import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.background import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import app.morphe.gui.ui.theme.shiftLightness import app.morphe.gui.data.model.MorpheFill +import app.morphe.gui.ui.components.color.toBrush import app.morphe.gui.ui.components.handCursor +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.contrastingForeground +import app.morphe.gui.ui.theme.shiftLightness import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource diff --git a/src/main/kotlin/app/morphe/gui/ui/components/AppCardFillDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/AppCardFillDialog.kt index 2221858c..92459c1e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/AppCardFillDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/AppCardFillDialog.kt @@ -5,12 +5,21 @@ package app.morphe.gui.ui.components +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.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,30 +29,21 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +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.unit.dp import androidx.compose.ui.unit.sp import app.morphe.gui.data.model.MorpheFill import app.morphe.gui.ui.components.color.MorpheGradientEditor import app.morphe.gui.ui.components.color.MorpheSwatchRow -import app.morphe.gui.ui.theme.LocalMorpheFont -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.shape.RoundedCornerShape -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily 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.morphe_desktop.generated.resources.* -import androidx.compose.material3.Icon -import androidx.compose.foundation.layout.size import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource 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/LicensesDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt index 1b91bdbb..cda66f03 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt @@ -47,13 +47,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import app.morphe.engine.util.Logger 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.ui.theme.MorpheAccentColors import app.morphe.gui.ui.theme.MorpheCornerStyle -import app.morphe.gui.util.Logger import app.morphe.morphe_desktop.generated.resources.* import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library 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..321eb05c 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,8 @@ 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.engine.util.Logger import app.morphe.gui.LocalAdbPreference import app.morphe.gui.LocalCustomAccentColor import app.morphe.gui.LocalIsPatching @@ -42,7 +44,6 @@ import app.morphe.gui.data.repository.UpdateCheckRepository import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalThemeState -import app.morphe.gui.util.Logger import app.morphe.morphe_desktop.generated.resources.* import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -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..9783e96d 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.config.EngineConfigRepository +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.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..a19ffb68 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt @@ -19,13 +19,13 @@ 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.engine.util.Logger 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 import app.morphe.morphe_desktop.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -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..c9cbf79d 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.engine.util.Logger 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..5051a83c 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,9 @@ 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.Logger import app.morphe.engine.util.PortablePaths import app.morphe.gui.ui.components.LabeledField import app.morphe.gui.ui.components.SlimTextField @@ -41,12 +44,9 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners 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/icons/MorpheIcons.kt b/src/main/kotlin/app/morphe/gui/ui/icons/MorpheIcons.kt index f330bd35..a159506f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/icons/MorpheIcons.kt +++ b/src/main/kotlin/app/morphe/gui/ui/icons/MorpheIcons.kt @@ -5,13 +5,13 @@ package app.morphe.gui.ui.icons +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathParser -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp 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..6106f57d 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.MorpheConstants 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.engine.util.Logger 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.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..116bc5c0 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 @@ -29,6 +29,7 @@ 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.model.VersionStatus import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.screens.home.ApkInfo @@ -39,7 +40,6 @@ import app.morphe.gui.ui.theme.panelFill import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.FormatUtils 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..f9d7ea34 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 @@ -28,16 +28,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.model.VersionStatus import app.morphe.gui.ui.components.handCursor import app.morphe.gui.ui.icons.MorpheIcons import app.morphe.gui.ui.screens.home.ApkInfo import app.morphe.gui.ui.screens.home.HomeUiState -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.gui.ui.theme.contrastingForeground 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..2887ea5c 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,7 +17,9 @@ 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.engine.model.VersionStatus import app.morphe.gui.ui.components.MorpheDialogButton import app.morphe.gui.ui.components.MorpheDialogCard import app.morphe.gui.ui.components.MorpheDialogText @@ -27,7 +29,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.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..61c42657 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.engine.util.Logger 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..6f1ff0d1 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.PatchBundleLoader +import app.morphe.engine.patches.PatchCache +import app.morphe.engine.patches.PatchRepository +import app.morphe.engine.patches.PullRequestPatchSource +import app.morphe.engine.util.Logger +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.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/patches/components/selection/PatchSelectionHeader.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchSelectionHeader.kt index 75453765..31c770a6 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchSelectionHeader.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/components/selection/PatchSelectionHeader.kt @@ -135,10 +135,9 @@ internal fun PatchSelectionHeader( // Command preview toggle if (!uiState.isLoading && uiState.bundles.isNotEmpty()) { val cmdHover = remember { MutableInteractionSource() } - val cmdActive = showCommandPreview val cmdAccent = MaterialTheme.colorScheme.onSurface val cmdBorder by animateColorAsState( - if (cmdActive) cmdAccent.copy(alpha = 0.5f) + if (showCommandPreview) cmdAccent.copy(alpha = 0.5f) else baseBorderColor, animationSpec = tween(150) ) @@ -150,7 +149,7 @@ internal fun PatchSelectionHeader( .hoverable(cmdHover) .clip(RoundedCornerShape(corners.small)) .background(containerColor) - .background(if (cmdActive) cmdAccent.copy(alpha = 0.08f) else Color.Transparent) + .background(if (showCommandPreview) cmdAccent.copy(alpha = 0.08f) else Color.Transparent) .border(1.dp, cmdBorder, RoundedCornerShape(corners.small)) .handCursor() .clickable { onToggleCommandPreview() }, @@ -159,7 +158,7 @@ internal fun PatchSelectionHeader( Icon( imageVector = MorpheIcons.Terminal, contentDescription = stringResource(Res.string.patch_selection_cmd_preview), - tint = if (cmdActive) cmdAccent + tint = if (showCommandPreview) cmdAccent else baseIconTint, modifier = Modifier.size(16.dp) ) 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..1c61528d 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,24 +8,20 @@ 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.engine.util.Logger 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 import java.lang.management.ManagementFactory import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -34,15 +30,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 +59,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,154 +129,93 @@ 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, + val engineResult = try { + PatchEngine.patch( + config = engineConfig, onProgress = { message -> - parseAndAddLog(message) + if (_uiState.value.status != PatchingStatus.CANCELLED) { + 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) + } + } } ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + addLog("Patching error: ${e.message ?: "unknown error"}", LogLevel.ERROR) + _uiState.update { it.copy( + status = PatchingStatus.FAILED, + error = e.stackTraceToString() + ) } + Logger.error("Patching error", e) + return@launch } 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. - - val elapsedMs = System.currentTimeMillis() - startTime - val outputApkFile = File(config.outputApkPath) - val outSizeMb = if (outputApkFile.exists()) FormatUtils.formatFileSize(outputApkFile.length(), locale) else "?" - - _uiState.value = _uiState.value.copy( - status = PatchingStatus.COMPLETED, - outputPath = config.outputApkPath, - progress = 1f, - outputSizeMb = outSizeMb, - elapsedSec = formatElapsed(elapsedMs) - ) - Logger.info("Patching completed: ${config.outputApkPath}") - recordPatchedApp(patchResult) - } else { - val reason = patchResult.failureDetail - ?: patchResult.getLocalizedFailureReason() - ?: if (patchResult.failedPatches.isNotEmpty()) - getString(Res.string.patching_error_failed_patches, patchResult.failedPatches.joinToString(", ")) - else getString(Res.string.error_patching_unknown) - addLog("Patching failed: ${patchResult.failureReason ?: "Unknown reason"}", LogLevel.ERROR) - _uiState.value = _uiState.value.copy( - status = PatchingStatus.FAILED, - error = reason, - ) + if (_uiState.value.status == PatchingStatus.CANCELLED) return@launch + + if (engineResult.success) { + val elapsedMs = System.currentTimeMillis() - startTime + 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 = engineResult.outputPath, + progress = 1f, + outputSizeMb = outSizeMb, + elapsedSec = formatElapsed(elapsedMs) + ) + Logger.info("Patching completed: ${engineResult.outputPath}") + } else { + 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 ?: "") } - }, - onFailure = { e -> - addLog("Patching error: ${e.message ?: "unknown error"}", LogLevel.ERROR) - _uiState.value = _uiState.value.copy( - status = PatchingStatus.FAILED, - error = e.stackTraceToString() - ) - Logger.error("Patching error", e) - } - ) + ?: 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: ${engineResult.failureReason ?: "Unknown reason"}", LogLevel.ERROR) + _uiState.value = _uiState.value.copy( + status = PatchingStatus.FAILED, + error = reason, + ) + } } } fun cancelPatching() { - viewModelScope.launch { - patchingJob?.cancel() - patchingJob = null - addLog("Patching cancelled by user", LogLevel.WARNING) - _uiState.value = _uiState.value.copy( - status = PatchingStatus.CANCELLED - ) - Logger.info("Patching cancelled by user") - } - } - - /** - * 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) - } + patchingJob?.cancel() + patchingJob = null + addLog("Patching cancelled by user", LogLevel.WARNING) + _uiState.update { it.copy( + status = PatchingStatus.CANCELLED + ) } + Logger.info("Patching cancelled by user") } private fun addLog(message: String, level: LogLevel) { @@ -292,18 +226,23 @@ class PatchingViewModel( } private fun parseAndAddLog(line: String) { - val level = when { - line.contains("error", ignoreCase = true) -> LogLevel.ERROR - line.contains("warning", ignoreCase = true) -> LogLevel.WARNING + if (_uiState.value.status == PatchingStatus.CANCELLED) return + 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/LogFileViewerDialog.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/LogFileViewerDialog.kt index 115c1888..65751221 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/LogFileViewerDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/components/LogFileViewerDialog.kt @@ -40,13 +40,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import app.morphe.engine.util.Logger 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.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheMono import app.morphe.gui.ui.theme.MorpheCornerStyle -import app.morphe.gui.util.Logger import app.morphe.morphe_desktop.generated.resources.* import java.awt.Desktop import java.awt.datatransfer.StringSelection 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..586dcd05 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,9 @@ 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.util.Logger +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,9 +43,7 @@ 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 import app.morphe.morphe_desktop.generated.resources.* import java.awt.datatransfer.StringSelection @@ -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,9 +292,9 @@ internal fun ExpertFailureContent( .clip(RoundedCornerShape(corners.small)) .background(cleanBg) .clickable { - FileUtils.cleanupAllTempDirs() + WorkspaceManager.clearScratch() tempFilesCleared = true - Logger.info("Cleaned temp files after failed patching") + Logger.info("Cleaned temp files after failed/cancelled patching") } .padding(horizontal = 10.dp, vertical = 4.dp) ) { 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..3a5daacf 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,8 @@ 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.engine.util.Logger import app.morphe.gui.LocalBackgroundSpeed import app.morphe.gui.LocalPatchingCompleted import app.morphe.gui.data.repository.PatchSourceManager @@ -29,7 +31,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 @@ -160,7 +162,7 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { } if (showLogViewer) { - val logFile = File(MorpheData.root, "logs/morphe-gui.log") + val logFile = Logger.getLogFile() ?: File(MorpheData.logsDir, "morphe.log") LogFileViewerDialog( file = logFile, corners = corners, @@ -365,7 +367,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..2815e85f 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,28 @@ 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.engine.util.Logger 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 +36,11 @@ 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,148 +540,20 @@ 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 - } - 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, - onProgress = { message -> - val entry = LogEntry(message, LogLevel.INFO) - _uiState.value = _uiState.value.copy( - statusMessage = message.take(60), - logs = _uiState.value.logs + entry - ) - parseProgress(message) - } - ) - } finally { - PatcherLogInterceptor.detach(logHandler) - } + // Keystore: pass user-configured keystore if present, or null to let PatchEngine + // use the shared MorpheData default keystore. + val keystoreDetails = engineConfigData.toKeyStoreDetails() - patchResult.fold( - onSuccess = { result -> - if (result.success) { - // Force 100% progress immediately upon engine success - _uiState.value = _uiState.value.copy( - progress = 1.0f, - statusMessage = "", - isAllStepsDone = true - ) - - // Delay transition so the 90% -> 100% animation can visually finish - delay(1500.milliseconds) - - _uiState.value = _uiState.value.copy( - phase = QuickPatchPhase.COMPLETED, - outputPath = outputPath, - progress = 1f, - statusMessage = "" - ) - Logger.info("Quick mode: Patching completed - $outputPath (${result.appliedPatches.size} patches)") - recordPatchedApp(result, apkFile.absolutePath, outputPath, apkInfo.displayName) - recordSeenPatches(apkInfo.packageName) - } else { - val errorMsg = result.failureDetail ?: result.getLocalizedFailureReason() - ?: getString(Res.string.error_patching_unknown) - _uiState.value = _uiState.value.copy( - phase = QuickPatchPhase.ERROR, - error = errorMsg - ) - } - }, - onFailure = { e -> - val exceptionStr = e.stackTraceToString() - val newLogs = _uiState.value.logs + LogEntry(exceptionStr, LogLevel.ERROR) - _uiState.value = _uiState.value.copy( - phase = QuickPatchPhase.ERROR, - error = getString(Res.string.error_patching_general, e.message ?: ""), - logs = newLogs - ) - } - ) - } - } - - /** - * 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. - */ - private suspend fun recordSeenPatches(packageName: String) { - val sources = cachedSourcesResult?.resolved ?: return - sources.forEach { resolved -> - val path = resolved.patchFile?.absolutePath ?: return@forEach - val names = patchService.listPatches(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. + // Resolve sources snapshot for history recording val resolvedSources = cachedSourcesResult?.resolved?.filter { it.patchFile != null } val sources = if (!resolvedSources.isNullOrEmpty()) { resolvedSources.map { r -> @@ -709,26 +574,117 @@ class QuickPatchViewModel( ) } } - 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, + + 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, - patchedAt = System.currentTimeMillis(), - patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", - ) + ), ) - } catch (e: Exception) { - Logger.error("Failed to record patched app (quick mode)", e) + + val engineResult = try { + PatchEngine.patch( + config = engineConfig, + onProgress = { message -> + if (_uiState.value.phase == QuickPatchPhase.PATCHING) { + 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) + } + } + } + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + val exceptionStr = e.stackTraceToString() + val newLogs = _uiState.value.logs + LogEntry(exceptionStr, LogLevel.ERROR) + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.ERROR, + error = getString(Res.string.error_patching_general, e.message ?: ""), + logs = newLogs + ) + return@launch + } + + if (_uiState.value.phase != QuickPatchPhase.PATCHING) return@launch + + if (engineResult.success) { + // Force 100% progress immediately upon engine success + _uiState.value = _uiState.value.copy( + progress = 1.0f, + statusMessage = "", + isAllStepsDone = true + ) + + // Delay transition so the 90% -> 100% animation can visually finish + delay(1500.milliseconds) + + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.COMPLETED, + outputPath = outputPath, + progress = 1f, + statusMessage = "" + ) + Logger.info("Quick mode: Patching completed - $outputPath (${engineResult.appliedPatches.size} patches)") + recordSeenPatches(apkInfo.packageName) + } else { + 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, + error = errorMsg + ) + } + } + } + + /** + * Snapshot what each source offered for this app, so expert mode can tell a + * genuinely new patch from one the user has already seen. + */ + private suspend fun recordSeenPatches(packageName: String) { + val sources = cachedSourcesResult?.resolved ?: return + sources.forEach { resolved -> + val path = resolved.patchFile?.absolutePath ?: return@forEach + val names = runCatching { SupportedAppCatalog.loadPatches(File(path), packageName) }.getOrNull() + ?.mapTo(mutableSetOf()) { it.name } + ?: return@forEach + seenPatchesRepository.save(packageName, resolved.source.name, names) } } @@ -738,6 +694,7 @@ class QuickPatchViewModel( */ @Synchronized private fun parseProgress(line: String) { + if (_uiState.value.phase != QuickPatchPhase.PATCHING) return val currentState = _uiState.value // Detect Split APK dynamically based on CLI lines if not already set @@ -874,12 +831,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..e05afcea 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.util.Logger +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/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..88e68836 100644 --- a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt +++ b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt @@ -5,6 +5,9 @@ package app.morphe.gui.util +import app.morphe.engine.util.AdbDevice +import app.morphe.engine.util.AdbManager +import app.morphe.engine.util.Logger 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 deleted file mode 100644 index fb14d290..00000000 --- a/src/main/kotlin/app/morphe/gui/util/Logger.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-desktop - */ - -package app.morphe.gui.util - -import app.morphe.engine.MorpheComponents -import app.morphe.gui.data.constants.AppConstants -import java.io.File -import java.io.PrintWriter -import java.io.StringWriter -import java.text.MessageFormat -import java.text.SimpleDateFormat -import java.util.* -import java.util.logging.Handler -import java.util.logging.Level as JVLevel -import java.util.logging.LogRecord -import java.util.logging.Logger as JVLogger - -/** - * Simple file logger with rotation support. - * - * Log file location: `/logs/morphe-gui.log` — JAR-adjacent - * `morphe-data/logs/` for shipped jars, `~/morphe/logs/` for IDE/dev runs. - * See [app.morphe.engine.MorpheData] for the full resolution + fallback rules. - */ -object Logger { - - private const val MAX_LOG_SIZE = 2 * 1024 * 1024 // 2 MB - private const val MAX_LOG_FILES = 3 - private const val MAX_LINES_TO_KEEP = 5000 // Keep last 5000 lines on startup - private const val LOG_FILE_NAME = "morphe-gui.log" - - private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) - private var logFile: File? = null - private var initialized = false - - enum class Level { - DEBUG, INFO, WARN, ERROR - } - - /** - * Initialize the logger. Call once at app startup. - */ - fun init() { - if (initialized) return - - try { - // Install JUL bridge early so logs during startup (e.g. MorpheData) are unified - installJulBridge() - - val logsDir = FileUtils.getLogsDir() - logFile = File(logsDir, LOG_FILE_NAME) - - // Trim log file if it's too large (keep only last N lines) - trimLogFile() - - // Rotate if needed - rotateIfNeeded() - - // Log startup info - info("=".repeat(60)) - info("Morphe-GUI Started") - info("Version: ${AppConstants.APP_VERSION}") - info("morphe-patcher: ${MorpheComponents.patcherVersion ?: "unknown"}") - info("morphe-library: ${MorpheComponents.libraryVersion ?: "unknown"}") - info("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")} (${System.getProperty("os.arch")})") - 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("Working Dir: ${System.getProperty("user.dir")}") - info("=".repeat(60)) - - initialized = true - } catch (e: Exception) { - System.err.println("Failed to initialize logger: ${e.message}") - } - } - - /** - * Bridges java.util.logging for `app.morphe` into this Logger. - * Sets useParentHandlers = false on "app.morphe" to isolate it from the root ConsoleHandler. - */ - private fun installJulBridge() { - val morpheLogger = JVLogger.getLogger("app.morphe") - morpheLogger.useParentHandlers = false - - for (handler in morpheLogger.handlers.toList()) { - if (handler is JulBridgeHandler) { - morpheLogger.removeHandler(handler) - } - } - - morpheLogger.addHandler(JulBridgeHandler()) - } - - private class JulBridgeHandler : Handler() { - override fun publish(record: LogRecord) { - val rawMessage = record.message ?: return - if (rawMessage.isBlank()) return - - val message = runCatching { - val params = record.parameters - if (params != null && params.isNotEmpty()) { - MessageFormat.format(rawMessage, *params) - } else rawMessage - }.getOrDefault(rawMessage) - - val loggerName = record.loggerName ?: "" - val isPatcher = loggerName.startsWith("app.morphe.patcher") - val taggedMessage = if (isPatcher) "[PATCH] $message" else message - - when { - record.level.intValue() >= JVLevel.SEVERE.intValue() -> { - if (record.thrown != null) { - error(taggedMessage, record.thrown) - } else { - error(taggedMessage) - } - } - record.level.intValue() >= JVLevel.WARNING.intValue() -> { - warn(taggedMessage) - } - record.level.intValue() >= JVLevel.INFO.intValue() -> { - info(taggedMessage) - } - else -> { - debug(taggedMessage) - } - } - } - - override fun flush() {} - override fun close() {} - } - - /** - * Trim log file to keep only the last MAX_LINES_TO_KEEP lines. - */ - private fun trimLogFile() { - val file = logFile ?: return - if (!file.exists()) return - - try { - val lines = file.readLines() - if (lines.size > MAX_LINES_TO_KEEP) { - val trimmedLines = lines.takeLast(MAX_LINES_TO_KEEP) - file.writeText(trimmedLines.joinToString("\n") + "\n") - } - } catch (e: Exception) { - System.err.println("Failed to trim log file: ${e.message}") - } - } - - fun debug(message: String) = log(Level.DEBUG, message) - fun info(message: String) = log(Level.INFO, message) - fun warn(message: String) = log(Level.WARN, message) - fun error(message: String) = log(Level.ERROR, message) - - fun error(message: String, throwable: Throwable) { - val sw = StringWriter() - throwable.printStackTrace(PrintWriter(sw)) - log(Level.ERROR, "$message\n$sw") - } - - /** - * Log a CLI command execution. - */ - fun logCliCommand(command: List) { - info("CLI Command: ${command.joinToString(" ")}") - } - - /** - * Log CLI output. - */ - fun logCliOutput(output: String) { - if (output.isNotBlank()) { - debug("CLI Output: $output") - } - } - - private fun log(level: Level, message: String) { - val timestamp = dateFormat.format(Date()) - val logLine = "[$timestamp] [${level.name}] $message" - - // Print to console - when (level) { - Level.ERROR -> System.err.println(logLine) - else -> println(logLine) - } - - // Write to file - try { - logFile?.let { file -> - rotateIfNeeded() - file.appendText("$logLine\n") - } - } catch (e: Exception) { - System.err.println("Failed to write to log file: ${e.message}") - } - } - - private fun rotateIfNeeded() { - val file = logFile ?: return - if (!file.exists()) return - if (file.length() < MAX_LOG_SIZE) return - - try { - // Shift existing log files - for (i in MAX_LOG_FILES - 1 downTo 1) { - val older = File(file.parent, "$LOG_FILE_NAME.$i") - val newer = if (i == 1) file else File(file.parent, "$LOG_FILE_NAME.${i - 1}") - if (newer.exists()) { - if (older.exists()) older.delete() - newer.renameTo(older) - } - } - - // Create fresh log file - file.createNewFile() - } catch (e: Exception) { - System.err.println("Failed to rotate logs: ${e.message}") - } - } - - /** - * Get the current log file for export. - */ - fun getLogFile(): File? = logFile - - /** - * Get all log files for export. - */ - fun getAllLogFiles(): List { - val logsDir = FileUtils.getLogsDir() - return logsDir.listFiles() - ?.filter { it.name.startsWith(LOG_FILE_NAME) } - ?.sortedByDescending { it.lastModified() } - ?: emptyList() - } - - /** - * Export logs to a specified location. - */ - fun exportLogs(destination: File): Boolean { - return try { - val logs = getAllLogFiles() - if (logs.isEmpty()) return false - - if (logs.size == 1) { - logs.first().copyTo(destination, overwrite = true) - } else { - // Combine all logs into one file - destination.writeText("") - logs.reversed().forEach { log -> - destination.appendText("=== ${log.name} ===\n") - destination.appendText(log.readText()) - destination.appendText("\n") - } - } - true - } catch (e: Exception) { - error("Failed to export logs", e) - 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/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/UserErrorMessages.kt b/src/main/kotlin/app/morphe/gui/util/UserErrorMessages.kt new file mode 100644 index 00000000..3f5daa36 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/UserErrorMessages.kt @@ -0,0 +1,75 @@ +/* + * 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 + +/** + * UI-specific exception carrying an optional Compose string resource for localized user display. + */ +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 ?: "") +} + +/** + * Maps an [AdbErrorCode] to its corresponding localized Compose [StringResource]. + */ +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 +} + +/** + * Resolves localized user-facing message for an [AdbException]. + */ +suspend fun AdbException.getUserMessage(): String = + getString(errorCode.toStringResource(), *formatArgs.toTypedArray()) + +/** + * Resolves localized user-facing progress text for link handling operations. + */ +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/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/PatchingTest.kt b/src/test/kotlin/app/morphe/desktop/command/PatchingTest.kt index 96e17800..d8595517 100644 --- a/src/test/kotlin/app/morphe/desktop/command/PatchingTest.kt +++ b/src/test/kotlin/app/morphe/desktop/command/PatchingTest.kt @@ -5,24 +5,22 @@ package app.morphe.desktop.command +import app.morphe.engine.util.Logger import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Disabled import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import picocli.CommandLine import java.io.File -import java.util.logging.Logger import kotlin.io.path.createTempDirectory class PatchingTest { - private val logger = Logger.getLogger(PatchingTest::class.java.name) - @ParameterizedTest @ValueSource(booleans = [true, false]) @Disabled("Need to create lighter weight patch bundle") fun `patch example apk`(useArsclib: Boolean) { - val apkFileStream = javaClass.getResourceAsStream("/nowinandroid-apk") - val patchesFileStream = javaClass.getResourceAsStream("/patches.mpp") + val apkFileStream = checkNotNull(javaClass.getResourceAsStream("/nowinandroid-apk")) + val patchesFileStream = checkNotNull(javaClass.getResourceAsStream("/patches.mpp")) // Create output directories val tempDir = createTempDirectory().toFile() @@ -34,7 +32,7 @@ class PatchingTest { val outputApk = tempDir.resolve("${apkFile.nameWithoutExtension}-merged.apk") val resultFile = tempDir.resolve("results.json") - logger.info("Starting to patch") + Logger.info("Starting to patch") val patchStartTime = System.currentTimeMillis() val exitCode = patchApk( apkFile = apkFile, @@ -45,7 +43,7 @@ class PatchingTest { useArsclib = useArsclib, ) val duration = System.currentTimeMillis() - patchStartTime - logger.info("Patching completed in ${duration}ms") + Logger.info("Patching completed in ${duration}ms") Assertions.assertTrue(exitCode == 0, "Patching with ARSCLib failed with exit code $exitCode") Assertions.assertTrue(outputApk.exists(), "Output APK was not created") 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/icon/IconProjectSerializationTest.kt b/src/test/kotlin/app/morphe/gui/icon/IconProjectSerializationTest.kt index 9bed9ac2..98bb9d20 100644 --- a/src/test/kotlin/app/morphe/gui/icon/IconProjectSerializationTest.kt +++ b/src/test/kotlin/app/morphe/gui/icon/IconProjectSerializationTest.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + package app.morphe.gui.icon import app.morphe.gui.data.model.GradientType diff --git a/src/test/kotlin/app/morphe/gui/icon/IconRendererTest.kt b/src/test/kotlin/app/morphe/gui/icon/IconRendererTest.kt index db68787d..0e3c54ed 100644 --- a/src/test/kotlin/app/morphe/gui/icon/IconRendererTest.kt +++ b/src/test/kotlin/app/morphe/gui/icon/IconRendererTest.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + package app.morphe.gui.icon import app.morphe.gui.data.model.MorpheFill 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 {