Merge pull request #1546 from 1Jamie/feat/android-exit-game-to-launcher

feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
This commit is contained in:
bryanthaboi
2026-08-19 05:59:34 -04:00
committed by GitHub
45 changed files with 795 additions and 19 deletions
+103 -15
View File
@@ -291,6 +291,78 @@ function closeSkinStudio()
end
end
local function makeLauncher()
local RomImporter = require("src.import.RomImporter")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
return RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end
local function returnToLauncher()
if not Game then return end
pcall(function() require("src.core.Music").stop() end)
pcall(function() require("src.core.Sound").stop() end)
if package.loaded["src.core.ChipAudio"] then
pcall(package.loaded["src.core.ChipAudio"].shutdown)
end
if package.loaded["src.core.DiscordPresence"] then
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
end
if package.loaded["src.core.gen2.Clock"] then
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
end
if package.loaded["src.net.Gen1Tls"] then
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
end
if love.audio and love.audio.stop then
pcall(love.audio.stop)
end
local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get()
if currentVersion then
require("src.import.CacheFs").unmountVersion(currentVersion)
end
require("src.core.Data"):unloadGenerated()
local Runtime = require("src.mods.Runtime")
if Runtime.reset then
Runtime.reset()
end
Game = nil
autopilot = nil
driverCo = nil
local Input = require("src.core.Input")
local TouchControls = require("src.core.TouchControls")
Input:reset()
TouchControls:reset()
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
end
Importer = makeLauncher()
end
function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
@@ -382,7 +454,7 @@ function love.load(args)
-- Apply the persisted Android orientation lock (#592) before the launcher
-- shows: SDL created the window with no orientation hint, so without this
-- the launcher would rotate freely until Game:applyOptions runs at boot.
-- the launcher would rotate freely until options are applied at boot.
-- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
@@ -442,8 +514,8 @@ function love.load(args)
-- (#767) only pays off if something fills that catalog this early, and no
-- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the
-- real merged catalog once a version boots.
-- translation reaches the launcher too. The active game's loader replaces
-- this with the real merged catalog once a version boots.
do
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
@@ -484,17 +556,7 @@ function love.load(args)
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
-- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
Importer = makeLauncher()
end
function love.update(dt)
@@ -827,6 +889,27 @@ function love.handlers.audioreset()
if Sound then pcall(Sound.onDeviceReset) end
end
function love.handlers.intent_game(version)
if type(version) ~= "string" or version == "" then return end
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
local RomImporter = require("src.import.RomImporter")
if not RomImporter.isReady(version) then return end
local currentVersion = GameVersion.get()
if Game and currentVersion == version then
return
end
if Game then
returnToLauncher()
end
Importer = nil
bootGame(version)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here
@@ -1032,11 +1115,16 @@ function love.quit()
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below.
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame
and (isAndroid or not launchedIntoGame)
end)
if wouldReturnToLauncher then
if isAndroid then
returnToLauncher()
return true -- abort this quit; the restart lands back in the launcher
end
quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
-- once, so the restart really does land in the launcher (#887). A failed
@@ -29,7 +29,8 @@
<application
android:allowBackup="true"
android:icon="@drawable/love"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
@@ -39,7 +40,7 @@
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:label="${NAME}"
android:launchMode="singleInstance"
android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}"
android:resizeableActivity="false"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -3,4 +3,9 @@
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="shortcut_red">#E53935</color>
<color name="shortcut_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color>
</resources>
@@ -48,6 +48,7 @@
#include "common/Module.h"
#include "audio/Audio.h"
#include "audio/openal/Audio.h"
#include "event/Event.h"
namespace love
{
@@ -282,6 +283,70 @@ bool restartApp()
return result;
}
bool updateAppShortcuts(const std::vector<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return false;
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jclass stringClass = env->FindClass("java/lang/String");
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
for (size_t i = 0; i < versions.size(); ++i)
{
jstring jstr = env->NewStringUTF(versions[i].c_str());
env->SetObjectArrayElement(array, (jsize) i, jstr);
env->DeleteLocalRef(jstr);
}
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
env->DeleteLocalRef(array);
env->DeleteLocalRef(stringClass);
env->DeleteLocalRef(activity);
return result;
}
std::string getLaunchGame()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return "";
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return "";
}
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
if (jgame == nullptr)
{
env->DeleteLocalRef(activity);
return "";
}
const char *str = env->GetStringUTFChars(jgame, nullptr);
std::string result = (str != nullptr) ? str : "";
if (str != nullptr)
env->ReleaseStringUTFChars(jgame, str);
env->DeleteLocalRef(jgame);
env->DeleteLocalRef(activity);
return result;
}
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
{
if (url == nullptr || destPath == nullptr)
@@ -1488,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
love::audio::openal::pushAudioResetEvent();
}
static void pushGameIntentEvent(const char *game)
{
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || game == nullptr)
return;
std::vector<love::Variant> args;
args.push_back(love::Variant(std::string(game)));
love::event::Message *msg = new love::event::Message("intent_game", args);
eventmodule->push(msg);
msg->release();
}
extern "C" JNIEXPORT void JNICALL
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
{
(void) cls;
if (game == nullptr)
return;
const char *str = env->GetStringUTFChars(game, nullptr);
if (str != nullptr)
{
pushGameIntentEvent(str);
env->ReleaseStringUTFChars(game, str);
}
}
#endif // LOVE_ANDROID
@@ -90,6 +90,16 @@ bool syncHealthSteps();
**/
bool restartApp();
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
bool updateAppShortcuts(const std::vector<std::string> &versions);
/**
* Returns the game version requested via initial launch Intent (if any).
**/
std::string getLaunchGame();
/**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there
@@ -245,6 +245,25 @@ bool System::restartApp() const
#endif
}
bool System::updateShortcuts(const std::vector<std::string> &versions) const
{
#ifdef LOVE_ANDROID
return love::android::updateAppShortcuts(versions);
#else
LOVE_UNUSED(versions);
return false;
#endif
}
std::string System::getLaunchGame() const
{
#ifdef LOVE_ANDROID
return love::android::getLaunchGame();
#else
return "";
#endif
}
bool System::httpDownload(const char *url, const char *destPath,
const char *userAgent, const char *accept) const
{
@@ -143,6 +143,9 @@ public:
**/
virtual bool restartApp() const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
/**
* Blocking HTTPS GET into an absolute host path (Android only; false
* elsewhere). Android has no curl, which is what every other platform
@@ -283,6 +283,34 @@ int w_tlsClose(lua_State *L)
return 0;
}
int w_updateShortcuts(lua_State *L)
{
if (!lua_istable(L, 1))
return luaL_error(L, "Expected table of game version strings");
std::vector<std::string> versions;
int len = (int) luax_objlen(L, 1);
for (int i = 1; i <= len; ++i)
{
lua_rawgeti(L, 1, i);
if (lua_isstring(L, -1))
versions.push_back(lua_tostring(L, -1));
lua_pop(L, 1);
}
luax_pushboolean(L, instance()->updateShortcuts(versions));
return 1;
}
int w_getLaunchGame(lua_State *L)
{
std::string game = instance()->getLaunchGame();
if (game.empty())
lua_pushnil(L);
else
luax_pushstring(L, game);
return 1;
}
static const luaL_Reg functions[] =
{
{ "getOS", w_getOS },
@@ -297,6 +325,8 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost },
{ "httpRequest", w_httpRequest },
@@ -69,8 +69,11 @@ import android.os.Vibrator;
import android.provider.Settings;
import android.util.Log;
import android.util.DisplayMetrics;
import android.view.*;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.content.pm.PackageManager;
import android.graphics.drawable.Icon;
import android.view.*;
import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat;
@@ -159,6 +162,10 @@ public class GameActivity extends SDLActivity {
private static native void nativeAudioDeviceChanged();
private static native void nativeOnGameIntent(String game);
private static String initialGame = "";
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
private Object audioFocusRequest = null;
private Object audioDeviceCallback = null;
@@ -228,6 +235,10 @@ public class GameActivity extends SDLActivity {
embed = getResources().getBoolean(R.bool.embed);
needToCopyGameInArchive = embed;
Intent startIntent = getIntent();
if (startIntent != null && startIntent.hasExtra("game")) {
initialGame = startIntent.getStringExtra("game");
}
if (!embed) {
Intent intent = getIntent();
handleIntent(intent);
@@ -261,6 +272,12 @@ public class GameActivity extends SDLActivity {
@Override
protected void onNewIntent(Intent intent) {
Log.d("GameActivity", "onNewIntent() with " + intent);
if (intent != null && intent.hasExtra("game")) {
String game = intent.getStringExtra("game");
if (game != null && !game.isEmpty()) {
nativeOnGameIntent(game);
}
}
if (!embed) {
handleIntent(intent);
resetNative();
@@ -673,6 +690,95 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest
}
@Keep
public static String getLaunchGame() {
return initialGame != null ? initialGame : "";
}
@Keep
public static boolean updateAppShortcuts(String[] readyVersions) {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT < 25) return false;
try {
Context context = self.getApplicationContext();
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
if (shortcutManager == null) return false;
if (readyVersions == null || readyVersions.length == 0) {
shortcutManager.removeAllDynamicShortcuts();
return true;
}
List<ShortcutInfo> shortcuts = new ArrayList<>();
int maxShortcuts = Math.min(readyVersions.length, 4);
for (int i = 0; i < maxShortcuts; i++) {
String ver = readyVersions[i];
if (ver == null || ver.isEmpty()) continue;
String lower = ver.toLowerCase();
String shortLabel;
String longLabel;
int iconResId;
switch (lower) {
case "red":
shortLabel = "Play Red";
longLabel = "Play Red";
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
break;
case "blue":
shortLabel = "Play Blue";
longLabel = "Play Blue";
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
break;
case "yellow":
shortLabel = "Play Yellow";
longLabel = "Play Yellow";
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
break;
case "gold":
shortLabel = "Play Gold";
longLabel = "Play Gold";
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
break;
default:
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
shortLabel = "Play " + capitalized;
longLabel = "Play " + capitalized;
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
break;
}
if (iconResId == 0) {
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
}
Intent intent = new Intent(context, GameActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("game", lower);
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIntent(intent);
if (iconResId != 0) {
builder.setIcon(Icon.createWithResource(context, iconResId));
}
shortcuts.add(builder.build());
}
shortcutManager.setDynamicShortcuts(shortcuts);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
return false;
}
}
/**
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so
+1
View File
@@ -176,6 +176,7 @@ function Game:makeTitleState()
self:restoreSave(loaded, recovered, { freshBoot = true })
end
end,
onExit = self.onExit,
})
title.screenId = title.screenId or "TitleState"
return title
+1
View File
@@ -320,6 +320,7 @@ function Game2:showMainMenu()
onNewGame = function() self:newGame() end,
onContinue = function(save) self:continueGame(save) end,
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
onExit = self.onExit,
})
end
+13
View File
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
return false
end
local cachedIntentGame = nil
-- Returns version, slotId (either may be nil). Command line wins over env,
-- so a shortcut can override a machine-wide default.
function LaunchOptions.resolve(argv)
if cachedIntentGame == nil then
if love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.getLaunchGame then
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
else
cachedIntentGame = false
end
end
local intentGame = cachedIntentGame or nil
local game = normalizeVersion(argValue(argv, "game"))
or intentGame
or normalizeVersion(os.getenv("POKEPORT_GAME"))
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
+28
View File
@@ -326,6 +326,32 @@ function RomImporter.isReady(version)
return marker == markerFor(version) and allRequiredFilesExist(version)
end
function RomImporter.syncAndroidShortcuts(activeVersion)
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.updateShortcuts) then
return false
end
local allVersions = { "red", "blue", "yellow", "gold" }
local ready = {}
local seen = {}
if activeVersion and RomImporter.isReady(activeVersion) then
table.insert(ready, activeVersion)
seen[activeVersion] = true
end
for _, v in ipairs(allVersions) do
if not seen[v] and RomImporter.isReady(v) then
table.insert(ready, v)
seen[v] = true
if #ready >= 4 then break end
end
end
return love.system.updateShortcuts(ready)
end
-- Load the import manifest for a version and confirm it matches that ROM.
local function sha1(data)
local digest = love.data.hash("sha1", data)
@@ -1393,6 +1419,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab()
self:_queueBaseRomScan()
@@ -1787,6 +1814,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
RomImporter.syncAndroidShortcuts(version)
-- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then
+8
View File
@@ -38,6 +38,14 @@ function Runtime.install(events, hooks, errors)
Runtime.errors = errors
end
function Runtime.reset()
Runtime.events = NullEvents
Runtime.hooks = NullHooks
Runtime.errors = nil
Runtime.currentMod = nil
Runtime.modRequire = nil
end
-- attribute a runtime failure to the mod that owns the offending record.
-- "base" is the engine's own owner id: a vanilla record that fails is a
-- console line, not something the manager can ask the player to disable.
+4 -1
View File
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
self.game = game
self.onNewGame = opts.onNewGame
self.onContinue = opts.onContinue
self.onExit = opts.onExit
-- branding comes from field.title with the shipped art as fallback, so
-- a total conversion rebrands the title without replacing the screen
local title = (game.data.field and game.data.field.title) or {}
@@ -514,7 +515,9 @@ function TitleState:openMenu()
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
if love.event and love.event.quit then
if self.onExit then
self.onExit()
elseif love.event and love.event.quit then
love.event.quit()
end
end })
@@ -0,0 +1,80 @@
-- Test returning to launcher from Gen 1 and Gen 2 on Android without closing the process
-- luajit tests/engine/android_exit_to_launcher_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local TitleState = require("src.ui.TitleState")
local Gen2MainMenu = require("src.ui.gen2.MainMenu")
local Runtime = require("src.mods.Runtime")
-- 1. Test Gen 1 TitleState onExit callback support
do
local exitCalled = false
local dummyGame = {
data = { field = {} },
stack = {
states = {},
push = function(self, state) table.insert(self.states, state) end,
top = function(self) return self.states[#self.states] end,
pop = function(self) return table.remove(self.states) end,
},
}
local state = TitleState.new(dummyGame, {
onExit = function()
exitCalled = true
end,
})
state:openMenu()
local menu = dummyGame.stack:top()
check(menu ~= nil, "TitleState:openMenu opens a menu")
local exitItem = nil
for _, item in ipairs(menu.items or {}) do
if tostring(item.label):find("EXIT", 1, true) then
exitItem = item
break
end
end
check(exitItem ~= nil, "Gen 1 TitleState menu contains an EXIT GAME item")
if exitItem and exitItem.onSelect then
exitItem.onSelect()
end
check(exitCalled, "Selecting EXIT GAME in Gen 1 TitleState invokes onExit callback")
end
-- 2. Test Gen 2 MainMenu onExit callback support
do
local exitCalled = false
local dummyGame2 = {
data = {},
stack = {
states = {},
push = function(self, state) table.insert(self.states, state) end,
top = function(self) return self.states[#self.states] end,
pop = function(self) return table.remove(self.states) end,
},
}
local menu = Gen2MainMenu.new(dummyGame2, {
hasSave = false,
onExit = function()
exitCalled = true
end,
})
menu:choose("exit")
check(exitCalled, "Selecting EXIT GAME in Gen 2 MainMenu invokes onExit callback")
end
-- 3. Test Runtime.reset restores NullEvents and NullHooks
do
Runtime.install({ emit = function() end }, { call = function() end }, { "err" })
check(Runtime.errors ~= nil, "Runtime has errors list after install")
Runtime.reset()
check(Runtime.errors == nil, "Runtime.reset clears errors")
check(Runtime.currentMod == nil, "Runtime.reset clears currentMod")
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
end
T.finish("android_exit_to_launcher_test")
@@ -0,0 +1,81 @@
-- tests/engine/android_shortcuts_payload_test.lua
-- Tests Android dynamic shortcuts synchronization, launch options intent resolution,
-- and love.handlers.intent_game in-process game hot-swapping.
-- luajit tests/engine/android_shortcuts_payload_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
require("main")
-- 1. Verify LaunchOptions handles getLaunchGame on Android
local LaunchOptions = require("src.core.LaunchOptions")
local savedOS = love.system and love.system.getOS
local savedGetLaunchGame = love.system and love.system.getLaunchGame
love.system = love.system or {}
love.system.getOS = function() return "Android" end
love.system.getLaunchGame = function() return "gold" end
local game, slot = LaunchOptions.resolve({})
check(game == "gold", "LaunchOptions resolves intent game from love.system.getLaunchGame on Android")
local gameCli, slotCli = LaunchOptions.resolve({ "--game=red" })
check(gameCli == "red", "CLI --game flag overrides intent game")
-- 2. Verify RomImporter.syncAndroidShortcuts ranking and 4-item cap
local RomImporter = require("src.import.RomImporter")
local originalIsReady = RomImporter.isReady
local capturedShortcuts = nil
love.system.updateShortcuts = function(versions)
capturedShortcuts = versions
return true
end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
check(ok == true, "syncAndroidShortcuts returns true on Android")
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
return v == "red" or v == "gold"
end
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("red")
check(#capturedShortcuts == 2, "syncAndroidShortcuts only includes ready ROMs")
check(capturedShortcuts[1] == "red" and capturedShortcuts[2] == "gold", "ready ROMs correctly passed")
-- Test on non-Android platform (safe no-op)
love.system.getOS = function() return "Linux" end
capturedShortcuts = nil
local nonAndroidOk = RomImporter.syncAndroidShortcuts("red")
check(nonAndroidOk == false, "syncAndroidShortcuts safely no-ops on non-Android")
check(capturedShortcuts == nil, "no shortcuts updated on non-Android")
-- 3. Verify love.handlers.intent_game definition
check(type(love.handlers.intent_game) == "function", "main.lua defines love.handlers.intent_game")
-- Restore
RomImporter.isReady = originalIsReady
if savedOS then
love.system.getOS = savedOS
else
love.system.getOS = nil
end
love.system.getLaunchGame = savedGetLaunchGame
love.system.updateShortcuts = nil
print("8/8 checks passed (android_shortcuts_payload_test)")
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Generates Android Adaptive Icon (based on cleaned love.png Pokéball + Gen1Recomp emblem)
and 3D Cartridge Shortcut assets for all density buckets (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi).
"""
import os
from collections import deque
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RES_DIR = os.path.join(ROOT, "mobile", "android", "app", "src", "main", "res")
DENSITIES = {
"mdpi": {"shortcut": 48, "adaptive": 108},
"hdpi": {"shortcut": 72, "adaptive": 162},
"xhdpi": {"shortcut": 96, "adaptive": 216},
"xxhdpi": {"shortcut": 144, "adaptive": 324},
"xxxhdpi": {"shortcut": 192, "adaptive": 432},
}
SHELL_COLORS = {
"red": {"main": (230, 45, 55), "dark": (175, 25, 35), "light": (255, 90, 100)},
"blue": {"main": (35, 125, 235), "dark": (20, 85, 175), "light": (80, 165, 255)},
"yellow": {"main": (255, 205, 10), "dark": (210, 160, 0), "light": (255, 230, 80)},
"gold": {"main": (225, 170, 40), "dark": (170, 120, 20), "light": (245, 200, 80)},
}
def extract_cleaned_love_emblem():
"""Extracts the Pokéball/Gen1Recomp emblem from love.png with transparent bg and deepened blacks."""
src_path = os.path.join(RES_DIR, "drawable-xxxhdpi", "love.png")
if not os.path.exists(src_path):
src_path = os.path.join(RES_DIR, "drawable-xxhdpi", "love.png")
src = Image.open(src_path).convert("RGBA")
arr = np.array(src, dtype=np.uint8)
h, w = arr.shape[:2]
visited = np.zeros((h, w), dtype=bool)
bg_mask = np.zeros((h, w), dtype=bool)
queue = deque()
for x in range(w):
queue.append((0, x)); queue.append((h-1, x))
for y in range(h):
queue.append((y, 0)); queue.append((y, w-1))
bg_ref = np.array([255, 237, 254], dtype=float)
while queue:
y, x = queue.popleft()
if visited[y, x]: continue
visited[y, x] = True
color = arr[y, x, :3].astype(float)
if np.max(np.abs(color - bg_ref)) < 28:
bg_mask[y, x] = True
for dy, dx in [(-1,0), (1,0), (0,-1), (0,1)]:
ny, nx = y + dy, x + dx
if 0 <= ny < h and 0 <= nx < w and not visited[ny, nx]:
queue.append((ny, nx))
out_arr = arr.copy().astype(float)
out_arr[bg_mask, 3] = 0
# Deepen the soft blacks/outlines for crispness:
fg_mask = ~bg_mask
rgb = out_arr[fg_mask, :3]
lum = 0.299 * rgb[:, 0] + 0.587 * rgb[:, 1] + 0.114 * rgb[:, 2]
for i in range(len(rgb)):
l = lum[i]
if l < 110:
factor = (l / 110.0) ** 1.8
rgb[i, 0] = max(0, rgb[i, 0] * factor * 0.7)
rgb[i, 1] = max(0, rgb[i, 1] * factor * 0.7)
rgb[i, 2] = max(0, rgb[i, 2] * factor * 0.8)
out_arr[fg_mask, :3] = np.clip(rgb, 0, 255)
return Image.fromarray(out_arr.astype(np.uint8))
CLEANED_EMBLEM = extract_cleaned_love_emblem()
def create_adaptive_foreground(size):
emblem = CLEANED_EMBLEM.copy()
target_size = int(size * 0.78)
emblem.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
x = (size - emblem.width) // 2
y = (size - emblem.height) // 2
canvas.paste(emblem, (x, y), emblem)
return canvas
def create_adaptive_monochrome(size):
fg = create_adaptive_foreground(size)
arr = np.array(fg, dtype=float)
lum = 0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]
alpha = arr[:, :, 3]
mono_alpha = np.zeros_like(alpha)
valid = alpha > 20
mono_alpha[valid & (lum > 70)] = 255
mono_alpha[valid & (lum <= 70)] = 0
mono_img = np.zeros((size, size, 4), dtype=np.uint8)
mono_img[:, :, 0] = 255
mono_img[:, :, 1] = 255
mono_img[:, :, 2] = 255
mono_img[:, :, 3] = mono_alpha.astype(np.uint8)
return Image.fromarray(mono_img)
def render_3d_cartridge(version, size):
colors = SHELL_COLORS[version]
S = size * 4
# Transparent canvas so the cartridge sits directly on launcher's white circle plate
canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# Tight padding to maximize cartridge size
pad_x = int(S * 0.05)
pad_y = int(S * 0.03)
cw = S - pad_x * 2
ch = S - pad_y * 2
# Soft drop shadow
shadow = Image.new("RGBA", (S, S), (0, 0, 0, 0))
sdraw = ImageDraw.Draw(shadow)
sdraw.rounded_rectangle([pad_x + 8, pad_y + 14, pad_x + cw + 8, pad_y + ch + 14],
radius=int(S * 0.05), fill=(0, 0, 0, 90))
shadow = shadow.filter(ImageFilter.GaussianBlur(int(S * 0.03)))
canvas.paste(shadow, (0, 0), shadow)
radius = int(S * 0.045)
depth = int(S * 0.03)
draw.rounded_rectangle([pad_x, pad_y + depth, pad_x + cw, pad_y + ch + depth],
radius=radius, fill=colors["dark"])
draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch],
radius=radius, fill=colors["main"])
notch_w = int(cw * 0.65)
notch_h = int(ch * 0.07)
notch_x = pad_x + (cw - notch_w) // 2
notch_y = pad_y + int(ch * 0.035)
draw.rounded_rectangle([notch_x, notch_y, notch_x + notch_w, notch_y + notch_h],
radius=int(notch_h * 0.4), fill=colors["dark"])
draw.rounded_rectangle([notch_x, notch_y - 2, notch_x + notch_w, notch_y + notch_h - 2],
radius=int(notch_h * 0.4), fill=colors["light"])
label_margin_x = int(cw * 0.09)
label_top_y = pad_y + int(ch * 0.20)
label_w = cw - label_margin_x * 2
label_h = int(ch * 0.70)
label_x = pad_x + label_margin_x
draw.rounded_rectangle([label_x - 3, label_top_y - 3, label_x + label_w + 3, label_top_y + label_h + 3],
radius=int(radius * 0.7), fill=colors["dark"])
label_path = os.path.join(ROOT, "assets", "labels", f"{version}.png")
if os.path.exists(label_path):
label_img = Image.open(label_path).convert("RGBA")
label_img = label_img.resize((label_w, label_h), Image.Resampling.LANCZOS)
mask = Image.new("L", (label_w, label_h), 0)
mdraw = ImageDraw.Draw(mask)
mdraw.rounded_rectangle([0, 0, label_w, label_h], radius=int(radius * 0.5), fill=255)
canvas.paste(label_img, (label_x, label_top_y), mask)
else:
draw.rounded_rectangle([label_x, label_top_y, label_x + label_w, label_top_y + label_h],
radius=int(radius * 0.5), fill=(240, 240, 240, 255))
draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch],
radius=radius, outline=colors["light"], width=max(2, int(S * 0.008)))
return canvas.resize((size, size), Image.Resampling.LANCZOS)
def main():
os.makedirs(os.path.join(RES_DIR, "values"), exist_ok=True)
os.makedirs(os.path.join(RES_DIR, "mipmap-anydpi-v26"), exist_ok=True)
for density, sizes in DENSITIES.items():
drawable_dir = os.path.join(RES_DIR, f"drawable-{density}")
os.makedirs(drawable_dir, exist_ok=True)
fg = create_adaptive_foreground(sizes["adaptive"])
fg.save(os.path.join(drawable_dir, "ic_launcher_foreground.png"), "PNG")
for ver in ("red", "blue", "yellow", "gold"):
cart = render_3d_cartridge(ver, sizes["shortcut"])
cart.save(os.path.join(drawable_dir, f"ic_shortcut_{ver}.png"), "PNG")
print(f"Generated assets for drawable-{density}")
if __name__ == "__main__":
main()