Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
uses: nttld/setup-ndk@v1
with:
ndk-version: r27c
# Compiles the three JNI C TUs against the NDK sysroot and the module's
# Compiles the JNI C TUs against the NDK sysroot and the module's
# `extern "c"` / AAudio / MediaCodec bindings for aarch64 (object
# emission; nothing runs).
- name: Cross-build for Android
Expand Down
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub fn build(b: *std.Build) void {
"src/jni/intent_extras.c",
"src/jni/debuggable.c",
"src/jni/window_relayout.c",
"src/jni/renderer_query.c",
},
.flags = &.{ "-std=c11", "-Wall" },
});
Expand Down
25 changes: 25 additions & 0 deletions src/crash_guard.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Vulkan crash guard (labelle-bgfx#172 D11): the SEAM only.
//!
//! D11: mark "Vulkan start in progress" in the app's internal storage before a
//! Vulkan init, clear it once the game has run stably, and start on GLES at
//! the next launch if the mark is still there. That work is
//! labelle-toolkit/labelle-android#28. Until it lands this is a stub that
//! never disables Vulkan, so `renderer.resolve` (#27) already consults the
//! guard in the right place (after the intent override, before the provider
//! setting) and #28 only has to replace the body below.
const std = @import("std");

/// True when a previous launch crashed during a Vulkan start and this launch
/// must use GLES instead. `activity` is the running `ANativeActivity*`
/// (opaque), for the internal-storage path #28 will need.
///
/// Stub: always "not disabled".
pub fn isVulkanDisabled(activity: ?*const anyopaque) bool {
_ = activity;
return false;
}

test "stub: Vulkan is never disabled" {
try std.testing.expect(!isVulkanDisabled(null));
try std.testing.expect(!isVulkanDisabled(@ptrFromInt(0x1000)));
}
40 changes: 37 additions & 3 deletions src/intent_env.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,21 @@ pub const keys = [_]Key{
.{ .name = "LABELLE_PROFILE", .debuggable_only = false },
.{ .name = "LABELLE_SCREENSHOT_PATH", .debuggable_only = true },
.{ .name = "LABELLE_SCREENSHOT_AFTER_SEC", .debuggable_only = true },
// The renderer override (labelle-android#27, labelle-bgfx#172 D3): a
// test/debug channel, so debuggable builds only. `renderer.zig` decides
// the final `LABELLE_BGFX_RENDERER` after this runs (and re-applies the
// same gate and validates the value).
.{ .name = "LABELLE_BGFX_RENDERER", .debuggable_only = true },
};

/// Index of `name` in `keys` (comptime: a typo is a compile error).
pub fn indexOf(comptime name: []const u8) usize {
inline for (keys, 0..) |k, i| {
if (comptime std.mem.eql(u8, k.name, name)) return i;
}
@compileError("not an allow-listed intent key: " ++ name);
}

/// Is `name` one of the keys copied from the intent?
pub fn isAllowed(name: []const u8) bool {
for (keys) |k| {
Expand Down Expand Up @@ -189,15 +202,17 @@ fn extrasWith(pairs: []const struct { [:0]const u8, [:0]const u8 }) [keys.len]?[
return out;
}

test "the allow-list is exactly the four run-option keys" {
test "the allow-list is exactly the four run-option keys plus the renderer override" {
try testing.expect(isAllowed("LABELLE_SCENE"));
try testing.expect(isAllowed("LABELLE_PROFILE"));
try testing.expect(isAllowed("LABELLE_SCREENSHOT_PATH"));
try testing.expect(isAllowed("LABELLE_SCREENSHOT_AFTER_SEC"));
try testing.expectEqual(@as(usize, 4), keys.len);
try testing.expect(isAllowed("LABELLE_BGFX_RENDERER"));
try testing.expect(keys[indexOf("LABELLE_BGFX_RENDERER")].debuggable_only);
try testing.expectEqual(@as(usize, 5), keys.len);
// Other labelle knobs and arbitrary env names are not copied.
try testing.expect(!isAllowed("LABELLE_FIXED_DT"));
try testing.expect(!isAllowed("LABELLE_BGFX_RENDERER"));
try testing.expect(!isAllowed("LABELLE_BGFX_BACKEND"));
try testing.expect(!isAllowed("LD_PRELOAD"));
try testing.expect(!isAllowed("PATH"));
// Exact match only: no prefix, case or whitespace slack.
Expand Down Expand Up @@ -248,6 +263,25 @@ test "screenshot extras need a debuggable apk; debuggable is asked once" {
try testing.expectEqual(@as(usize, 1), debug.debuggable_calls);
}

test "the renderer override needs a debuggable apk and is reverted on a plain relaunch" {
const extras = extrasWith(&.{.{ "LABELLE_BGFX_RENDERER", "vulkan" }});

var state: State = .{};
var release: FakeEnv = .{ .is_debuggable = false };
apply(&state, extras, &release);
try testing.expect(release.get("LABELLE_BGFX_RENDERER") == null);
try testing.expectEqual(@as(usize, 1), release.debuggable_calls);

var state2: State = .{};
var debug: FakeEnv = .{ .is_debuggable = true };
apply(&state2, extras, &debug);
try testing.expectEqualStrings("vulkan", debug.get("LABELLE_BGFX_RENDERER").?);
try testing.expect(state2.set_by_intent[indexOf("LABELLE_BGFX_RENDERER")]);
apply(&state2, @splat(null), &debug);
try testing.expect(debug.get("LABELLE_BGFX_RENDERER") == null);
try testing.expect(!state2.set_by_intent[indexOf("LABELLE_BGFX_RENDERER")]);
}

test "an empty extra is treated as absent" {
var state: State = .{};
var env: FakeEnv = .{};
Expand Down
158 changes: 158 additions & 0 deletions src/jni/renderer_query.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// The two device/package questions the launch-time renderer resolution asks
// (labelle-android#27, labelle-bgfx#172 D1-D3). The pure decision is
// `renderer.zig`; this is the JNI half:
//
// * the provider setting the packager stamps into the manifest
// (`<meta-data android:name="labelle.renderer" android:value="..."/>`,
// labelle-android#26):
// getPackageManager()
// .getApplicationInfo(getPackageName(), GET_META_DATA)
// .metaData.getString("labelle.renderer")
// * `auto`'s device check:
// getPackageManager().hasSystemFeature("android.hardware.vulkan.version",
// 0x401000) // Vulkan 1.1
//
// In C for the same reason as intent_extras.c: <jni.h> already declares the
// JNI vtables. Off Android this is an empty TU.
#ifdef __ANDROID__

#include <android/log.h>
#include <android/native_activity.h>
#include <jni.h>
#include <stddef.h>

// android.content.pm.PackageManager.GET_META_DATA (API 1).
#define LABELLE_GET_META_DATA 0x00000080

// intent_extras.c: attach-if-detached, reporting what we attached.
JNIEnv *labelle_android_acquire_env(JavaVM *vm, int *we_attached);

// `activity.getPackageManager()`, or NULL (possibly with an exception
// pending; the callers clear it). A local ref inside the caller's frame.
static jobject package_manager(JNIEnv *env, jobject activity) {
jclass activity_cls = (*env)->GetObjectClass(env, activity);
if (activity_cls == NULL) return NULL;
jmethodID get_pm = (*env)->GetMethodID(env, activity_cls, "getPackageManager", "()Landroid/content/pm/PackageManager;");
if (get_pm == NULL || (*env)->ExceptionCheck(env)) return NULL;
jobject pm = (*env)->CallObjectMethod(env, activity, get_pm);
if ((*env)->ExceptionCheck(env)) return NULL;
return pm;
}

// Copy the `labelle.renderer` meta-data string into `buf` (NUL-terminated).
// Returns its length (>= 0), -1 when the key is absent (or not a string),
// -2 on any JNI failure, -3 when the value does not fit in `buf_cap`.
// Never leaves a Java exception pending and never leaks a local ref.
// Callable from any thread (attached or not).
int labelle_android_read_renderer_meta(const void *activity_ptr, char *buf, size_t buf_cap) {
const ANativeActivity *na = (const ANativeActivity *)activity_ptr;
if (na == NULL || na->vm == NULL || na->clazz == NULL || buf == NULL || buf_cap == 0) return -2;
JavaVM *vm = na->vm;
jobject activity = na->clazz;

int we_attached = 0;
JNIEnv *env = labelle_android_acquire_env(vm, &we_attached);
if (env == NULL) return -2;

int result = -2;
if ((*env)->PushLocalFrame(env, 16) == JNI_OK) {
jobject pm = package_manager(env, activity);
jclass activity_cls = (pm != NULL) ? (*env)->GetObjectClass(env, activity) : NULL;
jmethodID get_pkg = activity_cls ? (*env)->GetMethodID(env, activity_cls, "getPackageName", "()Ljava/lang/String;") : NULL;
jstring pkg = (get_pkg && !(*env)->ExceptionCheck(env)) ? (jstring)(*env)->CallObjectMethod(env, activity, get_pkg) : NULL;
jclass pm_cls = (pkg && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, pm) : NULL;
// The (String, int) overload: deprecated from API 33 in favour of
// the ApplicationInfoFlags one, but still present and working.
jmethodID get_app_info = pm_cls ? (*env)->GetMethodID(env, pm_cls, "getApplicationInfo", "(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;") : NULL;
// Throws NameNotFoundException only if our own package vanished.
jobject app_info = (get_app_info && !(*env)->ExceptionCheck(env)) ? (*env)->CallObjectMethod(env, pm, get_app_info, pkg, (jint)LABELLE_GET_META_DATA) : NULL;
if (app_info != NULL && !(*env)->ExceptionCheck(env)) {
jclass app_info_cls = (*env)->GetObjectClass(env, app_info);
jfieldID meta_fid = app_info_cls ? (*env)->GetFieldID(env, app_info_cls, "metaData", "Landroid/os/Bundle;") : NULL;
if (meta_fid != NULL && !(*env)->ExceptionCheck(env)) {
jobject bundle = (*env)->GetObjectField(env, app_info, meta_fid);
if (bundle == NULL) {
// No <meta-data> at all in <application>.
result = -1;
} else {
jclass bundle_cls = (*env)->GetObjectClass(env, bundle);
jmethodID get_string = bundle_cls ? (*env)->GetMethodID(env, bundle_cls, "getString", "(Ljava/lang/String;)Ljava/lang/String;") : NULL;
jstring jkey = (get_string && !(*env)->ExceptionCheck(env)) ? (*env)->NewStringUTF(env, "labelle.renderer") : NULL;
jstring jval = (jkey && !(*env)->ExceptionCheck(env)) ? (jstring)(*env)->CallObjectMethod(env, bundle, get_string, jkey) : NULL;
if (jkey != NULL && !(*env)->ExceptionCheck(env)) {
if (jval == NULL) {
// Missing key, or a non-string value.
result = -1;
} else {
// The accepted values are ASCII, so modified
// UTF-8 is byte-identical for every valid one.
jsize chars = (*env)->GetStringLength(env, jval);
jsize bytes = (*env)->GetStringUTFLength(env, jval);
if (bytes < 0 || (size_t)bytes + 1 > buf_cap) {
result = -3;
} else {
(*env)->GetStringUTFRegion(env, jval, 0, chars, buf);
if (!(*env)->ExceptionCheck(env)) {
buf[bytes] = 0;
result = (int)bytes;
}
}
}
}
}
}
}
// Any JNI call above may have raised; clear before popping so we
// never hand a pending exception back to the caller's thread.
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
result = -2;
}
(*env)->PopLocalFrame(env, NULL);
} else if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
__android_log_print(ANDROID_LOG_WARN, "labelle-android", "renderer meta-data: PushLocalFrame failed; exception cleared");
}

if (we_attached) (*vm)->DetachCurrentThread(vm);
return result;
}

// `PackageManager.hasSystemFeature(name, version)` (API 24+). Returns 1 when
// the device reports the feature at `version` or above, 0 otherwise — and 0
// on any JNI failure: `auto` then falls back to GLES, the safe choice.
int labelle_android_has_system_feature(const void *activity_ptr, const char *name, int version) {
const ANativeActivity *na = (const ANativeActivity *)activity_ptr;
if (na == NULL || na->vm == NULL || na->clazz == NULL || name == NULL) return 0;
JavaVM *vm = na->vm;
jobject activity = na->clazz;

int we_attached = 0;
JNIEnv *env = labelle_android_acquire_env(vm, &we_attached);
if (env == NULL) return 0;

int has = 0;
if ((*env)->PushLocalFrame(env, 8) == JNI_OK) {
jobject pm = package_manager(env, activity);
jclass pm_cls = (pm && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, pm) : NULL;
jmethodID has_feature = pm_cls ? (*env)->GetMethodID(env, pm_cls, "hasSystemFeature", "(Ljava/lang/String;I)Z") : NULL;
jstring jname = (has_feature && !(*env)->ExceptionCheck(env)) ? (*env)->NewStringUTF(env, name) : NULL;
if (jname != NULL && !(*env)->ExceptionCheck(env)) {
jboolean r = (*env)->CallBooleanMethod(env, pm, has_feature, jname, (jint)version);
if (!(*env)->ExceptionCheck(env)) has = r ? 1 : 0;
}
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
has = 0;
}
(*env)->PopLocalFrame(env, NULL);
} else if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
__android_log_print(ANDROID_LOG_WARN, "labelle-android", "system feature: PushLocalFrame failed; exception cleared");
}

if (we_attached) (*vm)->DetachCurrentThread(vm);
return has;
}

#endif /* __ANDROID__ */
10 changes: 10 additions & 0 deletions src/launch_intent.zig
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@
//! which only that loop delivers, so the env is complete first.
//! * sokol: from the top of the generated `sokol_main()` on the UI thread
//! (already attached), before the render thread that runs `init` exists.
//!
//! `apply` ends with `renderer.resolve` (labelle-android#27), which decides
//! and exports `LABELLE_BGFX_RENDERER`; it runs last so its value is final
//! (it overrides whatever the raw intent extra set) and, like the extras, it
//! lands before bgfx is initialised on the first INIT_WINDOW.

const std = @import("std");
const intent_env = @import("intent_env.zig");
const debuggable_mod = @import("debuggable.zig");
const renderer = @import("renderer.zig");
const is_android = @import("root.zig").is_android;

// `jni/intent_extras.c` — only referenced on Android, where build.zig
Expand Down Expand Up @@ -90,6 +96,7 @@ pub fn apply(activity: ?*const anyopaque) void {
// Still revert what an earlier launch's intent set in this process
// (all-absent extras only ever revert/keep, never set).
intent_env.apply(&state, @splat(null), LibcEnv{ .activity = a });
renderer.resolve(a, null);
return;
}
var extras: [intent_env.keys.len]?[:0]const u8 = @splat(null);
Expand All @@ -102,6 +109,9 @@ pub fn apply(activity: ?*const anyopaque) void {
off += n + 1;
}
intent_env.apply(&state, extras, LibcEnv{ .activity = a });
// LAST: the renderer decision (intent override → crash guard → provider
// setting → auto) overwrites the raw extra with the validated value.
renderer.resolve(a, extras[intent_env.indexOf(renderer.env_name)]);
}

test "apply is a no-op off Android (and never touches the externs)" {
Expand Down
Loading
Loading