feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
@@ -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" >
|
||||
|
||||
|
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="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
|
||||
|
||||