nandi/jolt-nativepublic Fork 0
fd0e21a
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Let the media plane cross to the phone, camera and all

libjoltmoq was desktop-only because its capture end is: V4L2 for the
camera, PipeWire for the microphone, neither of which a phone has. The
rest of it — MoQ over QUIC, Opus, H.264 — never cared what it was running
on. So what moves here is only the two ends.

The camera becomes Camera2, reached over JNI. `android_camera.rs` and
`nv12_orient.rs` come from sleek, kept close enough to their originals
that a fix still travels between the two by eye. The microphone becomes
cpal's AAudio host, which needs nothing said about it beyond letting the
crate build.

`android_jni.rs` is the one part sleek has no counterpart for. It is one
shared object, so its bridge reads the JavaVM and the Activity out of the
AndroidApp android-activity handed it. Here the glue is in libvidya.so and
the media plane is not, and the usual way across does not work: the
handles ndk_context holds live in a static, and a static is per object, so
each cdylib gets its own and the one this library would read is the one
nobody set. They are pushed instead — libvidya exposes them, jolt_main.c
is the only place both objects are in scope, and joltmoq_android_init
takes them. It fills in ndk_context too, for cpal, which reaches for the
VM that way and panics on the audio thread the moment a call starts if it
is empty.

Three things were already broken and only now had anything that would
notice. The NDK shim put its --target ahead of the caller's arguments, so
cc-rs's own — which carries no API level — won every build script's C
compile and dropped it to a bionic with no getentropy. aws-lc-sys and
openh264-sys2 compile their C and C++ into OUT_DIR and tell cargo to link
it; buck2 keeps the cfgs and drops the link directives, so both archives
sat there unreferenced and 133 symbols stayed undefined in every
buck2-built libjoltmoq.so, desktop included. Undefined symbols are legal
in a cdylib and nothing had ever loaded one, so it took a phone refusing
to dlopen it to say so. The worker image moves to 24.04 for the glibc
floor frq already found from the other side.

