Merge pull request #1553 from bryanthaboi/dev

fix stuff baby
This commit is contained in:
bryanthaboi
2026-08-19 06:10:44 -04:00
committed by GitHub
109 changed files with 11478 additions and 335 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)
@@ -378,6 +443,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
return result;
}
bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent, std::string &out)
{
out.clear();
if (url == nullptr)
return false;
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// Same resolution rule as httpDownload: the activity's own class via
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
// runs on a love.thread worker, whose class loader cannot see them.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
// Old APK / new liblove skew: report "no transport" instead of aborting
// on a missing method (#597).
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
if (method_id == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jobjectArray jheaders = nullptr;
if (headerPairCount > 0)
{
// java/lang/String, unlike an app class, resolves from any thread.
jclass stringClass = env->FindClass("java/lang/String");
if (stringClass == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
env->DeleteLocalRef(stringClass);
if (jheaders == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
for (int i = 0; i < headerPairCount; i++)
{
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
env->SetObjectArrayElement(jheaders, (jsize) i, field);
if (field != nullptr)
env->DeleteLocalRef(field);
}
}
jstring jurl = env->NewStringUTF(url);
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
// raw bytes across the bridge, as httpPost does: a request body is JSON
// carrying a base64 save, and a jstring would run it through modified UTF-8
jbyteArray jbody = nullptr;
if (body != nullptr && bodyLen >= 0)
{
jbody = env->NewByteArray((jsize) bodyLen);
if (jbody != nullptr && bodyLen > 0)
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
}
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
jheaders, jbody, jua);
env->DeleteLocalRef(jurl);
env->DeleteLocalRef(jmethod);
if (jheaders != nullptr)
env->DeleteLocalRef(jheaders);
if (jbody != nullptr)
env->DeleteLocalRef(jbody);
env->DeleteLocalRef(jua);
env->DeleteLocalRef(activity);
if (result == nullptr)
return false;
jbyteArray bytes = (jbyteArray) result;
jsize length = env->GetArrayLength(bytes);
if (length > 0)
{
out.resize((size_t) length);
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
}
env->DeleteLocalRef(result);
return true;
}
/*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a
@@ -1390,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
love::audio::openal::pushAudioResetEvent();
}
static void pushGameIntentEvent(const char *game)
{
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || game == nullptr)
return;
std::vector<love::Variant> args;
args.push_back(love::Variant(std::string(game)));
love::event::Message *msg = new love::event::Message("intent_game", args);
eventmodule->push(msg);
msg->release();
}
extern "C" JNIEXPORT void JNICALL
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
{
(void) cls;
if (game == nullptr)
return;
const char *str = env->GetStringUTFChars(game, nullptr);
if (str != nullptr)
{
pushGameIntentEvent(str);
env->ReleaseStringUTFChars(game, str);
}
}
#endif // LOVE_ANDROID
@@ -90,6 +90,16 @@ bool syncHealthSteps();
**/
bool restartApp();
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
bool updateAppShortcuts(const std::vector<std::string> &versions);
/**
* Returns the game version requested via initial launch Intent (if any).
**/
std::string getLaunchGame();
/**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there
@@ -106,6 +116,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
**/
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
/**
* Blocking HTTPS request with a method, headers and a byte body
* (GameActivity.httpRequest). What save sync needs and neither of the two
* above can give it: PUT, per-request auth headers, and the response body of
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
* headerPairCount entries; body/userAgent may be null. `out` receives the
* Java side's envelope -- a head line of "STATUS <code>" or "ERROR <text>",
* a newline, then the raw response bytes. False means the platform has no
* such bridge at all (an old APK under a newer liblove), which the Lua side
* reports as "update the app" rather than as a failed request.
**/
bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent, std::string &out);
/**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -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
{
@@ -274,6 +293,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
#endif
}
bool System::httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent,
std::string &out) const
{
#ifdef LOVE_ANDROID
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
body, bodyLen, userAgent, out);
#else
LOVE_UNUSED(url);
LOVE_UNUSED(method);
LOVE_UNUSED(headerPairs);
LOVE_UNUSED(headerPairCount);
LOVE_UNUSED(body);
LOVE_UNUSED(bodyLen);
LOVE_UNUSED(userAgent);
out.clear();
return false;
#endif
}
int System::tlsOpen(const char *host, int port) const
{
#ifdef LOVE_ANDROID
@@ -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
@@ -159,6 +162,18 @@ public:
virtual bool httpPost(const char *url, const char *body, int bodyLen,
const char *contentType = nullptr, const char *userAgent = nullptr) const;
/**
* Blocking HTTPS request with a method, headers and a byte body (Android
* only; false elsewhere). Save sync needs PUT, auth headers and the body
* of a 4xx, none of which the two bridges above can express. headerPairs
* is a flat name, value array; `out` receives the response envelope
* ("STATUS <code>" or "ERROR <text>", a newline, then the raw body).
**/
virtual bool httpRequest(const char *url, const char *method,
const char *const *headerPairs, int headerPairCount,
const char *body, int bodyLen, const char *userAgent,
std::string &out) const;
/**
* TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract:
@@ -22,6 +22,9 @@
#include "wrap_System.h"
#include "sdl/System.h"
#include <string>
#include <vector>
namespace love
{
namespace system
@@ -150,6 +153,57 @@ int w_httpPost(lua_State *L)
return 1;
}
/*
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
*
* `headers` is a flat array of alternating header name and value strings, so
* it maps straight onto the Java bridge's String[] without any parsing here.
* The single return is the response envelope -- a head line of
* "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
* where the build has no bridge, which src/core/HostShell.lua turns into an
* "update the app" notice rather than a failed request.
*/
int w_httpRequest(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *method = luaL_optstring(L, 2, "GET");
std::vector<std::string> fields;
if (!lua_isnoneornil(L, 3))
{
luaL_checktype(L, 3, LUA_TTABLE);
size_t count = luax_objlen(L, 3);
for (size_t i = 1; i <= count; i++)
{
lua_rawgeti(L, 3, (int) i);
const char *field = lua_tostring(L, -1);
fields.push_back(field != nullptr ? field : "");
lua_pop(L, 1);
}
}
std::vector<const char *> pairs;
for (size_t i = 0; i < fields.size(); i++)
pairs.push_back(fields[i].c_str());
size_t bodyLen = 0;
const char *body = nullptr;
if (!lua_isnoneornil(L, 4))
body = luaL_checklstring(L, 4, &bodyLen);
const char *ua = luaL_optstring(L, 5, nullptr);
std::string out;
bool ok = instance()->httpRequest(url, method,
pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(),
body, (int) bodyLen, ua, out);
if (!ok)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, out.data(), out.size());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -229,6 +283,34 @@ int w_tlsClose(lua_State *L)
return 0;
}
int w_updateShortcuts(lua_State *L)
{
if (!lua_istable(L, 1))
return luaL_error(L, "Expected table of game version strings");
std::vector<std::string> versions;
int len = (int) luax_objlen(L, 1);
for (int i = 1; i <= len; ++i)
{
lua_rawgeti(L, 1, i);
if (lua_isstring(L, -1))
versions.push_back(lua_tostring(L, -1));
lua_pop(L, 1);
}
luax_pushboolean(L, instance()->updateShortcuts(versions));
return 1;
}
int w_getLaunchGame(lua_State *L)
{
std::string game = instance()->getLaunchGame();
if (game.empty())
lua_pushnil(L);
else
luax_pushstring(L, game);
return 1;
}
static const luaL_Reg functions[] =
{
{ "getOS", w_getOS },
@@ -243,8 +325,11 @@ 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 },
{ "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend },
@@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
@@ -36,6 +37,7 @@ import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import android.Manifest;
@@ -67,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;
@@ -157,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;
@@ -226,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);
@@ -259,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();
@@ -671,6 +690,95 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest
}
@Keep
public static String getLaunchGame() {
return initialGame != null ? initialGame : "";
}
@Keep
public static boolean updateAppShortcuts(String[] readyVersions) {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT < 25) return false;
try {
Context context = self.getApplicationContext();
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
if (shortcutManager == null) return false;
if (readyVersions == null || readyVersions.length == 0) {
shortcutManager.removeAllDynamicShortcuts();
return true;
}
List<ShortcutInfo> shortcuts = new ArrayList<>();
int maxShortcuts = Math.min(readyVersions.length, 4);
for (int i = 0; i < maxShortcuts; i++) {
String ver = readyVersions[i];
if (ver == null || ver.isEmpty()) continue;
String lower = ver.toLowerCase();
String shortLabel;
String longLabel;
int iconResId;
switch (lower) {
case "red":
shortLabel = "Play Red";
longLabel = "Play Red";
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
break;
case "blue":
shortLabel = "Play Blue";
longLabel = "Play Blue";
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
break;
case "yellow":
shortLabel = "Play Yellow";
longLabel = "Play Yellow";
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
break;
case "gold":
shortLabel = "Play Gold";
longLabel = "Play Gold";
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
break;
default:
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
shortLabel = "Play " + capitalized;
longLabel = "Play " + capitalized;
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
break;
}
if (iconResId == 0) {
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
}
Intent intent = new Intent(context, GameActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra("game", lower);
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIntent(intent);
if (iconResId != 0) {
builder.setIcon(Icon.createWithResource(context, iconResId));
}
shortcuts.add(builder.build());
}
shortcutManager.setDynamicShortcuts(shortcuts);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
return false;
}
}
/**
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so
@@ -847,6 +955,149 @@ public class GameActivity extends SDLActivity {
}
}
/** Response ceiling for httpRequest; anything larger is refused, not buffered. */
private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024;
/** Builds an httpRequest envelope: one head line, a newline, then the body. */
private static byte[] httpEnvelope(String head, byte[] payload) {
byte[] prefix;
try {
prefix = (head + "\n").getBytes("UTF-8");
} catch (Exception e) {
prefix = (head + "\n").getBytes();
}
if (payload == null || payload.length == 0) return prefix;
byte[] out = new byte[prefix.length + payload.length];
System.arraycopy(prefix, 0, out, 0, prefix.length);
System.arraycopy(payload, 0, out, prefix.length, payload.length);
return out;
}
/** One-line, CR/LF-free failure text, so an envelope head stays one line. */
private static String httpErrorText(Exception e) {
String text = e.getMessage();
if (text == null || text.length() == 0) text = e.getClass().getSimpleName();
text = text.replace('\r', ' ').replace('\n', ' ');
if (text.length() > 160) text = text.substring(0, 160);
return text;
}
/**
* Blocking HTTPS request with a chosen method, headers and byte body,
* exposed as love.system.httpRequest and used by src/core/HostShell.lua
* for save sync. Sync needs PUT, per-request auth headers and the response
* body of a 4xx as well as a 2xx (a conflict answers 409 with the save
* that won), none of which httpDownload or httpPost above can express.
*
* Same rules as those two: https only, redirects followed by hand
* (re-sending method and body on each hop), 15s connect / 60s read, and
* blocking on the Lua/worker thread -- never the UI thread. Headers arrive
* as a flat name, value array; a field carrying CR or LF is refused rather
* than sent, so a header value can never inject a second header.
*
* The reply is an envelope: a head line of "STATUS &lt;code&gt;" or
* "ERROR &lt;text&gt;", a newline, then the raw response bytes.
*/
@Keep
public static byte[] httpRequest(String url, String method, String[] headerPairs,
byte[] body, String userAgent) {
if (url == null) return httpEnvelope("ERROR missing url", null);
String verb = method == null ? "GET" : method.toUpperCase(Locale.US);
if (!"GET".equals(verb) && !"POST".equals(verb)
&& !"PUT".equals(verb) && !"DELETE".equals(verb)) {
return httpEnvelope("ERROR unsupported request method", null);
}
if (headerPairs != null) {
if ((headerPairs.length % 2) != 0) {
return httpEnvelope("ERROR bad request header", null);
}
for (int i = 0; i < headerPairs.length; i++) {
String field = headerPairs[i];
if (field == null) return httpEnvelope("ERROR bad request header", null);
if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) {
return httpEnvelope("ERROR bad request header", null);
}
if ((i % 2) == 0 && field.length() == 0) {
return httpEnvelope("ERROR bad request header", null);
}
}
}
HttpURLConnection conn = null;
try {
String current = url;
for (int hop = 0; hop < 5; hop++) {
URL parsed = new URL(current);
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
return httpEnvelope("ERROR https only", null);
}
conn = (HttpURLConnection) parsed.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setRequestMethod(verb);
conn.setRequestProperty("User-Agent",
userAgent == null ? "gen1recomp" : userAgent);
if (headerPairs != null) {
for (int i = 0; i + 1 < headerPairs.length; i += 2) {
conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]);
}
}
if (body != null && !"GET".equals(verb)) {
conn.setDoOutput(true);
conn.setFixedLengthStreamingMode(body.length);
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
try {
out.write(body);
} finally {
try { out.close(); } catch (IOException ignored) {}
}
}
int code = conn.getResponseCode();
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
String next = conn.getHeaderField("Location");
conn.disconnect();
conn = null;
if (next == null) {
return httpEnvelope("ERROR redirect without a location", null);
}
current = new URL(parsed, next).toString();
continue;
}
// A rejection's body is the diagnosis the caller wants, so 4xx
// and 5xx are read through getErrorStream rather than dropped.
InputStream in;
try {
in = conn.getInputStream();
} catch (IOException e) {
in = conn.getErrorStream();
}
ByteArrayOutputStream sink = new ByteArrayOutputStream();
if (in != null) {
InputStream reader = new BufferedInputStream(in);
try {
byte[] buf = new byte[16384];
int n;
while ((n = reader.read(buf)) > 0) {
if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) {
return httpEnvelope("ERROR the reply was too large", null);
}
sink.write(buf, 0, n);
}
} finally {
try { reader.close(); } catch (IOException ignored) {}
}
}
return httpEnvelope("STATUS " + code, sink.toByteArray());
}
return httpEnvelope("ERROR too many redirects", null);
} catch (Exception e) {
Log.d("GameActivity", "httpRequest failed: " + e.getMessage());
return httpEnvelope("ERROR " + httpErrorText(e), null);
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
+118
View File
@@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject {
return succeeded
}
// MARK: - General HTTP request (love.system.httpRequest)
private static let httpMaxResponse = 4 * 1024 * 1024
// URLSession turns a 301/302/303 POST into a GET on its own. Save sync
// signs a method and a body, so every hop re-sends the original request
// against the new URL instead, and only over https.
private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
guard let original = task.originalRequest,
let target = request.url,
target.scheme?.lowercased() == "https" else {
completionHandler(nil)
return
}
var next = original
next.url = target
completionHandler(next)
}
}
private static let httpSession = URLSession(configuration: .ephemeral,
delegate: GRRedirectKeeper(),
delegateQueue: nil)
private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData {
var out = Data((head + "\n").utf8)
if let payload { out.append(payload) }
return out as NSData
}
private static func httpErrorText(_ error: Error) -> String {
var text = error.localizedDescription
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
if text.isEmpty { text = "the request failed" }
if text.count > 160 { text = String(text.prefix(160)) }
return text
}
/// Blocking HTTPS request with a chosen method, headers and byte body, the
/// iOS half of love.system.httpRequest (see the Android GameActivity one).
/// Headers arrive as "name: value" lines joined by newlines. The reply is
/// an envelope: a head line of "STATUS <code>" or "ERROR <text>", a
/// newline, then the raw response bytes -- read for 4xx and 5xx as well,
/// because a sync conflict answers 409 with the save that won.
@objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:)
public static func httpRequest(url: UnsafePointer<CChar>?,
method: UnsafePointer<CChar>?,
headers: UnsafePointer<CChar>?,
body: UnsafePointer<UInt8>?,
bodyLength: Int32,
userAgent: UnsafePointer<CChar>?) -> NSData? {
guard let url, let requestURL = URL(string: String(cString: url)) else {
return httpEnvelope("ERROR missing url", nil)
}
guard requestURL.scheme?.lowercased() == "https" else {
return httpEnvelope("ERROR https only", nil)
}
let verb = (method.map { String(cString: $0) } ?? "GET").uppercased()
guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else {
return httpEnvelope("ERROR unsupported request method", nil)
}
var request = URLRequest(url: requestURL)
request.httpMethod = verb
request.timeoutInterval = 60
request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp",
forHTTPHeaderField: "User-Agent")
if let headers, headers.pointee != 0 {
for line in String(cString: headers).split(separator: "\n") {
guard let colon = line.firstIndex(of: ":") else {
return httpEnvelope("ERROR bad request header", nil)
}
let name = line[line.startIndex..<colon]
.trimmingCharacters(in: .whitespaces)
let value = line[line.index(after: colon)...]
.trimmingCharacters(in: .whitespaces)
if name.isEmpty {
return httpEnvelope("ERROR bad request header", nil)
}
request.setValue(value, forHTTPHeaderField: name)
}
}
if verb != "GET", let body, bodyLength > 0 {
request.httpBody = Data(bytes: body, count: Int(bodyLength))
}
let semaphore = DispatchSemaphore(value: 0)
var envelope = httpEnvelope("ERROR no response", nil)
let task = httpSession.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error {
envelope = httpEnvelope("ERROR " + httpErrorText(error), nil)
return
}
guard let http = response as? HTTPURLResponse else {
envelope = httpEnvelope("ERROR no response", nil)
return
}
let payload = data ?? Data()
if payload.count > httpMaxResponse {
envelope = httpEnvelope("ERROR the reply was too large", nil)
return
}
envelope = httpEnvelope("STATUS \(http.statusCode)", payload)
}
task.resume()
guard semaphore.wait(timeout: .now() + 65) == .success else {
task.cancel()
return httpEnvelope("ERROR the request timed out", nil)
}
return envelope
}
// MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:)
+80 -2
View File
@@ -9,7 +9,8 @@ What it does:
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
love.system.createFile, and love.system.syncHealthSteps on iOS (each
love.system.createFile, love.system.syncHealthSteps,
love.system.httpDownload and love.system.httpRequest on iOS (each
calls a GR*Bridge Swift class through the Objective-C runtime, so
liblove never links against Swift directly).
3. Patches love.xcodeproj so the love-ios app target compiles the native
@@ -50,6 +51,7 @@ WRAP_INCLUDES = """
#include <objc/runtime.h>
#include <objc/message.h>
#include <string>
#include <vector>
#include "filesystem/Filesystem.h"
#endif
""" % MARKER
@@ -159,6 +161,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif
"""
@@ -201,6 +204,7 @@ int w_syncHealthSteps(lua_State *L)
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
{ "httpRequest", w_httpRequest },
#endif
"""
@@ -226,6 +230,80 @@ int w_httpDownload(lua_State *L)
lua_pushboolean(L, ok != 0);
return 1;
}
// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
//
// The transport save sync needs: a chosen method, per-request auth headers,
// and the response body of a 4xx as well as a 2xx. `headers` is a flat array
// of alternating name and value strings, joined into "name: value" lines here
// because the Swift bridge takes C strings and no Foundation type may be
// NAMED in this translation unit (see w_pickFileKinds above).
//
// The single return is the response envelope -- a head line of
// "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
// where the build carries no bridge at all, which src/core/HostShell.lua
// turns into an "update the app" notice rather than a failed request.
int w_httpRequest(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *method = luaL_optstring(L, 2, "GET");
std::string headerBlob;
if (!lua_isnoneornil(L, 3))
{
luaL_checktype(L, 3, LUA_TTABLE);
std::vector<std::string> fields;
size_t count = luax_objlen(L, 3);
for (size_t i = 1; i <= count; i++)
{
lua_rawgeti(L, 3, (int) i);
const char *field = lua_tostring(L, -1);
fields.push_back(field != nullptr ? field : "");
lua_pop(L, 1);
}
for (size_t i = 0; i + 1 < fields.size(); i += 2)
headerBlob += fields[i] + ": " + fields[i + 1] + "\\n";
}
size_t bodyLen = 0;
const char *body = nullptr;
if (!lua_isnoneornil(L, 4))
body = luaL_checklstring(L, 4, &bodyLen);
const char *ua = luaL_optstring(L, 5, "gen1recomp");
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushnil(L);
return 1;
}
typedef id (*GRRequest)(Class, SEL, const char *, const char *,
const char *, const unsigned char *, int,
const char *);
id reply = ((GRRequest)objc_msgSend)(
cls,
sel_registerName("httpRequestWithUrl:method:headers:body:bodyLength:userAgent:"),
url, method, headerBlob.c_str(), (const unsigned char *) body,
(int) bodyLen, ua);
if (reply == nullptr)
{
lua_pushnil(L);
return 1;
}
// NSData read through the runtime, for the same reason as above: the
// bytes are copied out immediately, before any autorelease pool drains.
typedef const void *(*GRBytes)(id, SEL);
typedef unsigned long (*GRLength)(id, SEL);
const void *bytes = ((GRBytes)objc_msgSend)(reply, sel_registerName("bytes"));
unsigned long length = ((GRLength)objc_msgSend)(reply, sel_registerName("length"));
if (bytes == nullptr || length == 0)
{
lua_pushnil(L);
return 1;
}
lua_pushlstring(L, (const char *) bytes, (size_t) length);
return 1;
}
#endif
"""
@@ -308,7 +386,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps/httpDownload)")
"(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)")
def patch_public_documents():