Merge pull request #1791 from HighDrexler/fix/mobile-required-import-streaming-v2

Fix/mobile required import streaming v2
This commit is contained in:
bryanthaboi
2026-08-25 15:55:34 -04:00
committed by GitHub
9 changed files with 496 additions and 38 deletions
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile(const char *kind) const
bool System::pickFile(const char *kind, const char *destination) const
{
#ifdef LOVE_ANDROID
const char *dest = "picked_rom.gb";
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
dest = (destination != nullptr && destination[0] != '\0')
? destination : "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them
@@ -116,7 +116,7 @@ public:
* "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown.
**/
virtual bool pickFile(const char *kind = nullptr) const;
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
virtual const char *pickFileKinds() const;
/**
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
const char *destination = luaL_optstring(L, 2, nullptr);
luax_pushboolean(L, instance()->pickFile(kind, destination));
return 1;
}
@@ -39,6 +39,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.security.MessageDigest;
import android.Manifest;
import android.app.AlarmManager;
@@ -111,6 +112,8 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Written after a direct required-import copy has been fully published.
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
@@ -576,6 +579,21 @@ public class GameActivity extends SDLActivity {
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
* picked_required_import.bin)
*/
private static boolean isDirectRequiredDestination(String relative) {
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
String normalized = relative.replace('\\', '/');
if (!normalized.startsWith("mods/")) return false;
int marker = normalized.indexOf("/baseroms/");
if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) {
return false;
}
return !normalized.contains("//")
&& !normalized.equals("..")
&& !normalized.startsWith("../")
&& !normalized.contains("/../")
&& !normalized.endsWith("/..");
}
/** Legacy single-argument entry; resolves the save dir itself. */
@Keep
public static boolean showFilePicker(String destFilename) {
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
// onActivityResult copies the pick there, not into a recomputed
// (possibly different-volume) root (#604, #839).
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
// 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);
// Basename destinations keep the historical ROM/mod/save staging path.
// A nested destination is accepted only for an engine-generated mod
// baseroms path, then canonicalized beneath LOVE's mounted save root.
String normalizedDest = destFilename.replace('\\', '/');
boolean nested = normalizedDest.indexOf('/') >= 0;
if (nested && !isDirectRequiredDestination(normalizedDest)) {
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
return false;
}
try {
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
String rootPrefix = rootCanonical.getPath() + File.separator;
if (destCanonical.equals(rootCanonical)
|| !destCanonical.getPath().startsWith(rootPrefix)) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
} catch (IOException e) {
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
return false;
}
self.pendingPickFilename = destFilename;
self.pendingPickFilename = normalizedDest;
if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
@@ -1277,13 +1311,7 @@ public class GameActivity extends SDLActivity {
/** Drops a small flag file in the save identity for Lua to consume on focus. */
private void writeSaveDirFlag(String name, String body) {
try {
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
fos.write(body.getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
writeFlagFile(saveIdentityDir(), name, body);
}
/**
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "could not create " + destDir);
return;
}
String destName = pendingPickFilename != null
final String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
final boolean directRequired = isDirectRequiredDestination(destName);
final File destFile;
try {
File rootCanonical = destDir.getCanonicalFile();
destFile = new File(rootCanonical, destName).getCanonicalFile();
String rootPrefix = rootCanonical.getPath() + File.separator;
if (destFile.equals(rootCanonical)
|| !destFile.getPath().startsWith(rootPrefix)
|| (destName.indexOf('/') >= 0 && !directRequired)) {
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
} catch (IOException e) {
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
// some OEM shells (ColorOS) offer third-party file managers in a
@@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity {
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
final InputStream pickedSource = source;
final File pickedRoot = destDir;
if (directRequired) {
// Optical-disc-sized imports must not block Android's UI thread and
// must not create a second picked_required_import.bin copy.
new Thread(new Runnable() {
@Override public void run() {
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
if (!result.ok) {
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
return;
}
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
+ Long.toString(result.bytes) + "\n";
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
}
}, "gen1recomp-required-import").start();
return;
}
if (!copyAssetFile(pickedSource, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked file to " + destFile);
// A truncated pick would only fail verification later, so drop it
// and report instead.
destFile.delete();
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
}
}
private static final class PickCopyResult {
boolean ok = false;
long bytes = 0;
String md5 = "";
}
private static String hex(byte[] bytes) {
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff));
return out.toString();
}
private static void writeFlagFile(File dir, String name, String body) {
try {
if (!dir.isDirectory() && !dir.mkdirs()) return;
File tmp = new File(dir, name + ".tmp");
File dest = new File(dir, name);
FileOutputStream out = new FileOutputStream(tmp, false);
out.write(body.getBytes("UTF-8"));
out.getFD().sync();
out.close();
if (dest.exists() && !dest.delete()) { tmp.delete(); return; }
if (!tmp.renameTo(dest)) tmp.delete();
} catch (Exception e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
}
private static PickCopyResult copyRequiredImport(InputStream source, File destination) {
PickCopyResult result = new PickCopyResult();
File parent = destination.getParentFile();
File partial = new File(destination.getPath() + ".part");
BufferedInputStream in = null;
BufferedOutputStream out = null;
FileOutputStream rawOut = null;
try {
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result;
if (partial.exists() && !partial.delete()) return result;
MessageDigest md5 = MessageDigest.getInstance("MD5");
in = new BufferedInputStream(source, 1024 * 1024);
rawOut = new FileOutputStream(partial, false);
out = new BufferedOutputStream(rawOut, 1024 * 1024);
byte[] buf = new byte[1024 * 1024];
int n;
long total = 0;
while ((n = in.read(buf)) != -1) {
if (n == 0) continue;
out.write(buf, 0, n);
md5.update(buf, 0, n);
total += n;
}
out.flush();
rawOut.getFD().sync();
out.close(); out = null; rawOut = null;
in.close(); in = null;
// Publish only a complete same-directory file. Validation still
// happens in Lua against the manifest before a receipt is written.
if (destination.exists() && !destination.delete()) return result;
if (!partial.renameTo(destination)) return result;
result.ok = true;
result.bytes = total;
result.md5 = hex(md5.digest());
Log.d("GameActivity", "direct required import copied " + total
+ " bytes to " + destination);
return result;
} catch (Exception e) {
Log.d("GameActivity", "direct required import failed: " + e.getMessage());
return result;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {}
if (!result.ok && partial.exists()) partial.delete();
}
}
/**
* Copies a given file from the assets folder to the destination.
*
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
assert (source != null && destination != null);
try {
byte[] buf = new byte[1024];
chunk_read = source.read(buf);
do {
byte[] buf = new byte[1024 * 1024];
while ((chunk_read = source.read(buf)) != -1) {
if (chunk_read == 0) continue;
destination.write(buf, 0, chunk_read);
bytes_written += chunk_read;
chunk_read = source.read(buf);
} while (chunk_read != -1);
}
} catch (IOException e) {
Log.d("GameActivity", "Copying failed:" + e.getMessage());
}
+101 -4
View File
@@ -13,6 +13,7 @@
import UIKit
import UniformTypeIdentifiers
import CryptoKit
@objc(GRPickerBridge)
public final class GRPickerBridge: NSObject {
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
@objc(presentPickerWithKind:saveDir:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?) -> Bool {
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
}
@objc(presentPickerWithKind:saveDir:destination:)
public static func presentPicker(kind: UnsafePointer<CChar>?,
saveDir: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?) -> Bool {
let kindStr = kind.map { String(cString: $0) } ?? "rom"
guard let dir = resolvedSaveDir(saveDir) else { return false }
let requestedDestination = destination.map { String(cString: $0) }
let destName: String
var types: [UTType] = []
switch kindStr {
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
case "sav":
destName = "picked_save.sav"
case "required_import":
destName = "picked_required_import.bin"
if let requestedDestination,
isDirectRequiredDestination(requestedDestination),
safeDestination(in: dir, relative: requestedDestination) != nil {
destName = requestedDestination
} else {
destName = "picked_required_import.bin"
}
// A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this
// was added for. Its own filename on purpose: an N64 ROM landing on
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
types.append(.data)
if !types.contains(.item) { types.append(.item) }
let directRequired = kindStr == "required_import"
&& destName != "picked_required_import.bin"
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
asCopy: true)
asCopy: !directRequired)
picker.allowsMultipleSelection = false
let delegate = PickerDelegate { urls in
guard let src = urls.first else { return }
copyItem(at: src, into: dir, named: destName)
if directRequired {
copyRequiredItemAsync(at: src, into: dir, relative: destName)
} else {
copyItem(at: src, into: dir, named: destName)
}
}
return present(picker, with: delegate)
}
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
try? fm.moveItem(at: item, to: target)
}
private static func isDirectRequiredDestination(_ relative: String) -> Bool {
let normalized = relative.replacingOccurrences(of: "\\", with: "/")
guard normalized.hasPrefix("mods/"),
let range = normalized.range(of: "/baseroms/"),
range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5),
range.upperBound < normalized.endIndex else { return false }
return !normalized.hasPrefix("/")
&& !normalized.contains("//")
&& !normalized.contains("/../")
&& !normalized.hasSuffix("/..")
}
private static func safeDestination(in root: URL, relative: String) -> URL? {
guard isDirectRequiredDestination(relative) else { return nil }
let rootURL = root.standardizedFileURL
let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
guard candidate.path.hasPrefix(rootPath) else { return nil }
return candidate
}
private static func writeFlag(in dir: URL, name: String, body: String) {
try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name),
options: .atomic)
}
private static func copyRequiredItemAsync(at src: URL, into dir: URL,
relative: String) {
DispatchQueue.global(qos: .userInitiated).async {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
guard let dest = safeDestination(in: dir, relative: relative) else {
writeFlag(in: dir, name: "pick_error.flag", body: relative)
return
}
let fm = FileManager.default
ensureDirectory(dest.deletingLastPathComponent())
let partial = URL(fileURLWithPath: dest.path + ".part")
try? fm.removeItem(at: partial)
guard fm.createFile(atPath: partial.path, contents: nil) else {
writeFlag(in: dir, name: "pick_error.flag", body: relative)
return
}
var hasher = Insecure.MD5()
var total: UInt64 = 0
do {
let input = try FileHandle(forReadingFrom: src)
let output = try FileHandle(forWritingTo: partial)
defer {
try? input.close()
try? output.close()
}
while true {
let data = input.readData(ofLength: 1024 * 1024)
if data.isEmpty { break }
hasher.update(data: data)
output.write(data)
total += UInt64(data.count)
}
output.synchronizeFile()
try? fm.removeItem(at: dest)
try fm.moveItem(at: partial, to: dest)
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
let marker = "v1\n" + relative + "\n" + digest + "\n"
+ String(total) + "\n"
writeFlag(in: dir, name: "pick_complete.flag", body: marker)
NSLog("GRPickerBridge: direct required import delivered %llu bytes", total)
} catch {
try? fm.removeItem(at: partial)
NSLog("GRPickerBridge: direct required import failed: \(error)")
writeFlag(in: dir, name: "pick_error.flag", body: relative)
}
}
}
private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
let report = "Could not copy \(src.lastPathComponent): " +
error.localizedDescription
try? report.data(using: .utf8)?
.write(to: dir.appendingPathComponent("pick_error.txt"))
.write(to: dir.appendingPathComponent("pick_error.flag"))
}
}
+14 -1
View File
@@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className,
int w_pickFile(lua_State *L)
{
const char *kind = luaL_optstring(L, 1, "rom");
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
const char *destination = luaL_optstring(L, 2, nullptr);
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
const char *);
signed char ok = ((GRPick)objc_msgSend)(
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
kind, gr_saveDirectory(), destination);
lua_pushboolean(L, ok != 0);
return 1;
}
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
+150 -2
View File
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
local PICK_COMPLETE_FILENAME = "pick_complete.flag"
local finishDirectRequiredImport
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
-- builds that compiled one (Android, and iOS builds patched by
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
@@ -1582,8 +1585,11 @@ function RomImporter:focus(f)
.. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import"
if pickError:find("picked_required_import", 1, true)
if self.pickerPendingKind == "required_import"
or pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true)
or pickError:find("/baseroms/", 1, true)
or pickError:find("\\baseroms\\", 1, true)
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
@@ -1606,6 +1612,22 @@ function RomImporter:focus(f)
end
return
end
-- Current mobile bridge: the native picker has already streamed a raw
-- dependency into mods/<id>/baseroms and published its digest/size marker.
local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file")
and love.filesystem.read(PICK_COMPLETE_FILENAME)
if completedRequired then
love.filesystem.remove(PICK_COMPLETE_FILENAME)
self.pickPending = nil
finishDirectRequiredImport(self, completedRequired)
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
return
end
local requiredName = findPendingRequiredImport(self)
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
@@ -2059,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text)
}
end
-- Current Android/iOS bridges can stream a raw required import directly from
-- the system document provider into its engine-owned baseroms destination.
-- The bridge hashes/counts the same bytes and then publishes this tiny marker:
-- v1\n<relative destination>\n<md5>\n<byte count>\n
-- Older mobile builds still stage picked_required_import.bin, so this is an
-- additive completion path rather than a replacement for the legacy one.
local function directRequiredImportTarget(self, marker)
local fields = {}
for line in tostring(marker or ""):gmatch("[^\r\n]+") do
fields[#fields + 1] = line
if #fields > 4 then break end
end
local version, path, digest, count = fields[1], fields[2], fields[3], fields[4]
if version ~= "v1" or not path or not digest or not count or #digest ~= 32
or not digest:match("^%x+$") or not count:match("^%d+$") then
return nil, "The completed dependency marker was malformed."
end
digest = digest:lower()
count = tonumber(count)
if not count then return nil, "The completed dependency size was invalid." end
if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end
local RequiredImports = require("src.mods.RequiredImports")
local function matchRow(row, wantedImportId)
local manifest = row and row.manifest
if not manifest then return nil end
for _, spec in ipairs(RequiredImports.specs(manifest)) do
if (not wantedImportId or spec.id == wantedImportId)
and RequiredImports.path(manifest, spec) == path then
return manifest, spec, row.id or manifest.id
end
end
return nil
end
-- Normal resume: bind the marker to exactly the request that opened picker.
if self.pickerPendingModId and self.pickerPendingImportId then
for _, row in ipairs(self.mods or {}) do
if row.id == self.pickerPendingModId then
local manifest, spec, modId = matchRow(row, self.pickerPendingImportId)
if manifest then return path, digest, count, manifest, spec, modId end
end
end
return nil, "The completed dependency did not match the pending import request."
end
-- Android may recreate GameActivity while DocumentsUI is open. If Lua was
-- restarted too, recover by the exact engine-owned destination. The path
-- includes the mod id and only a manifest-declared import can match it.
for _, row in ipairs(self.mods or {}) do
local manifest, spec, modId = matchRow(row)
if manifest then return path, digest, count, manifest, spec, modId end
end
return nil, "The completed dependency did not match an installed mod request."
end
finishDirectRequiredImport = function(self, marker)
local path, digestOrErr, nativeSize, manifest, spec, modId =
directRequiredImportTarget(self, marker)
if not path then
self.modNotice = { ok = false, text = tostring(digestOrErr) }
return nil
end
local digest = digestOrErr
local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(path, "file")
if not info or type(info.size) ~= "number" then
requiredImportNotice(self, modId, spec.id,
"The completed dependency file was not found.")
self.modNotice = nil
return nil
end
if info.size ~= nativeSize then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id,
("Dependency copy size changed after completion (expected %d bytes, found %d).")
:format(nativeSize, info.size))
self.modNotice = nil
return nil
end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id, sizeErr)
self.modNotice = nil
return nil
end
-- No second 665 MiB / 1.46 GiB read: native calculated this digest while
-- streaming the selected document into the one final copy.
local ok, detail = RequiredImports.acceptStoredDigest(
manifest, spec.id, digest, love.filesystem)
if not ok then
love.filesystem.remove(path)
if RequiredImports.receiptPath then
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
end
requiredImportNotice(self, modId, spec.id, detail)
self.modNotice = nil
return nil
end
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId)
if not manifest then
@@ -2218,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
-- Raw imports can go straight to their final private destination on current
-- Android/iOS bridges. N64 stays on staging because the launcher still has
-- to canonicalize byte order/copier headers before storing it.
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
and spec.format ~= "n64"
and type(manifest.path) == "string" and manifest.path ~= "")
and require("src.mods.RequiredImports").path(manifest, spec) or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
directDestination) then
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
@@ -2549,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt)
return
end
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
or love.filesystem.getInfo("pick_error.flag", "file") ~= nil
or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
+6 -1
View File
@@ -118,8 +118,13 @@ 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('destination != nullptr', 1, true)
and system:find('"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")
check(source:find('normalized.startsWith("mods/")', 1, true)
and source:find('/baseroms/', 1, true)
and source:find('PICK_COMPLETE_FILENAME', 1, true),
"direct required imports stay inside mod baseroms and publish completion")
print("android_host_extension_test: ok")
+58 -4
View File
@@ -17,10 +17,11 @@ local saved = {
pickFileKinds = love.system.pickFileKinds,
}
local pickCalls = {}
local pickCalls, pickDestinations = {}, {}
love.system.getOS = function() return "Android" end
love.system.pickFile = function(kind)
love.system.pickFile = function(kind, destination)
pickCalls[#pickCalls + 1] = kind or "rom"
pickDestinations[#pickCalls] = destination
return true
end
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
@@ -103,13 +104,15 @@ ri.nativePicker = true
ri.mobileFileBridge = true
ri.mods = { {
id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source",
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source",
required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} }
ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "required_import",
"required file asks for the dedicated picker kind")
eq(pickDestinations[1], "mods/needs_source/baseroms/source.bin",
"raw required file receives its direct private baseroms destination")
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
@@ -166,6 +169,52 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick")
-- Current bridge completion: the large source already lives in final baseroms;
-- only the tiny path/digest/size marker crosses the launcher focus path.
local directPath = "mods/needs_source/baseroms/source.bin"
local directSavedGetInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(name, kind)
local info = directSavedGetInfo(name, kind)
if info and name == directPath then
info.size = 12
info.modtime = 123456
end
return info
end
love.filesystem.createDirectory("mods/needs_source/baseroms")
love.filesystem.write(directPath, "source bytes")
ri.mods[1].manifest.required_imports[1].md5 = {
"fe1eb7483479c3a4e44fd41ce6f6d6ad"
}
ri.pickerPendingKind = "required_import"
ri.pickerPendingModId = "needs_source"
ri.pickerPendingImportId = "source"
love.filesystem.write("pick_complete.flag",
"v1\n" .. directPath .. "\nfe1eb7483479c3a4e44fd41ce6f6d6ad\n12\n")
ri:focus(true)
check(ri.modNotice ~= nil and ri.modNotice.ok == true,
"focus accepts a direct native required-import completion")
check(love.filesystem.getInfo(directPath, "file") ~= nil,
"direct required import remains in final baseroms")
check(love.filesystem.getInfo("pick_complete.flag") == nil,
"direct completion marker is consumed")
-- A native digest that does not match the manifest is rejected and the direct
-- copy is removed, so an invalid source cannot masquerade as a validated one.
love.filesystem.write(directPath, "source bytes")
ri.pickerPendingKind = "required_import"
ri.pickerPendingModId = "needs_source"
ri.pickerPendingImportId = "source"
love.filesystem.write("pick_complete.flag",
"v1\n" .. directPath .. "\n00000000000000000000000000000000\n12\n")
ri:focus(true)
check(ri.requiredImportNotice ~= nil,
"bad direct digest reports a dependency validation error")
check(love.filesystem.getInfo(directPath, "file") == nil,
"bad direct digest removes the rejected final copy")
love.filesystem.getInfo = directSavedGetInfo
-- Android releases with the updated launcher but the older native bridge do
-- not advertise required_import. They still support the established ROM SAF
-- picker, whose result must be quarantined to the pending dependency request.
@@ -176,12 +225,14 @@ ri.nativePicker = true
ri.mobileFileBridge = true
ri.mods = { {
id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source",
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source",
required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} }
ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker")
check(pickDestinations[1] == nil,
"legacy ROM fallback receives no nested dependency destination")
check(ri.requiredImportLegacyRomPick,
"legacy Android ROM picker result is marked as a required import")
ri._importRequiredSource = function(self, modId, importId, source)
@@ -216,5 +267,8 @@ love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin")
love.filesystem.remove("picked_rom.gb")
love.filesystem.remove("pick_complete.flag")
love.filesystem.remove("pick_error.flag")
love.filesystem.remove("mods/needs_source/baseroms/source.bin")
S.finish()