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.
This commit is contained in:
Shane McGovern
2026-08-16 20:23:19 +01:00
parent 46f73b7bb3
commit 0b00faf38e
7 changed files with 177 additions and 3 deletions
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return result; 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 * TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a * own class, never FindClass -- and the same tolerance for an old APK: a
@@ -98,6 +98,14 @@ bool restartApp();
**/ **/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept); 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). * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
#endif #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 int System::tlsOpen(const char *host, int port) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -151,6 +151,14 @@ public:
virtual bool httpDownload(const char *url, const char *destPath, virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const; 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 * TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract: * LuaSec or another provider is the answer). Non-blocking by contract:
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
return 1; 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) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost },
{ "tlsOpen", w_tlsOpen }, { "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus }, { "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend }, { "tlsSend", w_tlsSend },
@@ -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 * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive / * (pending_export.sav in the app save identity) to Downloads / Drive /
+15 -3
View File
@@ -452,9 +452,11 @@ end
-- POST returning success/failure. Strictly one-way: the response body is -- POST returning success/failure. Strictly one-way: the response body is
-- discarded, only the HTTP status class is surfaced (postLog callers never -- 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 -- 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 -- large body never lands in the command line; where curl is absent (Android
-- transport, and httpPost reports that instead of half-working through -- and the other bridge-only platforms) the POST rides the JNI bridge --
-- httpDownload (a GET round-trip to a POST endpoint would be a lie). -- 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) function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(body) ~= "string" then return nil, "missing body" 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 if not haveBridge() then
return nil, "no network transport on this platform" return nil, "no network transport on this platform"
end 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" return nil, "no POST transport on this platform"
end end