fix: stream large required imports on mobile

This commit is contained in:
HighDrexler
2026-08-25 13:22:45 -04:00
parent bbe0f0d9ae
commit ec6b03f84b
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());
}