Merge pull request #1353 from anxiousintrovert/fix/required-import-platform-pickers

This commit is contained in:
bryanthaboi
2026-08-15 13:37:51 -04:00
committed by GitHub
6 changed files with 122 additions and 1 deletions
+1
View File
@@ -16,6 +16,7 @@ save directory as:
| nil / `"rom"` | `picked_rom.gb` (open) | | nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) | | `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) | | `"sav"` / `"save"` | `picked_save.sav` (open) |
| `"required_import"` | `picked_required_import.bin` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)` Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies `GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
+12
View File
@@ -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 Android now returns `false` for unknown picker kinds instead of treating them
as game-ROM picks. as game-ROM picks.
### Platform import flow
The same per-mod validation and private `mods/<mod-id>/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) ## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle The mod API is one API across both generations, but Gold runs its own battle
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; 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_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; 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 PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag"; private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Written when a SAF pick cannot be read at all, with the destination // 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. * picker-agnostic and unchanged.
* *
* @param destFilename basename under the app save identity (e.g. * @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. */ /** Legacy single-argument entry; resolves the save dir itself. */
@Keep @Keep
@@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); 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 { try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true; return true;
@@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT); Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); intent.setType("*/*");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
try { try {
self.startActivityForResult( self.startActivityForResult(
Intent.createChooser(intent, "Choose a file"), Intent.createChooser(intent, "Choose a file"),
@@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity {
return showFilePicker(PICKED_SAVE_FILENAME); 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 * Relaunches the whole app for love.system.restartApp, used by
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575). * src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
@@ -55,4 +55,24 @@ check(not source:find("QuestActivity", 1, true) and
not source:find("QuestBridge", 1, true), not source:find("QuestBridge", 1, true),
"generic Android activity must not require Quest classes") "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") print("android_host_extension_test: ok")
@@ -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")
+40
View File
@@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]],
check(removedPath == [[C:\LocalState\picked_mod.zip]], check(removedPath == [[C:\LocalState\picked_mod.zip]],
"removes the temporary copy after installation") "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.getOS = saved.getOS
love.system.pickFile = saved.pickFile love.system.pickFile = saved.pickFile
love.system.getPickedFile = saved.getPickedFile love.system.getPickedFile = saved.getPickedFile