diff --git a/main.lua b/main.lua index 1a9cd3aa..87d81589 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a2de3161..33ad1ea6 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,8 @@ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..35b25473 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..292907ed Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..a9fb6040 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png new file mode 100644 index 00000000..c2b97381 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..424d1614 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ac37e148 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..253cac46 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..1df17543 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png new file mode 100644 index 00000000..591974c8 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..d61705ab Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..fc0f5e49 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..c77b0413 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..b1927bba Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png new file mode 100644 index 00000000..4e30e872 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..35aadb5c Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..53f8022e Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..595c1747 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..93596378 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png new file mode 100644 index 00000000..4efbcd33 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..7afb90e5 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..f645ea96 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..a594a435 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..a1d10860 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png new file mode 100644 index 00000000..44d26881 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..825a5760 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a8a8fa55 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a8a8fa55 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml index 3ab3e9cb..caf722e0 100644 --- a/mobile/android/app/src/main/res/values/colors.xml +++ b/mobile/android/app/src/main/res/values/colors.xml @@ -3,4 +3,9 @@ #3F51B5 #303F9F #FF4081 + #FFFFFF + #E53935 + #1E88E5 + #FDD835 + #D4AF37 diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 31940e30..6aa94eef 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -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 &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::Module::M_EVENT); + if (eventmodule == nullptr || game == nullptr) + return; + + std::vector 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 diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index c323d21a..0b56890c 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -90,6 +90,16 @@ bool syncHealthSteps(); **/ bool restartApp(); +/** + * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions. + **/ +bool updateAppShortcuts(const std::vector &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 diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 7f7e2d30..329cff19 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -245,6 +245,25 @@ bool System::restartApp() const #endif } +bool System::updateShortcuts(const std::vector &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 { diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index b5d0ff3f..17fd2e39 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -143,6 +143,9 @@ public: **/ virtual bool restartApp() const; + virtual bool updateShortcuts(const std::vector &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 diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index d75f9c85..2405c607 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -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 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 }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index b60b5d5a..c484e900 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -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 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 diff --git a/src/core/Game.lua b/src/core/Game.lua index 31f63ebd..136aab99 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 3de97efc..f95fca7c 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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 diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index cac66de6..0bc98a3c 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -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") diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 7a94df36..34848aac 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index 0b2c94a8..be115dea 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -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. diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 2159dc4a..4956c75d 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -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 }) diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua new file mode 100644 index 00000000..247366d5 --- /dev/null +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -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") diff --git a/tests/engine/android_shortcuts_payload_test.lua b/tests/engine/android_shortcuts_payload_test.lua new file mode 100644 index 00000000..46da7e7c --- /dev/null +++ b/tests/engine/android_shortcuts_payload_test.lua @@ -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)") diff --git a/tools/generate_android_icons.py b/tools/generate_android_icons.py new file mode 100644 index 00000000..9cf412c8 --- /dev/null +++ b/tools/generate_android_icons.py @@ -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()