Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+2
View File
@@ -24,6 +24,8 @@ android {
versionName releaseVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Dungeon objects already carry verified lossless gzip compression.
noCompress 'gzip'
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
@@ -0,0 +1,85 @@
package com.phenomrom.healerman;
import android.content.res.AssetManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import com.getcapacitor.Bridge;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.GZIPInputStream;
/** Streams shared APK objects at their original logical URLs; no expanded disk copy. */
final class BundledContentAssets {
private final Bridge bridge;
private final AssetManager assets;
private JSONObject files;
BundledContentAssets(Bridge bridge) {
this.bridge = bridge;
this.assets = bridge.getContext().getAssets();
}
private synchronized JSONObject files() throws Exception {
if (files == null) {
try (InputStream input = assets.open("public/content/bootstrap-manifest.json")) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[16384];
int count;
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
files = new JSONObject(output.toString("UTF-8")).getJSONObject("files");
}
}
return files;
}
WebResourceResponse intercept(WebResourceRequest request) {
if (!"GET".equals(request.getMethod()) || !bridge.getHost().equals(request.getUrl().getAuthority())
|| !bridge.getScheme().equals(request.getUrl().getScheme())) return null;
String path = request.getUrl().getPath();
if (path == null || !(path.startsWith("/assets/") || path.startsWith("/basis/") || path.startsWith("/data/"))) return null;
try {
JSONObject entry = files().optJSONObject(path);
if (entry == null) return null;
JSONObject download = entry.optJSONObject("download");
String objectUrl = (download == null ? entry : download).getString("url");
if (!objectUrl.matches("/content/bundled/[a-f0-9]{64}\\.[a-z0-9]+(?:\\.g(?:z|zip))?")) return null;
InputStream input = assets.open("public" + objectUrl);
if (download != null) {
try { input = new GZIPInputStream(input, 128 * 1024); }
catch (Exception error) { input.close(); throw error; }
}
Map<String, String> headers = new HashMap<>();
headers.put("Content-Length", String.valueOf(entry.getLong("bytes")));
headers.put("Cache-Control", "public, max-age=3600");
return new WebResourceResponse(mimeType(path), null, 200, "OK", headers, input);
} catch (Exception error) {
return new WebResourceResponse("text/plain", "UTF-8", 500, "Bundled asset unavailable", new HashMap<>(),
new ByteArrayInputStream("Bundled asset unavailable.".getBytes(StandardCharsets.UTF_8)));
}
}
private static String mimeType(String path) {
String extension = path.substring(path.lastIndexOf('.') + 1);
switch (extension) {
case "glb": return "model/gltf-binary";
case "json": return "application/json";
case "js": return "text/javascript";
case "wasm": return "application/wasm";
case "ktx2": return "image/ktx2";
case "png": return "image/png";
case "webp": return "image/webp";
case "avif": return "image/avif";
case "svg": return "image/svg+xml";
case "mp3": return "audio/mpeg";
case "ogg": return "audio/ogg";
case "wav": return "audio/wav";
case "webm": return "video/webm";
default: return "application/octet-stream";
}
}
}
@@ -8,6 +8,10 @@ import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.BridgeWebViewClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
@@ -20,6 +24,7 @@ import java.net.URI;
import java.net.URL;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
/** Streams verified, resumable content objects into Android app-private storage. */
@CapacitorPlugin(name = "ContentStorage")
@@ -27,6 +32,18 @@ public class ContentStoragePlugin extends Plugin {
private static final int BUFFER_BYTES = 128 * 1024;
private static final long PROGRESS_INTERVAL_MS = 200L;
@Override
public void load() {
BundledContentAssets bundled = new BundledContentAssets(bridge);
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
WebResourceResponse response = bundled.intercept(request);
return response != null ? response : super.shouldInterceptRequest(view, request);
}
});
}
@PluginMethod
public void download(PluginCall call) {
execute(() -> {
@@ -36,6 +53,10 @@ public class ContentStoragePlugin extends Plugin {
String sha256 = normalizedSha(requiredString(call, "sha256"));
String extension = normalizedExtension(requiredString(call, "extension"));
long expectedBytes = numericLong(call, "bytes", -1L);
boolean compressed = "gzip".equals(call.getString("encoding"));
String transferSha = compressed ? normalizedSha(requiredString(call, "downloadSha256")) : sha256;
long transferBytes = compressed ? numericLong(call, "downloadBytes", -1L) : expectedBytes;
if (expectedBytes < 0 || transferBytes < 0) throw new IllegalArgumentException("Missing content size.");
validateRemoteUrl(url);
File destination = objectFile(sha256, extension);
@@ -47,54 +68,89 @@ public class ContentStoragePlugin extends Plugin {
throw new IllegalStateException("Unable to replace invalid cached content.");
}
File partial = new File(destination.getParentFile(), destination.getName() + ".part");
long existingBytes = partial.isFile() ? partial.length() : 0L;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(45_000);
connection.setRequestProperty("Accept-Encoding", "identity");
if (existingBytes > 0L) connection.setRequestProperty("Range", "bytes=" + existingBytes + "-");
int status = connection.getResponseCode();
boolean resumed = existingBytes > 0L && status == HttpURLConnection.HTTP_PARTIAL;
if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
connection.disconnect();
throw new IllegalStateException("Content server returned HTTP " + status + ".");
}
if (!resumed) existingBytes = 0L;
long downloadedBytes = existingBytes;
long totalBytes = expectedBytes > 0L
? expectedBytes
: existingBytes + Math.max(0L, connection.getContentLengthLong());
long lastProgressAt = 0L;
try (
InputStream input = new BufferedInputStream(connection.getInputStream(), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(partial, resumed), BUFFER_BYTES)
) {
byte[] buffer = new byte[BUFFER_BYTES];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
downloadedBytes += count;
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
notifyProgress(logicalPath, downloadedBytes, totalBytes);
lastProgressAt = now;
File partial = new File(destination.getParentFile(), destination.getName() + (compressed ? "." + transferSha + ".gz" : "") + ".part");
if (!verifies(partial, transferSha, transferBytes)) {
if (partial.isFile() && partial.length() >= transferBytes && !partial.delete()) {
throw new IllegalStateException("Unable to reset an invalid partial download.");
}
long existingBytes = partial.isFile() ? partial.length() : 0L;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(45_000);
connection.setRequestProperty("Accept-Encoding", "identity");
if (existingBytes > 0L) connection.setRequestProperty("Range", "bytes=" + existingBytes + "-");
int status = connection.getResponseCode();
boolean resumed = existingBytes > 0L && status == HttpURLConnection.HTTP_PARTIAL;
if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
connection.disconnect();
throw new IllegalStateException("Content server returned HTTP " + status + ".");
}
if (status == HttpURLConnection.HTTP_PARTIAL) {
String range = connection.getHeaderField("Content-Range");
if (!resumed || range == null || !range.startsWith("bytes " + existingBytes + "-")) {
connection.disconnect();
partial.delete();
throw new IllegalStateException("Content server returned an invalid resume range.");
}
}
} finally {
connection.disconnect();
}
notifyProgress(logicalPath, downloadedBytes, totalBytes);
if (!resumed) existingBytes = 0L;
long downloadedBytes = existingBytes;
long totalBytes = transferBytes > 0L
? transferBytes
: existingBytes + Math.max(0L, connection.getContentLengthLong());
long lastProgressAt = 0L;
if (!verifies(partial, sha256, expectedBytes)) {
try (
InputStream input = new BufferedInputStream(connection.getInputStream(), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(partial, resumed), BUFFER_BYTES)
) {
byte[] buffer = new byte[BUFFER_BYTES];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
downloadedBytes += count;
if (downloadedBytes > transferBytes) throw new IllegalStateException("Downloaded content exceeds expected size.");
long now = System.currentTimeMillis();
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
notifyProgress(logicalPath, downloadedBytes, totalBytes);
lastProgressAt = now;
}
}
} finally {
connection.disconnect();
}
notifyProgress(logicalPath, downloadedBytes, totalBytes);
}
if (!verifies(partial, transferSha, transferBytes)) {
partial.delete();
throw new IllegalStateException("Downloaded content failed SHA-256 or size verification.");
}
File verified = partial;
if (compressed) {
verified = new File(destination.getParentFile(), destination.getName() + ".decoded");
try (InputStream input = new GZIPInputStream(new FileInputStream(partial), BUFFER_BYTES);
BufferedOutputStream output = new BufferedOutputStream(new FileOutputStream(verified), BUFFER_BYTES)) {
byte[] buffer = new byte[BUFFER_BYTES];
long decodedBytes = 0;
int count;
while ((count = input.read(buffer)) != -1) {
decodedBytes += count;
if (decodedBytes > expectedBytes) throw new IllegalStateException("Expanded content exceeds expected size.");
output.write(buffer, 0, count);
}
} catch (Exception error) { verified.delete(); throw error; }
if (!verifies(verified, sha256, expectedBytes)) {
verified.delete();
partial.delete();
throw new IllegalStateException("Expanded content failed SHA-256 or size verification.");
}
partial.delete();
}
if (destination.exists() && !destination.delete()) {
throw new IllegalStateException("Unable to activate downloaded content.");
}
if (!partial.renameTo(destination)) {
if (!verified.renameTo(destination)) {
throw new IllegalStateException("Unable to atomically activate downloaded content.");
}
call.resolve(fileResult(destination, false));
@@ -100,6 +100,8 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
addAxisTokens(currentTokens, rightStickX, "Axis2-", "Axis2+");
addAxisTokens(currentTokens, rightStickY, "Axis3-", "Axis3+");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE), "Button6");
addPositiveAxisToken(currentTokens, positiveControllerAxisValue(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS), "Button7");
@@ -288,7 +288,7 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
webView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return bridge.getLocalServer().shouldInterceptRequest(request);
return bridge.getWebViewClient().shouldInterceptRequest(view, request);
}
});
}