From 25df838b451302dc224b4f59576340bc7309f497 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 27 Jul 2026 09:05:57 -0400 Subject: [PATCH] android fixes for mods and saves (#297) --- docs/launcher.md | 62 +++- mobile/ANDROID.md | 10 +- mobile/android/gradle.properties | 2 +- .../love/src/jni/love/src/common/android.cpp | 30 +- .../love/src/jni/love/src/common/android.h | 11 +- .../jni/love/src/modules/system/System.cpp | 27 +- .../src/jni/love/src/modules/system/System.h | 14 +- .../love/src/modules/system/wrap_System.cpp | 11 +- .../java/org/love2d/android/GameActivity.java | 170 +++++++++- src/core/SaveData.lua | 38 +++ src/import/RomImporter.lua | 293 +++++++++++++++--- src/import/SaveFileIO.lua | 18 +- src/mods/LauncherMods.lua | 37 ++- tests/engine/launcher_mods_tests.lua | 18 ++ tests/engine/save_slots.lua | 35 +++ tests/rom_importer_android_mod_pick_test.lua | 104 +++++++ tests/run_tests.lua | 2 + 17 files changed, 787 insertions(+), 95 deletions(-) create mode 100644 tests/rom_importer_android_mod_pick_test.lua diff --git a/docs/launcher.md b/docs/launcher.md index 57b14d76..e2c323e7 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header) it hosts a tabbed shell covering per-game save slots and a mod manager. This file documents the runtime model; the visual spec lives separately. -## Android multi-ROM import +## Android multi-ROM / mod / save import -On Android, `love.system.pickFile()` opens the Storage Access Framework -picker (`GameActivity.showRomFilePicker`); the chosen file is copied to -`picked_rom.gb` in the app save directory. `RomImporter` then imports on -refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps -to a version that is **not** yet ready counts as pending. A leftover -`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). -After a successful import the consumed save-dir `.gb` is removed. +On Android, `love.system.pickFile([kind])` opens the Storage Access Framework +picker (`GameActivity.showFilePicker`); the chosen file is copied into the app +save directory as: + +| `kind` | Destination | +| --- | --- | +| nil / `"rom"` | `picked_rom.gb` (open) | +| `"mod"` | `picked_mod.zip` (open) | +| `"sav"` / `"save"` | `picked_save.sav` (open) | + +Export uses a separate API: `love.system.createFile(suggestedName)` → +`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies +staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag` +for the launcher to acknowledge on refocus. + +`RomImporter` then imports on refocus / Choose: + +- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a + version that is **not** yet ready counts as pending. A leftover + `picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). +- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any + other `.zip` at the save-dir root (USB copy). +- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any + other `.sav` at the save-dir root. + +After a successful import the consumed save-dir file is removed. **Manual check (device/emulator):** import Red → switch to Blue → Choose → system file picker must appear (not a silent Red re-extract) → pick Blue → -Blue becomes ready beside Red. +Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the +same system picker and install the chosen archive on return. ## Tab structure @@ -78,6 +98,10 @@ The launcher-facing API: - `SaveData.createSlot(version)` -> new slot id, registered but with **no save file written**. An empty slot means the title screen offers NEW GAME only, which needs no further changes. +- `SaveData.deleteSlot(version, slotId)` removes the slot's + main/`.bak`/`.tmp` files, drops it from the registry, and if it was active + points active at another remaining slot (or clears active when the list is + empty). The launcher's SAVE SLOT panel Delete control calls this. ## Launcher mod manager @@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only temp first (mount only reaches save-dir-relative paths), the same way `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree back, and every path unmounts and clears the staged temp file. +- `LauncherMods.uninstall(id)` removes `mods//` and clears + `options.mods[id]` so a later reinstall starts from the loader's default + (enabled). The mods panel Delete control calls this and re-derives the list. ## Import / Export save @@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of `src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. - **Import save** is live once the game's ROM is imported (playable). It opens - a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`; - Android has no picker and shows a drop hint). `SaveFileIO.importToSlot` - reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), + a native `.sav` picker (`chooseSav` on desktop; on Android, + `love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs). + `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir + relative name, a dropped LOVE file, or raw bytes), guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). @@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps `rawImport`, so this is a zero-filled template export, which is valid), and writes `exports/gen1recomp--.sav` in the save directory - (`love.filesystem.createDirectory("exports")`). It returns the absolute path - (`love.filesystem.getSaveDirectory()`), which the notice line shows with a - desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`). + (`love.filesystem.createDirectory("exports")`). On desktop it returns the + absolute path (`love.filesystem.getSaveDirectory()`), which the notice line + shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`). + On Android the bytes are also staged as `pending_export.sav` and + `love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the + player can save to Downloads / Drive / etc.; on return `export_done.flag` + makes focus show "Save exported." - **Drag-drop.** `filedropped` routes a `.sav` to the import path for the currently active game tab; when a non-game tab (mods, or the locked yellow placeholder) is showing it defaults to red, the always-present first game diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 6d3251b6..1d4d6849 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`, `assets/generated/`, and any ROM. It contains the first-boot Lua importer and `tools/rom_manifest.json`. -ROM import on Android uses `love.system.pickFile()` → -`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the -chosen file to `picked_rom.gb` under the app save directory. `RomImporter` -imports pending (not-yet-ready) `.gb` files from that folder on Choose / -refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload +ROM / mod / save import on Android uses `love.system.pickFile([kind])` → +`GameActivity.showFilePicker` (Storage Access Framework), which copies the +chosen file under the app save directory as `picked_rom.gb`, +`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files +from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload itself remains data-free (no embedded ROM or generated cache). ### SDK / NDK diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index a3c7a5f7..df2f35ec 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -18,4 +18,4 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=true android.nonFinalResIds=true -app.name=Pokemon Red +app.name=gen1recomp diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 875d4b77..7f0cfdca 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -183,13 +183,37 @@ void vibrate(double seconds) env->DeleteLocalRef(activity); } -bool showFilePicker() +bool showFilePicker(const char *destFilename) { + if (destFilename == nullptr || destFilename[0] == '\0') + destFilename = "picked_rom.gb"; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); jclass activity = env->FindClass("org/love2d/android/GameActivity"); - jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z"); - jboolean result = env->CallStaticBooleanMethod(activity, method); + jmethodID method = env->GetStaticMethodID(activity, "showFilePicker", + "(Ljava/lang/String;)Z"); + jstring jname = env->NewStringUTF(destFilename); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + env->DeleteLocalRef(jname); + + env->DeleteLocalRef(activity); + return result; +} + +bool showCreateDocument(const char *suggestedName) +{ + if (suggestedName == nullptr || suggestedName[0] == '\0') + suggestedName = "export.sav"; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + + jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument", + "(Ljava/lang/String;)Z"); + jstring jname = env->NewStringUTF(suggestedName); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + env->DeleteLocalRef(jname); env->DeleteLocalRef(activity); return result; diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 7902066b..10cd0802 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -63,9 +63,16 @@ void vibrate(double seconds); * Shows the system's "pick a document" UI (Storage Access Framework). * Returns true if the picker was launched; the picked file (if any) is * copied asynchronously by GameActivity.onActivityResult into the app's - * external save directory, not returned here -- see src/import/RomImporter.lua. + * external save directory under destFilename (default picked_rom.gb), not + * returned here -- see src/import/RomImporter.lua. **/ -bool showFilePicker(); +bool showFilePicker(const char *destFilename = nullptr); + +/** + * Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav + * to a user-chosen location. suggestedName is the dialog default filename. + **/ +bool showCreateDocument(const char *suggestedName = nullptr); /* * Helper functions for the filesystem module diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 3afbbca0..0a844e58 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -22,6 +22,8 @@ #include "common/config.h" #include "System.h" +#include + #if defined(LOVE_MACOSX) #include #elif defined(LOVE_IOS) @@ -180,11 +182,32 @@ void System::vibrate(double seconds) const #endif } -bool System::pickFile() const +bool System::pickFile(const char *kind) const { #ifdef LOVE_ANDROID - return love::android::showFilePicker(); + const char *dest = "picked_rom.gb"; + if (kind != nullptr) + { + if (strcmp(kind, "mod") == 0) + dest = "picked_mod.zip"; + else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) + dest = "picked_save.sav"; + else if (strcmp(kind, "rom") == 0) + dest = "picked_rom.gb"; + } + return love::android::showFilePicker(dest); #else + LOVE_UNUSED(kind); + return false; +#endif +} + +bool System::createFile(const char *suggestedName) const +{ +#ifdef LOVE_ANDROID + return love::android::showCreateDocument(suggestedName); +#else + LOVE_UNUSED(suggestedName); return false; #endif } diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 6542820c..c3a174fa 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -111,9 +111,21 @@ public: * Android only for now; the result (if any) is not returned here -- see * love::android::showFilePicker and src/import/RomImporter.lua. * + * @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb, + * "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav. * @return Whether the picker was shown. **/ - virtual bool pickFile() const; + virtual bool pickFile(const char *kind = nullptr) const; + + /** + * Shows the platform's native "create / save a file" UI (Android SAF + * ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app + * save directory to the user-chosen URI. See GameActivity.showCreateDocument. + * + * @param suggestedName Default filename shown in the dialog. + * @return Whether the create dialog was shown. + **/ + virtual bool createFile(const char *suggestedName = nullptr) const; /** * Gets if the user is playing music on background. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 567d2b46..07d572a1 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -97,7 +97,15 @@ int w_vibrate(lua_State *L) int w_pickFile(lua_State *L) { - luax_pushboolean(L, instance()->pickFile()); + const char *kind = luaL_optstring(L, 1, nullptr); + luax_pushboolean(L, instance()->pickFile(kind)); + return 1; +} + +int w_createFile(lua_State *L) +{ + const char *suggested = luaL_optstring(L, 1, nullptr); + luax_pushboolean(L, instance()->createFile(suggested)); return 1; } @@ -117,6 +125,7 @@ static const luaL_Reg functions[] = { "openURL", w_openURL }, { "vibrate", w_vibrate }, { "pickFile", w_pickFile }, + { "createFile", w_createFile }, { "hasBackgroundMusic", w_hasBackgroundMusic }, { 0, 0 } }; diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 8a9a63ae..96f4aee5 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -22,12 +22,15 @@ package org.love2d.android; import org.libsdl.app.SDLActivity; +import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity { protected final int[] recordAudioRequestDummy = new int[1]; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int RECORD_AUDIO_REQUEST_CODE = 3; - public static final int ROM_PICKER_REQUEST_CODE = 4; - // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM + public static final int FILE_PICKER_REQUEST_CODE = 4; + public static final int FILE_CREATE_REQUEST_CODE = 5; + /** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */ + public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE; + // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file // is dropped so RomImporter's existing folder scan finds it -- see // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's // save directory to getExternalFilesDir()/save/). private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; + private static final String PICKED_MOD_FILENAME = "picked_mod.zip"; + private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; + private static final String PENDING_EXPORT_FILENAME = "pending_export.sav"; + private static final String EXPORT_DONE_FILENAME = "export_done.flag"; + // Destination basename for the in-flight SAF pick (set by showFilePicker). + private String pendingPickFilename = PICKED_ROM_FILENAME; + // Suggested download name for the in-flight SAF create (set by showCreateDocument). + private String pendingCreateSuggestedName = "export.sav"; private static boolean immersiveActive = false; private static boolean needToCopyGameInArchive = false; private boolean storagePermissionUnnecessary = false; @@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity { /** * Shows the system document picker (Storage Access Framework) so the - * player can pick their ROM from anywhere (Downloads, Drive, etc.) - * without needing to know where the app's external files folder is. - * Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) + * player can pick a ROM / mod / save from anywhere (Downloads, Drive, + * etc.) without needing to know where the app's external files folder + * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) * arrives later in onActivityResult, not synchronously here. + * + * @param destFilename basename under the app save identity (e.g. + * picked_rom.gb, picked_mod.zip, picked_save.sav) */ @Keep - public static boolean showRomFilePicker() { + public static boolean showFilePicker(String destFilename) { if (android.os.Build.VERSION.SDK_INT < 19) return false; GameActivity self = (GameActivity) mSingleton; if (self == null) return false; + if (destFilename == null || destFilename.length() == 0) { + destFilename = PICKED_ROM_FILENAME; + } + // Reject path separators so a hostile JNI caller cannot escape the + // save identity directory. + if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { + Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename); + return false; + } + self.pendingPickFilename = destFilename; Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); try { - self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE); + self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); return true; } catch (Exception e) { - Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage()); + Log.d("GameActivity", "could not open file picker: " + e.getMessage()); return false; } } + /** ROM convenience wrapper; prefer showFilePicker with an explicit name. */ + @Keep + public static boolean showRomFilePicker() { + return showFilePicker(PICKED_ROM_FILENAME); + } + + /** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */ + @Keep + public static boolean showModFilePicker() { + return showFilePicker(PICKED_MOD_FILENAME); + } + + /** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */ + @Keep + public static boolean showSaveFilePicker() { + return showFilePicker(PICKED_SAVE_FILENAME); + } + + /** + * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export + * (pending_export.sav in the app save identity) to Downloads / Drive / + * etc. Suggested name is the dialog's default filename. + */ + @Keep + public static boolean showCreateDocument(String suggestedName) { + if (android.os.Build.VERSION.SDK_INT < 19) return false; + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (suggestedName == null || suggestedName.length() == 0) { + suggestedName = "export.sav"; + } + if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) { + Log.d("GameActivity", "refusing unsafe create name: " + suggestedName); + return false; + } + File source = new File( + new File(self.getExternalFilesDir(null), "save"), + ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME); + if (!source.isFile()) { + Log.d("GameActivity", "no pending export at " + source); + return false; + } + + self.pendingCreateSuggestedName = suggestedName; + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/octet-stream"); + intent.putExtra(Intent.EXTRA_TITLE, suggestedName); + try { + self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open create-document picker: " + e.getMessage()); + return false; + } + } + + private File saveIdentityDir() { + return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + } + + private boolean copyFileToUri(File source, Uri destUri) { + InputStream in = null; + OutputStream out = null; + try { + in = new BufferedInputStream(new FileInputStream(source)); + out = getContentResolver().openOutputStream(destUri); + if (out == null) return false; + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + out.flush(); + return true; + } catch (IOException e) { + Log.d("GameActivity", "copy to URI failed: " + e.getMessage()); + return false; + } finally { + try { if (in != null) in.close(); } catch (IOException ignored) {} + try { if (out != null) out.close(); } catch (IOException ignored) {} + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode != ROM_PICKER_REQUEST_CODE) return; + if (requestCode == FILE_CREATE_REQUEST_CODE) { + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + Log.d("GameActivity", "create-document cancelled"); + return; + } + File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME); + if (!source.isFile()) { + Log.d("GameActivity", "pending export missing at result time"); + return; + } + Uri uri = data.getData(); + if (copyFileToUri(source, uri)) { + // Signal Lua on next focus that the SAF export finished. + File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME); + try { + FileOutputStream fos = new FileOutputStream(flag, false); + fos.write("ok".getBytes()); + fos.close(); + } catch (IOException e) { + Log.d("GameActivity", "could not write export_done flag: " + e.getMessage()); + } + // Keep pending_export.sav so a retry still works; Lua may remove it. + } else { + Log.d("GameActivity", "could not write export to " + uri); + } + return; + } + if (requestCode != FILE_PICKER_REQUEST_CODE) return; if (resultCode != RESULT_OK || data == null || data.getData() == null) { - Log.d("GameActivity", "ROM picker returned no file (cancelled?)"); + Log.d("GameActivity", "file picker returned no file (cancelled?)"); return; } Uri uri = data.getData(); - File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + File destDir = saveIdentityDir(); if (!destDir.exists() && !destDir.mkdirs()) { Log.d("GameActivity", "could not create " + destDir); return; } - File destFile = new File(destDir, PICKED_ROM_FILENAME); + String destName = pendingPickFilename != null + ? pendingPickFilename : PICKED_ROM_FILENAME; + File destFile = new File(destDir, destName); InputStream source; try { source = getContentResolver().openInputStream(uri); } catch (FileNotFoundException e) { - Log.d("GameActivity", "could not open picked ROM: " + e.getMessage()); + Log.d("GameActivity", "could not open picked file: " + e.getMessage()); return; } if (source == null) { - Log.d("GameActivity", "ContentResolver returned no stream for picked ROM"); + Log.d("GameActivity", "ContentResolver returned no stream for picked file"); return; } if (!copyAssetFile(source, destFile.getPath())) { - Log.d("GameActivity", "could not copy picked ROM to " + destFile); + Log.d("GameActivity", "could not copy picked file to " + destFile); } } diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 1214db33..d9f6182b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable) return true end +-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the +-- options registry, and if it was active point active at another remaining +-- slot (or clear active when the list is empty). Returns true, or false + +-- an error string when the id is unknown / not registered. +function SaveData.deleteSlot(version, slotId) + version = version or GameVersion.get() + if not knownVersion(version) then return false, "unknown version" end + if type(slotId) ~= "string" or slotId == "" then + return false, "missing slot id" + end + local fs = persistFs(nil) + ensureVersionSlots(version, fs) + local opts = SaveData.loadOptions(fs) + opts.saveSlots = opts.saveSlots or {} + local reg = opts.saveSlots[version] + if not reg or not reg.list then return false, "slot not registered" end + local found, idx = false, nil + for i, id in ipairs(reg.list) do + if id == slotId then found = true; idx = i; break end + end + if not found then return false, "slot not registered" end + + local main, bak, tmp = slotNames(version, slotId) + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + + table.remove(reg.list, idx) + if reg.active == slotId then + reg.active = reg.list[1] -- may be nil when the list is now empty + end + opts.saveSlots[version] = reg + SaveData.saveOptions(opts, fs) + slotsChecked[version] = true + activeSlotCache[version] = reg.active or false + return true +end + -- Test seam: drop the process-global slot cache so a suite can exercise -- migration/resolution against a freshly injected filesystem. Unused by -- the game, which resolves each version exactly once per boot. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 57546109..de9f14c2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -290,6 +290,39 @@ local function findPendingRom(ready) return nil end +-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any +-- .zip basename at the save-dir root. preferAny=true also accepts those USB +-- copies (Choose / Import); focus only consumes the SAF basename so a random +-- leftover archive is never auto-installed on every refocus. +local function findPendingMod(preferAny) + local preferred = "picked_mod.zip" + if love.filesystem.getInfo(preferred, "file") then + return preferred + end + if not preferAny then return nil end + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then + return name + end + end + return nil +end + +-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav). +local function findPendingSav(preferAny) + local preferred = "picked_save.sav" + if love.filesystem.getInfo(preferred, "file") then + return preferred + end + if not preferAny then return nil end + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then + return name + end + end + return nil +end + local function chooseRom(promptName) promptName = promptName or "Pokemon" local prompt = "Choose your " .. promptName .. " ROM" @@ -320,8 +353,8 @@ local function chooseRom(promptName) end -- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). --- Returns the chosen absolute path or nil. Android has no picker; the mods --- panel steers that case to the drag-drop hint instead. +-- Returns the chosen absolute path or nil. Android uses love.system.pickFile +-- ("mod") instead -- see RomImporter:chooseMod. local function chooseZip() local prompt = "Choose a mod .zip" local platform = love.system.getOS() @@ -351,8 +384,8 @@ local function chooseZip() end -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS --- dialogs). Returns the chosen absolute path or nil. Android has no picker; --- the SAVE FILES card steers that case to the drag-drop hint instead. +-- dialogs). Returns the chosen absolute path or nil. Android uses +-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. local function chooseSav() local prompt = "Choose a .sav save file" local platform = love.system.getOS() @@ -429,10 +462,16 @@ function RomImporter.new(onComplete, opts) -- affordance (desktop love.system.openURL). saveNotice = {}, -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array - -- (refreshed lazily on first draw and after any toggle/install); modScroll - -- is the list scroll offset (px, clamped in draw); modNotice is the last - -- install result { ok, text } shown as a line above the list. + -- (refreshed lazily on first draw and after any toggle/install/delete); + -- modScroll is the list scroll offset (px, clamped in draw); modNotice is + -- the last install/delete result { ok, text } shown as a line above the list. mods = nil, modScroll = 0, modNotice = nil, + -- Android SAF: which game tab should receive the next picked_save.sav when + -- focus consumes it (set by chooseSaveImport before opening the picker). + androidPendingVersion = nil, + -- Android SAF create-document: which game's SAVE FILES card should show + -- "Save exported." when export_done.flag appears on focus. + androidPendingExportVersion = nil, }, RomImporter) for _, version in ipairs(GameVersion.ORDER) do @@ -491,10 +530,40 @@ end -- The system picker runs as a separate top activity, so LOVE's own -- love.focus/love.visible pause while it's up (see main.lua) -- once the -- player returns here with a file picked, GameActivity has already copied --- it into the save directory, so a pending-ROM rescan on refocus picks it --- up without the player needing to tap the button again. +-- it into the save directory, so a pending-file rescan on refocus picks it +-- up without the player needing to tap the button again. Mod and save SAF +-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover +-- ROM pick cannot steal the focus path when both games are already ready. function RomImporter:focus(f) if not (f and self.android and self.workState ~= "working") then return end + -- SAF create-document finished: GameActivity wrote export_done.flag. + if love.filesystem.getInfo("export_done.flag", "file") then + love.filesystem.remove("export_done.flag") + love.filesystem.remove("pending_export.sav") + local version = self.androidPendingExportVersion or self:_savedropTarget() + self.androidPendingExportVersion = nil + self.saveNotice[version] = { ok = true, text = "Save exported." } + if self.tab == "mods" or self.tab == "yellow" then self.tab = version end + return + end + local modName = findPendingMod(false) + if modName then + self:_installMod(modName) + if self.modNotice and self.modNotice.ok then + love.filesystem.remove(modName) + end + return + end + local savName = findPendingSav(false) + if savName then + local version = self.androidPendingVersion or self:_savedropTarget() + self.androidPendingVersion = nil + self:_importSave(version, savName) + if self.saveNotice[version] and self.saveNotice[version].ok then + love.filesystem.remove(savName) + end + return + end if self.ready.red and self.ready.blue then return end local name, data = findPendingRom(self.ready) if name then self:startData(data, name) end @@ -672,13 +741,38 @@ function RomImporter:_installMod(source) self.tab = "mods" end --- "Import mod .zip" button: open a native picker (desktop) and install the --- pick. Android has no picker, so it points the player at the drop path. +-- Remove an installed mod from the save-dir mods/ tree and refresh the panel. +function RomImporter:_deleteMod(id) + if self.workState == "working" then return end + local LauncherMods = require("src.mods.LauncherMods") + local ok, res = LauncherMods.uninstall(id) + if ok then + self:_refreshMods() + self.modNotice = { ok = true, text = "Deleted " .. tostring(id) } + else + self.modNotice = { ok = false, text = tostring(res) } + end +end + +-- "Import mod .zip" button: open a native picker and install the pick. +-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB +-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip +-- which focus/Choose consumes on return. function RomImporter:chooseMod() if self.workState == "working" then return end if self.android then - self.modNotice = - { ok = false, text = "Drop a mod .zip onto the window to install it." } + local name = findPendingMod(true) + if name then + self:_installMod(name) + if self.modNotice and self.modNotice.ok then + love.filesystem.remove(name) + end + return + end + if not love.system.pickFile("mod") then + self.modNotice = { ok = false, + text = "Could not open the file picker. Copy a mod .zip via USB." } + end return end local path = chooseZip() @@ -722,13 +816,26 @@ function RomImporter:_importSave(version, source) end end --- "Import save" button: open a native .sav picker (desktop) and import the pick. --- Android has no picker, so it points the player at the drop path. +-- "Import save" button: open a native .sav picker and import the pick. +-- Android mirrors ROM / mod import via love.system.pickFile("sav"). function RomImporter:chooseSaveImport(version) if self.workState == "working" then return end if self.android then - self.saveNotice[version] = - { ok = false, text = "Drop a .sav onto the window to import it." } + local name = findPendingSav(true) + if name then + self.androidPendingVersion = version + self:_importSave(version, name) + if self.saveNotice[version] and self.saveNotice[version].ok then + love.filesystem.remove(name) + end + return + end + self.androidPendingVersion = version + if not love.system.pickFile("sav") then + self.androidPendingVersion = nil + self.saveNotice[version] = { ok = false, + text = "Could not open the file picker. Copy a .sav via USB." } + end return end local path = chooseSav() @@ -736,16 +843,58 @@ function RomImporter:chooseSaveImport(version) end -- "Export save" button: write the active slot back out to a raw .sav in the save --- directory's exports/ folder and show the path (with an open-folder affordance) --- on the SAVE FILES card. +-- directory's exports/ folder. On desktop, show the path with an open-folder +-- affordance. On Android, stage pending_export.sav and open the system +-- create-document picker (love.system.createFile) so the player can save to +-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there. function RomImporter:exportSave(version) if self.workState == "working" then return end local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version) - if ok then - local dir = res:match("^(.*)[/\\][^/\\]+$") - self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir } - else + if not ok then self.saveNotice[version] = { ok = false, text = tostring(res) } + return + end + if self.android then + local rel = res:match("exports[/\\][^/\\]+$") + local data = rel and love.filesystem.read(rel) + if not data then + self.saveNotice[version] = { ok = false, + text = "Exported, but could not stage the file for the picker." } + return + end + local suggested = rel:match("[^/\\]+$") or "export.sav" + local wrote, writeErr = love.filesystem.write("pending_export.sav", data) + if not wrote then + self.saveNotice[version] = { ok = false, + text = "Could not stage the export: " .. tostring(writeErr) } + return + end + self.androidPendingExportVersion = version + if love.system.createFile and love.system.createFile(suggested) then + self.saveNotice[version] = { ok = true, + text = "Pick where to save " .. suggested .. "..." } + else + self.androidPendingExportVersion = nil + self.saveNotice[version] = { ok = true, + text = "Exported inside the app folder (picker unavailable)." } + end + return + end + local dir = res:match("^(.*)[/\\][^/\\]+$") + self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir } +end + +-- Delete a save slot from the registry and disk, then refresh the panel. If the +-- deleted slot was active, SaveData.deleteSlot points active at another slot. +function RomImporter:_deleteSlot(version, id) + if self.workState == "working" then return end + local SaveData = require("src.core.SaveData") + local ok, err = SaveData.deleteSlot(version, id) + if ok then + self:_refreshSlots(version) + self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." } + else + self.saveNotice[version] = { ok = false, text = tostring(err) } end end @@ -1427,9 +1576,17 @@ function RomImporter:mousepressed(x, y, button) end return end - -- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag - -- commits it on release when the pointer did not move (a moved pointer scrolls - -- instead). Android has no reliable pointer polling, so it selects on press. + -- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete + -- label never also selects the row. On desktop a press only ARMS a click: + -- _updateSlotDrag commits it on release when the pointer did not move (a + -- moved pointer scrolls instead). Android has no reliable pointer polling, + -- so it selects on press. Delete fires immediately (small fixed target). + for _, r in ipairs(self.slotDeleteRects or {}) do + if inside(r, x, y) then + self:_deleteSlot(self.panelVersion, r.id) + return + end + end for _, r in ipairs(self.slotRects or {}) do if inside(r, x, y) then if self.android then @@ -1445,12 +1602,18 @@ function RomImporter:mousepressed(x, y, button) self:_newSlot(self.panelVersion); return end -- Mods panel: the import button dispatches on press (fixed header, no scroll - -- conflict); a toggle switch, which lives in the scrollable list, only ARMS a - -- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with - -- no pointer polling, toggles on press). + -- conflict); Delete fires immediately; a toggle switch, which lives in the + -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from + -- a drag-scroll (Android, with no pointer polling, toggles on press). if inside(self.modImportRect, x, y) then self:chooseMod(); return end + for _, r in ipairs(self.modDeleteRects or {}) do + if inside(r, x, y) then + self:_deleteMod(r.id) + return + end + end for _, r in ipairs(self.modRects or {}) do if inside(r, x, y) then if self.android then @@ -1759,7 +1922,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h) elseif locked then sfHintText, sfHintCol = "Not available yet.", PAL.warning elseif self.android then - sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning + sfHintText, sfHintCol = + "Import or export a .sav with the system file picker.", PAL.warning else sfHintText, sfHintCol = "Import a .sav to a new slot, or export the active slot.", PAL.warning @@ -1999,6 +2163,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) love.graphics.printf("No saves yet - start a new game or import one.", rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, rw - 24 * s, "center") + self.slotRects = {} + self.slotDeleteRects = {} elseif listH > 0 then local nameH = self.slotNameFont:getHeight() local metaH = self.labelFont:getHeight() @@ -2017,6 +2183,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) self.slotScroll[version] = scroll self.slotRects = {} + self.slotDeleteRects = {} love.graphics.setScissor(math.floor(rx), math.floor(listTop), math.ceil(rw), math.ceil(listH)) for i, slot in ipairs(slots) do @@ -2029,6 +2196,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) + -- Delete label (bottom-right); reserve its width so name/meta don't overlap + love.graphics.setFont(self.hintFont) + local delText = "Delete" + local delW = self.hintFont:getWidth(delText) + local delH = self.hintFont:getHeight() + local delX = rx + rw - 12 * s - delW + local delY = ry + rowH - rowPadV - delH + local drect = { x = delX - 6 * s, y = delY - 4 * s, + width = delW + 12 * s, height = delH + 8 * s, id = slot.id } + local dhot = self:_hover(drect) + col(dhot and PAL.red or PAL.warning) + love.graphics.print(delText, delX, delY) + local rightReserve = delW + 18 * s + -- LOADED pill (top-right of the active row), then reserve its width local pillW = 0 if selected then @@ -2048,7 +2229,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) love.graphics.setFont(self.slotNameFont) col(PAL.white) local name = slot.name or "NEW GAME" - printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW), + printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)), rx + 12 * s, ry + rowPadV) local metaTxt @@ -2061,7 +2242,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) end love.graphics.setFont(self.labelFont) col(PAL.warning) - love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s), + love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve), rx + 12 * s, ry + rowPadV + nameH + 4 * s) -- clip the hit rect to the visible list band so a partly-scrolled row @@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) self.slotRects[#self.slotRects + 1] = { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } end + local dvy = math.max(drect.y, listTop) + local dvy2 = math.min(drect.y + drect.height, listBottom) + if dvy2 > dvy then + self.slotDeleteRects[#self.slotDeleteRects + 1] = + { x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id } + end end end love.graphics.setScissor() @@ -2165,14 +2352,14 @@ function RomImporter:_drawModsPanel(x, y, w, h) local top = y + headerH + 14 * s - -- notice line: the last install result, else the drag-drop hint + -- notice line: the last install/delete result, else the platform hint love.graphics.setFont(self.hintFont) if self.modNotice then col(self.modNotice.ok and PAL.green or PAL.red) love.graphics.printf(self.modNotice.text, x, top, w, "left") else col(PAL.warning) - love.graphics.printf(self.android and "Copy a mod .zip via USB." + love.graphics.printf(self.android and "Or copy a mod .zip via USB." or "Or drop a mod .zip onto the window.", x, top, w, "left") end top = top + self.hintFont:getHeight() + 12 * s @@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h) dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) love.graphics.setFont(self.hintFont) col(PAL.warning) - love.graphics.printf("No mods installed - drop a mod .zip here to add one.", + local emptyHint = self.android + and "No mods installed - tap Import mod .zip to add one." + or "No mods installed - drop a mod .zip here to add one." + love.graphics.printf(emptyHint, x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") self.modRects = {} + self.modDeleteRects = {} self._modMax = 0 return end - -- card metrics (design: rounded 14, padding 14x16; toggle 56x28) + -- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under) local padH, padV = 16 * s, 14 * s local cardGap, cardR = 10 * s, 14 * s local tw, th = 52 * s, 28 * s local innerW = w - 2 * padH local chipH = self.hintFont:getHeight() + 8 * s - local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle + local delH = self.hintFont:getHeight() + local clusterH = chipH + 6 * s + th + 6 * s + delH love.graphics.setFont(self.stateFont) local nameH = self.stateFont:getHeight() @@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h) for i, m in ipairs(mods) do local chipText = modStatusChip(m.status) local chipW = self.hintFont:getWidth(chipText) + 20 * s - local clusterW = math.max(chipW, tw) + local delW = self.hintFont:getWidth("Delete") + local clusterW = math.max(chipW, tw, delW) local leftW = math.max(40 * s, innerW - clusterW - 14 * s) local descH = 0 if m.description ~= "" then @@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) local cardH = math.max(contentH, padV * 2 + clusterH) layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, - chipText = chipText, chipW = chipW } + chipText = chipText, chipW = chipW, delW = delW } total = total + cardH end total = total + (#mods - 1) * cardGap @@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) local scroll = clamp(self.modScroll or 0, 0, maxScroll) self.modScroll = scroll self.modRects = {} + self.modDeleteRects = {} love.graphics.setScissor(math.floor(x), math.floor(top), math.ceil(w), math.ceil(listH)) @@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") end - -- right cluster: status chip stacked over the toggle, vertically centred + -- right cluster: status chip, toggle, Delete — vertically centred local clusterX = x + w - padH - L.clusterW local clusterY = cy + (cardH - clusterH) / 2 local _, chipColor = modStatusChip(m.status) @@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h) col(PAL.white) love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) - -- hit rect clipped to the visible list band (a partly-scrolled toggle is - -- only clickable where it actually shows) + -- Delete under the toggle + local delX = clusterX + (L.clusterW - L.delW) / 2 + local delY = ty + th + 6 * s + local drect = { x = delX - 6 * s, y = delY - 2 * s, + width = L.delW + 12 * s, height = delH + 4 * s, id = m.id } + local dhot = self:_hover(drect) + love.graphics.setFont(self.hintFont) + col(dhot and PAL.red or PAL.warning) + love.graphics.print("Delete", delX, delY) + + -- hit rects clipped to the visible list band local vy = math.max(trect.y, top) local vy2 = math.min(trect.y + trect.height, top + listH) if vy2 > vy then self.modRects[#self.modRects + 1] = { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } end + local dvy = math.max(drect.y, top) + local dvy2 = math.min(drect.y + drect.height, top + listH) + if dvy2 > dvy then + self.modDeleteRects[#self.modDeleteRects + 1] = + { x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id } + end end cy = cy + cardH + cardGap end diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 8824e7e2..7efc09c2 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -47,11 +47,19 @@ local function readSource(source) return source end local f, openErr = io.open(source, "rb") - if not f then return nil, "could not read the save file: " .. tostring(openErr) end - local data = f:read("*a") - f:close() - if type(data) ~= "string" then return nil, "the save file was empty" end - return data + if f then + local data = f:read("*a") + f:close() + if type(data) ~= "string" then return nil, "the save file was empty" end + return data + end + -- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save + -- directory; io.open cannot see them, so fall back to love.filesystem. + if love and love.filesystem and love.filesystem.read then + local data = love.filesystem.read(source) + if type(data) == "string" then return data end + end + return nil, "could not read the save file: " .. tostring(openErr) end -- importToSlot(source, version) -> ok, slotIdOrErr diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 1b0b6aa3..47d90488 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -3,11 +3,12 @@ -- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- load at boot; this reads the same options.mods enable-state the loader -- writes, derives per-mod status with the pure ManagerState.resolveToggle, --- and installs a dropped/chosen .zip into the save-dir "mods//" tree. +-- installs a dropped/chosen .zip into the save-dir "mods//" tree, and +-- uninstalls a mod by removing that tree + clearing options.mods[id]. -- -- Split in two: the pure derivation (deriveList, locateRoot) has no love and --- no filesystem, so the engine tier can table-drive it; the discovery and --- install paths reach for love.filesystem and SaveData. +-- no filesystem, so the engine tier can table-drive it; the discovery, +-- install, and uninstall paths reach for love.filesystem and SaveData. local Manifest = require("src.mods.Manifest") local ManagerState = require("src.mods.ManagerState") @@ -332,4 +333,34 @@ function LauncherMods.installZip(source) return true, manifest.id end +-- uninstall(id) -> true | nil, errString +-- Removes mods// from the save directory and clears options.mods[id] so the +-- loader and in-game manager no longer see it. Rejects unknown / missing ids. +-- Does not touch other mods' enable state. +function LauncherMods.uninstall(id) + if type(id) ~= "string" or id == "" then + return nil, "missing mod id" + end + if id:find("[/\\]") or id == "." or id == ".." then + return nil, "invalid mod id" + end + if not (love and love.filesystem) then + return nil, "mod uninstall needs LOVE" + end + local fs = love.filesystem + local dest = "mods/" .. id + if not fs.getInfo(dest) then + return nil, "mod '" .. id .. "' is not installed" + end + removeTree(dest) + -- Drop the enable flag so a reinstall of the same id starts from the + -- loader's default (enabled) rather than a stale false. + local options = SaveData.loadOptions() + if options.mods and options.mods[id] ~= nil then + options.mods[id] = nil + SaveData.saveOptions(options) + end + return true +end + return LauncherMods diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index 0f1babbf..9199b31d 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -190,4 +190,22 @@ do check(err ~= nil, "the no-root case carries a reason") end +-- ------- uninstall: rejects bad ids without needing a real mods tree + +do + local ok, err = LauncherMods.uninstall("") + eq(ok, nil, "empty id is rejected") + check(tostring(err):find("missing", 1, true) ~= nil, "empty-id reason") + + ok, err = LauncherMods.uninstall("../escape") + eq(ok, nil, "path-like ids are rejected") + check(tostring(err):find("invalid", 1, true) ~= nil, "path-id reason") + + ok, err = LauncherMods.uninstall("ghost") + -- Without a mods/ghost tree (and with the love stub's getInfo), uninstall + -- either needs LOVE or reports not installed -- never silently succeeds. + eq(ok, nil, "a missing mod does not uninstall") + check(err ~= nil, "missing-mod uninstall carries a reason") +end + T.finish("launcher_mods") diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index 9f12626d..b86dd1fa 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -165,6 +165,41 @@ do T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest") end +-- ---------------------------------------------- deleteSlot + +do + local files = fresh() + local a = SaveData.createSlot("red") + local b = SaveData.createSlot("red") + SaveData.setActiveSlot("red", b) + local save = SaveData.newGame() + save.player.name = "KEEP" + T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save") + save.player.name = "GONE" + T.check(SaveData.writeSlot("red", b, save), "seed slot2 with a save") + + local ok, err = SaveData.deleteSlot("red", b) + T.check(ok, "deleteSlot removes the active slot: " .. tostring(err)) + T.eq(files["saves/red/slot2.lua"], nil, "slot2's file is gone") + T.check(files["saves/red/slot1.lua"] ~= nil, "the other slot's file stays") + local opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.active, a, "active falls back to the remaining slot") + T.eq(#opts.saveSlots.red.list, 1, "the deleted id is dropped from the list") + T.eq(opts.saveSlots.red.list[1], a, "only slot1 remains registered") + + ok = SaveData.deleteSlot("red", a) + T.check(ok, "deleting the last slot succeeds") + opts = SaveSerializer.decode(files["options.lua"]) + T.eq(#opts.saveSlots.red.list, 0, "the registry list is empty") + T.eq(opts.saveSlots.red.active, nil, "active clears when no slots remain") + T.eq(#SaveData.listSlots("red"), 0, "listSlots reports an empty install") + + local bad, badErr = SaveData.deleteSlot("red", "slot99") + T.check(not bad, "deleting an unknown slot fails") + T.check(tostring(badErr):find("not registered", 1, true) ~= nil, + "unknown-slot error is user-presentable") +end + -- ---------------------------------------------- saveNames follows the slot do diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua new file mode 100644 index 00000000..6e06fece --- /dev/null +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -0,0 +1,104 @@ +-- Android mod / save Import must open the SAF picker (love.system.pickFile +-- with kind) and consume picked_mod.zip / picked_save.sav on focus, mirroring +-- the ROM flow. Self-contained: `luajit tests/rom_importer_android_mod_pick_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer android mod/save pick") +local eq = S.eq +local check = S.check + +local RomImporter = require("src.import.RomImporter") + +love.system = love.system or {} +local saved = { + getOS = love.system.getOS, + pickFile = love.system.pickFile, +} + +local pickCalls = {} +love.system.getOS = function() return "Android" end +love.system.pickFile = function(kind) + pickCalls[#pickCalls + 1] = kind or "rom" + return true +end + +local function freshImporter(ready) + return setmetatable({ + android = true, + workState = nil, + tab = "mods", + ready = { + red = ready.red and true or false, + blue = ready.blue and true or false, + }, + saveNotice = {}, + modNotice = nil, + androidPendingVersion = nil, + _installMod = function(self, source) + self._installed = source + self.modNotice = { ok = true, text = "Installed test" } + end, + _importSave = function(self, version, source) + self._imported = { version = version, source = source } + self.saveNotice[version] = { ok = true, text = "Imported" } + end, + _savedropTarget = RomImporter._savedropTarget, + _refreshMods = function() end, + _refreshSlots = function() end, + }, RomImporter) +end + +-- Choose mod with nothing pending opens the mod picker. +pickCalls = {} +local ri = freshImporter({ red = true, blue = true }) +ri:chooseMod() +eq(#pickCalls, 1, "chooseMod opens the picker when no pending zip exists") +eq(pickCalls[1], "mod", "chooseMod asks pickFile for a mod") + +-- Pending USB zip installs without opening the picker. +love.filesystem.write("usb_mod.zip", "PK\0fake") +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri:chooseMod() +eq(#pickCalls, 0, "chooseMod installs a pending zip without opening the picker") +eq(ri._installed, "usb_mod.zip", "chooseMod consumed the USB zip") +check(love.filesystem.getInfo("usb_mod.zip") == nil, + "successful install removes the pending zip") + +-- Focus consumes picked_mod.zip even when both ROMs are already ready. +love.filesystem.write("picked_mod.zip", "PK\0saf") +ri = freshImporter({ red = true, blue = true }) +ri:focus(true) +eq(ri._installed, "picked_mod.zip", "focus installs the SAF mod drop") +check(love.filesystem.getInfo("picked_mod.zip") == nil, + "successful focus install removes picked_mod.zip") + +-- Choose save opens the sav picker when nothing is pending. +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri.tab = "blue" +ri:chooseSaveImport("blue") +eq(#pickCalls, 1, "chooseSaveImport opens the picker when no pending sav exists") +eq(pickCalls[1], "sav", "chooseSaveImport asks pickFile for a sav") +eq(ri.androidPendingVersion, "blue", "pending version is remembered for focus") + +-- Focus consumes picked_save.sav into the remembered version. +love.filesystem.write("picked_save.sav", string.rep("S", 32)) +ri = freshImporter({ red = true, blue = true }) +ri.androidPendingVersion = "blue" +ri:focus(true) +check(ri._imported ~= nil, "focus imports the SAF save drop") +eq(ri._imported.version, "blue", "focus imports into the pending version") +eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename") +check(love.filesystem.getInfo("picked_save.sav") == nil, + "successful focus import removes picked_save.sav") + +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile +-- leftover cleanup if a failed assertion left files behind +love.filesystem.remove("usb_mod.zip") +love.filesystem.remove("picked_mod.zip") +love.filesystem.remove("picked_save.sav") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 0f1ef9f4..f7f0bf9f 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3115,6 +3115,8 @@ runSuites({ "tests/rom_importer_cursor_test.lua" }) -- ---------------------------------------------- Android second ROM pick (#167) runSuites({ "tests/rom_importer_android_pick_test.lua" }) +-- ---------------------------------------------- Android mod / save SAF pick +runSuites({ "tests/rom_importer_android_mod_pick_test.lua" }) -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity