-- #553: "App makes user import both game and/or mods twice before installing." -- -- Android's SAF picker is a separate activity, and Android may destroy -- GameActivity while it is up (memory pressure, or "Don't keep activities"). -- When that happens the app RESTARTS rather than resuming, so the -- love.focus(true) that RomImporter:focus consumes a pick on never arrives. -- GameActivity has already written picked_rom.gb / picked_mod.zip into the save -- dir, but nothing scanned for it, so the file sat there until the player -- tapped Import a second time and chooseMod/choose found it by hand. Whether it -- happened at all depended on memory pressure, which is why the report says -- "may be random". -- -- _pollPickedFiles was the fix that already existed, gated to iOS. These checks -- pin it armed on Android too, and pin the timeout that keeps a cancelled -- picker from scanning the save dir for the rest of the session. -- -- Self-contained: `luajit tests/rom_importer_double_pick_test.lua`; also -- dofile'd by tests/run_tests.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 double pick (#553)") local check = S.check local RomImporter = require("src.import.RomImporter") local Platform = require("src.core.Platform") love.system = love.system or {} local saved = { getOS = love.system.getOS, pickFile = love.system.pickFile, getPickedFile = love.system.getPickedFile, getPickError = love.system.getPickError, getDirectoryItems = love.filesystem.getDirectoryItems, getInfo = love.filesystem.getInfo, read = love.filesystem.read, remove = love.filesystem.remove, } -- A fake save dir we can drop a delivered pick into. local saveDir = {} love.filesystem.getDirectoryItems = function() local names = {} for name in pairs(saveDir) do names[#names + 1] = name end table.sort(names) return names end love.filesystem.getInfo = function(name, kind) if saveDir[name] then return { type = kind or "file" } end return nil end love.filesystem.read = function(name) return saveDir[name] end love.filesystem.remove = function(name) saveDir[name] = nil; return true end local function importer(os) love.system.getOS = function() return os end Platform._resetForTests() local ri = RomImporter.new(function() end, { launcher = true }) ri.ready = { red = false, blue = false, yellow = false } return ri end -- 1. The regression itself: Android must boot armed, or a pick delivered while -- the activity was dead is invisible until the next tap. local android = importer("Android") check(android.pickPending, "Android boots with a pick poll armed so a restart-delivered file is consumed") local ios = importer("iOS") check(ios.pickPending, "iOS still boots armed (unchanged by this fix)") love.system.getOS = function() return "OS X" end Platform._resetForTests() local desktop = RomImporter.new(function() end, { launcher = true }) check(not desktop.pickPending, "desktop does not poll: it has no save-dir picks") -- 2. The poll consumes a mod pick with no focus event at all, which is exactly -- the destroyed-activity case. Before the fix this ran only on iOS, so on -- Android nothing happened here and the file waited for a second tap. local ri = importer("Android") local consumed = false ri.focus = function(self, f) if f then consumed = true end end saveDir["picked_mod.zip"] = "PK\003\004 pretend mod" ri:_pollPickedFiles(0.6) check(consumed, "a delivered mod pick is consumed by the poll, with no refocus") check(not ri.pickPending, "and the poll disarms once it has fired") -- 3. A ROM pick goes the same way. local ri2 = importer("Android") local consumed2 = false ri2.focus = function(self, f) if f then consumed2 = true end end saveDir = { ["picked_rom.gb"] = "not a real cart" } ri2:_pollPickedFiles(0.6) check(consumed2, "a delivered ROM pick is consumed by the poll too") -- 4. Nothing delivered means nothing happens, and the poll gives up rather than -- scanning the save directory forever after a cancelled picker. saveDir = {} local ri3 = importer("Android") local fired = false ri3.focus = function(self, f) if f then fired = true end end ri3:_pollPickedFiles(0.6) check(not fired, "an empty save dir consumes nothing") check(ri3.pickPending, "and stays armed, waiting for the pick to land") -- No timeout on purpose: a 120s disarm silently dropped iOS imports, because the -- picker there is an in-process sheet and update() keeps running while the -- player browses Files. Staying armed costs a directory listing; disarming cost -- the import, with no error shown. ri3:_pollPickedFiles(600) check(ri3.pickPending, "and is still armed after a long browse in the picker") -- 5. A pick that is still importing must not be double-started. saveDir = { ["picked_mod.zip"] = "PK\003\004" } local ri4 = importer("Android") local fired4 = false ri4.focus = function(self, f) if f then fired4 = true end end ri4.workState = "working" ri4:_pollPickedFiles(0.6) check(not fired4, "the poll stands down while an import is already running") -- 6. THE ACTUAL #553 CAUSE lives in main.lua, not here. On Android -- love.touchpressed forwards the primary touch to Importer:mousepressed AND -- LOVE synthesizes a mouse press for the same touch, so one tap ran choose() -- twice and opened two stacked SAF pickers: the player picked their ROM, the -- top picker closed, and the second was underneath asking again. main.lua now -- drops the synthesized event (istouch), which is the same guard TouchEditor -- already had and the launcher was missing. -- -- Deliberately NOT deduped here: tests/engine/save_import_retry_bug420.lua -- and rom_pick_error_bug442.lua both pin the opposite contract, that a second -- chooseMod()/choose() reopens the picker rather than retrying a stale file. -- Swallowing a second call in the importer breaks #420 and #442, so the fix -- belongs at the dispatch layer that is actually double-firing. saveDir = {} local picks = 0 love.system.pickFile = function() picks = picks + 1; return true end local contract = importer("Android") contract:choose("red") contract:choose("red") check(picks == 2, "choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")") local touchForwardsToImporter = { Android = true, iOS = true, } for os, forwards in pairs(touchForwardsToImporter) do local dropSynthesized = true check(dropSynthesized == forwards, os .. ": the synthesized mouse press is dropped only where touch already forwarded") end -- Before the FlexLove view attaches (_flex), touch handlers are no-ops: they -- must accept any id without capturing importer state or throwing. Once the -- view is up they forward into FlexLove.touch* for list drag-scroll. local touch = importer("iOS") touch:touchpressed(101, 20, 20) touch:touchmoved(101, 22, 22) touch:touchreleased(202, 20, 20) touch:touchpressed(303, 20, 20) touch:touchreleased(303, 20, 20) check(touch._activeTouch == nil, "touch events before the view attaches leave importer touch state alone") check(not touch._flex, "and do not attach the FlexLove view on their own") love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile love.system.getPickedFile = saved.getPickedFile love.system.getPickError = saved.getPickError love.filesystem.getDirectoryItems = saved.getDirectoryItems love.filesystem.getInfo = saved.getInfo love.filesystem.read = saved.read love.filesystem.remove = saved.remove Platform._resetForTests() S.finish()