From 52efdabf61bd32f0d2f3b11ac5017eaa02ae2383 Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:04:43 -0500 Subject: [PATCH 1/2] Harden required import picker for Android 13 --- .../java/org/love2d/android/GameActivity.java | 18 ++++++++++++++++- tests/engine/android_host_extension_test.lua | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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 dbd409e8..7a30850c 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 @@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity { 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"; + // Kept separate from the game-ROM destination so a dependency pick can + // never be mistaken for a game import when the picker returns on Android. + private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"; private static final String PENDING_EXPORT_FILENAME = "pending_export.sav"; private static final String EXPORT_DONE_FILENAME = "export_done.flag"; // Written when a SAF pick cannot be read at all, with the destination @@ -504,7 +507,8 @@ public class GameActivity extends SDLActivity { * picker-agnostic and unchanged. * * @param destFilename basename under the app save identity (e.g. - * picked_rom.gb, picked_mod.zip, picked_save.sav) + * picked_rom.gb, picked_mod.zip, picked_save.sav, or + * picked_required_import.bin) */ /** Legacy single-argument entry; resolves the save dir itself. */ @Keep @@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); + // The Storage Access Framework grants the returned content URI + // directly to this activity. Request the read grant explicitly as + // well: Android 13's scoped storage deliberately does not expose + // arbitrary paths or require broad media/storage permissions. + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); try { self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); return true; @@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); try { self.startActivityForResult( Intent.createChooser(intent, "Choose a file"), @@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity { return showFilePicker(PICKED_SAVE_FILENAME); } + /** Required-mod-file wrapper used by love.system.pickFile("required_import"). */ + @Keep + public static boolean showRequiredImportFilePicker() { + return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME); + } + /** * Relaunches the whole app for love.system.restartApp, used by * src/core/HostShell.lua when a mod toggle needs a cold boot (#575). diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 14d7ae5a..13d25a5e 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -55,4 +55,24 @@ check(not source:find("QuestActivity", 1, true) and not source:find("QuestBridge", 1, true), "generic Android activity must not require Quest classes") +-- Required mod files use Android's Storage Access Framework, which works with +-- Android 13 scoped storage without broad media/storage permissions. Keep the +-- native destination distinct so it cannot be consumed as a game ROM. +check(source:find('PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"', + 1, true), "required imports use their own Android picker destination") +check(source:find("showRequiredImportFilePicker", 1, true), + "Android exposes a required-import picker entry point") +check(source:find("Intent.ACTION_OPEN_DOCUMENT", 1, true) + and source:find("Intent.FLAG_GRANT_READ_URI_PERMISSION", 1, true), + "Android 13 uses SAF with an explicit read grant") + +local systemPath = "mobile/android/love/src/jni/love/src/modules/system/System.cpp" +local systemFile = assert(io.open(systemPath, "rb")) +local system = systemFile:read("*a") +systemFile:close() +check(system:find('strcmp(kind, "required_import")', 1, true) + and system:find('dest = "picked_required_import.bin"', 1, true) + and system:find('return "rom,mod,sav,required_import"', 1, true), + "native Android bridge advertises and routes required imports") + print("android_host_extension_test: ok") From 9bf15c33fd6112e117dce3cb06a3d92ca1687dc2 Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:56:58 -0500 Subject: [PATCH 2/2] Cover required imports across platforms --- docs/launcher.md | 1 + docs/modding.md | 12 ++++++ .../ios_required_import_picker_test.lua | 32 +++++++++++++++ tests/engine/uwp_native_picker_test.lua | 40 +++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 tests/engine/ios_required_import_picker_test.lua diff --git a/docs/launcher.md b/docs/launcher.md index aa087e48..24f79d0d 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -16,6 +16,7 @@ save directory as: | nil / `"rom"` | `picked_rom.gb` (open) | | `"mod"` | `picked_mod.zip` (open) | | `"sav"` / `"save"` | `picked_save.sav` (open) | +| `"required_import"` | `picked_required_import.bin` (open) | Export uses a separate API: `love.system.createFile(suggestedName)` → `GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies diff --git a/docs/modding.md b/docs/modding.md index 48074cbd..b369f504 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -148,6 +148,18 @@ older iOS-only `"stadium"` picker kind remains temporarily for compatibility. Android now returns `false` for unknown picker kinds instead of treating them as game-ROM picks. +### Platform import flow + +The same per-mod validation and private `mods//baseroms/` destination +applies on every supported platform. Windows, macOS, and Linux use the +launcher file chooser. Android uses the Storage Access Framework, and iOS uses +the Files document picker; both stage the choice as `picked_required_import.bin` +before validation. Xbox/UWP uses its native picker and hands the launcher a +temporary path. Switch/NX has no host picker, so the player copies a file to +`imports/baseroms/` over MTP and chooses the import again. No platform grants +the mod a host filesystem path or bypasses the manifest's size, format, and MD5 +checks. + ## Mods and Gold (Gen 2) The mod API is one API across both generations, but Gold runs its own battle diff --git a/tests/engine/ios_required_import_picker_test.lua b/tests/engine/ios_required_import_picker_test.lua new file mode 100644 index 00000000..56a13b1c --- /dev/null +++ b/tests/engine/ios_required_import_picker_test.lua @@ -0,0 +1,32 @@ +-- iOS required imports travel through the same document-picker contract as +-- Android. Keep the Swift bridge and liblove patch aligned: a build that has +-- only one side would show the import button but fail on device. +local function read(path) + local file = assert(io.open(path, "rb")) + local data = file:read("*a") + file:close() + return data +end + +local function check(value, message) + if not value then error(message, 2) end +end + +local bridge = read("mobile/ios/native/GRPickerBridge.swift") +check(bridge:find('case "required_import":', 1, true) + and bridge:find('destName = "picked_required_import.bin"', 1, true), + "iOS routes required imports to their own staged filename") +check(bridge:find("types.append(.data)", 1, true) + and bridge:find("types.append(.item)", 1, true), + "iOS required imports accept user-owned binary ROM files") +check(bridge:find('"rom,mod,sav,stadium,required_import"', 1, true), + "iOS advertises required_import to Lua before opening the picker") + +local patch = read("mobile/ios/patch_love_src.py") +check(patch:find("int w_pickFileKinds", 1, true) + and patch:find('{ "pickFileKinds", w_pickFileKinds }', 1, true), + "iOS liblove patch exposes the picker capability query") +check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true), + "iOS build patch compiles the required-import picker bridge") + +print("ios_required_import_picker_test: ok") diff --git a/tests/engine/uwp_native_picker_test.lua b/tests/engine/uwp_native_picker_test.lua index ab5c34d2..69fefef6 100644 --- a/tests/engine/uwp_native_picker_test.lua +++ b/tests/engine/uwp_native_picker_test.lua @@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]], check(removedPath == [[C:\LocalState\picked_mod.zip]], "removes the temporary copy after installation") +-- The UWP picker returns a temporary path rather than a mobile staged name. +-- Required imports must use that same picker and remain scoped to the selected +-- mod instead of relying on a desktop shell or Android/iOS inbox handling. +local pickedKind +love.system.pickFile = function(kind) + pickedKind = kind + return true +end +love.system.getPickedFile = function() + love.system.getPickedFile = function() return nil end + return [[C:\LocalState\picked_required_import.bin]] +end +removedPath = nil +local required = RomImporter.new(function() end, { launcher = true }) +required.mods = { { + id = "needs-source", + manifest = { + id = "needs-source", name = "Needs source", + required_imports = { { + id = "source", name = "Source", file = "source.bin", + md5 = { "00000000000000000000000000000000" }, + } }, + }, +} } +required._importRequiredSource = function(self, modId, importId, path) + self.requiredPath = { modId = modId, importId = importId, path = path } + return true +end +required:chooseRequiredImport("needs-source", "source") +check(pickedKind == "required_import", "UWP requests the required-import picker kind") +required:update(0) +check(required.requiredPath and required.requiredPath.path + == [[C:\LocalState\picked_required_import.bin]], + "UWP routes the picked dependency to its declared import") +check(required.requiredPath.modId == "needs-source" + and required.requiredPath.importId == "source", + "UWP preserves the pending mod and import identity") +check(removedPath == [[C:\LocalState\picked_required_import.bin]], + "UWP removes its temporary required-import copy after validation") + love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile love.system.getPickedFile = saved.getPickedFile