From 0b00faf38e1b7f14062097b605f265065f6d7e7f Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Sun, 16 Aug 2026 20:23:19 +0100 Subject: [PATCH] feat(android): add httpPost bridge for mod.postLog log sends Android ships no curl and the JNI bridge was GET-only, so mod.postLog failed there with 'no POST transport on this platform' (HostShell.lua). Add the mirror of httpDownload: GameActivity.httpPost (https-only, hand-followed redirects re-POSTing the body, one-way), the JNI bridge with the same old-APK-skew tolerance, the love.system.httpPost binding, and the HostShell arm that rides it when curl is absent. The body crosses the JNI as raw bytes (jbyteArray) so a log ring with arbitrary UTF-8 cannot corrupt through modified-UTF-8 jstring conversion. --- .../love/src/jni/love/src/common/android.cpp | 49 +++++++++++++ .../love/src/jni/love/src/common/android.h | 8 +++ .../jni/love/src/modules/system/System.cpp | 15 ++++ .../src/jni/love/src/modules/system/System.h | 8 +++ .../love/src/modules/system/wrap_System.cpp | 12 ++++ .../java/org/love2d/android/GameActivity.java | 70 +++++++++++++++++++ src/core/HostShell.lua | 18 ++++- 7 files changed, 177 insertions(+), 3 deletions(-) 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 429e0353..1cde858e 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent, return result; } +bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent) +{ + if (url == nullptr || body == nullptr || bodyLen < 0) + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + // Same resolution rule as httpDownload: the activity's own class via + // SDL_AndroidGetActivity, never FindClass -- this bridge is called off + // the main thread (love.thread workers), whose class loader cannot see + // app classes. + 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" the same way a + // missing curl does, instead of aborting on a missing method (#597). + jmethodID method = env->GetStaticMethodID(activity, "httpPost", + "(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jstring jurl = env->NewStringUTF(url); + // raw bytes across the bridge: a log ring can carry arbitrary UTF-8, + // and a jstring would run it through modified UTF-8 + jbyteArray jbody = env->NewByteArray(bodyLen); + if (jbody != nullptr) + env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body); + jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr; + jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr; + + jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua); + + env->DeleteLocalRef(jurl); + if (jbody != nullptr) + env->DeleteLocalRef(jbody); + if (jct != nullptr) + env->DeleteLocalRef(jct); + if (jua != nullptr) + env->DeleteLocalRef(jua); + env->DeleteLocalRef(activity); + return result; +} + /* * TLS sockets. Same resolution rule as httpDownload above -- the activity's * own class, never FindClass -- and the same tolerance for an old APK: a 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 6e2fa017..a412b25c 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -98,6 +98,14 @@ bool restartApp(); **/ bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept); +/** + * Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The + * mirror of httpDownload for mod.postLog log sends, which need POST and + * have no curl on Android. contentType / userAgent may be null. Returns + * whether the server accepted the send (2xx). + **/ +bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent); + /** * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise 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 fce90d63..032228cb 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 @@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath, #endif } +bool System::httpPost(const char *url, const char *body, int bodyLen, + const char *contentType, const char *userAgent) const +{ +#ifdef LOVE_ANDROID + return love::android::httpPost(url, body, bodyLen, contentType, userAgent); +#else + LOVE_UNUSED(url); + LOVE_UNUSED(body); + LOVE_UNUSED(bodyLen); + LOVE_UNUSED(contentType); + LOVE_UNUSED(userAgent); + return false; +#endif +} + int System::tlsOpen(const char *host, int port) const { #ifdef LOVE_ANDROID 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 7ab0484a..bbffd544 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 @@ -151,6 +151,14 @@ public: virtual bool httpDownload(const char *url, const char *destPath, const char *userAgent = nullptr, const char *accept = nullptr) const; + /** + * Blocking HTTPS POST of a raw byte body (Android only; false + * elsewhere). The mirror of httpDownload for mod.postLog log sends, + * which need POST and have no curl on Android (#597). + **/ + virtual bool httpPost(const char *url, const char *body, int bodyLen, + const char *contentType = nullptr, const char *userAgent = nullptr) const; + /** * TLS client sockets (Android only; every call fails elsewhere, where * LuaSec or another provider is the answer). Non-blocking by contract: 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 9dfa18ea..48436638 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 @@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L) return 1; } +int w_httpPost(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + size_t bodyLen = 0; + const char *body = luaL_checklstring(L, 2, &bodyLen); + const char *ct = luaL_optstring(L, 3, nullptr); + const char *ua = luaL_optstring(L, 4, nullptr); + luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua)); + return 1; +} + int w_hasBackgroundMusic(lua_State *L) { lua_pushboolean(L, instance()->hasBackgroundMusic()); @@ -233,6 +244,7 @@ static const luaL_Reg functions[] = { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, + { "httpPost", w_httpPost }, { "tlsOpen", w_tlsOpen }, { "tlsStatus", w_tlsStatus }, { "tlsSend", w_tlsSend }, 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 cec24cde..85af7d48 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 @@ -741,6 +741,76 @@ public class GameActivity extends SDLActivity { } } + /** + * Blocking HTTPS POST, exposed as love.system.httpPost and used by + * src/core/HostShell.lua for mod.postLog. The GET bridge above covers + * downloads; log sends need POST, and Android ships no curl, so this is + * the only POST transport the platform has. Strictly one-way, matching + * the curl branch it mirrors: the response body is drained and + * discarded, and only the 2xx verdict comes back. + * + * Same rules as httpDownload: https only, redirects followed by hand + * (re-POSTing the body on each hop, the way curl -X POST behaves), and + * the call is blocking on the Lua/worker thread -- never the UI thread. + * The body arrives as raw bytes (a jbyteArray across the JNI) because a + * log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8 + * corruption on characters outside the BMP. + */ + @Keep + public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) { + if (url == null || body == null) return false; + 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 false; + conn = (HttpURLConnection) parsed.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("User-Agent", + userAgent == null ? "gen1recomp" : userAgent); + conn.setRequestProperty("Content-Type", + contentType == null ? "text/plain" : contentType); + 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 false; + current = new URL(parsed, next).toString(); + continue; + } + if (code < 200 || code > 299) return false; + // drain and discard, so a slow server cannot wedge the + // worker on a full socket buffer + InputStream in = new BufferedInputStream(conn.getInputStream()); + try { + byte[] buf = new byte[16384]; + while (in.read(buf) > 0) {} + } finally { + try { in.close(); } catch (IOException ignored) {} + } + return true; + } + return false; + } catch (Exception e) { + Log.d("GameActivity", "httpPost failed: " + e.getMessage()); + return false; + } 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 / diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 9939ef39..b619ebaa 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -452,9 +452,11 @@ end -- POST returning success/failure. Strictly one-way: the response body is -- discarded, only the HTTP status class is surfaced (postLog callers never -- trust the reply). curl --data-binary reads the payload from a pipe, so a --- large body never lands in the command line; the Android bridge has no POST --- transport, and httpPost reports that instead of half-working through --- httpDownload (a GET round-trip to a POST endpoint would be a lie). +-- large body never lands in the command line; where curl is absent (Android +-- and the other bridge-only platforms) the POST rides the JNI bridge -- +-- love.system.httpPost, the dedicated POST arm added beside httpDownload -- +-- instead of half-working through httpDownload (a GET round-trip to a POST +-- endpoint would be a lie). function HostShell.httpPost(url, body, contentType, userAgent, maxTime) if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(body) ~= "string" then return nil, "missing body" end @@ -530,6 +532,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) if not haveBridge() then return nil, "no network transport on this platform" end + -- The GET bridge has no POST; the dedicated love.system.httpPost arm + -- (GameActivity.httpPost) is the transport where curl is missing. A + -- build without it reports the same "no POST transport" a missing curl + -- would -- the old-APK skew path in the JNI bridge returns false. + if love.system and type(love.system.httpPost) == "function" then + local ok, sent = pcall(love.system.httpPost, url, body, contentType, + userAgent) + if ok and sent then return true end + return nil, "log post rejected" + end return nil, "no POST transport on this platform" end