Verified on a Pixel 6a: the handles arrive non-null, the front camera
opens, and NV12 frames reach the publish path at 24fps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-31T21:34:32-07:00 Browse files
fd0e21a parent: aabacba
modified BUCK +24 -4
@@ -21,10 +21,9 @@ filegroup(
2121 visibility = ["PUBLIC"],
2222 )
2323
24-# The same C ABI, cross-compiled for the phone. The sources and the deps are
25-# the ones above; only the target configuration moves, which is what
26-# configured_alias is for — build scripts and proc macros still run on the
27-# host. There is no Android libjoltmoq: the media plane is V4L2 and PipeWire.
24+# The same two C ABIs, cross-compiled for the phone. The sources and the deps
25+# are the ones above; only the target configuration moves, which is what
26+# configured_alias is for — build scripts and proc macros still run on the host.
2827 configured_alias(
2928 name = "vidya-ffi-android",
3029 actual = "//crates/jolt-vidya:vidya-ffi",
@@ -38,3 +37,24 @@ genrule(
3837 cmd = "cp $(location :vidya-ffi-android[shared]) $OUT",
3938 visibility = ["PUBLIC"],
4039 )
40+
41+configured_alias(
42+ name = "jolt-moq-android",
43+ actual = "//crates/jolt-moq:jolt-moq",
44+ platform = "//platforms:android-arm64",
45+ visibility = ["PUBLIC"],
46+)
47+
48+genrule(
49+ name = "libjoltmoq-android",
50+ out = "libjoltmoq.so",
51+ cmd = "cp $(location :jolt-moq-android[shared]) $OUT",
52+ visibility = ["PUBLIC"],
53+)
54+
55+# Both device objects, for a build that wants them in one go.
56+filegroup(
57+ name = "libs-android",
58+ srcs = [":libjoltmoq-android", ":libvidya-android"],
59+ visibility = ["PUBLIC"],
60+)
@@ -21,10 +21,9 @@ filegroup(
21 visibility = ["PUBLIC"],21 visibility = ["PUBLIC"],
22 )22 )
23 23
24-# The same C ABI, cross-compiled for the phone. The sources and the deps are24+# The same two C ABIs, cross-compiled for the phone. The sources and the deps
25-# the ones above; only the target configuration moves, which is what25+# are the ones above; only the target configuration moves, which is what
26-# configured_alias is for — build scripts and proc macros still run on the26+# configured_alias is for — build scripts and proc macros still run on the host.
27-# host. There is no Android libjoltmoq: the media plane is V4L2 and PipeWire.
28 configured_alias(27 configured_alias(
29 name = "vidya-ffi-android",28 name = "vidya-ffi-android",
30 actual = "//crates/jolt-vidya:vidya-ffi",29 actual = "//crates/jolt-vidya:vidya-ffi",
@@ -38,3 +37,24 @@ genrule(
38 cmd = "cp $(location :vidya-ffi-android[shared]) $OUT",37 cmd = "cp $(location :vidya-ffi-android[shared]) $OUT",
39 visibility = ["PUBLIC"],38 visibility = ["PUBLIC"],
40 )39 )
40+
41+configured_alias(
42+ name = "jolt-moq-android",
43+ actual = "//crates/jolt-moq:jolt-moq",
44+ platform = "//platforms:android-arm64",
45+ visibility = ["PUBLIC"],
46+)
47+
48+genrule(
49+ name = "libjoltmoq-android",
50+ out = "libjoltmoq.so",
51+ cmd = "cp $(location :jolt-moq-android[shared]) $OUT",
52+ visibility = ["PUBLIC"],
53+)
54+
55+# Both device objects, for a build that wants them in one go.
56+filegroup(
57+ name = "libs-android",
58+ srcs = [":libjoltmoq-android", ":libvidya-android"],
59+ visibility = ["PUBLIC"],
60+)
modified Cargo.lock +3 -0
@@ -3242,12 +3242,14 @@ dependencies = [
32423242 "env_logger",
32433243 "image",
32443244 "iroh-live",
3245+ "jni 0.22.4",
32453246 "jolt-abi",
32463247 "libc",
32473248 "log",
32483249 "moq-lite",
32493250 "moq-native",
32503251 "n0-future",
3252+ "ndk-context",
32513253 "rand 0.8.7",
32523254 "rustls",
32533255 "tokio",
@@ -5698,6 +5700,7 @@ dependencies = [
56985700 "image",
56995701 "libc",
57005702 "n0-future",
5703+ "ndk",
57015704 "openh264",
57025705 "pic-scale",
57035706 "rubato 1.0.1",
@@ -3242,12 +3242,14 @@ dependencies = [
3242 "env_logger",3242 "env_logger",
3243 "image",3243 "image",
3244 "iroh-live",3244 "iroh-live",
3245+ "jni 0.22.4",
3245 "jolt-abi",3246 "jolt-abi",
3246 "libc",3247 "libc",
3247 "log",3248 "log",
3248 "moq-lite",3249 "moq-lite",
3249 "moq-native",3250 "moq-native",
3250 "n0-future",3251 "n0-future",
3252+ "ndk-context",
3251 "rand 0.8.7",3253 "rand 0.8.7",
3252 "rustls",3254 "rustls",
3253 "tokio",3255 "tokio",
@@ -5698,6 +5700,7 @@ dependencies = [
5698 "image",5700 "image",
5699 "libc",5701 "libc",
5700 "n0-future",5702 "n0-future",
5703+ "ndk",
5701 "openh264",5704 "openh264",
5702 "pic-scale",5705 "pic-scale",
5703 "rubato 1.0.1",5706 "rubato 1.0.1",
modified README.md +9 -2
@@ -97,8 +97,8 @@ call than this does. The shape of it:
9797
9898 ### Its relationship to sleek
9999
100-`av.rs`, `av_media.rs` and `v4l2cam.rs` came from sleek's `android/src`. They
101-are kept close to their originals so changes can still be moved between the
100+`av.rs`, `av_media.rs`, `v4l2cam.rs`, `android_camera.rs` and `nv12_orient.rs`
101+came from sleek's `android/src`. They are kept close to their originals so changes can still be moved between the
102102 two by eye. What was dropped on the way was the signaling-and-presentation
103103 half — `ChannelCall`, `LocalCall`, `apply_av_state`, `av_state_message`
104104 which is sleek's own app state and belongs to a client, not to a media plane.
@@ -108,6 +108,13 @@ Sleek still has its own copy. Pointing it at this crate instead is the obvious
108108 next step and has not been taken yet; until it is, a fix made in one place
109109 needs making in the other.
110110
111+`android_camera.rs` is the one that did not come across unchanged. Sleek is a
112+single shared object, so its Camera2 bridge reads the `JavaVM` and the Activity
113+straight out of the `AndroidApp` that android-activity handed it. Here the glue
114+is in `libvidya.so` and the media plane is not, so the handles arrive through
115+`joltmoq_android_init` instead — see `android_jni.rs` for why the usual bridge,
116+`ndk_context`, cannot cross two cdylibs. Everything below that line is sleek's.
117+
111118 ## vidya
112119
113120 The theme layer, the widget set and the retained-tree ABI that glimmer paints
@@ -97,8 +97,8 @@ call than this does. The shape of it:
97 97
98 ### Its relationship to sleek98 ### Its relationship to sleek
99 99
100-`av.rs`, `av_media.rs` and `v4l2cam.rs` came from sleek's `android/src`. They100+`av.rs`, `av_media.rs`, `v4l2cam.rs`, `android_camera.rs` and `nv12_orient.rs`
101-are kept close to their originals so changes can still be moved between the101+came from sleek's `android/src`. They are kept close to their originals so changes can still be moved between the
102 two by eye. What was dropped on the way was the signaling-and-presentation102 two by eye. What was dropped on the way was the signaling-and-presentation
103 half — `ChannelCall`, `LocalCall`, `apply_av_state`, `av_state_message`103 half — `ChannelCall`, `LocalCall`, `apply_av_state`, `av_state_message`
104 which is sleek's own app state and belongs to a client, not to a media plane.104 which is sleek's own app state and belongs to a client, not to a media plane.
@@ -108,6 +108,13 @@ Sleek still has its own copy. Pointing it at this crate instead is the obvious
108 next step and has not been taken yet; until it is, a fix made in one place108 next step and has not been taken yet; until it is, a fix made in one place
109 needs making in the other.109 needs making in the other.
110 110
111+`android_camera.rs` is the one that did not come across unchanged. Sleek is a
112+single shared object, so its Camera2 bridge reads the `JavaVM` and the Activity
113+straight out of the `AndroidApp` that android-activity handed it. Here the glue
114+is in `libvidya.so` and the media plane is not, so the handles arrive through
115+`joltmoq_android_init` instead — see `android_jni.rs` for why the usual bridge,
116+`ndk_context`, cannot cross two cdylibs. Everything below that line is sleek's.
117+
111 ## vidya118 ## vidya
112 119
113 The theme layer, the widget set and the retained-tree ABI that glimmer paints120 The theme layer, the widget set and the retained-tree ABI that glimmer paints
modified android/jolt_main.c +81 -5
@@ -8,12 +8,18 @@
88 * `vidya_jolt_main`, so the entry point below is an ordinary C function rather
99 * than a `main`.
1010 *
11- * Every Vidya symbol is registered with Chez explicitly. Jolt resolves a
11+ * Every foreign symbol is registered with Chez explicitly. Jolt resolves a
1212 * `defcfn` through Chez's foreign-entry table, and on Android nothing populates
13- * that table from the dynamic loader: the symbols live in a *different* shared
14- * object (`libvidya.so`, a DT_NEEDED of this one), which is exactly the case
15- * the loader will not answer for. Registering them by hand is what makes both
16- * ABIs the push/pop one and the retained tree one glimmer binds reachable.
13+ * that table from the dynamic loader: the symbols live in *different* shared
14+ * objects (`libvidya.so` and `libjoltmoq.so`, both DT_NEEDEDs of this one),
15+ * which is exactly the case the loader will not answer for. Registering them by
16+ * hand is what makes all three ABIs the push/pop one, the retained tree one
17+ * glimmer binds, and the media plane reachable.
18+ *
19+ * The media plane needs one thing more than a symbol table. Its camera is
20+ * Camera2, reached over JNI, and the handles that takes only arrive in
21+ * android-activity's glue inside libvidya. This file is the only place both
22+ * objects are in scope, so it is where they are handed across.
1723 */
1824
1925 #include <stddef.h>
@@ -23,6 +29,7 @@
2329 #include <android/log.h>
2430 #include <pthread.h>
2531
32+#include "joltmoq.h"
2633 #include "scheme.h"
2734 #include "vidya.h"
2835 #include "vidya_tree.h"
@@ -32,6 +39,7 @@ extern const unsigned char _binary_jolt_boot_start[];
3239 extern const unsigned char _binary_jolt_boot_end[];
3340
3441 #define REGISTER_VIDYA(name) Sregister_symbol(#name, (void *)&name)
42+#define REGISTER_JOLTMOQ(name) Sregister_symbol(#name, (void *)&name)
3543
3644 static void register_vidya_api(void) {
3745 /* vidya.h */
@@ -106,6 +114,57 @@ static void register_vidya_api(void) {
106114 REGISTER_VIDYA(vidya_tree_dump);
107115 }
108116
117+/*
118+ * The media plane's symbols, registered for the same reason the Vidya ones
119+ * above are: they live in libjoltmoq.so, a third object, and Android's loader
120+ * will not answer a foreign-entry lookup that crosses one.
121+ *
122+ * A call reaches none of this without it — `joltmoq_start` would be a defcfn
123+ * that resolves to nothing at the moment someone dials.
124+ */
125+static void register_joltmoq_api(void) {
126+ REGISTER_JOLTMOQ(joltmoq_init_logging);
127+
128+ /* Lifecycle. */
129+ REGISTER_JOLTMOQ(joltmoq_start);
130+ REGISTER_JOLTMOQ(joltmoq_stop);
131+ REGISTER_JOLTMOQ(joltmoq_is_live);
132+
133+ /* In-call controls. */
134+ REGISTER_JOLTMOQ(joltmoq_set_muted);
135+ REGISTER_JOLTMOQ(joltmoq_set_speaker_muted);
136+ REGISTER_JOLTMOQ(joltmoq_set_camera);
137+ REGISTER_JOLTMOQ(joltmoq_set_camera_device);
138+ REGISTER_JOLTMOQ(joltmoq_set_mic_device);
139+ REGISTER_JOLTMOQ(joltmoq_set_speaker_device);
140+ REGISTER_JOLTMOQ(joltmoq_mic_level);
141+
142+ /* Status, drained rather than delivered. */
143+ REGISTER_JOLTMOQ(joltmoq_poll_status);
144+ REGISTER_JOLTMOQ(joltmoq_status_text);
145+ REGISTER_JOLTMOQ(joltmoq_status_has_camera);
146+ REGISTER_JOLTMOQ(joltmoq_status_has_mic);
147+
148+ /* Remote video, borrowed a frame at a time. */
149+ REGISTER_JOLTMOQ(joltmoq_frame_poll);
150+ REGISTER_JOLTMOQ(joltmoq_frame_key);
151+ REGISTER_JOLTMOQ(joltmoq_frame_width);
152+ REGISTER_JOLTMOQ(joltmoq_frame_height);
153+ REGISTER_JOLTMOQ(joltmoq_frame_rgba);
154+ REGISTER_JOLTMOQ(joltmoq_video_keys);
155+
156+ /* Devices. On the phone the cameras come from Camera2 and the microphone
157+ and speaker lists are empty — the platform picks those itself. */
158+ REGISTER_JOLTMOQ(joltmoq_cameras);
159+ REGISTER_JOLTMOQ(joltmoq_microphones);
160+ REGISTER_JOLTMOQ(joltmoq_speakers);
161+
162+ /* Dialling. */
163+ REGISTER_JOLTMOQ(joltmoq_sfu_url);
164+ REGISTER_JOLTMOQ(joltmoq_can_dial);
165+ REGISTER_JOLTMOQ(joltmoq_new_instance);
166+}
167+
109168 /*
110169 * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into
111170 * logcat so a Scheme-level error is visible rather than a silent exit.
@@ -145,6 +204,23 @@ int vidya_jolt_main(void) {
145204 (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start));
146205 Sbuild_heap(NULL, NULL);
147206 register_vidya_api();
207+ register_joltmoq_api();
208+
209+ /* The media plane's own handles, which it cannot get for itself: they arrive
210+ in android-activity's glue, and the glue is in libvidya.so. This is the
211+ only place both objects are in scope. Before Scheme starts, so no call can
212+ reach the camera ahead of it.
213+
214+ Logged because there is otherwise no way to see it happen: joltmoq's own
215+ logging goes through env_logger, which nothing has initialised this early,
216+ so a failure here would be silent until a camera failed to open much later
217+ for reasons that would look like anything but this. */
218+ void *moq_vm = vidya_android_vm();
219+ void *moq_activity = vidya_android_activity();
220+ __android_log_print(moq_vm && moq_activity ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR,
221+ "VidyaJolt", "media plane JNI handles: vm=%p activity=%p",
222+ moq_vm, moq_activity);
223+ joltmoq_android_init(moq_vm, moq_activity);
148224
149225 __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application");
150226 int status = Sscheme_start(1, argv);
@@ -8,12 +8,18 @@
8 * `vidya_jolt_main`, so the entry point below is an ordinary C function rather8 * `vidya_jolt_main`, so the entry point below is an ordinary C function rather
9 * than a `main`.9 * than a `main`.
10 *10 *
11- * Every Vidya symbol is registered with Chez explicitly. Jolt resolves a11+ * Every foreign symbol is registered with Chez explicitly. Jolt resolves a
12 * `defcfn` through Chez's foreign-entry table, and on Android nothing populates12 * `defcfn` through Chez's foreign-entry table, and on Android nothing populates
13- * that table from the dynamic loader: the symbols live in a *different* shared13+ * that table from the dynamic loader: the symbols live in *different* shared
14- * object (`libvidya.so`, a DT_NEEDED of this one), which is exactly the case14+ * objects (`libvidya.so` and `libjoltmoq.so`, both DT_NEEDEDs of this one),
15- * the loader will not answer for. Registering them by hand is what makes both15+ * which is exactly the case the loader will not answer for. Registering them by
16- * ABIs the push/pop one and the retained tree one glimmer binds reachable.16+ * hand is what makes all three ABIs the push/pop one, the retained tree one
17+ * glimmer binds, and the media plane reachable.
18+ *
19+ * The media plane needs one thing more than a symbol table. Its camera is
20+ * Camera2, reached over JNI, and the handles that takes only arrive in
21+ * android-activity's glue inside libvidya. This file is the only place both
22+ * objects are in scope, so it is where they are handed across.
17 */23 */
18 24
19 #include <stddef.h>25 #include <stddef.h>
@@ -23,6 +29,7 @@
23 #include <android/log.h>29 #include <android/log.h>
24 #include <pthread.h>30 #include <pthread.h>
25 31
32+#include "joltmoq.h"
26 #include "scheme.h"33 #include "scheme.h"
27 #include "vidya.h"34 #include "vidya.h"
28 #include "vidya_tree.h"35 #include "vidya_tree.h"
@@ -32,6 +39,7 @@ extern const unsigned char _binary_jolt_boot_start[];
32 extern const unsigned char _binary_jolt_boot_end[];39 extern const unsigned char _binary_jolt_boot_end[];
33 40
34 #define REGISTER_VIDYA(name) Sregister_symbol(#name, (void *)&name)41 #define REGISTER_VIDYA(name) Sregister_symbol(#name, (void *)&name)
42+#define REGISTER_JOLTMOQ(name) Sregister_symbol(#name, (void *)&name)
35 43
36 static void register_vidya_api(void) {44 static void register_vidya_api(void) {
37 /* vidya.h */45 /* vidya.h */
@@ -106,6 +114,57 @@ static void register_vidya_api(void) {
106 REGISTER_VIDYA(vidya_tree_dump);114 REGISTER_VIDYA(vidya_tree_dump);
107 }115 }
108 116
117+/*
118+ * The media plane's symbols, registered for the same reason the Vidya ones
119+ * above are: they live in libjoltmoq.so, a third object, and Android's loader
120+ * will not answer a foreign-entry lookup that crosses one.
121+ *
122+ * A call reaches none of this without it — `joltmoq_start` would be a defcfn
123+ * that resolves to nothing at the moment someone dials.
124+ */
125+static void register_joltmoq_api(void) {
126+ REGISTER_JOLTMOQ(joltmoq_init_logging);
127+
128+ /* Lifecycle. */
129+ REGISTER_JOLTMOQ(joltmoq_start);
130+ REGISTER_JOLTMOQ(joltmoq_stop);
131+ REGISTER_JOLTMOQ(joltmoq_is_live);
132+
133+ /* In-call controls. */
134+ REGISTER_JOLTMOQ(joltmoq_set_muted);
135+ REGISTER_JOLTMOQ(joltmoq_set_speaker_muted);
136+ REGISTER_JOLTMOQ(joltmoq_set_camera);
137+ REGISTER_JOLTMOQ(joltmoq_set_camera_device);
138+ REGISTER_JOLTMOQ(joltmoq_set_mic_device);
139+ REGISTER_JOLTMOQ(joltmoq_set_speaker_device);
140+ REGISTER_JOLTMOQ(joltmoq_mic_level);
141+
142+ /* Status, drained rather than delivered. */
143+ REGISTER_JOLTMOQ(joltmoq_poll_status);
144+ REGISTER_JOLTMOQ(joltmoq_status_text);
145+ REGISTER_JOLTMOQ(joltmoq_status_has_camera);
146+ REGISTER_JOLTMOQ(joltmoq_status_has_mic);
147+
148+ /* Remote video, borrowed a frame at a time. */
149+ REGISTER_JOLTMOQ(joltmoq_frame_poll);
150+ REGISTER_JOLTMOQ(joltmoq_frame_key);
151+ REGISTER_JOLTMOQ(joltmoq_frame_width);
152+ REGISTER_JOLTMOQ(joltmoq_frame_height);
153+ REGISTER_JOLTMOQ(joltmoq_frame_rgba);
154+ REGISTER_JOLTMOQ(joltmoq_video_keys);
155+
156+ /* Devices. On the phone the cameras come from Camera2 and the microphone
157+ and speaker lists are empty — the platform picks those itself. */
158+ REGISTER_JOLTMOQ(joltmoq_cameras);
159+ REGISTER_JOLTMOQ(joltmoq_microphones);
160+ REGISTER_JOLTMOQ(joltmoq_speakers);
161+
162+ /* Dialling. */
163+ REGISTER_JOLTMOQ(joltmoq_sfu_url);
164+ REGISTER_JOLTMOQ(joltmoq_can_dial);
165+ REGISTER_JOLTMOQ(joltmoq_new_instance);
166+}
167+
109 /*168 /*
110 * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into169 * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into
111 * logcat so a Scheme-level error is visible rather than a silent exit.170 * logcat so a Scheme-level error is visible rather than a silent exit.
@@ -145,6 +204,23 @@ int vidya_jolt_main(void) {
145 (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start));204 (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start));
146 Sbuild_heap(NULL, NULL);205 Sbuild_heap(NULL, NULL);
147 register_vidya_api();206 register_vidya_api();
207+ register_joltmoq_api();
208+
209+ /* The media plane's own handles, which it cannot get for itself: they arrive
210+ in android-activity's glue, and the glue is in libvidya.so. This is the
211+ only place both objects are in scope. Before Scheme starts, so no call can
212+ reach the camera ahead of it.
213+
214+ Logged because there is otherwise no way to see it happen: joltmoq's own
215+ logging goes through env_logger, which nothing has initialised this early,
216+ so a failure here would be silent until a camera failed to open much later
217+ for reasons that would look like anything but this. */
218+ void *moq_vm = vidya_android_vm();
219+ void *moq_activity = vidya_android_activity();
220+ __android_log_print(moq_vm && moq_activity ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR,
221+ "VidyaJolt", "media plane JNI handles: vm=%p activity=%p",
222+ moq_vm, moq_activity);
223+ joltmoq_android_init(moq_vm, moq_activity);
148 224
149 __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application");225 __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application");
150 int status = Sscheme_start(1, argv);226 int status = Sscheme_start(1, argv);
modified crates/jolt-moq/BUCK +26 -3
@@ -1,4 +1,10 @@
11 # The AV media plane behind a C ABI: libjoltmoq.so.
2+#
3+# It cross-compiles now. What moves on the device is the capture end and only
4+# the capture end: the camera is Camera2 over JNI rather than V4L2, and the
5+# microphone is cpal's AAudio host rather than PipeWire. MoQ, QUIC, Opus and
6+# H.264 are the same code on both. The selects below mirror the target sections
7+# in Cargo.toml — when one changes, the other has to.
28 rust_library(
39 name = "jolt-moq",
410 srcs = glob(["src/**/*.rs"]),
@@ -8,10 +14,16 @@ rust_library(
814 preferred_linkage = "shared",
915 soname = "libjoltmoq.so",
1016 visibility = ["PUBLIC"],
17+ # jni is renamed in third-party/rust/Cargo.toml — cpal wants 0.21 on the
18+ # same platform and two crates cannot share one alias — so the source's
19+ # `jni` has to be spelled out here. Same as jolt-vidya does.
20+ named_deps = select({
21+ "DEFAULT": {},
22+ "prelude//os:android": {"jni": "//third-party/rust:jni-0-22"},
23+ }),
1124 deps = [
1225 "//crates/jolt-abi:jolt-abi",
1326 "//third-party/rust:anyhow",
14- "//third-party/rust/cpal:cpal",
1527 "//third-party/rust:env_logger",
1628 "//third-party/rust:image",
1729 "//third-party/rust:iroh-live",
@@ -25,6 +37,17 @@ rust_library(
2537 "//third-party/rust:tokio",
2638 "//third-party/rust:url",
2739 "//third-party/rust:urlencoding",
28- "//third-party/rust:v4l2r",
29- ],
40+ ] + select({
41+ # The desktop capture stack. cpal is here for the same reason Cargo.toml
42+ # names it — nothing in this crate calls it; moq-media does — and v4l2r
43+ # is the loopback-safe capture path. Neither has anything to say to a
44+ # phone, whose camera is `src/android_camera.rs`.
45+ "DEFAULT": [
46+ "//third-party/rust/cpal:cpal",
47+ "//third-party/rust:v4l2r",
48+ ],
49+ # ndk-context is cpal's route to the JavaVM, not this crate's; see the
50+ # note in Cargo.toml.
51+ "prelude//os:android": ["//third-party/rust:ndk-context"],
52+ }),
3053 )
@@ -1,4 +1,10 @@
1 # The AV media plane behind a C ABI: libjoltmoq.so.1 # The AV media plane behind a C ABI: libjoltmoq.so.
2+#
3+# It cross-compiles now. What moves on the device is the capture end and only
4+# the capture end: the camera is Camera2 over JNI rather than V4L2, and the
5+# microphone is cpal's AAudio host rather than PipeWire. MoQ, QUIC, Opus and
6+# H.264 are the same code on both. The selects below mirror the target sections
7+# in Cargo.toml — when one changes, the other has to.
2 rust_library(8 rust_library(
3 name = "jolt-moq",9 name = "jolt-moq",
4 srcs = glob(["src/**/*.rs"]),10 srcs = glob(["src/**/*.rs"]),
@@ -8,10 +14,16 @@ rust_library(
8 preferred_linkage = "shared",14 preferred_linkage = "shared",
9 soname = "libjoltmoq.so",15 soname = "libjoltmoq.so",
10 visibility = ["PUBLIC"],16 visibility = ["PUBLIC"],
17+ # jni is renamed in third-party/rust/Cargo.toml — cpal wants 0.21 on the
18+ # same platform and two crates cannot share one alias — so the source's
19+ # `jni` has to be spelled out here. Same as jolt-vidya does.
20+ named_deps = select({
21+ "DEFAULT": {},
22+ "prelude//os:android": {"jni": "//third-party/rust:jni-0-22"},
23+ }),
11 deps = [24 deps = [
12 "//crates/jolt-abi:jolt-abi",25 "//crates/jolt-abi:jolt-abi",
13 "//third-party/rust:anyhow",26 "//third-party/rust:anyhow",
14- "//third-party/rust/cpal:cpal",
15 "//third-party/rust:env_logger",27 "//third-party/rust:env_logger",
16 "//third-party/rust:image",28 "//third-party/rust:image",
17 "//third-party/rust:iroh-live",29 "//third-party/rust:iroh-live",
@@ -25,6 +37,17 @@ rust_library(
25 "//third-party/rust:tokio",37 "//third-party/rust:tokio",
26 "//third-party/rust:url",38 "//third-party/rust:url",
27 "//third-party/rust:urlencoding",39 "//third-party/rust:urlencoding",
28- "//third-party/rust:v4l2r",40+ ] + select({
29- ],41+ # The desktop capture stack. cpal is here for the same reason Cargo.toml
42+ # names it — nothing in this crate calls it; moq-media does — and v4l2r
43+ # is the loopback-safe capture path. Neither has anything to say to a
44+ # phone, whose camera is `src/android_camera.rs`.
45+ "DEFAULT": [
46+ "//third-party/rust/cpal:cpal",
47+ "//third-party/rust:v4l2r",
48+ ],
49+ # ndk-context is cpal's route to the JavaVM, not this crate's; see the
50+ # note in Cargo.toml.
51+ "prelude//os:android": ["//third-party/rust:ndk-context"],
52+ }),
30 )53 )
modified crates/jolt-moq/Cargo.toml +31 -4
@@ -32,7 +32,6 @@ log.workspace = true
3232 iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
3333 "opus",
3434 "h264",
35- "capture-camera",
3635 ] }
3736 moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main", default-features = false, features = [
3837 "noq",
@@ -42,9 +41,6 @@ moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main",
4241 moq-lite = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main" }
4342 # aws-lc-rs rather than ring, to match what moq-native and iroh already pull in.
4443 rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
45-cpal = { path = "../../third-party/rust/cpal" }
46-# V4L2 capture: the fallback path for cameras rusty-capture mishandles.
47-v4l2r = "0.0.7"
4844 # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or
4945 # writes a picture file, so the rest of the format set is dead weight.
5046 image = { version = "0.25", default-features = false, features = ["jpeg"] }
@@ -57,3 +53,34 @@ anyhow = "1"
5753 url = "2"
5854 rand = "0.8"
5955 env_logger = "0.11"
56+
57+# The desktop capture stack. cpal is named only so the `pipewire` feature above
58+# can reach it — nothing here calls cpal; moq-media does, and a feature is how
59+# you reach a dependency's dependency.
60+[target.'cfg(not(target_os = "android"))'.dependencies]
61+cpal = { path = "../../third-party/rust/cpal" }
62+# V4L2 capture: the fallback path for cameras rusty-capture mishandles.
63+v4l2r = "0.0.7"
64+# The camera, on a machine that has one behind an ioctl.
65+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
66+ "capture-camera",
67+] }
68+
69+# The phone. Audio needs nothing named here — cpal's AAudio host is what it
70+# builds on Android, and moq-media pulls cpal in either way.
71+[target.'cfg(target_os = "android")'.dependencies]
72+# Camera2 is Java, and `android_camera` is the JNI half of reaching it. 0.22 to
73+# match the version libvidya binds against, so one APK holds one `jni`.
74+jni = "0.22"
75+# Not for this crate's own use: cpal's AAudio host reads the JavaVM out of
76+# `ndk_context`, and its handles live in a `static` that nothing in *this*
77+# object has ever set. `android_jni` fills it in from the same pair the glue
78+# hands over. See the note there.
79+ndk-context = "0.1"
80+# NOT `capture-camera`: rusty-capture is V4L2 and friends, none of which the
81+# phone has. The `android` feature is a different thing — it links `ndk`, which
82+# rusty-codecs' `format.rs` references under `cfg(android)` for the MediaCodec
83+# path, and without it the crate fails on its own imports.
84+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
85+ "android",
86+] }
@@ -32,7 +32,6 @@ log.workspace = true
32 iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [32 iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
33 "opus",33 "opus",
34 "h264",34 "h264",
35- "capture-camera",
36 ] }35 ] }
37 moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main", default-features = false, features = [36 moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main", default-features = false, features = [
38 "noq",37 "noq",
@@ -42,9 +41,6 @@ moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main",
42 moq-lite = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main" }41 moq-lite = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main" }
43 # aws-lc-rs rather than ring, to match what moq-native and iroh already pull in.42 # aws-lc-rs rather than ring, to match what moq-native and iroh already pull in.
44 rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }43 rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
45-cpal = { path = "../../third-party/rust/cpal" }
46-# V4L2 capture: the fallback path for cameras rusty-capture mishandles.
47-v4l2r = "0.0.7"
48 # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or44 # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or
49 # writes a picture file, so the rest of the format set is dead weight.45 # writes a picture file, so the rest of the format set is dead weight.
50 image = { version = "0.25", default-features = false, features = ["jpeg"] }46 image = { version = "0.25", default-features = false, features = ["jpeg"] }
@@ -57,3 +53,34 @@ anyhow = "1"
57 url = "2"53 url = "2"
58 rand = "0.8"54 rand = "0.8"
59 env_logger = "0.11"55 env_logger = "0.11"
56+
57+# The desktop capture stack. cpal is named only so the `pipewire` feature above
58+# can reach it — nothing here calls cpal; moq-media does, and a feature is how
59+# you reach a dependency's dependency.
60+[target.'cfg(not(target_os = "android"))'.dependencies]
61+cpal = { path = "../../third-party/rust/cpal" }
62+# V4L2 capture: the fallback path for cameras rusty-capture mishandles.
63+v4l2r = "0.0.7"
64+# The camera, on a machine that has one behind an ioctl.
65+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
66+ "capture-camera",
67+] }
68+
69+# The phone. Audio needs nothing named here — cpal's AAudio host is what it
70+# builds on Android, and moq-media pulls cpal in either way.
71+[target.'cfg(target_os = "android")'.dependencies]
72+# Camera2 is Java, and `android_camera` is the JNI half of reaching it. 0.22 to
73+# match the version libvidya binds against, so one APK holds one `jni`.
74+jni = "0.22"
75+# Not for this crate's own use: cpal's AAudio host reads the JavaVM out of
76+# `ndk_context`, and its handles live in a `static` that nothing in *this*
77+# object has ever set. `android_jni` fills it in from the same pair the glue
78+# hands over. See the note there.
79+ndk-context = "0.1"
80+# NOT `capture-camera`: rusty-capture is V4L2 and friends, none of which the
81+# phone has. The `android` feature is a different thing — it links `ndk`, which
82+# rusty-codecs' `format.rs` references under `cfg(android)` for the MediaCodec
83+# path, and without it the crate fails on its own imports.
84+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
85+ "android",
86+] }
modified crates/jolt-moq/include/joltmoq.h +17 -0
@@ -36,6 +36,23 @@ extern "C" {
3636 * about why, and says none of it otherwise. */
3737 void joltmoq_init_logging(void);
3838
39+/* Android only. Hand over the process's JavaVM and its Activity, once, before
40+ * any call starts.
41+ *
42+ * The camera on the phone is Camera2, which is Java, and this library cannot
43+ * reach the handles a JNI call needs: android-activity's glue receives them and
44+ * the glue lives in libvidya.so, a different shared object. `ndk_context` does
45+ * not bridge that — its handles sit in a static, and a static is per object —
46+ * so the APK's glue reads them out of libvidya (vidya_android_vm,
47+ * vidya_android_activity) and passes them here.
48+ *
49+ * Everything else works without it. Only the camera calls fail, and they say
50+ * which call was missed. Not declared on other platforms, where there is
51+ * nothing to hand over. */
52+#ifdef __ANDROID__
53+void joltmoq_android_init(void *vm, void *activity);
54+#endif
55+
3956 /* Lifecycle
4057 *
4158 * `joltmoq_start` joins the call at `sfu_url` (see joltmoq_sfu_url) as `nick`.
@@ -36,6 +36,23 @@ extern "C" {
36 * about why, and says none of it otherwise. */36 * about why, and says none of it otherwise. */
37 void joltmoq_init_logging(void);37 void joltmoq_init_logging(void);
38 38
39+/* Android only. Hand over the process's JavaVM and its Activity, once, before
40+ * any call starts.
41+ *
42+ * The camera on the phone is Camera2, which is Java, and this library cannot
43+ * reach the handles a JNI call needs: android-activity's glue receives them and
44+ * the glue lives in libvidya.so, a different shared object. `ndk_context` does
45+ * not bridge that — its handles sit in a static, and a static is per object —
46+ * so the APK's glue reads them out of libvidya (vidya_android_vm,
47+ * vidya_android_activity) and passes them here.
48+ *
49+ * Everything else works without it. Only the camera calls fail, and they say
50+ * which call was missed. Not declared on other platforms, where there is
51+ * nothing to hand over. */
52+#ifdef __ANDROID__
53+void joltmoq_android_init(void *vm, void *activity);
54+#endif
55+
39 /* Lifecycle56 /* Lifecycle
40 *57 *
41 * `joltmoq_start` joins the call at `sfu_url` (see joltmoq_sfu_url) as `nick`.58 * `joltmoq_start` joins the call at `sfu_url` (see joltmoq_sfu_url) as `nick`.
added crates/jolt-moq/src/android_camera.rs +413 -0
new file mode 100644
@@ -0,0 +1,413 @@
1+//! Android Camera2 → MoQ video publish bridge.
2+//!
3+//! Java `CameraCapture` (in APK `classes.dex`) opens Camera2 / ImageReader and
4+//! calls native NV12 push methods implemented here. Frames land in a
5+//! latest-only [`PushCameraSource`] that implements iroh-live's [`VideoSource`].
6+//!
7+//! JNI note: never resolve `CameraCapture` with `Env::find_class` from a
8+//! native worker thread — that uses the system ClassLoader and misses APK
9+//! classes. Use [`android_jni::load_app_class`] (Activity ClassLoader).
10+//!
11+//! The `JavaVM` and the Activity come from [`crate::android_jni`], which the
12+//! glue fills in before a call can start — see the note there for why they
13+//! arrive by hand rather than out of `ndk_context`.
14+
15+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16+use std::sync::{Arc, Mutex, OnceLock};
17+use std::time::Duration;
18+
19+use anyhow::{anyhow, Context, Result};
20+use iroh_live::media::{
21+ format::{Nv12Planes, PixelFormat, VideoFormat, VideoFrame},
22+ traits::VideoSource,
23+};
24+
25+use crate::android_jni;
26+
27+/// Java binary name for the Camera2 helper in APK `classes.dex`.
28+const CAMERA_CAPTURE_CLASS: &str = "uk.nandi.frq.CameraCapture";
29+
30+/// What every camera call says when the glue never handed the handles over —
31+/// an APK packaging `libjoltmoq.so` but not calling `joltmoq_android_init`.
32+const NOT_INITIALISED: &str = "joltmoq_android_init has not run";
33+
34+/// Shared sink written by JNI and read by the encoder thread.
35+struct FrameSink {
36+ pending: Mutex<Option<VideoFrame>>,
37+ format: Mutex<VideoFormat>,
38+ /// Set true after Java reports the capture session is live.
39+ opened: AtomicBool,
40+ /// Last open error detail (empty when ok / idle).
41+ last_error: Mutex<String>,
42+ frames_pushed: AtomicU64,
43+}
44+
45+impl FrameSink {
46+ fn new(width: u32, height: u32) -> Self {
47+ Self {
48+ pending: Mutex::new(None),
49+ format: Mutex::new(VideoFormat {
50+ // Encoders that receive FrameData::Nv12 ignore this field;
51+ // keep Rgba as the VideoFormat default (rusty-codecs has no Nv12 variant).
52+ pixel_format: PixelFormat::Rgba,
53+ dimensions: [width, height],
54+ }),
55+ opened: AtomicBool::new(false),
56+ last_error: Mutex::new(String::new()),
57+ frames_pushed: AtomicU64::new(0),
58+ }
59+ }
60+}
61+
62+static SINK: OnceLock<Arc<FrameSink>> = OnceLock::new();
63+
64+fn sink() -> Arc<FrameSink> {
65+ SINK.get_or_init(|| Arc::new(FrameSink::new(640, 480)))
66+ .clone()
67+}
68+
69+/// Latest-frame-only [`VideoSource`] fed by Camera2 JNI callbacks.
70+pub struct PushCameraSource {
71+ sink: Arc<FrameSink>,
72+ name: String,
73+}
74+
75+impl PushCameraSource {
76+ pub fn new(label: impl Into<String>) -> Self {
77+ Self {
78+ sink: sink(),
79+ name: label.into(),
80+ }
81+ }
82+}
83+
84+impl VideoSource for PushCameraSource {
85+ fn name(&self) -> &str {
86+ &self.name
87+ }
88+
89+ fn format(&self) -> VideoFormat {
90+ self.sink
91+ .format
92+ .lock()
93+ .map(|g| g.clone())
94+ .unwrap_or(VideoFormat {
95+ pixel_format: PixelFormat::Rgba,
96+ dimensions: [640, 480],
97+ })
98+ }
99+
100+ fn pop_frame(&mut self) -> Result<Option<VideoFrame>> {
101+ Ok(self.sink.pending.lock().ok().and_then(|mut g| g.take()))
102+ }
103+
104+ fn start(&mut self) -> Result<()> {
105+ Ok(())
106+ }
107+
108+ fn stop(&mut self) -> Result<()> {
109+ if let Ok(mut g) = self.sink.pending.lock() {
110+ *g = None;
111+ }
112+ Ok(())
113+ }
114+}
115+
116+/// Enumerate cameras for the device picker (front first).
117+/// Returns `(id, label)` pairs.
118+pub fn list_cameras() -> Vec<(String, String)> {
119+ match list_cameras_jni() {
120+ Ok(list) => list,
121+ Err(e) => {
122+ // Warn: empty list makes AV report "camera unavailable" permanently.
123+ log::warn!("android camera list: {e}");
124+ Vec::new()
125+ }
126+ }
127+}
128+
129+fn list_cameras_jni() -> Result<Vec<(String, String)>> {
130+ let vm = android_jni::vm().context(NOT_INITIALISED)?;
131+ let activity_ptr = android_jni::activity().context(NOT_INITIALISED)?;
132+
133+ use jni::objects::{JObject, JObjectArray, JString, JValue};
134+ use jni::refs::Global;
135+ use jni::{jni_sig, jni_str};
136+
137+ let mut out: Option<Result<Vec<(String, String)>>> = None;
138+ vm.attach_current_thread(|env| -> jni::errors::Result<()> {
139+ let activity = unsafe { env.as_cast_raw::<Global<JObject>>(&activity_ptr)? };
140+ let cls = match android_jni::load_app_class(env, activity.as_ref(), CAMERA_CAPTURE_CLASS) {
141+ Ok(c) => c,
142+ Err(e) => {
143+ out = Some(Err(anyhow!("CameraCapture class: {e}")));
144+ return Ok(());
145+ }
146+ };
147+ let arr_obj = env
148+ .call_static_method(
149+ &cls,
150+ jni_str!("listCameras"),
151+ jni_sig!((android.content.Context) -> [java.lang.String]),
152+ &[JValue::Object(activity.as_ref())],
153+ )?
154+ .l()?;
155+ let arr = env.cast_local::<JObjectArray>(arr_obj)?;
156+ let n = arr.len(env)?;
157+ let mut list = Vec::with_capacity(n);
158+ for i in 0..n {
159+ let obj = arr.get_element(env, i)?;
160+ if obj.is_null() {
161+ continue;
162+ }
163+ let jstr = env.cast_local::<JString>(obj)?;
164+ let s = format!("{jstr}");
165+ let (id, name) = match s.split_once('\t') {
166+ Some((id, name)) => (id.to_string(), name.to_string()),
167+ None => (s.clone(), s),
168+ };
169+ list.push((id, name));
170+ }
171+ out = Some(Ok(list));
172+ Ok(())
173+ })
174+ .map_err(|e| anyhow!("list cameras JNI: {e}"))?;
175+ out.unwrap_or_else(|| Err(anyhow!("list cameras JNI: no result")))
176+}
177+
178+/// Start Camera2 capture. Returns when the open request is dispatched (session
179+/// readiness is async — wait with [`wait_until_opened`]).
180+pub fn start_capture(camera_id: Option<&str>) -> Result<()> {
181+ let s = sink();
182+ s.opened.store(false, Ordering::Relaxed);
183+ s.frames_pushed.store(0, Ordering::Relaxed);
184+ if let Ok(mut e) = s.last_error.lock() {
185+ e.clear();
186+ }
187+ if let Ok(mut p) = s.pending.lock() {
188+ *p = None;
189+ }
190+
191+ let vm = android_jni::vm().context(NOT_INITIALISED)?;
192+ let activity_ptr = android_jni::activity().context(NOT_INITIALISED)?;
193+
194+ use jni::objects::{JObject, JValue};
195+ use jni::refs::Global;
196+ use jni::{jni_sig, jni_str};
197+
198+ let id = camera_id.unwrap_or("").to_string();
199+ let mut start_err: Option<anyhow::Error> = None;
200+ vm.attach_current_thread(|env| -> jni::errors::Result<()> {
201+ let activity = unsafe { env.as_cast_raw::<Global<JObject>>(&activity_ptr)? };
202+ let cls = match android_jni::load_app_class(env, activity.as_ref(), CAMERA_CAPTURE_CLASS) {
203+ Ok(c) => c,
204+ Err(e) => {
205+ start_err = Some(anyhow!("CameraCapture class: {e}"));
206+ return Ok(());
207+ }
208+ };
209+ let jid = env.new_string(&id)?;
210+ env.call_static_method(
211+ &cls,
212+ jni_str!("start"),
213+ jni_sig!((android.app.Activity, java.lang.String) -> void),
214+ &[JValue::Object(activity.as_ref()), JValue::Object(&jid)],
215+ )?;
216+ Ok(())
217+ })
218+ .map_err(|e| anyhow!("CameraCapture.start: {e}"))?;
219+ if let Some(e) = start_err {
220+ return Err(e).context("CameraCapture.start");
221+ }
222+ Ok(())
223+}
224+
225+/// Block briefly until Java reports the capture session is live.
226+pub fn wait_until_opened(timeout: Duration) -> Result<()> {
227+ let s = sink();
228+ let deadline = std::time::Instant::now() + timeout;
229+ while std::time::Instant::now() < deadline {
230+ if s.opened.load(Ordering::Relaxed) {
231+ return Ok(());
232+ }
233+ let err = s
234+ .last_error
235+ .lock()
236+ .ok()
237+ .map(|g| g.clone())
238+ .unwrap_or_default();
239+ if !err.is_empty() {
240+ return Err(anyhow!("camera open failed: {err}"));
241+ }
242+ std::thread::sleep(Duration::from_millis(20));
243+ }
244+ let err = s
245+ .last_error
246+ .lock()
247+ .ok()
248+ .map(|g| g.clone())
249+ .unwrap_or_default();
250+ if s.opened.load(Ordering::Relaxed) {
251+ Ok(())
252+ } else if !err.is_empty() {
253+ Err(anyhow!("camera open failed: {err}"))
254+ } else {
255+ Err(anyhow!("camera open timed out"))
256+ }
257+}
258+
259+/// Stop Camera2 capture (idempotent).
260+pub fn stop_capture() {
261+ let (Some(vm), Some(activity_ptr)) = (android_jni::vm(), android_jni::activity()) else {
262+ return;
263+ };
264+
265+ use jni::objects::{JObject, JValue};
266+ use jni::refs::Global;
267+ use jni::{jni_sig, jni_str};
268+
269+ let _ = vm.attach_current_thread(|env| -> jni::errors::Result<()> {
270+ let activity = unsafe { env.as_cast_raw::<Global<JObject>>(&activity_ptr)? };
271+ let cls = match android_jni::load_app_class(env, activity.as_ref(), CAMERA_CAPTURE_CLASS) {
272+ Ok(c) => c,
273+ Err(e) => {
274+ log::warn!("android camera stop: CameraCapture class: {e}");
275+ return Ok(());
276+ }
277+ };
278+ env.call_static_method(
279+ &cls,
280+ jni_str!("stop"),
281+ jni_sig!((android.app.Activity) -> void),
282+ &[JValue::Object(activity.as_ref())],
283+ )?;
284+ Ok(())
285+ });
286+ let s = sink();
287+ s.opened.store(false, Ordering::Relaxed);
288+ if let Ok(mut p) = s.pending.lock() {
289+ *p = None;
290+ };
291+}
292+
293+/// RAII guard that stops Camera2 when the media session ends.
294+pub struct CameraCaptureGuard;
295+
296+impl Drop for CameraCaptureGuard {
297+ fn drop(&mut self) {
298+ stop_capture();
299+ log::info!("android camera: capture stopped (guard drop)");
300+ }
301+}
302+
303+// ── JNI callbacks from CameraCapture.java ─────────────────────────────────
304+
305+#[unsafe(no_mangle)]
306+pub extern "system" fn Java_uk_nandi_frq_CameraCapture_onNv12Frame<'local>(
307+ mut unowned_env: jni::JNIEnv<'local>,
308+ _class: jni::objects::JClass<'local>,
309+ y_data: jni::objects::JByteArray<'local>,
310+ uv_data: jni::objects::JByteArray<'local>,
311+ width: jni::sys::jint,
312+ height: jni::sys::jint,
313+ y_stride: jni::sys::jint,
314+ uv_stride: jni::sys::jint,
315+ rotation_degrees: jni::sys::jint,
316+) {
317+ if width <= 0 || height <= 0 {
318+ return;
319+ }
320+ let _ = unowned_env
321+ .with_env(|env| -> jni::errors::Result<()> {
322+ let y_bytes = env.convert_byte_array(&y_data)?;
323+ let uv_bytes = env.convert_byte_array(&uv_data)?;
324+ let src_w = width as u32;
325+ let src_h = height as u32;
326+ let rot = if rotation_degrees < 0 {
327+ 0
328+ } else {
329+ rotation_degrees as u32
330+ };
331+ // Apply sensor/display orientation so published + preview frames
332+ // are upright (Camera2 ImageReader buffers are sensor-oriented).
333+ let (y_bytes, uv_bytes, w, h, y_str, uv_str) =
334+ match crate::nv12_orient::orient_nv12(
335+ &y_bytes,
336+ &uv_bytes,
337+ src_w,
338+ src_h,
339+ y_stride as u32,
340+ uv_stride as u32,
341+ rot,
342+ ) {
343+ Some((y, uv, w, h)) => (y, uv, w, h, w, w),
344+ None => (
345+ y_bytes,
346+ uv_bytes,
347+ src_w,
348+ src_h,
349+ y_stride as u32,
350+ uv_stride as u32,
351+ ),
352+ };
353+ let frame = VideoFrame::new_nv12(
354+ Nv12Planes {
355+ y_data: y_bytes,
356+ y_stride: y_str,
357+ uv_data: uv_bytes,
358+ uv_stride: uv_str,
359+ width: w,
360+ height: h,
361+ },
362+ Duration::ZERO,
363+ );
364+ let s = sink();
365+ if let Ok(mut fmt) = s.format.lock() {
366+ if fmt.dimensions != [w, h] {
367+ *fmt = VideoFormat {
368+ pixel_format: PixelFormat::Rgba,
369+ dimensions: [w, h],
370+ };
371+ }
372+ }
373+ if let Ok(mut pending) = s.pending.lock() {
374+ *pending = Some(frame);
375+ }
376+ let n = s.frames_pushed.fetch_add(1, Ordering::Relaxed);
377+ if n == 0 {
378+ log::info!("android camera: first NV12 frame {w}x{h} (src {src_w}x{src_h} rot={rot})");
379+ }
380+ Ok(())
381+ })
382+ .into_outcome();
383+}
384+
385+#[unsafe(no_mangle)]
386+pub extern "system" fn Java_uk_nandi_frq_CameraCapture_onCameraState<'local>(
387+ mut unowned_env: jni::JNIEnv<'local>,
388+ _class: jni::objects::JClass<'local>,
389+ opened: jni::sys::jboolean,
390+ detail: jni::objects::JString<'local>,
391+) {
392+ let _ = unowned_env
393+ .with_env(|env| -> jni::errors::Result<()> {
394+ let detail_str = format!("{detail}");
395+ let s = sink();
396+ let ok = opened != jni::sys::JNI_FALSE;
397+ s.opened.store(ok, Ordering::Relaxed);
398+ if let Ok(mut e) = s.last_error.lock() {
399+ if ok {
400+ e.clear();
401+ } else {
402+ *e = detail_str.clone();
403+ }
404+ }
405+ if ok {
406+ log::info!("android camera: opened ({detail_str})");
407+ } else {
408+ log::warn!("android camera: not opened ({detail_str})");
409+ }
410+ Ok(())
411+ })
412+ .into_outcome();
413+}
new file mode 100644
@@ -0,0 +1,413 @@
1+//! Android Camera2 → MoQ video publish bridge.
2+//!
3+//! Java `CameraCapture` (in APK `classes.dex`) opens Camera2 / ImageReader and
4+//! calls native NV12 push methods implemented here. Frames land in a
5+//! latest-only [`PushCameraSource`] that implements iroh-live's [`VideoSource`].
6+//!
7+//! JNI note: never resolve `CameraCapture` with `Env::find_class` from a
8+//! native worker thread — that uses the system ClassLoader and misses APK
9+//! classes. Use [`android_jni::load_app_class`] (Activity ClassLoader).
10+//!
11+//! The `JavaVM` and the Activity come from [`crate::android_jni`], which the
12+//! glue fills in before a call can start — see the note there for why they
13+//! arrive by hand rather than out of `ndk_context`.
14+
15+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16+use std::sync::{Arc, Mutex, OnceLock};
17+use std::time::Duration;
18+
19+use anyhow::{anyhow, Context, Result};
20+use iroh_live::media::{
21+ format::{Nv12Planes, PixelFormat, VideoFormat, VideoFrame},
22+ traits::VideoSource,
23+};
24+
25+use crate::android_jni;
26+
27+/// Java binary name for the Camera2 helper in APK `classes.dex`.
28+const CAMERA_CAPTURE_CLASS: &str = "uk.nandi.frq.CameraCapture";
29+
30+/// What every camera call says when the glue never handed the handles over —
31+/// an APK packaging `libjoltmoq.so` but not calling `joltmoq_android_init`.
32+const NOT_INITIALISED: &str = "joltmoq_android_init has not run";
33+
34+/// Shared sink written by JNI and read by the encoder thread.
35+struct FrameSink {
36+ pending: Mutex<Option<VideoFrame>>,
37+ format: Mutex<VideoFormat>,
38+ /// Set true after Java reports the capture session is live.
39+ opened: AtomicBool,
40+ /// Last open error detail (empty when ok / idle).
41+ last_error: Mutex<String>,
42+ frames_pushed: AtomicU64,
43+}
44+
45+impl FrameSink {
46+ fn new(width: u32, height: u32) -> Self {
47+ Self {
48+ pending: Mutex::new(None),
49+ format: Mutex::new(VideoFormat {
50+ // Encoders that receive FrameData::Nv12 ignore this field;
51+ // keep Rgba as the VideoFormat default (rusty-codecs has no Nv12 variant).
52+ pixel_format: PixelFormat::Rgba,
53+ dimensions: [width, height],
54+ }),
55+ opened: AtomicBool::new(false),
56+ last_error: Mutex::new(String::new()),
57+ frames_pushed: AtomicU64::new(0),
58+ }
59+ }
60+}
61+
62+static SINK: OnceLock<Arc<FrameSink>> = OnceLock::new();
63+
64+fn sink() -> Arc<FrameSink> {
65+ SINK.get_or_init(|| Arc::new(FrameSink::new(640, 480)))
66+ .clone()
67+}
68+
69+/// Latest-frame-only [`VideoSource`] fed by Camera2 JNI callbacks.
70+pub struct PushCameraSource {
71+ sink: Arc<FrameSink>,
72+ name: String,
73+}
74+
75+impl PushCameraSource {
76+ pub fn new(label: impl Into<String>) -> Self {
77+ Self {
78+ sink: sink(),
79+ name: label.into(),
80+ }
81+ }
82+}
83+
84+impl VideoSource for PushCameraSource {
85+ fn name(&self) -> &str {
86+ &self.name
87+ }
88+
89+ fn format(&self) -> VideoFormat {
90+ self.sink
91+ .format
92+ .lock()
93+ .map(|g| g.clone())
94+ .unwrap_or(VideoFormat {
95+ pixel_format: PixelFormat::Rgba,
96+ dimensions: [640, 480],
97+ })
98+ }
99+
100+ fn pop_frame(&mut self) -> Result<Option<VideoFrame>> {
101+ Ok(self.sink.pending.lock().ok().and_then(|mut g| g.take()))
102+ }
103+
104+ fn start(&mut self) -> Result<()> {
105+ Ok(())
106+ }
107+
108+ fn stop(&mut self) -> Result<()> {
109+ if let Ok(mut g) = self.sink.pending.lock() {
110+ *g = None;
111+ }
112+ Ok(())
113+ }
114+}
115+
116+/// Enumerate cameras for the device picker (front first).
117+/// Returns `(id, label)` pairs.
118+pub fn list_cameras() -> Vec<(String, String)> {
119+ match list_cameras_jni() {
120+ Ok(list) => list,
121+ Err(e) => {
122+ // Warn: empty list makes AV report "camera unavailable" permanently.
123+ log::warn!("android camera list: {e}");
124+ Vec::new()
125+ }
126+ }
127+}
128+
129+fn list_cameras_jni() -> Result<Vec<(String, String)>> {
130+ let vm = android_jni::vm().context(NOT_INITIALISED)?;
131+ let activity_ptr = android_jni::activity().context(NOT_INITIALISED)?;
132+
133+ use jni::objects::{JObject, JObjectArray, JString, JValue};
134+ use jni::refs::Global;
135+ use jni::{jni_sig, jni_str};
136+
137+ let mut out: Option<Result<Vec<(String, String)>>> = None;
138+ vm.attach_current_thread(|env| -> jni::errors::Result<()> {
139+ let activity = unsafe { env.as_cast_raw::<Global<JObject>>(&activity_ptr)? };
140+ let cls = match android_jni::load_app_class(env, activity.as_ref(), CAMERA_CAPTURE_CLASS) {
141+ Ok(c) => c,
142+ Err(e) => {
143+ out = Some(Err(anyhow!("CameraCapture class: {e}")));
144+ return Ok(());
145+ }
146+ };
147+ let arr_obj = env
148+ .call_static_method(
149+ &cls,
150+ jni_str!("listCameras"),
151+ jni_sig!((android.content.Context) -> [java.lang.String]),
152+ &[JValue::Object(activity.as_ref())],
153+ )?
154+ .l()?;
155+ let arr = env.cast_local::<JObjectArray>(arr_obj)?;
156+ let n = arr.len(env)?;
157+ let mut list = Vec::with_capacity(n);
158+ for i in 0..n {
159+ let obj = arr.get_element(env, i)?;
160+ if obj.is_null() {
161+ continue;
162+ }
163+ let jstr = env.cast_local::<JString>(obj)?;
164+ let s = format!("{jstr}");
165+ let (id, name) = match s.split_once('\t') {
166+ Some((id, name)) => (id.to_string(), name.to_string()),
167+ None => (s.clone(), s),
168+ };
169+ list.push((id, name));
170+ }
171+ out = Some(Ok(list));
172+ Ok(())
173+ })
174+ .map_err(|e| anyhow!("list cameras JNI: {e}"))?;
175+ out.unwrap_or_else(|| Err(anyhow!("list cameras JNI: no result")))
176+}
177+
178+/// Start Camera2 capture. Returns when the open request is dispatched (session
179+/// readiness is async — wait with [`wait_until_opened`]).
180+pub fn start_capture(camera_id: Option<&str>) -> Result<()> {
181+ let s = sink();
182+ s.opened.store(false, Ordering::Relaxed);
183+ s.frames_pushed.store(0, Ordering::Relaxed);
184+ if let Ok(mut e) = s.last_error.lock() {
185+ e.clear();
186+ }
187+ if let Ok(mut p) = s.pending.lock() {
188+ *p = None;
189+ }
190+
191+ let vm = android_jni::vm().context(NOT_INITIALISED)?;
192+ let activity_ptr = android_jni::activity().context(NOT_INITIALISED)?;
193+
194+ use jni::objects::{JObject, JValue};
195+ use jni::refs::Global;
196+ use jni::{jni_sig, jni_str};
197+
198+ let id = camera_id.unwrap_or("").to_string();
199+ let mut start_err: Option<anyhow::Error> = None;
200+ vm.attach_current_thread(|env| -> jni::errors::Result<()> {
201+ let activity = unsafe { env.as_cast_raw::<Global<JObject>>(&activity_ptr)? };
202+ let cls = match android_jni::load_app_class(env, activity.as_ref(), CAMERA_CAPTURE_CLASS) {
203+ Ok(c) => c,
204+ Err(e) => {
205+ start_err = Some(anyhow!("CameraCapture class: {e}"));
206+ return Ok(());
207+ }
208+ };
209+ let jid = env.new_string(&id)?;
210+ env.call_static_method(
211+ &cls,
212+ jni_str!("start"),
213+ jni_sig!((android.app.Activity, java.lang.String) -> void),
214+ &[JValue::Object(activity.as_ref()), JValue::Object(&jid)],
215+ )?;
216+ Ok(())
217+ })
218+ .map_err(|e| anyhow!("CameraCapture.start: {e}"))?;
219+ if let Some(e) = start_err {
220+ return Err(e).context("CameraCapture.start");
221+ }
222+ Ok(())
223+}
224+
225+/// Block briefly until Java reports the capture session is live.
226+pub fn wait_until_opened(timeout: Duration) -> Result<()> {
227+ let s = sink();
228+ let deadline = std::time::Instant::now() + timeout;
229+ while std::time::Instant::now() < deadline {
230+ if s.opened.load(Ordering::Relaxed) {
231+ return Ok(());
232+ }
233+ let err = s
234+ .last_error
235+ .lock()
236+ .ok()
237+ .map(|g| g.clone())
238+ .unwrap_or_default();
239+ if !err.is_empty() {
240+ return Err(anyhow!("camera open failed: {err}"));
241+ }
242+ std::thread::sleep(Duration::from_millis(20));
243+ }
244+ let err = s
245+ .last_error
246+ .lock()
247+ .ok()
248+ .map(|g| g.clone())
249+ .unwrap_or_default();
250+ if s.opened.load(Ordering::Relaxed) {
251+ Ok(())
252+ } else if !err.is_empty() {
253+ Err(anyhow!("camera open failed: {err}"))
254+ } else {
255+ Err(anyhow!("camera open timed out"))
256+ }
257+}
258+
259+/// Stop Camera2 capture (idempotent).
260+pub fn stop_capture() {
261+ let (Some(vm), Some(activity_ptr)) = (android_jni::vm(), android_jni::activity()) else {
262+ return;
263+ };
264+
265+ use jni::objects::{JObject, JValue};
266+ use jni::refs::Global;
267+ use jni::{jni_sig, jni_str};
268+
269+ let _ = vm.attach_current_thread(|env| -> jni::errors::Result<()> {
270+ let activity = unsafe { env.as_cast_raw::<Global<JObject>>(&activity_ptr)? };
271+ let cls = match android_jni::load_app_class(env, activity.as_ref(), CAMERA_CAPTURE_CLASS) {
272+ Ok(c) => c,
273+ Err(e) => {
274+ log::warn!("android camera stop: CameraCapture class: {e}");
275+ return Ok(());
276+ }
277+ };
278+ env.call_static_method(
279+ &cls,
280+ jni_str!("stop"),
281+ jni_sig!((android.app.Activity) -> void),
282+ &[JValue::Object(activity.as_ref())],
283+ )?;
284+ Ok(())
285+ });
286+ let s = sink();
287+ s.opened.store(false, Ordering::Relaxed);
288+ if let Ok(mut p) = s.pending.lock() {
289+ *p = None;
290+ };
291+}
292+
293+/// RAII guard that stops Camera2 when the media session ends.
294+pub struct CameraCaptureGuard;
295+
296+impl Drop for CameraCaptureGuard {
297+ fn drop(&mut self) {
298+ stop_capture();
299+ log::info!("android camera: capture stopped (guard drop)");
300+ }
301+}
302+
303+// ── JNI callbacks from CameraCapture.java ─────────────────────────────────
304+
305+#[unsafe(no_mangle)]
306+pub extern "system" fn Java_uk_nandi_frq_CameraCapture_onNv12Frame<'local>(
307+ mut unowned_env: jni::JNIEnv<'local>,
308+ _class: jni::objects::JClass<'local>,
309+ y_data: jni::objects::JByteArray<'local>,
310+ uv_data: jni::objects::JByteArray<'local>,
311+ width: jni::sys::jint,
312+ height: jni::sys::jint,
313+ y_stride: jni::sys::jint,
314+ uv_stride: jni::sys::jint,
315+ rotation_degrees: jni::sys::jint,
316+) {
317+ if width <= 0 || height <= 0 {
318+ return;
319+ }
320+ let _ = unowned_env
321+ .with_env(|env| -> jni::errors::Result<()> {
322+ let y_bytes = env.convert_byte_array(&y_data)?;
323+ let uv_bytes = env.convert_byte_array(&uv_data)?;
324+ let src_w = width as u32;
325+ let src_h = height as u32;
326+ let rot = if rotation_degrees < 0 {
327+ 0
328+ } else {
329+ rotation_degrees as u32
330+ };
331+ // Apply sensor/display orientation so published + preview frames
332+ // are upright (Camera2 ImageReader buffers are sensor-oriented).
333+ let (y_bytes, uv_bytes, w, h, y_str, uv_str) =
334+ match crate::nv12_orient::orient_nv12(
335+ &y_bytes,
336+ &uv_bytes,
337+ src_w,
338+ src_h,
339+ y_stride as u32,
340+ uv_stride as u32,
341+ rot,
342+ ) {
343+ Some((y, uv, w, h)) => (y, uv, w, h, w, w),
344+ None => (
345+ y_bytes,
346+ uv_bytes,
347+ src_w,
348+ src_h,
349+ y_stride as u32,
350+ uv_stride as u32,
351+ ),
352+ };
353+ let frame = VideoFrame::new_nv12(
354+ Nv12Planes {
355+ y_data: y_bytes,
356+ y_stride: y_str,
357+ uv_data: uv_bytes,
358+ uv_stride: uv_str,
359+ width: w,
360+ height: h,
361+ },
362+ Duration::ZERO,
363+ );
364+ let s = sink();
365+ if let Ok(mut fmt) = s.format.lock() {
366+ if fmt.dimensions != [w, h] {
367+ *fmt = VideoFormat {
368+ pixel_format: PixelFormat::Rgba,
369+ dimensions: [w, h],
370+ };
371+ }
372+ }
373+ if let Ok(mut pending) = s.pending.lock() {
374+ *pending = Some(frame);
375+ }
376+ let n = s.frames_pushed.fetch_add(1, Ordering::Relaxed);
377+ if n == 0 {
378+ log::info!("android camera: first NV12 frame {w}x{h} (src {src_w}x{src_h} rot={rot})");
379+ }
380+ Ok(())
381+ })
382+ .into_outcome();
383+}
384+
385+#[unsafe(no_mangle)]
386+pub extern "system" fn Java_uk_nandi_frq_CameraCapture_onCameraState<'local>(
387+ mut unowned_env: jni::JNIEnv<'local>,
388+ _class: jni::objects::JClass<'local>,
389+ opened: jni::sys::jboolean,
390+ detail: jni::objects::JString<'local>,
391+) {
392+ let _ = unowned_env
393+ .with_env(|env| -> jni::errors::Result<()> {
394+ let detail_str = format!("{detail}");
395+ let s = sink();
396+ let ok = opened != jni::sys::JNI_FALSE;
397+ s.opened.store(ok, Ordering::Relaxed);
398+ if let Ok(mut e) = s.last_error.lock() {
399+ if ok {
400+ e.clear();
401+ } else {
402+ *e = detail_str.clone();
403+ }
404+ }
405+ if ok {
406+ log::info!("android camera: opened ({detail_str})");
407+ } else {
408+ log::warn!("android camera: not opened ({detail_str})");
409+ }
410+ Ok(())
411+ })
412+ .into_outcome();
413+}
added crates/jolt-moq/src/android_jni.rs +129 -0
new file mode 100644
@@ -0,0 +1,129 @@
1+//! The JavaVM and the Activity, handed across from `libvidya.so`.
2+//!
3+//! On a desktop this library needs nothing from the host: it opens V4L2 nodes
4+//! and PipeWire streams by itself. On the phone the camera is Java, and every
5+//! call into it needs the process's `JavaVM` and the running `Activity`.
6+//!
7+//! Neither is reachable from here. `android-activity`'s glue receives them, and
8+//! the glue runs in `libvidya.so` — a different shared object, because whoever
9+//! holds the glue owns the event loop and that is the UI's job, not this one's.
10+//! The usual way across, `ndk_context`, does not answer: its handles live in a
11+//! `static` inside the `ndk-context` crate, and a `static` is per-object. Two
12+//! cdylibs that both link it get two of them, and the one this library would
13+//! read is the one nobody ever set.
14+//!
15+//! So the handles are pushed rather than pulled. `libjoltapp.so` links both
16+//! objects, reads them out of libvidya's C ABI, and passes them to
17+//! [`crate::joltmoq_android_init`] before any call starts. Everything here is
18+//! `None` until that has happened, and a camera call fails with a reason rather
19+//! than dereferencing a null.
20+
21+use std::ptr;
22+use std::sync::atomic::{AtomicPtr, Ordering};
23+
24+use jni::objects::{JClass, JObject, JValue};
25+use jni::{jni_sig, jni_str, JavaVM};
26+
27+/// Set once, before the application starts. Read from every thread that talks
28+/// to Camera2 — the tokio workers among them, which is why this is not a
29+/// `Mutex<Option<_>>` anyone could hold across a JNI call.
30+static VM: AtomicPtr<jni::sys::JavaVM> = AtomicPtr::new(ptr::null_mut());
31+
32+/// The Activity, as the glue's own global reference. Not a local ref that would
33+/// die with the frame that made it: libvidya holds this one for the lifetime of
34+/// the process, so it is as good here as it is there.
35+static ACTIVITY: AtomicPtr<jni::sys::_jobject> = AtomicPtr::new(ptr::null_mut());
36+
37+/// Store the handles. Called once, from the glue, before any session opens.
38+///
39+/// # Safety
40+///
41+/// `vm` must be the process's `JavaVM` and `activity` a global reference to the
42+/// running Activity, both still live — which is what libvidya's accessors
43+/// return for as long as the process runs.
44+pub unsafe fn set(vm: *mut jni::sys::JavaVM, activity: jni::sys::jobject) {
45+ VM.store(vm, Ordering::Release);
46+ ACTIVITY.store(activity, Ordering::Release);
47+
48+ // And the same pair again, into this object's `ndk_context`.
49+ //
50+ // Not for anything here: cpal's AAudio host asks `ndk_context` for the VM
51+ // when it opens a stream, and the copy it asks is the one linked into
52+ // *this* library, which nothing has ever filled in. libvidya's glue
53+ // initialised libvidya's copy, and that is a different static — the same
54+ // reason this module exists at all, arriving a second time from underneath.
55+ //
56+ // Unset, it is not a fallback but a panic: `android context was not
57+ // initialized`, on the audio thread, the moment a call starts.
58+ //
59+ // SAFETY: the caller's contract, and the same values stored above.
60+ unsafe { ndk_context::initialize_android_context(vm.cast(), activity.cast()) };
61+}
62+
63+/// The `JavaVM`, or `None` before the glue has handed it over.
64+pub fn vm() -> Option<JavaVM> {
65+ let ptr = VM.load(Ordering::Acquire);
66+ if ptr.is_null() {
67+ return None;
68+ }
69+ // SAFETY: non-null only after `set`, whose contract is that this is the
70+ // process's own VM. A JavaVM outlives every thread that could read it.
71+ Some(unsafe { JavaVM::from_raw(ptr) })
72+}
73+
74+/// The Activity's `jobject`, or `None` before the glue has handed it over.
75+pub fn activity() -> Option<jni::sys::jobject> {
76+ let ptr = ACTIVITY.load(Ordering::Acquire);
77+ if ptr.is_null() {
78+ None
79+ } else {
80+ Some(ptr)
81+ }
82+}
83+
84+/// Load an **application** class (APK `classes.dex`) by binary name.
85+///
86+/// `Env::find_class` on a thread this library made — a tokio worker, the media
87+/// pump — resolves against the *system* class loader, which knows nothing of
88+/// the APK. `uk.nandi.frq.CameraCapture` is not there and never will be. The
89+/// Activity's own loader is the one that can see it.
90+///
91+/// `binary_name` is the Java binary name: dots, not slashes.
92+pub fn load_app_class<'a>(
93+ env: &mut jni::Env<'a>,
94+ activity: &JObject<'_>,
95+ binary_name: &str,
96+) -> Result<JClass<'a>, String> {
97+ let loader = env
98+ .call_method(
99+ activity,
100+ jni_str!("getClassLoader"),
101+ jni_sig!(() -> java.lang.ClassLoader),
102+ &[],
103+ )
104+ .map_err(|e| format!("getClassLoader: {e}"))?
105+ .l()
106+ .map_err(|e| format!("getClassLoader: {e}"))?;
107+ if loader.is_null() {
108+ return Err("Activity.getClassLoader returned null".into());
109+ }
110+
111+ let class_name = env
112+ .new_string(binary_name)
113+ .map_err(|e| format!("new_string({binary_name}): {e}"))?;
114+ let loaded = env
115+ .call_method(
116+ &loader,
117+ jni_str!("loadClass"),
118+ jni_sig!((java.lang.String) -> java.lang.Class),
119+ &[JValue::Object(class_name.as_ref())],
120+ )
121+ .map_err(|e| format!("loadClass({binary_name}): {e}"))?
122+ .l()
123+ .map_err(|e| format!("loadClass({binary_name}): {e}"))?;
124+ if loaded.is_null() {
125+ return Err(format!("loadClass({binary_name}) returned null"));
126+ }
127+ env.cast_local::<JClass>(loaded)
128+ .map_err(|e| format!("cast {binary_name}: {e}"))
129+}
new file mode 100644
@@ -0,0 +1,129 @@
1+//! The JavaVM and the Activity, handed across from `libvidya.so`.
2+//!
3+//! On a desktop this library needs nothing from the host: it opens V4L2 nodes
4+//! and PipeWire streams by itself. On the phone the camera is Java, and every
5+//! call into it needs the process's `JavaVM` and the running `Activity`.
6+//!
7+//! Neither is reachable from here. `android-activity`'s glue receives them, and
8+//! the glue runs in `libvidya.so` — a different shared object, because whoever
9+//! holds the glue owns the event loop and that is the UI's job, not this one's.
10+//! The usual way across, `ndk_context`, does not answer: its handles live in a
11+//! `static` inside the `ndk-context` crate, and a `static` is per-object. Two
12+//! cdylibs that both link it get two of them, and the one this library would
13+//! read is the one nobody ever set.
14+//!
15+//! So the handles are pushed rather than pulled. `libjoltapp.so` links both
16+//! objects, reads them out of libvidya's C ABI, and passes them to
17+//! [`crate::joltmoq_android_init`] before any call starts. Everything here is
18+//! `None` until that has happened, and a camera call fails with a reason rather
19+//! than dereferencing a null.
20+
21+use std::ptr;
22+use std::sync::atomic::{AtomicPtr, Ordering};
23+
24+use jni::objects::{JClass, JObject, JValue};
25+use jni::{jni_sig, jni_str, JavaVM};
26+
27+/// Set once, before the application starts. Read from every thread that talks
28+/// to Camera2 — the tokio workers among them, which is why this is not a
29+/// `Mutex<Option<_>>` anyone could hold across a JNI call.
30+static VM: AtomicPtr<jni::sys::JavaVM> = AtomicPtr::new(ptr::null_mut());
31+
32+/// The Activity, as the glue's own global reference. Not a local ref that would
33+/// die with the frame that made it: libvidya holds this one for the lifetime of
34+/// the process, so it is as good here as it is there.
35+static ACTIVITY: AtomicPtr<jni::sys::_jobject> = AtomicPtr::new(ptr::null_mut());
36+
37+/// Store the handles. Called once, from the glue, before any session opens.
38+///
39+/// # Safety
40+///
41+/// `vm` must be the process's `JavaVM` and `activity` a global reference to the
42+/// running Activity, both still live — which is what libvidya's accessors
43+/// return for as long as the process runs.
44+pub unsafe fn set(vm: *mut jni::sys::JavaVM, activity: jni::sys::jobject) {
45+ VM.store(vm, Ordering::Release);
46+ ACTIVITY.store(activity, Ordering::Release);
47+
48+ // And the same pair again, into this object's `ndk_context`.
49+ //
50+ // Not for anything here: cpal's AAudio host asks `ndk_context` for the VM
51+ // when it opens a stream, and the copy it asks is the one linked into
52+ // *this* library, which nothing has ever filled in. libvidya's glue
53+ // initialised libvidya's copy, and that is a different static — the same
54+ // reason this module exists at all, arriving a second time from underneath.
55+ //
56+ // Unset, it is not a fallback but a panic: `android context was not
57+ // initialized`, on the audio thread, the moment a call starts.
58+ //
59+ // SAFETY: the caller's contract, and the same values stored above.
60+ unsafe { ndk_context::initialize_android_context(vm.cast(), activity.cast()) };
61+}
62+
63+/// The `JavaVM`, or `None` before the glue has handed it over.
64+pub fn vm() -> Option<JavaVM> {
65+ let ptr = VM.load(Ordering::Acquire);
66+ if ptr.is_null() {
67+ return None;
68+ }
69+ // SAFETY: non-null only after `set`, whose contract is that this is the
70+ // process's own VM. A JavaVM outlives every thread that could read it.
71+ Some(unsafe { JavaVM::from_raw(ptr) })
72+}
73+
74+/// The Activity's `jobject`, or `None` before the glue has handed it over.
75+pub fn activity() -> Option<jni::sys::jobject> {
76+ let ptr = ACTIVITY.load(Ordering::Acquire);
77+ if ptr.is_null() {
78+ None
79+ } else {
80+ Some(ptr)
81+ }
82+}
83+
84+/// Load an **application** class (APK `classes.dex`) by binary name.
85+///
86+/// `Env::find_class` on a thread this library made — a tokio worker, the media
87+/// pump — resolves against the *system* class loader, which knows nothing of
88+/// the APK. `uk.nandi.frq.CameraCapture` is not there and never will be. The
89+/// Activity's own loader is the one that can see it.
90+///
91+/// `binary_name` is the Java binary name: dots, not slashes.
92+pub fn load_app_class<'a>(
93+ env: &mut jni::Env<'a>,
94+ activity: &JObject<'_>,
95+ binary_name: &str,
96+) -> Result<JClass<'a>, String> {
97+ let loader = env
98+ .call_method(
99+ activity,
100+ jni_str!("getClassLoader"),
101+ jni_sig!(() -> java.lang.ClassLoader),
102+ &[],
103+ )
104+ .map_err(|e| format!("getClassLoader: {e}"))?
105+ .l()
106+ .map_err(|e| format!("getClassLoader: {e}"))?;
107+ if loader.is_null() {
108+ return Err("Activity.getClassLoader returned null".into());
109+ }
110+
111+ let class_name = env
112+ .new_string(binary_name)
113+ .map_err(|e| format!("new_string({binary_name}): {e}"))?;
114+ let loaded = env
115+ .call_method(
116+ &loader,
117+ jni_str!("loadClass"),
118+ jni_sig!((java.lang.String) -> java.lang.Class),
119+ &[JValue::Object(class_name.as_ref())],
120+ )
121+ .map_err(|e| format!("loadClass({binary_name}): {e}"))?
122+ .l()
123+ .map_err(|e| format!("loadClass({binary_name}): {e}"))?;
124+ if loaded.is_null() {
125+ return Err(format!("loadClass({binary_name}) returned null"));
126+ }
127+ env.cast_local::<JClass>(loaded)
128+ .map_err(|e| format!("cast {binary_name}: {e}"))
129+}
modified crates/jolt-moq/src/lib.rs +45 -0
@@ -36,7 +36,19 @@
3636
3737 pub mod av;
3838 pub mod av_media;
39+// Pure arithmetic on planes, so it builds and is tested everywhere even though
40+// only the Android camera path calls it — a rotate is easier to get wrong than
41+// to test, and a desktop `cargo test` is where that gets caught.
42+#[cfg_attr(not(target_os = "android"), allow(dead_code))]
43+mod nv12_orient;
44+// V4L2 is Linux's camera interface and the phone does not offer it; there the
45+// camera is Java, reached through the two modules below.
46+#[cfg(not(target_os = "android"))]
3947 mod v4l2cam;
48+#[cfg(target_os = "android")]
49+mod android_camera;
50+#[cfg(target_os = "android")]
51+mod android_jni;
4052
4153 use std::collections::HashMap;
4254 use std::ffi::{c_char, c_int};
@@ -134,6 +146,39 @@ fn with_slot<R>(fallback: R, f: impl FnOnce(&mut Slot) -> R) -> R {
134146
135147 // ── Lifecycle ───────────────────────────────────────────────────────────────
136148
149+/// Hand over the phone's `JavaVM` and Activity. Android only, and required
150+/// there before a call with a camera in it can start.
151+///
152+/// The camera on Android is Java, and this library cannot reach the handles a
153+/// JNI call needs: `android-activity`'s glue receives them, and the glue is in
154+/// `libvidya.so`. So the glue in `android/jolt_main.c` — which links both
155+/// objects — reads them out of libvidya's C ABI and passes them here, once,
156+/// before Jolt starts. See `android_jni` for why `ndk_context` cannot do this.
157+///
158+/// Everything else works without it. Only the camera calls fail, and they say
159+/// which call was missed rather than crashing.
160+///
161+/// # Safety
162+/// `vm` is the process's `JavaVM` and `activity` a global reference to the
163+/// running Activity, both live for the rest of the process — which is what
164+/// `vidya_android_vm` and `vidya_android_activity` return.
165+#[cfg(target_os = "android")]
166+#[no_mangle]
167+pub unsafe extern "C" fn joltmoq_android_init(
168+ vm: *mut std::ffi::c_void,
169+ activity: *mut std::ffi::c_void,
170+) {
171+ guard((), || {
172+ if vm.is_null() || activity.is_null() {
173+ log::error!("joltmoq_android_init: null JavaVM or Activity; camera will not open");
174+ return;
175+ }
176+ // SAFETY: the caller's contract, restated on this function.
177+ unsafe { android_jni::set(vm.cast(), activity.cast()) };
178+ log::info!("joltmoq: Android JNI handles received");
179+ })
180+}
181+
137182 /// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice.
138183 ///
139184 /// Worth calling first while a call refuses to connect: the media plane says a
@@ -36,7 +36,19 @@
36 36
37 pub mod av;37 pub mod av;
38 pub mod av_media;38 pub mod av_media;
39+// Pure arithmetic on planes, so it builds and is tested everywhere even though
40+// only the Android camera path calls it — a rotate is easier to get wrong than
41+// to test, and a desktop `cargo test` is where that gets caught.
42+#[cfg_attr(not(target_os = "android"), allow(dead_code))]
43+mod nv12_orient;
44+// V4L2 is Linux's camera interface and the phone does not offer it; there the
45+// camera is Java, reached through the two modules below.
46+#[cfg(not(target_os = "android"))]
39 mod v4l2cam;47 mod v4l2cam;
48+#[cfg(target_os = "android")]
49+mod android_camera;
50+#[cfg(target_os = "android")]
51+mod android_jni;
40 52
41 use std::collections::HashMap;53 use std::collections::HashMap;
42 use std::ffi::{c_char, c_int};54 use std::ffi::{c_char, c_int};
@@ -134,6 +146,39 @@ fn with_slot<R>(fallback: R, f: impl FnOnce(&mut Slot) -> R) -> R {
134 146
135 // ── Lifecycle ───────────────────────────────────────────────────────────────147 // ── Lifecycle ───────────────────────────────────────────────────────────────
136 148
149+/// Hand over the phone's `JavaVM` and Activity. Android only, and required
150+/// there before a call with a camera in it can start.
151+///
152+/// The camera on Android is Java, and this library cannot reach the handles a
153+/// JNI call needs: `android-activity`'s glue receives them, and the glue is in
154+/// `libvidya.so`. So the glue in `android/jolt_main.c` — which links both
155+/// objects — reads them out of libvidya's C ABI and passes them here, once,
156+/// before Jolt starts. See `android_jni` for why `ndk_context` cannot do this.
157+///
158+/// Everything else works without it. Only the camera calls fail, and they say
159+/// which call was missed rather than crashing.
160+///
161+/// # Safety
162+/// `vm` is the process's `JavaVM` and `activity` a global reference to the
163+/// running Activity, both live for the rest of the process — which is what
164+/// `vidya_android_vm` and `vidya_android_activity` return.
165+#[cfg(target_os = "android")]
166+#[no_mangle]
167+pub unsafe extern "C" fn joltmoq_android_init(
168+ vm: *mut std::ffi::c_void,
169+ activity: *mut std::ffi::c_void,
170+) {
171+ guard((), || {
172+ if vm.is_null() || activity.is_null() {
173+ log::error!("joltmoq_android_init: null JavaVM or Activity; camera will not open");
174+ return;
175+ }
176+ // SAFETY: the caller's contract, restated on this function.
177+ unsafe { android_jni::set(vm.cast(), activity.cast()) };
178+ log::info!("joltmoq: Android JNI handles received");
179+ })
180+}
181+
137 /// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice.182 /// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice.
138 ///183 ///
139 /// Worth calling first while a call refuses to connect: the media plane says a184 /// Worth calling first while a call refuses to connect: the media plane says a
added crates/jolt-moq/src/nv12_orient.rs +296 -0
new file mode 100644
@@ -0,0 +1,296 @@
1+//! Rotate NV12 frames so Camera2 sensor buffers appear upright.
2+//!
3+//! Phone sensors are usually mounted at 90°/270°. ImageReader delivers
4+//! buffers in sensor coordinates; without a CW rotate by
5+//! [`CameraCharacteristics.SENSOR_ORIENTATION`] ± display rotation, portrait
6+//! video looks sideways on the wire and in local preview.
7+
8+/// Orient an NV12 frame by rotating `rotation_cw` degrees clockwise (0/90/180/270).
9+///
10+/// Stride padding is stripped. For 90°/270°, width and height are swapped.
11+/// Odd dimensions are rejected (YUV 4:2:0 requires even chroma grid).
12+pub fn orient_nv12(
13+ y_data: &[u8],
14+ uv_data: &[u8],
15+ width: u32,
16+ height: u32,
17+ y_stride: u32,
18+ uv_stride: u32,
19+ rotation_cw: u32,
20+) -> Option<(Vec<u8>, Vec<u8>, u32, u32)> {
21+ if width == 0 || height == 0 || !width.is_multiple_of(2) || !height.is_multiple_of(2) {
22+ return None;
23+ }
24+ if y_stride < width || uv_stride < width {
25+ return None;
26+ }
27+ let (y, uv) = pack_nv12(y_data, uv_data, width, height, y_stride, uv_stride)?;
28+ let rot = normalize_rotation(rotation_cw);
29+ match rot {
30+ 0 => Some((y, uv, width, height)),
31+ 90 => Some(rotate_nv12_90_cw(&y, &uv, width, height)),
32+ 180 => Some(rotate_nv12_180(&y, &uv, width, height)),
33+ 270 => Some(rotate_nv12_270_cw(&y, &uv, width, height)),
34+ _ => Some((y, uv, width, height)),
35+ }
36+}
37+
38+fn normalize_rotation(degrees: u32) -> u32 {
39+ let d = degrees % 360;
40+ // Snap near-miss values from noisy sensors to the nearest cardinal.
41+ match d {
42+ 0..=44 | 316..=359 => 0,
43+ 45..=134 => 90,
44+ 135..=224 => 180,
45+ 225..=315 => 270,
46+ _ => 0,
47+ }
48+}
49+
50+fn pack_nv12(
51+ y_data: &[u8],
52+ uv_data: &[u8],
53+ width: u32,
54+ height: u32,
55+ y_stride: u32,
56+ uv_stride: u32,
57+) -> Option<(Vec<u8>, Vec<u8>)> {
58+ let w = width as usize;
59+ let h = height as usize;
60+ let ys = y_stride as usize;
61+ let uvs = uv_stride as usize;
62+ let y_need = ys.checked_mul(h)?;
63+ let uv_need = uvs.checked_mul(h / 2)?;
64+ if y_data.len() < y_need || uv_data.len() < uv_need {
65+ return None;
66+ }
67+ let mut y_out = vec![0u8; w * h];
68+ for row in 0..h {
69+ let src = row * ys;
70+ let dst = row * w;
71+ y_out[dst..dst + w].copy_from_slice(&y_data[src..src + w]);
72+ }
73+ let mut uv_out = vec![0u8; w * (h / 2)];
74+ for row in 0..(h / 2) {
75+ let src = row * uvs;
76+ let dst = row * w;
77+ uv_out[dst..dst + w].copy_from_slice(&uv_data[src..src + w]);
78+ }
79+ Some((y_out, uv_out))
80+}
81+
82+/// 90° CW: dst(dx, dy) = src(dy, H-1-dx); output is H×W.
83+fn rotate_nv12_90_cw(y: &[u8], uv: &[u8], width: u32, height: u32) -> (Vec<u8>, Vec<u8>, u32, u32) {
84+ let w = width as usize;
85+ let h = height as usize;
86+ let out_w = h;
87+ let out_h = w;
88+ let mut y_out = vec![0u8; out_w * out_h];
89+ for dy in 0..out_h {
90+ for dx in 0..out_w {
91+ let sx = dy;
92+ let sy = h - 1 - dx;
93+ y_out[dy * out_w + dx] = y[sy * w + sx];
94+ }
95+ }
96+ let cw = w / 2;
97+ let ch = h / 2;
98+ let out_cw = ch;
99+ let out_ch = cw;
100+ let mut uv_out = vec![0u8; out_w * (out_h / 2)];
101+ for dy in 0..out_ch {
102+ for dx in 0..out_cw {
103+ let sx = dy;
104+ let sy = ch - 1 - dx;
105+ let src = (sy * cw + sx) * 2;
106+ let dst = (dy * out_cw + dx) * 2;
107+ uv_out[dst] = uv[src];
108+ uv_out[dst + 1] = uv[src + 1];
109+ }
110+ }
111+ (y_out, uv_out, out_w as u32, out_h as u32)
112+}
113+
114+/// 270° CW (= 90° CCW): dst(dx, dy) = src(W-1-dy, dx); output is H×W.
115+fn rotate_nv12_270_cw(
116+ y: &[u8],
117+ uv: &[u8],
118+ width: u32,
119+ height: u32,
120+) -> (Vec<u8>, Vec<u8>, u32, u32) {
121+ let w = width as usize;
122+ let h = height as usize;
123+ let out_w = h;
124+ let out_h = w;
125+ let mut y_out = vec![0u8; out_w * out_h];
126+ for dy in 0..out_h {
127+ for dx in 0..out_w {
128+ let sx = w - 1 - dy;
129+ let sy = dx;
130+ y_out[dy * out_w + dx] = y[sy * w + sx];
131+ }
132+ }
133+ let cw = w / 2;
134+ let ch = h / 2;
135+ let out_cw = ch;
136+ let out_ch = cw;
137+ let mut uv_out = vec![0u8; out_w * (out_h / 2)];
138+ for dy in 0..out_ch {
139+ for dx in 0..out_cw {
140+ let sx = cw - 1 - dy;
141+ let sy = dx;
142+ let src = (sy * cw + sx) * 2;
143+ let dst = (dy * out_cw + dx) * 2;
144+ uv_out[dst] = uv[src];
145+ uv_out[dst + 1] = uv[src + 1];
146+ }
147+ }
148+ (y_out, uv_out, out_w as u32, out_h as u32)
149+}
150+
151+fn rotate_nv12_180(y: &[u8], uv: &[u8], width: u32, height: u32) -> (Vec<u8>, Vec<u8>, u32, u32) {
152+ let w = width as usize;
153+ let h = height as usize;
154+ let mut y_out = vec![0u8; w * h];
155+ for dy in 0..h {
156+ for dx in 0..w {
157+ let sx = w - 1 - dx;
158+ let sy = h - 1 - dy;
159+ y_out[dy * w + dx] = y[sy * w + sx];
160+ }
161+ }
162+ let cw = w / 2;
163+ let ch = h / 2;
164+ let mut uv_out = vec![0u8; w * (h / 2)];
165+ for dy in 0..ch {
166+ for dx in 0..cw {
167+ let sx = cw - 1 - dx;
168+ let sy = ch - 1 - dy;
169+ let src = (sy * cw + sx) * 2;
170+ let dst = (dy * cw + dx) * 2;
171+ uv_out[dst] = uv[src];
172+ uv_out[dst + 1] = uv[src + 1];
173+ }
174+ }
175+ (y_out, uv_out, width, height)
176+}
177+
178+/// Camera2 JPEG / buffer orientation: degrees CW to apply so the frame is
179+/// upright for the current display rotation.
180+pub fn camera2_rotation_degrees(
181+ sensor_orientation: u32,
182+ display_degrees: u32,
183+ front_facing: bool,
184+) -> u32 {
185+ let sensor = sensor_orientation % 360;
186+ let display = display_degrees % 360;
187+ if front_facing {
188+ (sensor + display) % 360
189+ } else {
190+ (sensor + 360 - display) % 360
191+ }
192+}
193+
194+#[cfg(test)]
195+mod tests {
196+ use super::*;
197+
198+ fn solid_nv12(w: u32, h: u32, y: u8, u: u8, v: u8) -> (Vec<u8>, Vec<u8>) {
199+ let y_plane = vec![y; (w * h) as usize];
200+ let mut uv = vec![0u8; (w * (h / 2)) as usize];
201+ for i in 0..(uv.len() / 2) {
202+ uv[i * 2] = u;
203+ uv[i * 2 + 1] = v;
204+ }
205+ (y_plane, uv)
206+ }
207+
208+ /// Marker at (sx,sy) on a black Y plane — used to verify rotate mapping.
209+ fn marker_nv12(w: u32, h: u32, sx: u32, sy: u32) -> (Vec<u8>, Vec<u8>) {
210+ let (mut y, uv) = solid_nv12(w, h, 0, 128, 128);
211+ y[(sy * w + sx) as usize] = 255;
212+ (y, uv)
213+ }
214+
215+ #[test]
216+ fn identity_keeps_dims_and_marker() {
217+ let (y, uv) = marker_nv12(4, 4, 1, 0);
218+ let (oy, _ouv, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 0).unwrap();
219+ assert_eq!((ow, oh), (4, 4));
220+ assert_eq!(oy[1], 255);
221+ }
222+
223+ #[test]
224+ fn rotate_90_cw_moves_top_left_edge_marker() {
225+ // Marker at top row, x=1 → after 90° CW sits at right column, y=1.
226+ // dst(dx,dy)=src(dy,H-1-dx) ⇒ src(1,0) → dx=H-1-0=3, dy=1.
227+ let (y, uv) = marker_nv12(4, 4, 1, 0);
228+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 90).unwrap();
229+ assert_eq!((ow, oh), (4, 4));
230+ assert_eq!(oy[1 * 4 + 3], 255);
231+ }
232+
233+ #[test]
234+ fn rotate_90_swaps_rect_dims() {
235+ let (y, uv) = solid_nv12(8, 4, 16, 80, 160);
236+ let (oy, ouv, ow, oh) = orient_nv12(&y, &uv, 8, 4, 8, 8, 90).unwrap();
237+ assert_eq!((ow, oh), (4, 8));
238+ assert_eq!(oy.len(), 4 * 8);
239+ assert_eq!(ouv.len(), 4 * 4);
240+ }
241+
242+ #[test]
243+ fn rotate_180_moves_marker_to_opposite_corner() {
244+ let (y, uv) = marker_nv12(4, 4, 0, 0);
245+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 180).unwrap();
246+ assert_eq!((ow, oh), (4, 4));
247+ assert_eq!(oy[3 * 4 + 3], 255);
248+ }
249+
250+ #[test]
251+ fn rotate_270_cw_moves_marker() {
252+ // src(1,0) → 270 CW: sx=W-1-dy, sy=dx ⇒ dy=W-1-1=2, dx=0 → (0,2)
253+ let (y, uv) = marker_nv12(4, 4, 1, 0);
254+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 270).unwrap();
255+ assert_eq!((ow, oh), (4, 4));
256+ assert_eq!(oy[2 * 4 + 0], 255);
257+ }
258+
259+ #[test]
260+ fn strips_y_stride_padding() {
261+ let w = 4u32;
262+ let h = 4u32;
263+ let y_stride = 8u32;
264+ let mut y = vec![0u8; (y_stride * h) as usize];
265+ // Put marker at (1,0) in logical coords (byte 1 of row 0).
266+ y[1] = 255;
267+ let uv = vec![128u8; (w * (h / 2)) as usize];
268+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, w, h, y_stride, w, 0).unwrap();
269+ assert_eq!((ow, oh), (4, 4));
270+ assert_eq!(oy[1], 255);
271+ assert_eq!(oy.len(), 16);
272+ }
273+
274+ #[test]
275+ fn camera2_back_portrait_sensor_90() {
276+ // Typical back camera, phone held in natural portrait (display 0°).
277+ assert_eq!(camera2_rotation_degrees(90, 0, false), 90);
278+ }
279+
280+ #[test]
281+ fn camera2_front_portrait_sensor_270() {
282+ assert_eq!(camera2_rotation_degrees(270, 0, true), 270);
283+ }
284+
285+ #[test]
286+ fn camera2_back_landscape_display_90() {
287+ // Rotated to landscape: sensor 90 − display 90 = 0 (already upright).
288+ assert_eq!(camera2_rotation_degrees(90, 90, false), 0);
289+ }
290+
291+ #[test]
292+ fn rejects_odd_dimensions() {
293+ let (y, uv) = solid_nv12(4, 4, 0, 128, 128);
294+ assert!(orient_nv12(&y, &uv, 3, 4, 3, 3, 0).is_none());
295+ }
296+}
new file mode 100644
@@ -0,0 +1,296 @@
1+//! Rotate NV12 frames so Camera2 sensor buffers appear upright.
2+//!
3+//! Phone sensors are usually mounted at 90°/270°. ImageReader delivers
4+//! buffers in sensor coordinates; without a CW rotate by
5+//! [`CameraCharacteristics.SENSOR_ORIENTATION`] ± display rotation, portrait
6+//! video looks sideways on the wire and in local preview.
7+
8+/// Orient an NV12 frame by rotating `rotation_cw` degrees clockwise (0/90/180/270).
9+///
10+/// Stride padding is stripped. For 90°/270°, width and height are swapped.
11+/// Odd dimensions are rejected (YUV 4:2:0 requires even chroma grid).
12+pub fn orient_nv12(
13+ y_data: &[u8],
14+ uv_data: &[u8],
15+ width: u32,
16+ height: u32,
17+ y_stride: u32,
18+ uv_stride: u32,
19+ rotation_cw: u32,
20+) -> Option<(Vec<u8>, Vec<u8>, u32, u32)> {
21+ if width == 0 || height == 0 || !width.is_multiple_of(2) || !height.is_multiple_of(2) {
22+ return None;
23+ }
24+ if y_stride < width || uv_stride < width {
25+ return None;
26+ }
27+ let (y, uv) = pack_nv12(y_data, uv_data, width, height, y_stride, uv_stride)?;
28+ let rot = normalize_rotation(rotation_cw);
29+ match rot {
30+ 0 => Some((y, uv, width, height)),
31+ 90 => Some(rotate_nv12_90_cw(&y, &uv, width, height)),
32+ 180 => Some(rotate_nv12_180(&y, &uv, width, height)),
33+ 270 => Some(rotate_nv12_270_cw(&y, &uv, width, height)),
34+ _ => Some((y, uv, width, height)),
35+ }
36+}
37+
38+fn normalize_rotation(degrees: u32) -> u32 {
39+ let d = degrees % 360;
40+ // Snap near-miss values from noisy sensors to the nearest cardinal.
41+ match d {
42+ 0..=44 | 316..=359 => 0,
43+ 45..=134 => 90,
44+ 135..=224 => 180,
45+ 225..=315 => 270,
46+ _ => 0,
47+ }
48+}
49+
50+fn pack_nv12(
51+ y_data: &[u8],
52+ uv_data: &[u8],
53+ width: u32,
54+ height: u32,
55+ y_stride: u32,
56+ uv_stride: u32,
57+) -> Option<(Vec<u8>, Vec<u8>)> {
58+ let w = width as usize;
59+ let h = height as usize;
60+ let ys = y_stride as usize;
61+ let uvs = uv_stride as usize;
62+ let y_need = ys.checked_mul(h)?;
63+ let uv_need = uvs.checked_mul(h / 2)?;
64+ if y_data.len() < y_need || uv_data.len() < uv_need {
65+ return None;
66+ }
67+ let mut y_out = vec![0u8; w * h];
68+ for row in 0..h {
69+ let src = row * ys;
70+ let dst = row * w;
71+ y_out[dst..dst + w].copy_from_slice(&y_data[src..src + w]);
72+ }
73+ let mut uv_out = vec![0u8; w * (h / 2)];
74+ for row in 0..(h / 2) {
75+ let src = row * uvs;
76+ let dst = row * w;
77+ uv_out[dst..dst + w].copy_from_slice(&uv_data[src..src + w]);
78+ }
79+ Some((y_out, uv_out))
80+}
81+
82+/// 90° CW: dst(dx, dy) = src(dy, H-1-dx); output is H×W.
83+fn rotate_nv12_90_cw(y: &[u8], uv: &[u8], width: u32, height: u32) -> (Vec<u8>, Vec<u8>, u32, u32) {
84+ let w = width as usize;
85+ let h = height as usize;
86+ let out_w = h;
87+ let out_h = w;
88+ let mut y_out = vec![0u8; out_w * out_h];
89+ for dy in 0..out_h {
90+ for dx in 0..out_w {
91+ let sx = dy;
92+ let sy = h - 1 - dx;
93+ y_out[dy * out_w + dx] = y[sy * w + sx];
94+ }
95+ }
96+ let cw = w / 2;
97+ let ch = h / 2;
98+ let out_cw = ch;
99+ let out_ch = cw;
100+ let mut uv_out = vec![0u8; out_w * (out_h / 2)];
101+ for dy in 0..out_ch {
102+ for dx in 0..out_cw {
103+ let sx = dy;
104+ let sy = ch - 1 - dx;
105+ let src = (sy * cw + sx) * 2;
106+ let dst = (dy * out_cw + dx) * 2;
107+ uv_out[dst] = uv[src];
108+ uv_out[dst + 1] = uv[src + 1];
109+ }
110+ }
111+ (y_out, uv_out, out_w as u32, out_h as u32)
112+}
113+
114+/// 270° CW (= 90° CCW): dst(dx, dy) = src(W-1-dy, dx); output is H×W.
115+fn rotate_nv12_270_cw(
116+ y: &[u8],
117+ uv: &[u8],
118+ width: u32,
119+ height: u32,
120+) -> (Vec<u8>, Vec<u8>, u32, u32) {
121+ let w = width as usize;
122+ let h = height as usize;
123+ let out_w = h;
124+ let out_h = w;
125+ let mut y_out = vec![0u8; out_w * out_h];
126+ for dy in 0..out_h {
127+ for dx in 0..out_w {
128+ let sx = w - 1 - dy;
129+ let sy = dx;
130+ y_out[dy * out_w + dx] = y[sy * w + sx];
131+ }
132+ }
133+ let cw = w / 2;
134+ let ch = h / 2;
135+ let out_cw = ch;
136+ let out_ch = cw;
137+ let mut uv_out = vec![0u8; out_w * (out_h / 2)];
138+ for dy in 0..out_ch {
139+ for dx in 0..out_cw {
140+ let sx = cw - 1 - dy;
141+ let sy = dx;
142+ let src = (sy * cw + sx) * 2;
143+ let dst = (dy * out_cw + dx) * 2;
144+ uv_out[dst] = uv[src];
145+ uv_out[dst + 1] = uv[src + 1];
146+ }
147+ }
148+ (y_out, uv_out, out_w as u32, out_h as u32)
149+}
150+
151+fn rotate_nv12_180(y: &[u8], uv: &[u8], width: u32, height: u32) -> (Vec<u8>, Vec<u8>, u32, u32) {
152+ let w = width as usize;
153+ let h = height as usize;
154+ let mut y_out = vec![0u8; w * h];
155+ for dy in 0..h {
156+ for dx in 0..w {
157+ let sx = w - 1 - dx;
158+ let sy = h - 1 - dy;
159+ y_out[dy * w + dx] = y[sy * w + sx];
160+ }
161+ }
162+ let cw = w / 2;
163+ let ch = h / 2;
164+ let mut uv_out = vec![0u8; w * (h / 2)];
165+ for dy in 0..ch {
166+ for dx in 0..cw {
167+ let sx = cw - 1 - dx;
168+ let sy = ch - 1 - dy;
169+ let src = (sy * cw + sx) * 2;
170+ let dst = (dy * cw + dx) * 2;
171+ uv_out[dst] = uv[src];
172+ uv_out[dst + 1] = uv[src + 1];
173+ }
174+ }
175+ (y_out, uv_out, width, height)
176+}
177+
178+/// Camera2 JPEG / buffer orientation: degrees CW to apply so the frame is
179+/// upright for the current display rotation.
180+pub fn camera2_rotation_degrees(
181+ sensor_orientation: u32,
182+ display_degrees: u32,
183+ front_facing: bool,
184+) -> u32 {
185+ let sensor = sensor_orientation % 360;
186+ let display = display_degrees % 360;
187+ if front_facing {
188+ (sensor + display) % 360
189+ } else {
190+ (sensor + 360 - display) % 360
191+ }
192+}
193+
194+#[cfg(test)]
195+mod tests {
196+ use super::*;
197+
198+ fn solid_nv12(w: u32, h: u32, y: u8, u: u8, v: u8) -> (Vec<u8>, Vec<u8>) {
199+ let y_plane = vec![y; (w * h) as usize];
200+ let mut uv = vec![0u8; (w * (h / 2)) as usize];
201+ for i in 0..(uv.len() / 2) {
202+ uv[i * 2] = u;
203+ uv[i * 2 + 1] = v;
204+ }
205+ (y_plane, uv)
206+ }
207+
208+ /// Marker at (sx,sy) on a black Y plane — used to verify rotate mapping.
209+ fn marker_nv12(w: u32, h: u32, sx: u32, sy: u32) -> (Vec<u8>, Vec<u8>) {
210+ let (mut y, uv) = solid_nv12(w, h, 0, 128, 128);
211+ y[(sy * w + sx) as usize] = 255;
212+ (y, uv)
213+ }
214+
215+ #[test]
216+ fn identity_keeps_dims_and_marker() {
217+ let (y, uv) = marker_nv12(4, 4, 1, 0);
218+ let (oy, _ouv, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 0).unwrap();
219+ assert_eq!((ow, oh), (4, 4));
220+ assert_eq!(oy[1], 255);
221+ }
222+
223+ #[test]
224+ fn rotate_90_cw_moves_top_left_edge_marker() {
225+ // Marker at top row, x=1 → after 90° CW sits at right column, y=1.
226+ // dst(dx,dy)=src(dy,H-1-dx) ⇒ src(1,0) → dx=H-1-0=3, dy=1.
227+ let (y, uv) = marker_nv12(4, 4, 1, 0);
228+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 90).unwrap();
229+ assert_eq!((ow, oh), (4, 4));
230+ assert_eq!(oy[1 * 4 + 3], 255);
231+ }
232+
233+ #[test]
234+ fn rotate_90_swaps_rect_dims() {
235+ let (y, uv) = solid_nv12(8, 4, 16, 80, 160);
236+ let (oy, ouv, ow, oh) = orient_nv12(&y, &uv, 8, 4, 8, 8, 90).unwrap();
237+ assert_eq!((ow, oh), (4, 8));
238+ assert_eq!(oy.len(), 4 * 8);
239+ assert_eq!(ouv.len(), 4 * 4);
240+ }
241+
242+ #[test]
243+ fn rotate_180_moves_marker_to_opposite_corner() {
244+ let (y, uv) = marker_nv12(4, 4, 0, 0);
245+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 180).unwrap();
246+ assert_eq!((ow, oh), (4, 4));
247+ assert_eq!(oy[3 * 4 + 3], 255);
248+ }
249+
250+ #[test]
251+ fn rotate_270_cw_moves_marker() {
252+ // src(1,0) → 270 CW: sx=W-1-dy, sy=dx ⇒ dy=W-1-1=2, dx=0 → (0,2)
253+ let (y, uv) = marker_nv12(4, 4, 1, 0);
254+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, 4, 4, 4, 4, 270).unwrap();
255+ assert_eq!((ow, oh), (4, 4));
256+ assert_eq!(oy[2 * 4 + 0], 255);
257+ }
258+
259+ #[test]
260+ fn strips_y_stride_padding() {
261+ let w = 4u32;
262+ let h = 4u32;
263+ let y_stride = 8u32;
264+ let mut y = vec![0u8; (y_stride * h) as usize];
265+ // Put marker at (1,0) in logical coords (byte 1 of row 0).
266+ y[1] = 255;
267+ let uv = vec![128u8; (w * (h / 2)) as usize];
268+ let (oy, _, ow, oh) = orient_nv12(&y, &uv, w, h, y_stride, w, 0).unwrap();
269+ assert_eq!((ow, oh), (4, 4));
270+ assert_eq!(oy[1], 255);
271+ assert_eq!(oy.len(), 16);
272+ }
273+
274+ #[test]
275+ fn camera2_back_portrait_sensor_90() {
276+ // Typical back camera, phone held in natural portrait (display 0°).
277+ assert_eq!(camera2_rotation_degrees(90, 0, false), 90);
278+ }
279+
280+ #[test]
281+ fn camera2_front_portrait_sensor_270() {
282+ assert_eq!(camera2_rotation_degrees(270, 0, true), 270);
283+ }
284+
285+ #[test]
286+ fn camera2_back_landscape_display_90() {
287+ // Rotated to landscape: sensor 90 − display 90 = 0 (already upright).
288+ assert_eq!(camera2_rotation_degrees(90, 90, false), 0);
289+ }
290+
291+ #[test]
292+ fn rejects_odd_dimensions() {
293+ let (y, uv) = solid_nv12(4, 4, 0, 128, 128);
294+ assert!(orient_nv12(&y, &uv, 3, 4, 3, 3, 0).is_none());
295+ }
296+}
modified crates/jolt-vidya/include/vidya.h +9 -0
@@ -68,6 +68,15 @@ VIDYA_API int vidya_checkbox_value(const char *label, int checked);
6868 VIDYA_API void vidya_status(const char *label, int live);
6969 VIDYA_API int vidya_text_field(char *text, size_t capacity, const char *placeholder);
7070
71+/* Android only, and null until the activity's glue has started.
72+ *
73+ * The process's JavaVM and its Activity, for a *different* shared object that
74+ * needs them: libjoltmoq reaches Camera2 over JNI, and the handles only arrive
75+ * in the library holding android-activity's glue, which is this one. Neither is
76+ * of any use to a desktop caller. See crates/jolt-vidya/src/android.rs. */
77+VIDYA_API void *vidya_android_vm(void);
78+VIDYA_API void *vidya_android_activity(void);
79+
7180 #ifdef __cplusplus
7281 }
7382 #endif
@@ -68,6 +68,15 @@ VIDYA_API int vidya_checkbox_value(const char *label, int checked);
68 VIDYA_API void vidya_status(const char *label, int live);68 VIDYA_API void vidya_status(const char *label, int live);
69 VIDYA_API int vidya_text_field(char *text, size_t capacity, const char *placeholder);69 VIDYA_API int vidya_text_field(char *text, size_t capacity, const char *placeholder);
70 70
71+/* Android only, and null until the activity's glue has started.
72+ *
73+ * The process's JavaVM and its Activity, for a *different* shared object that
74+ * needs them: libjoltmoq reaches Camera2 over JNI, and the handles only arrive
75+ * in the library holding android-activity's glue, which is this one. Neither is
76+ * of any use to a desktop caller. See crates/jolt-vidya/src/android.rs. */
77+VIDYA_API void *vidya_android_vm(void);
78+VIDYA_API void *vidya_android_activity(void);
79+
71 #ifdef __cplusplus80 #ifdef __cplusplus
72 }81 }
73 #endif82 #endif
modified crates/jolt-vidya/src/android.rs +31 -0
@@ -62,6 +62,37 @@ pub fn android_app() -> Option<AndroidApp> {
6262 ANDROID_APP.lock().ok()?.clone()
6363 }
6464
65+/// The process's `JavaVM`, for a library that is not this one.
66+///
67+/// `libjoltmoq.so` reaches Camera2 through JNI and cannot get here by itself:
68+/// the handle arrives in [`android_main`], which only runs in the object
69+/// holding android-activity's glue, and the usual way across — `ndk_context` —
70+/// keeps its handles in a `static`, which is per shared object. So the glue in
71+/// `android/jolt_main.c` reads them out through here and hands them on.
72+///
73+/// Null before the glue has started.
74+#[no_mangle]
75+pub extern "C" fn vidya_android_vm() -> *mut c_void {
76+ match android_app() {
77+ Some(app) => app.vm_as_ptr(),
78+ None => std::ptr::null_mut(),
79+ }
80+}
81+
82+/// The running Activity, as the glue's own global reference — see
83+/// [`vidya_android_vm`]. It is good for the lifetime of the process, which is
84+/// what makes it safe to hand to another object; a local reference would die
85+/// with the frame that made it.
86+///
87+/// Null before the glue has started.
88+#[no_mangle]
89+pub extern "C" fn vidya_android_activity() -> *mut c_void {
90+ match android_app() {
91+ Some(app) => app.activity_as_ptr(),
92+ None => std::ptr::null_mut(),
93+ }
94+}
95+
6596 extern "C" {
6697 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
6798 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
@@ -62,6 +62,37 @@ pub fn android_app() -> Option<AndroidApp> {
62 ANDROID_APP.lock().ok()?.clone()62 ANDROID_APP.lock().ok()?.clone()
63 }63 }
64 64
65+/// The process's `JavaVM`, for a library that is not this one.
66+///
67+/// `libjoltmoq.so` reaches Camera2 through JNI and cannot get here by itself:
68+/// the handle arrives in [`android_main`], which only runs in the object
69+/// holding android-activity's glue, and the usual way across — `ndk_context` —
70+/// keeps its handles in a `static`, which is per shared object. So the glue in
71+/// `android/jolt_main.c` reads them out through here and hands them on.
72+///
73+/// Null before the glue has started.
74+#[no_mangle]
75+pub extern "C" fn vidya_android_vm() -> *mut c_void {
76+ match android_app() {
77+ Some(app) => app.vm_as_ptr(),
78+ None => std::ptr::null_mut(),
79+ }
80+}
81+
82+/// The running Activity, as the glue's own global reference — see
83+/// [`vidya_android_vm`]. It is good for the lifetime of the process, which is
84+/// what makes it safe to hand to another object; a local reference would die
85+/// with the frame that made it.
86+///
87+/// Null before the glue has started.
88+#[no_mangle]
89+pub extern "C" fn vidya_android_activity() -> *mut c_void {
90+ match android_app() {
91+ Some(app) => app.activity_as_ptr(),
92+ None => std::ptr::null_mut(),
93+ }
94+}
95+
65 extern "C" {96 extern "C" {
66 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;97 fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
67 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;98 fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
modified justfile +10 -5
@@ -49,20 +49,25 @@ buck-build *args:
4949 buck-libdir:
5050 @echo "{{justfile_directory()}}/build/lib"
5151
52-# libvidya alone, cross-compiled for a 64-bit Android device, staged where an
53-# APK build can pick it up. There is no libjoltmoq here: the media plane is
54-# V4L2 and PipeWire, neither of which the phone has.
52+# Both objects, cross-compiled for a 64-bit Android device and staged where an
53+# APK build can pick them up. libjoltmoq crosses too now — its camera is
54+# Camera2 over JNI rather than V4L2, and its audio is cpal's AAudio host rather
55+# than PipeWire. The camera needs two things from the APK, though, which this
56+# recipe cannot supply: a `CameraCapture` class in its classes.dex (frq has
57+# one), and a call to joltmoq_android_init. See android/jolt_main.c.
5558 #
5659 # The NDK is fetched by buck2 from the pin in scripts/android-ndk.dotslash, so
5760 # the only thing a machine needs installed for this is nothing.
5861 ffi-android:
59- @just buck build //:libvidya-android
62+ @just buck build //:libs-android
6063 mkdir -p build/android/arm64-v8a
6164 cp "$(just buck build --show-output //:libvidya-android | awk '/libvidya.so/{print $2}')" \
6265 build/android/arm64-v8a/libvidya.so
66+ cp "$(just buck build --show-output //:libjoltmoq-android | awk '/libjoltmoq.so/{print $2}')" \
67+ build/android/arm64-v8a/libjoltmoq.so
6368 @echo "{{justfile_directory()}}/build/android/arm64-v8a"
6469
65-# Where an APK build finds the Android libvidya, and the glue beside it.
70+# Where an APK build finds the Android objects, and the glue beside them.
6671 android-libdir:
6772 @echo "{{justfile_directory()}}/build/android/arm64-v8a"
6873
@@ -49,20 +49,25 @@ buck-build *args:
49 buck-libdir:49 buck-libdir:
50 @echo "{{justfile_directory()}}/build/lib"50 @echo "{{justfile_directory()}}/build/lib"
51 51
52-# libvidya alone, cross-compiled for a 64-bit Android device, staged where an52+# Both objects, cross-compiled for a 64-bit Android device and staged where an
53-# APK build can pick it up. There is no libjoltmoq here: the media plane is53+# APK build can pick them up. libjoltmoq crosses too now — its camera is
54-# V4L2 and PipeWire, neither of which the phone has.54+# Camera2 over JNI rather than V4L2, and its audio is cpal's AAudio host rather
55+# than PipeWire. The camera needs two things from the APK, though, which this
56+# recipe cannot supply: a `CameraCapture` class in its classes.dex (frq has
57+# one), and a call to joltmoq_android_init. See android/jolt_main.c.
55 #58 #
56 # The NDK is fetched by buck2 from the pin in scripts/android-ndk.dotslash, so59 # The NDK is fetched by buck2 from the pin in scripts/android-ndk.dotslash, so
57 # the only thing a machine needs installed for this is nothing.60 # the only thing a machine needs installed for this is nothing.
58 ffi-android:61 ffi-android:
59- @just buck build //:libvidya-android62+ @just buck build //:libs-android
60 mkdir -p build/android/arm64-v8a63 mkdir -p build/android/arm64-v8a
61 cp "$(just buck build --show-output //:libvidya-android | awk '/libvidya.so/{print $2}')" \64 cp "$(just buck build --show-output //:libvidya-android | awk '/libvidya.so/{print $2}')" \
62 build/android/arm64-v8a/libvidya.so65 build/android/arm64-v8a/libvidya.so
66+ cp "$(just buck build --show-output //:libjoltmoq-android | awk '/libjoltmoq.so/{print $2}')" \
67+ build/android/arm64-v8a/libjoltmoq.so
63 @echo "{{justfile_directory()}}/build/android/arm64-v8a"68 @echo "{{justfile_directory()}}/build/android/arm64-v8a"
64 69
65-# Where an APK build finds the Android libvidya, and the glue beside it.70+# Where an APK build finds the Android objects, and the glue beside them.
66 android-libdir:71 android-libdir:
67 @echo "{{justfile_directory()}}/build/android/arm64-v8a"72 @echo "{{justfile_directory()}}/build/android/arm64-v8a"
68 73
modified platforms/BUCK +6 -1
@@ -23,7 +23,12 @@ execution_platform(
2323 remote_enabled = True,
2424 remote_execution_properties = {
2525 "OSFamily": "Linux",
26- "container-image": "docker://gcr.io/flame-public/rbe-ubuntu22-04:latest",
26+ # 24.04, not 22.04: a build script compiled against this machine's glibc
27+ # has to *run* on the worker, and 22.04's is 2.35 — old enough that
28+ # thiserror's build script died with "GLIBC_2.39 not found" before a
29+ # single crate was compiled. frq hit the same floor from the other side
30+ # (jolt itself needs 2.38) and moved for the same reason.
31+ "container-image": "docker://gcr.io/flame-public/rbe-ubuntu24-04:latest",
2732 },
2833 use_windows_path_separators = host_info().os.is_windows,
2934 visibility = ["PUBLIC"],
@@ -23,7 +23,12 @@ execution_platform(
23 remote_enabled = True,23 remote_enabled = True,
24 remote_execution_properties = {24 remote_execution_properties = {
25 "OSFamily": "Linux",25 "OSFamily": "Linux",
26- "container-image": "docker://gcr.io/flame-public/rbe-ubuntu22-04:latest",26+ # 24.04, not 22.04: a build script compiled against this machine's glibc
27+ # has to *run* on the worker, and 22.04's is 2.35 — old enough that
28+ # thiserror's build script died with "GLIBC_2.39 not found" before a
29+ # single crate was compiled. frq hit the same floor from the other side
30+ # (jolt itself needs 2.38) and moved for the same reason.
31+ "container-image": "docker://gcr.io/flame-public/rbe-ubuntu24-04:latest",
27 },32 },
28 use_windows_path_separators = host_info().os.is_windows,33 use_windows_path_separators = host_info().os.is_windows,
29 visibility = ["PUBLIC"],34 visibility = ["PUBLIC"],
modified third-party/rust/BUCK +98 -6
@@ -1933,6 +1933,8 @@ buildscript_run(
19331933 "OPT_LEVEL": "2",
19341934 },
19351935 features = ["prebuilt-nasm"],
1936+ rustc_link_lib = True,
1937+ rustc_link_search = True,
19361938 version = "0.43.0",
19371939 )
19381940
@@ -11711,10 +11713,26 @@ cargo.rust_library(
1171111713 "CARGO_PKG_VERSION_PRE": "",
1171211714 },
1171311715 features = [
11714- "capture-camera",
1171511716 "h264",
1171611717 "opus",
1171711718 ],
11719+ platform = {
11720+ "android-arm64": dict(
11721+ features = ["android"],
11722+ ),
11723+ "linux-arm64": dict(
11724+ features = ["capture-camera"],
11725+ ),
11726+ "linux-x86_64": dict(
11727+ features = ["capture-camera"],
11728+ ),
11729+ "macos-arm64": dict(
11730+ features = ["capture-camera"],
11731+ ),
11732+ "macos-x86_64": dict(
11733+ features = ["capture-camera"],
11734+ ),
11735+ },
1171811736 visibility = [],
1171911737 deps = [
1172011738 ":anyhow-1",
@@ -14022,10 +14040,30 @@ cargo.rust_library(
1402214040 "OUT_DIR": "$(location :moq-media-0.1-build-script-run[out_dir])",
1402314041 },
1402414042 features = [
14025- "capture-camera",
1402614043 "h264",
1402714044 "opus",
1402814045 ],
14046+ platform = {
14047+ "android-arm64": dict(
14048+ features = ["android"],
14049+ ),
14050+ "linux-arm64": dict(
14051+ features = ["capture-camera"],
14052+ deps = [":rusty-capture-0.1"],
14053+ ),
14054+ "linux-x86_64": dict(
14055+ features = ["capture-camera"],
14056+ deps = [":rusty-capture-0.1"],
14057+ ),
14058+ "macos-arm64": dict(
14059+ features = ["capture-camera"],
14060+ deps = [":rusty-capture-0.1"],
14061+ ),
14062+ "macos-x86_64": dict(
14063+ features = ["capture-camera"],
14064+ deps = [":rusty-capture-0.1"],
14065+ ),
14066+ },
1402914067 rustc_flags = ["@$(location :moq-media-0.1-build-script-run[rustc_flags])"],
1403014068 visibility = [],
1403114069 deps = [
@@ -14042,7 +14080,6 @@ cargo.rust_library(
1404214080 ":n0-watcher-0.6",
1404314081 ":ringbuf-0.4",
1404414082 ":rubato-1",
14045- ":rusty-capture-0.1",
1404614083 ":rusty-codecs-0.1",
1404714084 ":sonora-0.1",
1404814085 ":strum-0.28",
@@ -14079,10 +14116,26 @@ cargo.rust_binary(
1407914116 "CARGO_PKG_VERSION_PRE": "",
1408014117 },
1408114118 features = [
14082- "capture-camera",
1408314119 "h264",
1408414120 "opus",
1408514121 ],
14122+ platform = {
14123+ "android-arm64": dict(
14124+ features = ["android"],
14125+ ),
14126+ "linux-arm64": dict(
14127+ features = ["capture-camera"],
14128+ ),
14129+ "linux-x86_64": dict(
14130+ features = ["capture-camera"],
14131+ ),
14132+ "macos-arm64": dict(
14133+ features = ["capture-camera"],
14134+ ),
14135+ "macos-x86_64": dict(
14136+ features = ["capture-camera"],
14137+ ),
14138+ },
1408614139 visibility = [],
1408714140 )
1408814141
@@ -14104,11 +14157,27 @@ buildscript_run(
1410414157 "OPT_LEVEL": "2",
1410514158 },
1410614159 features = [
14107- "capture-camera",
1410814160 "h264",
1410914161 "opus",
1411014162 ],
1411114163 manifest_dir = ":iroh-live-cba230b1e993c078.git[moq-media]",
14164+ platform = {
14165+ "android-arm64": dict(
14166+ features = ["android"],
14167+ ),
14168+ "linux-arm64": dict(
14169+ features = ["capture-camera"],
14170+ ),
14171+ "linux-x86_64": dict(
14172+ features = ["capture-camera"],
14173+ ),
14174+ "macos-arm64": dict(
14175+ features = ["capture-camera"],
14176+ ),
14177+ "macos-x86_64": dict(
14178+ features = ["capture-camera"],
14179+ ),
14180+ },
1411214181 version = "0.1.0",
1411314182 )
1411414183
@@ -14575,6 +14644,8 @@ cargo.rust_library(
1457514644 "api-level-25",
1457614645 "api-level-26",
1457714646 "audio",
14647+ "default",
14648+ "media",
1457814649 "rwh_06",
1457914650 ],
1458014651 named_deps = {
@@ -14662,7 +14733,10 @@ cargo.rust_library(
1466214733 "CARGO_PKG_VERSION_PATCH": "0",
1466314734 "CARGO_PKG_VERSION_PRE": "",
1466414735 },
14665- features = ["audio"],
14736+ features = [
14737+ "audio",
14738+ "media",
14739+ ],
1466614740 visibility = [],
1466714741 deps = [":jni-sys-0.3"],
1466814742 )
@@ -18374,6 +18448,8 @@ buildscript_run(
1837418448 "OPT_LEVEL": "2",
1837518449 },
1837618450 features = ["source"],
18451+ rustc_link_lib = True,
18452+ rustc_link_search = True,
1837718453 version = "0.9.7",
1837818454 )
1837918455
@@ -22771,6 +22847,12 @@ cargo.rust_library(
2277122847 "hang",
2277222848 "opus",
2277322849 ],
22850+ platform = {
22851+ "android-arm64": dict(
22852+ features = ["android"],
22853+ deps = [":ndk-0.9"],
22854+ ),
22855+ },
2277422856 rustc_flags = ["@$(location :rusty-codecs-0.1-build-script-run[rustc_flags])"],
2277522857 visibility = [],
2277622858 deps = [
@@ -22822,6 +22904,11 @@ cargo.rust_binary(
2282222904 "hang",
2282322905 "opus",
2282422906 ],
22907+ platform = {
22908+ "android-arm64": dict(
22909+ features = ["android"],
22910+ ),
22911+ },
2282522912 visibility = [],
2282622913 )
2282722914
@@ -22848,6 +22935,11 @@ buildscript_run(
2284822935 "opus",
2284922936 ],
2285022937 manifest_dir = ":iroh-live-cba230b1e993c078.git[rusty-codecs]",
22938+ platform = {
22939+ "android-arm64": dict(
22940+ features = ["android"],
22941+ ),
22942+ },
2285122943 version = "0.1.0",
2285222944 )
2285322945
@@ -1933,6 +1933,8 @@ buildscript_run(
1933 "OPT_LEVEL": "2",1933 "OPT_LEVEL": "2",
1934 },1934 },
1935 features = ["prebuilt-nasm"],1935 features = ["prebuilt-nasm"],
1936+ rustc_link_lib = True,
1937+ rustc_link_search = True,
1936 version = "0.43.0",1938 version = "0.43.0",
1937 )1939 )
1938 1940
@@ -11711,10 +11713,26 @@ cargo.rust_library(
11711 "CARGO_PKG_VERSION_PRE": "",11713 "CARGO_PKG_VERSION_PRE": "",
11712 },11714 },
11713 features = [11715 features = [
11714- "capture-camera",
11715 "h264",11716 "h264",
11716 "opus",11717 "opus",
11717 ],11718 ],
11719+ platform = {
11720+ "android-arm64": dict(
11721+ features = ["android"],
11722+ ),
11723+ "linux-arm64": dict(
11724+ features = ["capture-camera"],
11725+ ),
11726+ "linux-x86_64": dict(
11727+ features = ["capture-camera"],
11728+ ),
11729+ "macos-arm64": dict(
11730+ features = ["capture-camera"],
11731+ ),
11732+ "macos-x86_64": dict(
11733+ features = ["capture-camera"],
11734+ ),
11735+ },
11718 visibility = [],11736 visibility = [],
11719 deps = [11737 deps = [
11720 ":anyhow-1",11738 ":anyhow-1",
@@ -14022,10 +14040,30 @@ cargo.rust_library(
14022 "OUT_DIR": "$(location :moq-media-0.1-build-script-run[out_dir])",14040 "OUT_DIR": "$(location :moq-media-0.1-build-script-run[out_dir])",
14023 },14041 },
14024 features = [14042 features = [
14025- "capture-camera",
14026 "h264",14043 "h264",
14027 "opus",14044 "opus",
14028 ],14045 ],
14046+ platform = {
14047+ "android-arm64": dict(
14048+ features = ["android"],
14049+ ),
14050+ "linux-arm64": dict(
14051+ features = ["capture-camera"],
14052+ deps = [":rusty-capture-0.1"],
14053+ ),
14054+ "linux-x86_64": dict(
14055+ features = ["capture-camera"],
14056+ deps = [":rusty-capture-0.1"],
14057+ ),
14058+ "macos-arm64": dict(
14059+ features = ["capture-camera"],
14060+ deps = [":rusty-capture-0.1"],
14061+ ),
14062+ "macos-x86_64": dict(
14063+ features = ["capture-camera"],
14064+ deps = [":rusty-capture-0.1"],
14065+ ),
14066+ },
14029 rustc_flags = ["@$(location :moq-media-0.1-build-script-run[rustc_flags])"],14067 rustc_flags = ["@$(location :moq-media-0.1-build-script-run[rustc_flags])"],
14030 visibility = [],14068 visibility = [],
14031 deps = [14069 deps = [
@@ -14042,7 +14080,6 @@ cargo.rust_library(
14042 ":n0-watcher-0.6",14080 ":n0-watcher-0.6",
14043 ":ringbuf-0.4",14081 ":ringbuf-0.4",
14044 ":rubato-1",14082 ":rubato-1",
14045- ":rusty-capture-0.1",
14046 ":rusty-codecs-0.1",14083 ":rusty-codecs-0.1",
14047 ":sonora-0.1",14084 ":sonora-0.1",
14048 ":strum-0.28",14085 ":strum-0.28",
@@ -14079,10 +14116,26 @@ cargo.rust_binary(
14079 "CARGO_PKG_VERSION_PRE": "",14116 "CARGO_PKG_VERSION_PRE": "",
14080 },14117 },
14081 features = [14118 features = [
14082- "capture-camera",
14083 "h264",14119 "h264",
14084 "opus",14120 "opus",
14085 ],14121 ],
14122+ platform = {
14123+ "android-arm64": dict(
14124+ features = ["android"],
14125+ ),
14126+ "linux-arm64": dict(
14127+ features = ["capture-camera"],
14128+ ),
14129+ "linux-x86_64": dict(
14130+ features = ["capture-camera"],
14131+ ),
14132+ "macos-arm64": dict(
14133+ features = ["capture-camera"],
14134+ ),
14135+ "macos-x86_64": dict(
14136+ features = ["capture-camera"],
14137+ ),
14138+ },
14086 visibility = [],14139 visibility = [],
14087 )14140 )
14088 14141
@@ -14104,11 +14157,27 @@ buildscript_run(
14104 "OPT_LEVEL": "2",14157 "OPT_LEVEL": "2",
14105 },14158 },
14106 features = [14159 features = [
14107- "capture-camera",
14108 "h264",14160 "h264",
14109 "opus",14161 "opus",
14110 ],14162 ],
14111 manifest_dir = ":iroh-live-cba230b1e993c078.git[moq-media]",14163 manifest_dir = ":iroh-live-cba230b1e993c078.git[moq-media]",
14164+ platform = {
14165+ "android-arm64": dict(
14166+ features = ["android"],
14167+ ),
14168+ "linux-arm64": dict(
14169+ features = ["capture-camera"],
14170+ ),
14171+ "linux-x86_64": dict(
14172+ features = ["capture-camera"],
14173+ ),
14174+ "macos-arm64": dict(
14175+ features = ["capture-camera"],
14176+ ),
14177+ "macos-x86_64": dict(
14178+ features = ["capture-camera"],
14179+ ),
14180+ },
14112 version = "0.1.0",14181 version = "0.1.0",
14113 )14182 )
14114 14183
@@ -14575,6 +14644,8 @@ cargo.rust_library(
14575 "api-level-25",14644 "api-level-25",
14576 "api-level-26",14645 "api-level-26",
14577 "audio",14646 "audio",
14647+ "default",
14648+ "media",
14578 "rwh_06",14649 "rwh_06",
14579 ],14650 ],
14580 named_deps = {14651 named_deps = {
@@ -14662,7 +14733,10 @@ cargo.rust_library(
14662 "CARGO_PKG_VERSION_PATCH": "0",14733 "CARGO_PKG_VERSION_PATCH": "0",
14663 "CARGO_PKG_VERSION_PRE": "",14734 "CARGO_PKG_VERSION_PRE": "",
14664 },14735 },
14665- features = ["audio"],14736+ features = [
14737+ "audio",
14738+ "media",
14739+ ],
14666 visibility = [],14740 visibility = [],
14667 deps = [":jni-sys-0.3"],14741 deps = [":jni-sys-0.3"],
14668 )14742 )
@@ -18374,6 +18448,8 @@ buildscript_run(
18374 "OPT_LEVEL": "2",18448 "OPT_LEVEL": "2",
18375 },18449 },
18376 features = ["source"],18450 features = ["source"],
18451+ rustc_link_lib = True,
18452+ rustc_link_search = True,
18377 version = "0.9.7",18453 version = "0.9.7",
18378 )18454 )
18379 18455
@@ -22771,6 +22847,12 @@ cargo.rust_library(
22771 "hang",22847 "hang",
22772 "opus",22848 "opus",
22773 ],22849 ],
22850+ platform = {
22851+ "android-arm64": dict(
22852+ features = ["android"],
22853+ deps = [":ndk-0.9"],
22854+ ),
22855+ },
22774 rustc_flags = ["@$(location :rusty-codecs-0.1-build-script-run[rustc_flags])"],22856 rustc_flags = ["@$(location :rusty-codecs-0.1-build-script-run[rustc_flags])"],
22775 visibility = [],22857 visibility = [],
22776 deps = [22858 deps = [
@@ -22822,6 +22904,11 @@ cargo.rust_binary(
22822 "hang",22904 "hang",
22823 "opus",22905 "opus",
22824 ],22906 ],
22907+ platform = {
22908+ "android-arm64": dict(
22909+ features = ["android"],
22910+ ),
22911+ },
22825 visibility = [],22912 visibility = [],
22826 )22913 )
22827 22914
@@ -22848,6 +22935,11 @@ buildscript_run(
22848 "opus",22935 "opus",
22849 ],22936 ],
22850 manifest_dir = ":iroh-live-cba230b1e993c078.git[rusty-codecs]",22937 manifest_dir = ":iroh-live-cba230b1e993c078.git[rusty-codecs]",
22938+ platform = {
22939+ "android-arm64": dict(
22940+ features = ["android"],
22941+ ),
22942+ },
22851 version = "0.1.0",22943 version = "0.1.0",
22852 )22944 )
22853 22945
modified third-party/rust/Cargo.lock +6 -0
@@ -3336,6 +3336,7 @@ dependencies = [
33363336 "glutin-winit",
33373337 "image",
33383338 "iroh-live",
3339+ "jni 0.21.1",
33393340 "jni 0.22.4",
33403341 "libc",
33413342 "log",
@@ -3344,6 +3345,10 @@ dependencies = [
33443345 "moq-native",
33453346 "mp4",
33463347 "n0-future",
3348+ "ndk",
3349+ "ndk-context",
3350+ "num-derive",
3351+ "num-traits",
33473352 "objc2 0.6.4",
33483353 "objc2-audio-toolbox",
33493354 "objc2-core-audio",
@@ -5858,6 +5863,7 @@ dependencies = [
58585863 "image",
58595864 "libc",
58605865 "n0-future",
5866+ "ndk",
58615867 "openh264",
58625868 "pic-scale",
58635869 "rubato 1.0.1",
@@ -3336,6 +3336,7 @@ dependencies = [
3336 "glutin-winit",3336 "glutin-winit",
3337 "image",3337 "image",
3338 "iroh-live",3338 "iroh-live",
3339+ "jni 0.21.1",
3339 "jni 0.22.4",3340 "jni 0.22.4",
3340 "libc",3341 "libc",
3341 "log",3342 "log",
@@ -3344,6 +3345,10 @@ dependencies = [
3344 "moq-native",3345 "moq-native",
3345 "mp4",3346 "mp4",
3346 "n0-future",3347 "n0-future",
3348+ "ndk",
3349+ "ndk-context",
3350+ "num-derive",
3351+ "num-traits",
3347 "objc2 0.6.4",3352 "objc2 0.6.4",
3348 "objc2-audio-toolbox",3353 "objc2-audio-toolbox",
3349 "objc2-core-audio",3354 "objc2-core-audio",
@@ -5858,6 +5863,7 @@ dependencies = [
5858 "image",5863 "image",
5859 "libc",5864 "libc",
5860 "n0-future",5865 "n0-future",
5866+ "ndk",
5861 "openh264",5867 "openh264",
5862 "pic-scale",5868 "pic-scale",
5863 "rubato 1.0.1",5869 "rubato 1.0.1",
modified third-party/rust/Cargo.toml +38 -4
@@ -44,10 +44,12 @@ glutin-winit = "0.5"
4444 # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or
4545 # writes a picture file.
4646 image = { version = "0.25", default-features = false, features = ["jpeg"] }
47+# `capture-camera` is NOT here: features declared on a shared dependency are
48+# resolved for every platform, and rusty-capture is V4L2 and friends. It goes in
49+# the desktop target section below, so the phone's graph never reaches it.
4750 iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
4851 "opus",
4952 "h264",
50- "capture-camera",
5153 ] }
5254 libc = "0.2"
5355 log = "0.4"
@@ -93,16 +95,48 @@ objc2-core-audio-types = { version = "0.3", default-features = false, features =
9395 objc2-core-foundation = "0.3"
9496 objc2-foundation = "0.3"
9597
96-# The device. libjoltmoq does not cross — the media plane is V4L2 and PipeWire —
97-# so this is libvidya's half of the graph and nothing else.
98+# The camera, on a machine that has one behind an ioctl. See the note on the
99+# shared `iroh-live` above for why the feature is here rather than there.
100+[target.'cfg(not(target_os = "android"))'.dependencies]
101+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
102+ "capture-camera",
103+] }
104+
105+# The device. Both objects cross now: libvidya for the UI, and libjoltmoq for
106+# the media plane, whose camera is Camera2 over JNI rather than V4L2 and whose
107+# audio is cpal's AAudio host rather than PipeWire.
98108 [target.'cfg(target_os = "android")'.dependencies]
109+# NOT `capture-camera`, for the reason given on the shared `iroh-live` above.
110+# `android` is a different feature and a needed one: it links `ndk`, which
111+# rusty-codecs' format.rs references under cfg(android) for the MediaCodec path,
112+# and without it the crate fails on its own imports.
113+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
114+ "android",
115+] }
99116 # `vidya_open_url` is an ACTION_VIEW intent there, which is a JNI call against
100-# the activity the glue holds.
117+# the activity the glue holds; libjoltmoq's Camera2 bridge is the other caller.
101118 #
102119 # Renamed because cpal wants jni 0.21 on the same platform, and cpal is a path
103120 # dependency above — so reindeer gives its deps public aliases too, and two
104121 # crates called `jni` cannot both have one. The consumer says `named_deps`.
105122 jni-0-22 = { package = "jni", version = "0.22" }
123+# cpal's AAudio host, and the crates it reaches it through. cpal is a path
124+# dependency, so its own manifest cannot give these public aliases — they are
125+# named here for the same reason the alsa and coreaudio rows above are, and
126+# ../cpal/BUCK's Android row is what consumes them.
127+#
128+# `audio` is the AAudio binding itself and `api-level-26` is the floor it needs;
129+# without either, cpal's android host compiles against imports that are not
130+# there. This unions with the plain `ndk` iroh-live asks for above.
131+ndk = { version = "0.9", default-features = false, features = [
132+ "audio",
133+ "api-level-26",
134+] }
135+ndk-context = "0.1"
136+# The unrenamed one, and cpal's: 0.21, not the 0.22 above.
137+jni = "0.21"
138+num-derive = "0.4"
139+num-traits = "0.2"
106140 # The feature is what pulls in android-activity, and android-activity is what
107141 # owns the event loop: the APK's NativeActivity has no Java main to call into,
108142 # so the glue is the entry point. Without it the crate compiles to a backend of
@@ -44,10 +44,12 @@ glutin-winit = "0.5"
44 # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or44 # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or
45 # writes a picture file.45 # writes a picture file.
46 image = { version = "0.25", default-features = false, features = ["jpeg"] }46 image = { version = "0.25", default-features = false, features = ["jpeg"] }
47+# `capture-camera` is NOT here: features declared on a shared dependency are
48+# resolved for every platform, and rusty-capture is V4L2 and friends. It goes in
49+# the desktop target section below, so the phone's graph never reaches it.
47 iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [50 iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
48 "opus",51 "opus",
49 "h264",52 "h264",
50- "capture-camera",
51 ] }53 ] }
52 libc = "0.2"54 libc = "0.2"
53 log = "0.4"55 log = "0.4"
@@ -93,16 +95,48 @@ objc2-core-audio-types = { version = "0.3", default-features = false, features =
93 objc2-core-foundation = "0.3"95 objc2-core-foundation = "0.3"
94 objc2-foundation = "0.3"96 objc2-foundation = "0.3"
95 97
96-# The device. libjoltmoq does not cross — the media plane is V4L2 and PipeWire —98+# The camera, on a machine that has one behind an ioctl. See the note on the
97-# so this is libvidya's half of the graph and nothing else.99+# shared `iroh-live` above for why the feature is here rather than there.
100+[target.'cfg(not(target_os = "android"))'.dependencies]
101+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
102+ "capture-camera",
103+] }
104+
105+# The device. Both objects cross now: libvidya for the UI, and libjoltmoq for
106+# the media plane, whose camera is Camera2 over JNI rather than V4L2 and whose
107+# audio is cpal's AAudio host rather than PipeWire.
98 [target.'cfg(target_os = "android")'.dependencies]108 [target.'cfg(target_os = "android")'.dependencies]
109+# NOT `capture-camera`, for the reason given on the shared `iroh-live` above.
110+# `android` is a different feature and a needed one: it links `ndk`, which
111+# rusty-codecs' format.rs references under cfg(android) for the MediaCodec path,
112+# and without it the crate fails on its own imports.
113+iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [
114+ "android",
115+] }
99 # `vidya_open_url` is an ACTION_VIEW intent there, which is a JNI call against116 # `vidya_open_url` is an ACTION_VIEW intent there, which is a JNI call against
100-# the activity the glue holds.117+# the activity the glue holds; libjoltmoq's Camera2 bridge is the other caller.
101 #118 #
102 # Renamed because cpal wants jni 0.21 on the same platform, and cpal is a path119 # Renamed because cpal wants jni 0.21 on the same platform, and cpal is a path
103 # dependency above — so reindeer gives its deps public aliases too, and two120 # dependency above — so reindeer gives its deps public aliases too, and two
104 # crates called `jni` cannot both have one. The consumer says `named_deps`.121 # crates called `jni` cannot both have one. The consumer says `named_deps`.
105 jni-0-22 = { package = "jni", version = "0.22" }122 jni-0-22 = { package = "jni", version = "0.22" }
123+# cpal's AAudio host, and the crates it reaches it through. cpal is a path
124+# dependency, so its own manifest cannot give these public aliases — they are
125+# named here for the same reason the alsa and coreaudio rows above are, and
126+# ../cpal/BUCK's Android row is what consumes them.
127+#
128+# `audio` is the AAudio binding itself and `api-level-26` is the floor it needs;
129+# without either, cpal's android host compiles against imports that are not
130+# there. This unions with the plain `ndk` iroh-live asks for above.
131+ndk = { version = "0.9", default-features = false, features = [
132+ "audio",
133+ "api-level-26",
134+] }
135+ndk-context = "0.1"
136+# The unrenamed one, and cpal's: 0.21, not the 0.22 above.
137+jni = "0.21"
138+num-derive = "0.4"
139+num-traits = "0.2"
106 # The feature is what pulls in android-activity, and android-activity is what140 # The feature is what pulls in android-activity, and android-activity is what
107 # owns the event loop: the APK's NativeActivity has no Java main to call into,141 # owns the event loop: the APK's NativeActivity has no Java main to call into,
108 # so the glue is the entry point. Without it the crate compiles to a backend of142 # so the glue is the entry point. Without it the crate compiles to a backend of
modified third-party/rust/cpal/BUCK +11 -0
@@ -18,6 +18,17 @@ rust_library(
1818 "//third-party/rust:alsa",
1919 "//third-party/rust:libc",
2020 ],
21+ # The AAudio host. `ndk` is the binding, `ndk-context` is how cpal finds
22+ # the VM, and jni here is 0.21 — the other one in this graph is 0.22 and
23+ # renamed for exactly that reason. No alsa row: the phone has none.
24+ "prelude//os:android": [
25+ "//third-party/rust:jni",
26+ "//third-party/rust:libc",
27+ "//third-party/rust:ndk",
28+ "//third-party/rust:ndk-context",
29+ "//third-party/rust:num-derive",
30+ "//third-party/rust:num-traits",
31+ ],
2132 # CoreAudio. The objc2 rows are pinned to the versions cpal asks for
2233 # rather than the older ones winit drags into the same graph.
2334 "prelude//os:macos": [
@@ -18,6 +18,17 @@ rust_library(
18 "//third-party/rust:alsa",18 "//third-party/rust:alsa",
19 "//third-party/rust:libc",19 "//third-party/rust:libc",
20 ],20 ],
21+ # The AAudio host. `ndk` is the binding, `ndk-context` is how cpal finds
22+ # the VM, and jni here is 0.21 — the other one in this graph is 0.22 and
23+ # renamed for exactly that reason. No alsa row: the phone has none.
24+ "prelude//os:android": [
25+ "//third-party/rust:jni",
26+ "//third-party/rust:libc",
27+ "//third-party/rust:ndk",
28+ "//third-party/rust:ndk-context",
29+ "//third-party/rust:num-derive",
30+ "//third-party/rust:num-traits",
31+ ],
21 # CoreAudio. The objc2 rows are pinned to the versions cpal asks for32 # CoreAudio. The objc2 rows are pinned to the versions cpal asks for
22 # rather than the older ones winit drags into the same graph.33 # rather than the older ones winit drags into the same graph.
23 "prelude//os:macos": [34 "prelude//os:macos": [
modified third-party/rust/fixups/aws-lc-sys/fixups.toml +14 -0
@@ -1,2 +1,16 @@
11 [buildscript.run]
22 env = { OPT_LEVEL = "2" }
3+
4+# Honour the build script's own linking directives, which buck2 drops by
5+# default. It compiles aws-lc's C into `libaws_lc_0_43_0_crypto.a` in OUT_DIR
6+# and emits `cargo:rustc-link-lib` and `cargo:rustc-link-search` to have it
7+# linked; without these two the flags file came out as `--cfg=universal` alone
8+# and the archive sat on disk unreferenced, leaving 129 `aws_lc_0_43_0_*`
9+# symbols undefined in every buck2-built libjoltmoq.so.
10+#
11+# Nothing caught that for as long as nothing loaded one. Undefined symbols are
12+# legal in a cdylib, and `--no-undefined` was only ever on libjoltapp's own
13+# link, which is a different object. The phone is the first consumer to dlopen
14+# it, and there it fails outright on `aws_lc_0_43_0_EVP_PKEY_free`.
15+rustc_link_lib = true
16+rustc_link_search = true
@@ -1,2 +1,16 @@
1 [buildscript.run]1 [buildscript.run]
2 env = { OPT_LEVEL = "2" }2 env = { OPT_LEVEL = "2" }
3+
4+# Honour the build script's own linking directives, which buck2 drops by
5+# default. It compiles aws-lc's C into `libaws_lc_0_43_0_crypto.a` in OUT_DIR
6+# and emits `cargo:rustc-link-lib` and `cargo:rustc-link-search` to have it
7+# linked; without these two the flags file came out as `--cfg=universal` alone
8+# and the archive sat on disk unreferenced, leaving 129 `aws_lc_0_43_0_*`
9+# symbols undefined in every buck2-built libjoltmoq.so.
10+#
11+# Nothing caught that for as long as nothing loaded one. Undefined symbols are
12+# legal in a cdylib, and `--no-undefined` was only ever on libjoltapp's own
13+# link, which is a different object. The phone is the first consumer to dlopen
14+# it, and there it fails outright on `aws_lc_0_43_0_EVP_PKEY_free`.
15+rustc_link_lib = true
16+rustc_link_search = true
modified third-party/rust/fixups/openh264-sys2/fixups.toml +9 -0
@@ -3,3 +3,12 @@
33 # cargo sets OPT_LEVEL for every build script; buck2 does not, and cc-rs and
44 # a number of configure scripts read it unconditionally.
55 env = { OPT_LEVEL = "2" }
6+
7+# And honour its linking directives, the same as aws-lc-sys — see the longer
8+# note there. This build script compiles openh264's C++ under the `source`
9+# feature and emits `cargo:rustc-link-lib` for the archive; buck2 drops that by
10+# default, which left `WelsCreateSVCEncoder` and its three siblings undefined —
11+# the whole H.264 encode and decode path, silently, in every buck2-built
12+# libjoltmoq.so.
13+rustc_link_lib = true
14+rustc_link_search = true
@@ -3,3 +3,12 @@
3 # cargo sets OPT_LEVEL for every build script; buck2 does not, and cc-rs and3 # cargo sets OPT_LEVEL for every build script; buck2 does not, and cc-rs and
4 # a number of configure scripts read it unconditionally.4 # a number of configure scripts read it unconditionally.
5 env = { OPT_LEVEL = "2" }5 env = { OPT_LEVEL = "2" }
6+
7+# And honour its linking directives, the same as aws-lc-sys — see the longer
8+# note there. This build script compiles openh264's C++ under the `source`
9+# feature and emits `cargo:rustc-link-lib` for the archive; buck2 drops that by
10+# default, which left `WelsCreateSVCEncoder` and its three siblings undefined —
11+# the whole H.264 encode and decode path, silently, in every buck2-built
12+# libjoltmoq.so.
13+rustc_link_lib = true
14+rustc_link_search = true
modified toolchains/ndk.sh +8 -2
@@ -44,9 +44,15 @@ fi
4444 # archive, so nothing depends on which permissions survived the unpacking.
4545 target=${JOLT_ANDROID_TARGET:-aarch64-linux-android}${JOLT_ANDROID_API:-28}
4646
47+# The target goes *after* the caller's arguments, because clang takes the last
48+# --target it is given and cc-rs passes one of its own. Its version carries no
49+# API level, so putting ours first silently dropped the app back to the NDK's
50+# default minimum — where bionic has no `getentropy`, and aws-lc-sys fails to
51+# compile against a header that does not declare it. The API level is not a
52+# default to be overridden; it is the floor the app is built for.
4753 case $mode in
48- cc) exec "$bin/clang" --target="$target" "$@" ;;
49- c++) exec "$bin/clang++" --target="$target" "$@" ;;
54+ cc) exec "$bin/clang" "$@" --target="$target" ;;
55+ c++) exec "$bin/clang++" "$@" --target="$target" ;;
5056 ar) exec "$bin/llvm-ar" "$@" ;;
5157 ranlib) exec "$bin/llvm-ranlib" "$@" ;;
5258 objcopy) exec "$bin/llvm-objcopy" "$@" ;;
@@ -44,9 +44,15 @@ fi
44 # archive, so nothing depends on which permissions survived the unpacking.44 # archive, so nothing depends on which permissions survived the unpacking.
45 target=${JOLT_ANDROID_TARGET:-aarch64-linux-android}${JOLT_ANDROID_API:-28}45 target=${JOLT_ANDROID_TARGET:-aarch64-linux-android}${JOLT_ANDROID_API:-28}
46 46
47+# The target goes *after* the caller's arguments, because clang takes the last
48+# --target it is given and cc-rs passes one of its own. Its version carries no
49+# API level, so putting ours first silently dropped the app back to the NDK's
50+# default minimum — where bionic has no `getentropy`, and aws-lc-sys fails to
51+# compile against a header that does not declare it. The API level is not a
52+# default to be overridden; it is the floor the app is built for.
47 case $mode in53 case $mode in
48- cc) exec "$bin/clang" --target="$target" "$@" ;;54+ cc) exec "$bin/clang" "$@" --target="$target" ;;
49- c++) exec "$bin/clang++" --target="$target" "$@" ;;55+ c++) exec "$bin/clang++" "$@" --target="$target" ;;
50 ar) exec "$bin/llvm-ar" "$@" ;;56 ar) exec "$bin/llvm-ar" "$@" ;;
51 ranlib) exec "$bin/llvm-ranlib" "$@" ;;57 ranlib) exec "$bin/llvm-ranlib" "$@" ;;
52 objcopy) exec "$bin/llvm-objcopy" "$@" ;;58 objcopy) exec "$bin/llvm-objcopy" "$@" ;;