feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher

This commit is contained in:
1jamie
2026-08-18 20:37:25 -05:00
parent 580449b8df
commit 302b2c9591
45 changed files with 792 additions and 16 deletions
@@ -29,7 +29,8 @@
<application
android:allowBackup="true"
android:icon="@drawable/love"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
@@ -39,7 +40,7 @@
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:label="${NAME}"
android:launchMode="singleInstance"
android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}"
android:resizeableActivity="false"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -3,4 +3,9 @@
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="shortcut_red">#E53935</color>
<color name="shortcut_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color>
</resources>
@@ -48,6 +48,7 @@
#include "common/Module.h"
#include "audio/Audio.h"
#include "audio/openal/Audio.h"
#include "event/Event.h"
namespace love
{
@@ -282,6 +283,70 @@ bool restartApp()
return result;
}
bool updateAppShortcuts(const std::vector<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return false;
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jclass stringClass = env->FindClass("java/lang/String");
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
for (size_t i = 0; i < versions.size(); ++i)
{
jstring jstr = env->NewStringUTF(versions[i].c_str());
env->SetObjectArrayElement(array, (jsize) i, jstr);
env->DeleteLocalRef(jstr);
}
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
env->DeleteLocalRef(array);
env->DeleteLocalRef(stringClass);
env->DeleteLocalRef(activity);
return result;
}
std::string getLaunchGame()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
if (activity == nullptr)
return "";
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return "";
}
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
if (jgame == nullptr)
{
env->DeleteLocalRef(activity);
return "";
}
const char *str = env->GetStringUTFChars(jgame, nullptr);
std::string result = (str != nullptr) ? str : "";
if (str != nullptr)
env->ReleaseStringUTFChars(jgame, str);
env->DeleteLocalRef(jgame);
env->DeleteLocalRef(activity);
return result;
}
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
{
if (url == nullptr || destPath == nullptr)
@@ -1390,4 +1455,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
love::audio::openal::pushAudioResetEvent();
}
static void pushGameIntentEvent(const char *game)
{
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || game == nullptr)
return;
std::vector<love::Variant> args;
args.push_back(love::Variant(std::string(game)));
love::event::Message *msg = new love::event::Message("intent_game", args);
eventmodule->push(msg);
msg->release();
}
extern "C" JNIEXPORT void JNICALL
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
{
(void) cls;
if (game == nullptr)
return;
const char *str = env->GetStringUTFChars(game, nullptr);
if (str != nullptr)
{
pushGameIntentEvent(str);
env->ReleaseStringUTFChars(game, str);
}
}
#endif // LOVE_ANDROID
@@ -90,6 +90,16 @@ bool syncHealthSteps();
**/
bool restartApp();
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
bool updateAppShortcuts(const std::vector<std::string> &versions);
/**
* Returns the game version requested via initial launch Intent (if any).
**/
std::string getLaunchGame();
/**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there
@@ -245,6 +245,25 @@ bool System::restartApp() const
#endif
}
bool System::updateShortcuts(const std::vector<std::string> &versions) const
{
#ifdef LOVE_ANDROID
return love::android::updateAppShortcuts(versions);
#else
LOVE_UNUSED(versions);
return false;
#endif
}
std::string System::getLaunchGame() const
{
#ifdef LOVE_ANDROID
return love::android::getLaunchGame();
#else
return "";
#endif
}
bool System::httpDownload(const char *url, const char *destPath,
const char *userAgent, const char *accept) const
{
@@ -143,6 +143,9 @@ public:
**/
virtual bool restartApp() const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
/**
* Blocking HTTPS GET into an absolute host path (Android only; false
* elsewhere). Android has no curl, which is what every other platform
@@ -229,6 +229,34 @@ int w_tlsClose(lua_State *L)
return 0;
}
int w_updateShortcuts(lua_State *L)
{
if (!lua_istable(L, 1))
return luaL_error(L, "Expected table of game version strings");
std::vector<std::string> versions;
int len = (int) luax_objlen(L, 1);
for (int i = 1; i <= len; ++i)
{
lua_rawgeti(L, 1, i);
if (lua_isstring(L, -1))
versions.push_back(lua_tostring(L, -1));
lua_pop(L, 1);
}
luax_pushboolean(L, instance()->updateShortcuts(versions));
return 1;
}
int w_getLaunchGame(lua_State *L)
{
std::string game = instance()->getLaunchGame();
if (game.empty())
lua_pushnil(L);
else
luax_pushstring(L, game);
return 1;
}
static const luaL_Reg functions[] =
{
{ "getOS", w_getOS },
@@ -243,6 +271,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 },
{ "tlsOpen", w_tlsOpen },
@@ -67,8 +67,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;
@@ -157,6 +160,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;
@@ -226,6 +233,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);
@@ -259,6 +270,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();
@@ -671,6 +688,95 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest
}
@Keep
public static String getLaunchGame() {
return initialGame != null ? initialGame : "";
}
@Keep
public static boolean updateAppShortcuts(String[] readyVersions) {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT < 25) return false;
try {
Context context = self.getApplicationContext();
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
if (shortcutManager == null) return false;
if (readyVersions == null || readyVersions.length == 0) {
shortcutManager.removeAllDynamicShortcuts();
return true;
}
List<ShortcutInfo> shortcuts = new ArrayList<>();
int maxShortcuts = Math.min(readyVersions.length, 4);
for (int i = 0; i < maxShortcuts; i++) {
String ver = readyVersions[i];
if (ver == null || ver.isEmpty()) continue;
String lower = ver.toLowerCase();
String shortLabel;
String longLabel;
int iconResId;
switch (lower) {
case "red":
shortLabel = "Play Red";
longLabel = "Play Red";
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
break;
case "blue":
shortLabel = "Play Blue";
longLabel = "Play Blue";
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
break;
case "yellow":
shortLabel = "Play Yellow";
longLabel = "Play Yellow";
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
break;
case "gold":
shortLabel = "Play Gold";
longLabel = "Play Gold";
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
break;
default:
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
shortLabel = "Play " + capitalized;
longLabel = "Play " + capitalized;
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
break;
}
if (iconResId == 0) {
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
}
Intent intent = new Intent(context, GameActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("game", lower);
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIntent(intent);
if (iconResId != 0) {
builder.setIcon(Icon.createWithResource(context, iconResId));
}
shortcuts.add(builder.build());
}
shortcutManager.setDynamicShortcuts(shortcuts);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
return false;
}
}
/**
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so