feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
@@ -291,6 +291,78 @@ function closeSkinStudio()
|
|||||||
end
|
end
|
||||||
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)
|
function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||||
@@ -484,17 +556,7 @@ function love.load(args)
|
|||||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
-- 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).
|
-- 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).
|
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||||
Importer = RomImporter.new(function(version)
|
Importer = makeLauncher()
|
||||||
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
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
@@ -827,6 +889,27 @@ function love.handlers.audioreset()
|
|||||||
if Sound then pcall(Sound.onDeviceReset) end
|
if Sound then pcall(Sound.onDeviceReset) end
|
||||||
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)
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then
|
if editorMode then
|
||||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
-- 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
|
-- 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
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
-- claims the hook) is exactly the condition below.
|
-- 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()
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
return Game and not Importer and not quitToLauncher and not scripted
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
and not launchedIntoGame
|
and (isAndroid or not launchedIntoGame)
|
||||||
end)
|
end)
|
||||||
if wouldReturnToLauncher then
|
if wouldReturnToLauncher then
|
||||||
|
if isAndroid then
|
||||||
|
returnToLauncher()
|
||||||
|
return true -- abort this quit; the restart lands back in the launcher
|
||||||
|
end
|
||||||
quitToLauncher = true
|
quitToLauncher = true
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
-- 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
|
-- once, so the restart really does land in the launcher (#887). A failed
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/love"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="${NAME}" >
|
android:label="${NAME}" >
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.allow_multiple_resumed_activities"
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
android:label="${NAME}"
|
android:label="${NAME}"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleTask"
|
||||||
android:screenOrientation="${ORIENTATION}"
|
android:screenOrientation="${ORIENTATION}"
|
||||||
android:resizeableActivity="false"
|
android:resizeableActivity="false"
|
||||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
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="colorPrimary">#3F51B5</color>
|
||||||
<color name="colorPrimaryDark">#303F9F</color>
|
<color name="colorPrimaryDark">#303F9F</color>
|
||||||
<color name="colorAccent">#FF4081</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>
|
</resources>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
#include "common/Module.h"
|
#include "common/Module.h"
|
||||||
#include "audio/Audio.h"
|
#include "audio/Audio.h"
|
||||||
#include "audio/openal/Audio.h"
|
#include "audio/openal/Audio.h"
|
||||||
|
#include "event/Event.h"
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
@@ -282,6 +283,70 @@ bool restartApp()
|
|||||||
return result;
|
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)
|
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
||||||
{
|
{
|
||||||
if (url == nullptr || destPath == nullptr)
|
if (url == nullptr || destPath == nullptr)
|
||||||
@@ -1390,4 +1455,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
|
|||||||
love::audio::openal::pushAudioResetEvent();
|
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
|
#endif // LOVE_ANDROID
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ bool syncHealthSteps();
|
|||||||
**/
|
**/
|
||||||
bool restartApp();
|
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
|
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||||
|
|||||||
@@ -245,6 +245,25 @@ bool System::restartApp() const
|
|||||||
#endif
|
#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,
|
bool System::httpDownload(const char *url, const char *destPath,
|
||||||
const char *userAgent, const char *accept) const
|
const char *userAgent, const char *accept) const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool restartApp() const;
|
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
|
* Blocking HTTPS GET into an absolute host path (Android only; false
|
||||||
* elsewhere). Android has no curl, which is what every other platform
|
* elsewhere). Android has no curl, which is what every other platform
|
||||||
|
|||||||
@@ -229,6 +229,34 @@ int w_tlsClose(lua_State *L)
|
|||||||
return 0;
|
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[] =
|
static const luaL_Reg functions[] =
|
||||||
{
|
{
|
||||||
{ "getOS", w_getOS },
|
{ "getOS", w_getOS },
|
||||||
@@ -243,6 +271,8 @@ static const luaL_Reg functions[] =
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
{ "httpPost", w_httpPost },
|
{ "httpPost", w_httpPost },
|
||||||
{ "tlsOpen", w_tlsOpen },
|
{ "tlsOpen", w_tlsOpen },
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ import android.os.Vibrator;
|
|||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.view.*;
|
import android.content.pm.ShortcutInfo;
|
||||||
|
import android.content.pm.ShortcutManager;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.graphics.drawable.Icon;
|
||||||
|
import android.view.*;
|
||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
@@ -157,6 +160,10 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static native void nativeAudioDeviceChanged();
|
private static native void nativeAudioDeviceChanged();
|
||||||
|
|
||||||
|
private static native void nativeOnGameIntent(String game);
|
||||||
|
|
||||||
|
private static String initialGame = "";
|
||||||
|
|
||||||
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
||||||
private Object audioFocusRequest = null;
|
private Object audioFocusRequest = null;
|
||||||
private Object audioDeviceCallback = null;
|
private Object audioDeviceCallback = null;
|
||||||
@@ -226,6 +233,10 @@ public class GameActivity extends SDLActivity {
|
|||||||
embed = getResources().getBoolean(R.bool.embed);
|
embed = getResources().getBoolean(R.bool.embed);
|
||||||
needToCopyGameInArchive = embed;
|
needToCopyGameInArchive = embed;
|
||||||
|
|
||||||
|
Intent startIntent = getIntent();
|
||||||
|
if (startIntent != null && startIntent.hasExtra("game")) {
|
||||||
|
initialGame = startIntent.getStringExtra("game");
|
||||||
|
}
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
@@ -259,6 +270,12 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent) {
|
protected void onNewIntent(Intent intent) {
|
||||||
Log.d("GameActivity", "onNewIntent() with " + 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) {
|
if (!embed) {
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
resetNative();
|
resetNative();
|
||||||
@@ -671,6 +688,95 @@ public class GameActivity extends SDLActivity {
|
|||||||
return true; // unreachable, but keeps the JNI signature honest
|
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
|
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
||||||
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ function Game:makeTitleState()
|
|||||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
onExit = self.onExit,
|
||||||
})
|
})
|
||||||
title.screenId = title.screenId or "TitleState"
|
title.screenId = title.screenId or "TitleState"
|
||||||
return title
|
return title
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ function Game2:showMainMenu()
|
|||||||
onNewGame = function() self:newGame() end,
|
onNewGame = function() self:newGame() end,
|
||||||
onContinue = function(save) self:continueGame(save) end,
|
onContinue = function(save) self:continueGame(save) end,
|
||||||
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
|
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
|
||||||
|
onExit = self.onExit,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local cachedIntentGame = nil
|
||||||
|
|
||||||
-- Returns version, slotId (either may be nil). Command line wins over env,
|
-- Returns version, slotId (either may be nil). Command line wins over env,
|
||||||
-- so a shortcut can override a machine-wide default.
|
-- so a shortcut can override a machine-wide default.
|
||||||
function LaunchOptions.resolve(argv)
|
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"))
|
local game = normalizeVersion(argValue(argv, "game"))
|
||||||
|
or intentGame
|
||||||
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
||||||
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
||||||
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
|
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
|
||||||
|
|||||||
@@ -326,6 +326,32 @@ function RomImporter.isReady(version)
|
|||||||
return marker == markerFor(version) and allRequiredFilesExist(version)
|
return marker == markerFor(version) and allRequiredFilesExist(version)
|
||||||
end
|
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.
|
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||||
local function sha1(data)
|
local function sha1(data)
|
||||||
local digest = love.data.hash("sha1", data)
|
local digest = love.data.hash("sha1", data)
|
||||||
@@ -1392,6 +1418,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
self.romName[version] = "pokemon_" .. info.id
|
self.romName[version] = "pokemon_" .. info.id
|
||||||
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
||||||
end
|
end
|
||||||
|
RomImporter.syncAndroidShortcuts()
|
||||||
self:_applyLastVersionTab()
|
self:_applyLastVersionTab()
|
||||||
self:_queueBaseRomScan()
|
self:_queueBaseRomScan()
|
||||||
|
|
||||||
@@ -1786,6 +1813,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
|
|||||||
self.workState = "complete"
|
self.workState = "complete"
|
||||||
self.completeVersion = version
|
self.completeVersion = version
|
||||||
self.status = "Ready"
|
self.status = "Ready"
|
||||||
|
RomImporter.syncAndroidShortcuts(version)
|
||||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||||
-- overwriting it with a "Starting…" line that never boots from here.
|
-- overwriting it with a "Starting…" line that never boots from here.
|
||||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ function Runtime.install(events, hooks, errors)
|
|||||||
Runtime.errors = errors
|
Runtime.errors = errors
|
||||||
end
|
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.
|
-- 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
|
-- "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.
|
-- console line, not something the manager can ask the player to disable.
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
|
|||||||
self.game = game
|
self.game = game
|
||||||
self.onNewGame = opts.onNewGame
|
self.onNewGame = opts.onNewGame
|
||||||
self.onContinue = opts.onContinue
|
self.onContinue = opts.onContinue
|
||||||
|
self.onExit = opts.onExit
|
||||||
-- branding comes from field.title with the shipped art as fallback, so
|
-- branding comes from field.title with the shipped art as fallback, so
|
||||||
-- a total conversion rebrands the title without replacing the screen
|
-- a total conversion rebrands the title without replacing the screen
|
||||||
local title = (game.data.field and game.data.field.title) or {}
|
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")
|
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||||
end })
|
end })
|
||||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
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()
|
love.event.quit()
|
||||||
end
|
end
|
||||||
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)")
|
||||||
@@ -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()
|
||||||